feat: add graduation archive and public document services

This commit is contained in:
2026-04-14 13:51:39 +00:00
parent d77ad72bcc
commit 58c9b47dc0
30 changed files with 2513 additions and 295 deletions
@@ -1,6 +1,6 @@
<x-master-page title="Kenaikan Kelas / Promosi Siswa" description="Proses semi otomatis untuk memindahkan siswa aktif ke kelas/tahun ajaran berikutnya.">
<x-master-card title="Filter Promosi" subtitle="Pilih tahun ajaran asal dan tujuan untuk menyiapkan rekomendasi promosi.">
<form method="GET" action="{{ route('student-promotions.index') }}" class="grid grid-cols-1 gap-4 lg:grid-cols-3 lg:items-end">
<x-master-card title="Filter Promosi" subtitle="Pilih tahun ajaran, lalu fokuskan proses per kelas agar pengecualian siswa lebih mudah dikelola.">
<form method="GET" action="{{ route('student-promotions.index') }}" class="grid grid-cols-1 gap-4 lg:grid-cols-4 lg:items-end">
<div>
<label for="from_academic_year_id" class="mb-2 block text-sm font-medium text-slate-700">Tahun Ajaran Asal</label>
<select id="from_academic_year_id" name="from_academic_year_id" class="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-700 focus:border-indigo-300 focus:outline-none focus:ring-2 focus:ring-indigo-100">
@@ -25,6 +25,18 @@
</select>
</div>
<div>
<label for="class_id" class="mb-2 block text-sm font-medium text-slate-700">Kelas Asal</label>
<select id="class_id" name="class_id" class="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-700 focus:border-indigo-300 focus:outline-none focus:ring-2 focus:ring-indigo-100">
<option value="">Pilih kelas asal</option>
@foreach ($availableClasses as $classRoom)
<option value="{{ $classRoom->id }}" @selected((int) $filters['class_id'] === (int) $classRoom->id)>
{{ $classRoom->name }} — {{ $classRoom->department?->name }} ({{ $classRoom->active_students_count }} siswa)
</option>
@endforeach
</select>
</div>
<div>
<button type="submit" class="inline-flex w-full items-center justify-center rounded-2xl bg-indigo-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-indigo-200 transition hover:bg-indigo-700">
Muat Rekomendasi
@@ -33,18 +45,64 @@
</form>
</x-master-card>
<x-master-card title="Daftar Rekomendasi Promosi" subtitle="Sistem memberi saran berdasarkan level kelas saat ini. Admin tetap bisa menyesuaikan sebelum diproses.">
<x-master-card title="Kenaikan Kelas per Rombel" subtitle="Mayoritas siswa biasanya mengikuti hasil kelasnya, lalu admin tinggal mengubah siswa pengecualian.">
@if (blank($filters['from_academic_year_id']) || blank($filters['to_academic_year_id']))
<div class="rounded-2xl border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-700">
Pilih tahun ajaran asal dan tujuan terlebih dahulu untuk memuat rekomendasi promosi siswa.
Pilih tahun ajaran asal dan tujuan terlebih dahulu untuk melihat kelas yang siap diproses.
</div>
@elseif ($classSummaries->isEmpty())
<div class="rounded-2xl border border-slate-200 bg-slate-50 px-5 py-4 text-sm text-slate-500">
Belum ada kelas aktif dengan siswa aktif pada tahun ajaran asal yang dipilih.
</div>
@elseif (blank($filters['class_id']) || ! $selectedClass)
<div class="mb-6 rounded-2xl border border-indigo-200 bg-indigo-50 px-5 py-4 text-sm text-indigo-700">
Pilih <span class="font-semibold">kelas asal</span> untuk memproses siswa per rombel. Ini lebih aman daripada menampilkan semua siswa sekaligus.
</div>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
@foreach ($classSummaries as $summary)
<a
href="{{ route('student-promotions.index', ['from_academic_year_id' => $filters['from_academic_year_id'], 'to_academic_year_id' => $filters['to_academic_year_id'], 'class_id' => $summary->id]) }}"
class="group rounded-3xl border border-slate-200 bg-white p-5 transition hover:border-indigo-200 hover:bg-indigo-50/40"
>
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<div class="text-lg font-semibold text-slate-800">{{ $summary->name }}</div>
<div class="mt-1 text-sm text-slate-500">{{ $summary->department_name ?: '-' }} • Level {{ $summary->level }}</div>
</div>
<div class="rounded-2xl bg-slate-100 px-3 py-2 text-right">
<div class="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Siswa Aktif</div>
<div class="mt-1 text-2xl font-bold text-slate-800">{{ $summary->active_students_count }}</div>
</div>
</div>
<div class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
<div class="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Rekomendasi Default</div>
<div class="mt-1 font-semibold text-slate-800">{{ $summary->recommendation_label }}</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
<div class="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Target Umum</div>
<div class="mt-1 font-semibold text-slate-800">{{ $summary->recommendation_target_name ?: 'Perlu cek setelah kelas dipilih' }}</div>
</div>
</div>
<div class="mt-4 inline-flex items-center text-sm font-semibold text-indigo-700 transition group-hover:translate-x-0.5">
Proses kelas ini →
</div>
</a>
@endforeach
</div>
@elseif ($students->isEmpty())
<div class="text-sm text-slate-500">Tidak ada siswa aktif pada tahun ajaran asal yang dipilih.</div>
<div class="rounded-2xl border border-slate-200 bg-slate-50 px-5 py-4 text-sm text-slate-500">
Tidak ada siswa aktif pada kelas asal yang dipilih.
</div>
@else
@php
$promotionCount = $students->filter(fn ($student) => $student->promotion_recommendation['action'] === 'promote')->count();
$repeatCount = $students->filter(fn ($student) => $student->promotion_recommendation['action'] === 'repeat')->count();
$graduateCount = $students->filter(fn ($student) => $student->promotion_recommendation['action'] === 'graduate')->count();
$skipCount = $students->filter(fn ($student) => $student->promotion_recommendation['action'] === 'skip')->count();
@endphp
@if ($errors->any())
@@ -58,36 +116,69 @@
</div>
@endif
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="rounded-3xl border border-emerald-200 bg-emerald-50 px-5 py-4">
<div class="mb-6 rounded-3xl border border-slate-200 bg-slate-50 px-5 py-4">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kelas Sedang Diproses</div>
<div class="mt-2 text-2xl font-bold text-slate-800">{{ $selectedClass->name }}</div>
<div class="mt-1 text-sm text-slate-500">{{ $selectedClass->department?->name ?: '-' }} • {{ $selectedClass->active_students_count }} siswa aktif</div>
</div>
<a href="{{ route('student-promotions.index', ['from_academic_year_id' => $filters['from_academic_year_id'], 'to_academic_year_id' => $filters['to_academic_year_id']]) }}" class="inline-flex items-center rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-100">
Ganti Kelas
</a>
</div>
</div>
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-4">
<div class="rounded-3xl border border-emerald-200 bg-emerald-50 px-5 py-4" data-promotion-counter-card="promote">
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-emerald-600">Naik Kelas</div>
<div class="mt-2 text-3xl font-bold text-emerald-700">{{ $promotionCount }}</div>
<div class="mt-1 text-sm text-emerald-600">Siswa direkomendasikan naik ke level berikutnya.</div>
<div class="mt-2 text-3xl font-bold text-emerald-700" data-promotion-counter="promote">{{ $promotionCount }}</div>
<div class="mt-1 text-sm text-emerald-600">Siswa yang akan naik ke level berikutnya.</div>
</div>
<div class="rounded-3xl border border-amber-200 bg-amber-50 px-5 py-4">
<div class="rounded-3xl border border-amber-200 bg-amber-50 px-5 py-4" data-promotion-counter-card="repeat">
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-amber-600">Tinggal Kelas</div>
<div class="mt-2 text-3xl font-bold text-amber-700">{{ $repeatCount }}</div>
<div class="mt-1 text-sm text-amber-600">Siswa akan tetap di level yang sama pada tahun ajaran baru.</div>
<div class="mt-2 text-3xl font-bold text-amber-700" data-promotion-counter="repeat">{{ $repeatCount }}</div>
<div class="mt-1 text-sm text-amber-600">Siswa yang akan tetap di level yang sama.</div>
</div>
<div class="rounded-3xl border border-sky-200 bg-sky-50 px-5 py-4">
<div class="rounded-3xl border border-sky-200 bg-sky-50 px-5 py-4" data-promotion-counter-card="graduate">
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-sky-600">Lulus</div>
<div class="mt-2 text-3xl font-bold text-sky-700">{{ $graduateCount }}</div>
<div class="mt-1 text-sm text-sky-600">Siswa kelas akhir yang direkomendasikan lulus.</div>
<div class="mt-2 text-3xl font-bold text-sky-700" data-promotion-counter="graduate">{{ $graduateCount }}</div>
<div class="mt-1 text-sm text-sky-600">Siswa kelas akhir yang akan diluluskan.</div>
</div>
<div class="rounded-3xl border border-slate-200 bg-slate-50 px-5 py-4" data-promotion-counter-card="skip">
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Lewati</div>
<div class="mt-2 text-3xl font-bold text-slate-700" data-promotion-counter="skip">{{ $skipCount }}</div>
<div class="mt-1 text-sm text-slate-500">Siswa yang tidak ikut diproses pada batch ini.</div>
</div>
</div>
<div class="mb-6 rounded-3xl border border-indigo-200 bg-indigo-50 px-5 py-4 text-sm text-indigo-700" data-promotion-live-summary>
Ringkasan live: <span class="font-semibold">{{ $promotionCount }}</span> naik, <span class="font-semibold">{{ $repeatCount }}</span> tinggal kelas, <span class="font-semibold">{{ $graduateCount }}</span> lulus, dan <span class="font-semibold">{{ $skipCount }}</span> dilewati.
</div>
<form method="POST" action="{{ route('student-promotions.store') }}" class="space-y-6" data-student-promotion-form>
@csrf
<input type="hidden" name="from_academic_year_id" value="{{ $filters['from_academic_year_id'] }}">
<input type="hidden" name="to_academic_year_id" value="{{ $filters['to_academic_year_id'] }}">
<input type="hidden" name="class_id" value="{{ $filters['class_id'] }}">
<div class="flex flex-wrap items-center gap-3 rounded-2xl border border-slate-200 bg-slate-50 px-5 py-4">
<div class="text-sm font-medium text-slate-700">Aksi cepat:</div>
<div class="flex flex-wrap items-center gap-3 rounded-2xl border border-slate-200 bg-slate-50 px-5 py-4" data-promotion-toolbar data-class-level="{{ strtoupper((string) $selectedClass->level) }}">
<div class="text-sm font-medium text-slate-700">Aksi cepat untuk kelas ini:</div>
<button type="button" class="rounded-xl border border-emerald-200 px-4 py-2 text-xs font-semibold text-emerald-700 transition hover:bg-emerald-50" data-promotion-bulk-action="promote">Set semua → Naik Kelas</button>
<button type="button" class="rounded-xl border border-sky-200 px-4 py-2 text-xs font-semibold text-sky-700 transition hover:bg-sky-50" data-promotion-bulk-action="graduate">Set semua → Lulus</button>
<button type="button" class="rounded-xl border border-amber-200 px-4 py-2 text-xs font-semibold text-amber-700 transition hover:bg-amber-50" data-promotion-bulk-action="repeat">Set semua → Tinggal Kelas</button>
<button type="button" class="rounded-xl border border-slate-200 px-4 py-2 text-xs font-semibold text-slate-700 transition hover:bg-slate-100" data-promotion-bulk-action="skip">Set semua → Lewati</button>
</div>
@if (strtoupper((string) $selectedClass->level) === 'XII')
<div class="mt-4 rounded-2xl border border-sky-200 bg-sky-50 px-5 py-4 text-sm text-sky-700">
Kelas ini berada di level <span class="font-semibold">XII</span>. Default operasionalnya biasanya <span class="font-semibold">Lulus</span>, lalu admin hanya mengubah siswa pengecualian bila perlu.
</div>
@endif
<div class="hidden rounded-3xl border px-5 py-4 text-sm" data-promotion-capacity-summary></div>
<div class="hidden rounded-3xl border border-indigo-200 bg-indigo-50 px-5 py-4 text-sm text-indigo-700" data-promotion-final-preview></div>
<div class="overflow-x-auto rounded-3xl border border-slate-200">
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50">
@@ -127,6 +218,7 @@
'option_label' => $classRoom->option_label,
'capacity_label' => $classRoom->capacity_label,
'remaining_capacity' => $classRoom->remaining_capacity,
'warning_level' => is_null($classRoom->remaining_capacity) ? 'normal' : ($classRoom->remaining_capacity <= 0 ? 'full' : ($classRoom->remaining_capacity <= 3 ? 'low' : 'normal')),
])->values();
$repeatClassOptions = $repeatClasses->map(fn ($classRoom) => [
'id' => $classRoom->id,
@@ -134,6 +226,7 @@
'option_label' => $classRoom->option_label,
'capacity_label' => $classRoom->capacity_label,
'remaining_capacity' => $classRoom->remaining_capacity,
'warning_level' => is_null($classRoom->remaining_capacity) ? 'normal' : ($classRoom->remaining_capacity <= 0 ? 'full' : ($classRoom->remaining_capacity <= 3 ? 'low' : 'normal')),
])->values();
$badgeClass = match ($recommendation['action']) {
'promote' => 'bg-emerald-100 text-emerald-700',
@@ -178,7 +271,7 @@
<option value="graduate" @selected($oldAction === 'graduate')>Lulus</option>
<option value="skip" @selected($oldAction === 'skip')>Lewati</option>
</select>
<div class="mt-2 text-xs text-slate-500">Admin bisa ubah rekomendasi sebelum proses.</div>
<div class="mt-2 text-xs text-slate-500">Ubah hanya untuk siswa pengecualian.</div>
</td>
<td class="px-4 py-4">
<div class="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-500 promotion-graduate-placeholder {{ $oldAction === 'graduate' ? '' : 'hidden' }}">
@@ -204,10 +297,11 @@
<div class="mt-2 text-xs {{ $defaultClasses->isEmpty() ? 'text-amber-600' : 'text-slate-500' }} promotion-target-help">
{{ $defaultClasses->isEmpty() ? 'Belum ada kelas aktif yang cocok pada tahun ajaran target untuk aksi ini.' : 'Pilih kelas tujuan sesuai aksi. Naik kelas memakai level berikutnya, tinggal kelas memakai level yang sama.' }}
</div>
<div class="mt-2 hidden rounded-2xl border px-3 py-2 text-xs promotion-capacity-warning" data-promotion-capacity-warning></div>
@if ($defaultClasses->isNotEmpty())
<div class="mt-2 space-y-1 text-[11px] text-slate-500">
@foreach ($defaultClasses as $classRoom)
<div>• {{ $classRoom->name }} — {{ $classRoom->capacity_label }}</div>
<div class="{{ filled($classRoom->remaining_capacity) && $classRoom->remaining_capacity <= 0 ? 'text-rose-600 font-semibold' : (filled($classRoom->remaining_capacity) && $classRoom->remaining_capacity <= 3 ? 'text-amber-600 font-semibold' : '') }}">• {{ $classRoom->name }} — {{ $classRoom->capacity_label }}</div>
@endforeach
</div>
@endif
@@ -223,11 +317,11 @@
<div class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-slate-50 px-5 py-4">
<div class="space-y-1 text-sm text-slate-500">
<div>Total siswa siap diproses: <span class="font-semibold text-slate-800">{{ $students->count() }}</span></div>
<div class="text-xs text-slate-400">Tips: untuk siswa tinggal kelas, pastikan tahun ajaran target punya kelas dengan level yang sama dan sisa kuota masih cukup.</div>
<div>Total siswa siap diproses dari kelas ini: <span class="font-semibold text-slate-800">{{ $students->count() }}</span></div>
<div class="text-xs text-slate-400">Tips: pola kerja idealnya set semua sesuai rekomendasi kelas, lalu ubah hanya siswa yang menjadi pengecualian.</div>
</div>
<button type="submit" class="inline-flex items-center rounded-2xl bg-indigo-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-indigo-200 transition hover:bg-indigo-700">
Proses Promosi Siswa
<button type="submit" class="inline-flex items-center rounded-2xl bg-indigo-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-indigo-200 transition hover:bg-indigo-700" data-promotion-submit-button>
Proses Promosi Kelas Ini
</button>
</div>
</form>