feat(tatib): kartu pelanggaran PDF per siswa (tombol PDF di /students, dompdf A4: identitas, rekap poin, riwayat 2 tabel Terlambat&Membolos/Lainnya, ttd Ketua Tim); fix null-safe nama user di riwayat handling; kolom NISN+Kelas di SP Letters

This commit is contained in:
Novy San87
2026-08-19 14:43:01 +07:00
parent 21722479d0
commit 0360838b56
7 changed files with 362 additions and 7 deletions
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\Student;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\RedirectResponse;
use App\Models\SpLetter;
use App\Models\SpThreshold;
@@ -112,6 +113,51 @@ class StudentReportController extends Controller
));
}
/**
* Kartu pelanggaran siswa (PDF).
*/
public function pdf(Student $student)
{
abort_unless(auth()->user()->canViewStudent($student), 403);
$student->load([
'violations' => fn ($q) => $q->with(['violationType.category', 'recorder'])
->whereNull('deleted_at')
->latest('violation_date'),
'spLetters.spThreshold',
]);
$totalPoints = $student->total_points;
$violations = $student->violations;
$spThresholds = SpThreshold::where('is_active', true)->orderBy('min_points')->get();
$currentSpLevel = null;
$nextSpThreshold = null;
foreach ($spThresholds as $threshold) {
if ($totalPoints >= $threshold->min_points) {
$currentSpLevel = $threshold;
} elseif (! $nextSpThreshold) {
$nextSpThreshold = $threshold;
}
}
$school = [
'name' => \App\Models\Setting::getValue('school_name', 'SMKN 1 WONOREJO'),
'address' => \App\Models\Setting::getValue('school_address', ''),
];
$ketuaTim = \App\Models\User::where('role', 'ketua_tim')
->where('is_active', true)
->first();
$pdf = Pdf::loadView('students.pdf-card', compact(
'student', 'totalPoints', 'violations',
'currentSpLevel', 'nextSpThreshold', 'school', 'ketuaTim'
))->setPaper('a4');
return $pdf->stream('kartu-pelanggaran-'.str_replace(' ', '-', $student->full_name).'.pdf');
}
public function update(Request $request, Student $student): RedirectResponse
{
abort_unless(auth()->user()->canViewStudent($student), 403);
+6 -1
View File
@@ -26,7 +26,12 @@
@forelse($letters as $sp)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-mono text-gray-700">{{ $sp->letter_number }}</td>
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ $sp->student->full_name ?? '-' }}</td>
<td class="px-4 py-3">
<div class="text-sm font-medium text-gray-900">{{ $sp->student->full_name ?? '-' }}</div>
<div class="mt-0.5 text-xs text-gray-500">
NISN: {{ $sp->student->nisn ?? '-' }} · {{ $sp->student->class_name ?? '-' }}
</div>
</td>
<td class="px-4 py-3">
<span class="inline-flex px-2 py-1 text-xs font-medium rounded-full" style="color: {{ $sp->spThreshold->color ?? '#ef4444' }}; background-color: {{ $sp->spThreshold->color ?? '#ef4444' }}20;">
{{ $sp->spThreshold->name ?? '-' }}
@@ -0,0 +1,32 @@
<div class="sub-title">{{ $title }}</div>
@if($items->isEmpty())
<div class="kosong">Tidak ada catatan {{ strtolower($title) }}.</div>
@else
<table class="riwayat">
<thead>
<tr>
<th style="width:5%;">No</th>
<th style="width:15%;">Tanggal</th>
<th style="width:48%;">Jenis Pelanggaran</th>
<th style="width:8%;">Poin</th>
<th style="width:24%;">Sanksi</th>
</tr>
</thead>
<tbody>
@foreach($items as $i => $v)
<tr>
<td style="text-align:center;">{{ $i + 1 }}</td>
<td>{{ \Carbon\Carbon::parse($v->violation_date)->locale('id')->translatedFormat('l, d/m/Y') }}</td>
<td>
<b>{{ $v->violationType?->name ?? '—' }}</b>
@if($v->description)
<div style="color:#475569;font-size:9px;">{{ $v->description }}</div>
@endif
</td>
<td style="text-align:center;font-weight:bold;color:#dc2626;">+{{ $v->points }}</td>
<td>{{ $v->sanction ?? '—' }}</td>
</tr>
@endforeach
</tbody>
</table>
@endif
+7
View File
@@ -173,11 +173,18 @@
</td>
{{-- Aksi --}}
<td class="px-5 py-4 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<a href="{{ route('students.pdf', $s->id) }}" target="_blank"
class="inline-flex items-center gap-1.5 px-3.5 py-1.5 text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded-lg hover:bg-red-100 transition">
<i class="fa-solid fa-file-pdf"></i>
PDF
</a>
<a href="{{ route('students.show', $s->id) }}"
class="inline-flex items-center gap-1.5 px-3.5 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded-lg hover:bg-blue-100 transition">
<i class="fa-solid fa-eye"></i>
Detail
</a>
</div>
</td>
</tr>
@empty
+264
View File
@@ -0,0 +1,264 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<title>Kartu Pelanggaran {{ $student->full_name }}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'DejaVu Sans', sans-serif;
font-size: 11px;
color: #1e293b;
line-height: 1.5;
}
.kop {
text-align: center;
border-bottom: 3px solid #0f766e;
padding-bottom: 8px;
margin-bottom: 14px;
}
.kop .nama-sekolah {
font-size: 17px;
font-weight: bold;
letter-spacing: 1px;
color: #0f172a;
}
.kop .alamat {
font-size: 9px;
color: #475569;
}
.judul {
text-align: center;
background: #0f766e;
color: #ffffff;
font-size: 13px;
font-weight: bold;
padding: 7px 0;
border-radius: 5px;
letter-spacing: 2px;
margin-bottom: 14px;
}
.section-title {
font-size: 10px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
color: #0f766e;
border-left: 4px solid #0f766e;
padding-left: 7px;
margin: 14px 0 7px 0;
}
.sub-title {
font-size: 9.5px;
font-weight: bold;
color: #0f766e;
margin: 8px 0 4px 0;
}
table.identitas {
width: 100%;
border-collapse: collapse;
}
table.identitas td {
padding: 4px 6px;
font-size: 11px;
}
table.identitas td.label {
width: 28%;
color: #64748b;
font-weight: bold;
padding-left: 0;
}
table.identitas td.value {
font-weight: bold;
color: #0f172a;
width: 22%;
}
.kartu-poin {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
}
.kartu-poin td {
border: 1px solid #e2e8f0;
padding: 5px;
text-align: center;
width: 33.33%;
}
.kartu-poin .angka {
font-size: 15px;
font-weight: bold;
color: #0f766e;
}
.kartu-poin .kecil {
font-size: 8.5px;
text-transform: uppercase;
letter-spacing: 1px;
color: #64748b;
}
table.riwayat {
width: 100%;
border-collapse: collapse;
margin-top: 6px;
}
table.riwayat th {
background: #0f766e;
color: #fff;
font-size: 8.5px;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 4px 4px;
border: 1px solid #0f766e;
text-align: center;
vertical-align: middle;
}
table.riwayat td {
border: 1px solid #e2e8f0;
padding: 3px 5px;
font-size: 9px;
vertical-align: top;
}
table.riwayat tr:nth-child(even) td {
background: #f8fafc;
}
.badge-sp {
display: inline-block;
padding: 2px 9px;
border-radius: 8px;
font-size: 9.5px;
font-weight: bold;
color: #fff;
background: #dc2626;
}
.ttd {
margin-top: 14px;
font-size: 11px;
color: #1e293b;
text-align: left;
padding-left: 118mm;
}
.ttd-tanggal {
font-weight: bold;
}
.ttd-jabatan {
font-weight: bold;
}
.ttd-nama {
font-weight: bold;
text-decoration: underline;
font-size: 12px;
margin-top: 4px;
}
.ttd-nip {
font-size: 10px;
color: #475569;
margin-top: 2px;
}
.footer-note {
margin-top: 16px;
font-size: 8.5px;
color: #94a3b8;
border-top: 1px solid #e2e8f0;
padding-top: 6px;
text-align: center;
}
.kosong {
text-align: center;
color: #94a3b8;
font-style: italic;
padding: 14px 0;
}
</style>
</head>
<body>
<div style="padding: 8mm 14mm 15mm 14mm;">
<div class="kop">
<div class="nama-sekolah">{{ strtoupper($school['name']) }}</div>
<div class="alamat">{{ $school['address'] }}</div>
</div>
<div class="judul">KARTU PELANGGARAN SISWA</div>
<div class="section-title">Identitas Siswa</div>
<table class="identitas">
<tr>
<td class="label">Nama Lengkap</td>
<td class="value">: {{ strtoupper($student->full_name) }}</td>
<td class="label">NIS</td>
<td class="value">: {{ $student->student_number ?? '-' }}</td>
</tr>
<tr>
<td class="label">NISN</td>
<td class="value">: {{ $student->nisn ?? '-' }}</td>
<td class="label">Kelas</td>
<td class="value">: {{ $student->class_name ?? '-' }}</td>
</tr>
<tr>
<td class="label">Jurusan</td>
<td class="value">: {{ $student->department_name ?? $student->department_code ?? '-' }}</td>
<td class="label">Tahun Akademik</td>
<td class="value">: {{ $student->academic_year_name ?? '-' }}</td>
</tr>
</table>
<div class="section-title">Rekap Poin Pelanggaran</div>
<table class="kartu-poin">
<tr>
<td>
<div class="angka">{{ $totalPoints }}</div>
<div class="kecil">Total Poin</div>
</td>
<td>
<div class="angka">{{ $violations->count() }}x</div>
<div class="kecil">Jumlah Pelanggaran</div>
</td>
<td>
@if($currentSpLevel)
<span class="badge-sp">{{ $currentSpLevel->name }}</span>
<div class="kecil" style="margin-top:2px;">Level SP</div>
@else
<div class="angka" style="color:#16a34a;">Aman</div>
<div class="kecil">Belum Ada SP</div>
@endif
</td>
</tr>
</table>
<div class="section-title">Riwayat Pelanggaran</div>
@if($violations->isEmpty())
<div class="kosong">Siswa tidak memiliki catatan pelanggaran.</div>
@else
@php
$tlMembolos = $violations->filter(fn ($v) => str_contains(strtolower($v->violationType?->name ?? ''), 'terlambat') || str_contains(strtolower($v->violationType?->name ?? ''), 'membolos'))->values();
$lainnya = $violations->filter(fn ($v) => ! str_contains(strtolower($v->violationType?->name ?? ''), 'terlambat') && ! str_contains(strtolower($v->violationType?->name ?? ''), 'membolos'))->values();
@endphp
@if($tlMembolos->isNotEmpty())
@include('students._riwayat', ['title' => 'Terlambat & Membolos', 'items' => $tlMembolos])
@endif
@if($lainnya->isNotEmpty())
@include('students._riwayat', ['title' => 'Pelanggaran Lainnya', 'items' => $lainnya])
@endif
@endif
@if($nextSpThreshold)
<div style="margin-top:10px;font-size:9px;color:#475569;">
* Poin menuju SP berikutnya ({{ $nextSpThreshold->name }}) : {{ $nextSpThreshold->min_points - $totalPoints }} poin lagi.
</div>
@endif
<div class="ttd">
<div class="ttd-tanggal">Wonorejo, {{ \Carbon\Carbon::now()->locale('id')->translatedFormat('d F Y') }}</div>
<div>Mengetahui,</div>
<div class="ttd-jabatan">Ketua Tatib SMKN 1 Wonorejo</div>
<br><br>
<div class="ttd-nama">{{ strtoupper($ketuaTim?->name ?? '&nbsp;') }}</div>
<div class="ttd-nip">{{ $ketuaTim?->username ?? '' }}</div>
</div>
<div class="footer-note">
Dokumen ini dibuat otomatis oleh Sistem Tata Tertib (TATIB) SMKN 1 Wonorejo &middot; {{ now()->format('d/m/Y H:i') }}
</div>
</div>
</body>
</html>
+1 -1
View File
@@ -370,7 +370,7 @@
<span class="w-3.5 h-3.5 rounded-full bg-gray-100 flex items-center justify-center">
<span class="text-[6px] font-bold text-gray-500">{{ strtoupper(substr($p->user->name ?? '?', 0, 1)) }}</span>
</span>
{{ $p->user->name }}
{{ $p->user?->name ?? '—' }}
@if($p->role)
<span class="text-[9px] text-gray-400 bg-gray-50 px-1 rounded border border-gray-100">{{ $p->role }}</span>
@endif
+1
View File
@@ -78,6 +78,7 @@ Route::middleware('auth')->group(function () {
Route::middleware('permission:view-students')->group(function () {
Route::get('/students', [StudentReportController::class, 'index'])->name('students.index');
Route::get('/students/{student}', [StudentReportController::class, 'show'])->name('students.show');
Route::get('/students/{student}/pdf', [StudentReportController::class, 'pdf'])->name('students.pdf');
Route::put('/students/{student}', [StudentReportController::class, 'update'])->name('students.update');
});