system-locked violation types (alpha & terlambat) + proporsional poin alpha per jam

- Migration: add is_system column to violation_types, mark alpha & terlambat
- DestroyType guard: system types cannot be deleted
- UpdateType guard: slug tetap walau nama diubah untuk system types
- UI: delete button hidden, badge lock untuk system types
- Seeder: ViolationTypeDefaultsSeeder untuk fresh deploy
- AttendanceController: lookup by slug (alpha/terlambat) instead of fuzzy match
- Alpha auto-violation: poin proporsional per jam (round((points/10)*count), min 1)
- Terlambat auto-violation: poin fix sesuai jenis (2 poin)
- Also handles both A (alpha) and T (terlambat) statuses for auto-violations
This commit is contained in:
Bro
2026-07-22 23:55:27 +07:00
parent 4bc3a84203
commit 31ef9c0b0b
19 changed files with 1674 additions and 51 deletions
@@ -0,0 +1,194 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class BackupController extends Controller
{
protected string $backupDir = 'backups';
public function index(): View
{
abort_unless(auth()->user()?->role === 'admin', 403, 'Hanya admin yang dapat mengakses fitur backup.');
$backups = collect();
if (Storage::disk('local')->exists($this->backupDir)) {
$files = Storage::disk('local')->files($this->backupDir);
$backups = collect($files)
->filter(fn($f) => str_ends_with($f, '.sql.gz'))
->map(function ($path) {
$fullPath = Storage::disk('local')->path($path);
return (object) [
'filename' => basename($path),
'path' => $path,
'size' => $this->formatBytes(filesize($fullPath)),
'size_bytes' => filesize($fullPath),
'date' => date('Y-m-d H:i:s', filemtime($fullPath)),
'timestamp' => filemtime($fullPath),
];
})
->sortByDesc('timestamp')
->values();
}
$dbSize = $this->getDatabaseSize();
$stats = [
'total_backups' => $backups->count(),
'latest_backup' => $backups->first()?->date ?? 'Belum pernah',
'total_size' => $this->formatBytes($backups->sum('size_bytes')),
'db_size' => $dbSize,
];
return view('settings.backup', compact('backups', 'stats'));
}
public function create(): RedirectResponse
{
abort_unless(auth()->user()?->role === 'admin', 403);
$db = config('database.connections.mysql');
$filename = 'aplikasi_ketertiban_' . now()->format('Y-m-d_His') . '.sql.gz';
$outputPath = Storage::disk('local')->path($this->backupDir . '/' . $filename);
if (!is_dir(dirname($outputPath))) {
mkdir(dirname($outputPath), 0755, true);
}
$command = sprintf(
'mysqldump --host=%s --port=%s --user=%s --password=%s %s --routines --events --single-transaction --quick | gzip > %s 2>&1',
escapeshellarg($db['host']),
escapeshellarg($db['port']),
escapeshellarg($db['username']),
escapeshellarg($db['password']),
escapeshellarg($db['database']),
escapeshellarg($outputPath)
);
$output = null;
$exitCode = null;
exec($command, $output, $exitCode);
if ($exitCode !== 0 || !file_exists($outputPath)) {
return redirect()->route('settings.backup')
->with('error', 'Backup gagal: ' . implode("\n", $output));
}
return redirect()->route('settings.backup')
->with('success', 'Backup berhasil: ' . $filename);
}
public function download(string $filename)
{
abort_unless(auth()->user()?->role === 'admin', 403);
$path = $this->backupDir . '/' . basename($filename);
if (!Storage::disk('local')->exists($path)) {
abort(404, 'File backup tidak ditemukan.');
}
return Storage::disk('local')->download($path, $filename);
}
public function destroy(string $filename)
{
abort_unless(auth()->user()?->role === 'admin', 403);
$path = $this->backupDir . '/' . basename($filename);
if (!Storage::disk('local')->exists($path)) {
return redirect()->route('settings.backup')
->with('error', 'File backup tidak ditemukan.');
}
Storage::disk('local')->delete($path);
return redirect()->route('settings.backup')
->with('success', 'Backup ' . $filename . ' berhasil dihapus.');
}
public function restore(Request $request): RedirectResponse
{
abort_unless(auth()->user()?->role === 'admin', 403);
$filename = basename($request->input('filename'));
$path = $this->backupDir . '/' . $filename;
if (!Storage::disk('local')->exists($path)) {
return redirect()->route('settings.backup')
->with('error', 'File backup tidak ditemukan.');
}
$fullPath = Storage::disk('local')->path($path);
$db = config('database.connections.mysql');
// Backup otomatis sebelum restore
$autoBackup = 'pre_restore_' . now()->format('Y-m-d_His') . '.sql.gz';
$autoPath = Storage::disk('local')->path($this->backupDir . '/' . $autoBackup);
exec(sprintf(
'mysqldump --host=%s --port=%s --user=%s --password=%s %s --routines --events --single-transaction --quick | gzip > %s',
escapeshellarg($db['host']),
escapeshellarg($db['port']),
escapeshellarg($db['username']),
escapeshellarg($db['password']),
escapeshellarg($db['database']),
escapeshellarg($autoPath)
));
// Restore
$command = sprintf(
'gunzip -c %s | mysql --host=%s --port=%s --user=%s --password=%s %s 2>&1',
escapeshellarg($fullPath),
escapeshellarg($db['host']),
escapeshellarg($db['port']),
escapeshellarg($db['username']),
escapeshellarg($db['password']),
escapeshellarg($db['database'])
);
$output = null;
$exitCode = null;
exec($command, $output, $exitCode);
if ($exitCode !== 0) {
return redirect()->route('settings.backup')
->with('error', 'Restore gagal: ' . implode("\n", $output));
}
return redirect()->route('settings.backup')
->with('success', 'Database berhasil direstore dari ' . $filename . '. Backup otomatis sebelum restore: ' . $autoBackup);
}
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
$i++;
}
return round($bytes, 2) . ' ' . $units[$i];
}
private function getDatabaseSize(): string
{
$db = config('database.connections.mysql');
$command = sprintf(
"mysql --host=%s --port=%s --user=%s --password=%s -e \"SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS size_mb FROM information_schema.tables WHERE table_schema = '%s'\" -sN 2>/dev/null",
escapeshellarg($db['host']),
escapeshellarg($db['port']),
escapeshellarg($db['username']),
escapeshellarg($db['password']),
escapeshellarg($db['database'])
);
$size = trim(shell_exec($command));
return $size ? $size . ' MB' : '—';
}
}
@@ -117,7 +117,10 @@ class MasterDataController extends Controller
'is_active' => ['boolean'],
]);
$validated['slug'] = Str::slug($validated['name']);
// System types: slug tetap, nama bisa diubah tapi slug gak ikut berubah
if (!$type->is_system) {
$validated['slug'] = Str::slug($validated['name']);
}
$validated['is_active'] = $request->boolean('is_active');
$type->update($validated);
@@ -127,6 +130,10 @@ class MasterDataController extends Controller
public function destroyType(ViolationType $type): RedirectResponse
{
if ($type->is_system) {
return back()->with('error', 'Jenis pelanggaran sistem tidak bisa dihapus. Nonaktifkan saja jika tidak diperlukan.');
}
if ($type->violations()->count() > 0) {
return back()->with('error', 'Jenis pelanggaran ini sudah tercatat pada pelanggaran siswa. Nonaktifkan saja.');
}
@@ -154,15 +161,53 @@ class MasterDataController extends Controller
'thresholds.*.default_description' => ['nullable', 'string', 'max:500'],
]);
$inputs = $request->input('thresholds', []);
$idx = 0;
foreach ($thresholds['thresholds'] as $data) {
SpThreshold::where('id', $data['id'])->update([
$update = [
'name' => $data['name'],
'min_points' => $data['min_points'],
'max_points' => $data['max_points'],
'default_description' => $data['default_description'],
]);
'is_active' => isset($inputs[$idx]['is_active']),
];
SpThreshold::where('id', $data['id'])->update($update);
$idx++;
}
return back()->with('success', 'Ambang batas SP berhasil diperbarui.');
}
public function storeThreshold(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'min_points' => ['required', 'integer', 'min:0'],
'max_points' => ['nullable', 'integer', 'min:0'],
'default_description' => ['nullable', 'string', 'max:500'],
'color' => ['nullable', 'string', 'max:20'],
]);
$slug = str($data['name'])->slug('-');
SpThreshold::create([
'name' => $data['name'],
'slug' => $slug,
'min_points' => $data['min_points'],
'max_points' => $data['max_points'],
'default_description' => $data['default_description'],
'color' => $data['color'] ?? '#6b7280',
'is_active' => true,
]);
return redirect()->route('settings.thresholds')->with('success', 'Threshold ' . $data['name'] . ' berhasil ditambahkan.');
}
public function destroyThreshold(SpThreshold $threshold): RedirectResponse
{
$name = $threshold->name;
$threshold->delete();
return redirect()->route('settings.thresholds')->with('success', 'Threshold ' . $name . ' berhasil dihapus.');
}
}
+45 -7
View File
@@ -20,6 +20,7 @@ class ResetController extends Controller
$stats = [
'violations' => DB::table('violations')->count(),
'evidences' => DB::table('violation_evidences')->count(),
'handlings' => DB::table('violation_handlings')->count(),
'sp_letters' => DB::table('sp_letters')->count(),
'notifications' => DB::table('notifications')->count(),
'students' => DB::table('students')->count(),
@@ -29,6 +30,7 @@ class ResetController extends Controller
'types' => DB::table('violation_types')->count(),
'thresholds' => DB::table('sp_thresholds')->count(),
'users_other' => DB::table('users')->where('role', '!=', 'admin')->count(),
'backups' => $this->countBackupFiles(),
];
return view('settings.reset', compact('stats'));
@@ -39,7 +41,7 @@ class ResetController extends Controller
$request->validate([
'confirm_password' => ['required', 'string'],
'reset_items' => ['required', 'array', 'min:1'],
'reset_items.*' => ['in:violations,evidences,sp_letters,notifications,students,classes,categories,types,thresholds,settings,users'],
'reset_items.*' => ['in:violations,evidences,handlings,sp_letters,notifications,students,classes,categories,types,thresholds,settings,users,backups'],
]);
if (!Hash::check($request->confirm_password, $request->user()->password)) {
@@ -53,12 +55,28 @@ class ResetController extends Controller
$cleared = [];
if (in_array('handlings', $items)) {
DB::table('handling_participants')->delete();
DB::table('violation_handlings')->delete();
// Juga reset handling_status di violations
if (!in_array('violations', $items)) {
DB::table('violations')->update([
'handling_status' => 'unhandled',
'handled_at' => null,
'handled_by' => null,
]);
}
$cleared[] = 'Riwayat penanganan';
}
if (in_array('evidences', $items)) {
DB::table('violation_evidences')->delete();
$cleared[] = 'Foto bukti';
}
if (in_array('violations', $items)) {
DB::table('handling_participants')->delete();
DB::table('violation_handlings')->delete();
DB::table('violations')->delete();
$cleared[] = 'Pelanggaran';
}
@@ -133,6 +151,18 @@ class ResetController extends Controller
}
}
// Hapus file backup jika backups dipilih
if (in_array('backups', $items)) {
$backupDir = storage_path('app/backups');
if (is_dir($backupDir)) {
$files = glob($backupDir . '/*.sql.gz');
foreach ($files as $f) {
unlink($f);
}
}
$cleared[] = 'File backup';
}
$msg = 'Reset berhasil! Data yang dibersihkan: ' . implode(', ', $cleared) . '.';
if ($admin) {
@@ -142,12 +172,20 @@ class ResetController extends Controller
return redirect()->route('settings.reset')->with('success', $msg);
}
private function countBackupFiles(): int
{
$backupDir = storage_path('app/backups');
if (!is_dir($backupDir)) return 0;
$files = glob($backupDir . '/*.sql.gz');
return count($files);
}
private function seedDefaultCategories(): void
{
$categories = [
['name' => 'Ringan', 'description' => 'Pelanggaran ringan (poin 1-15)', 'color' => '#eab308', 'min_points' => 1, 'max_points' => 15, 'is_active' => true, 'sort_order' => 1, 'created_at' => now(), 'updated_at' => now()],
['name' => 'Sedang', 'description' => 'Pelanggaran sedang (poin 15-50)', 'color' => '#f97316', 'min_points' => 15, 'max_points' => 50, 'is_active' => true, 'sort_order' => 2, 'created_at' => now(), 'updated_at' => now()],
['name' => 'Berat', 'description' => 'Pelanggaran berat (poin 50-100)', 'color' => '#ef4444', 'min_points' => 50, 'max_points' => 100, 'is_active' => true, 'sort_order' => 3, 'created_at' => now(), 'updated_at' => now()],
['name' => 'Ringan', 'slug' => 'ringan', 'description' => 'Pelanggaran ringan (poin 1-15)', 'color' => '#eab308', 'is_active' => true, 'sort_order' => 1, 'created_at' => now(), 'updated_at' => now()],
['name' => 'Sedang', 'slug' => 'sedang', 'description' => 'Pelanggaran sedang (poin 15-50)', 'color' => '#f97316', 'is_active' => true, 'sort_order' => 2, 'created_at' => now(), 'updated_at' => now()],
['name' => 'Berat', 'slug' => 'berat', 'description' => 'Pelanggaran berat (poin 50-100)', 'color' => '#ef4444', 'is_active' => true, 'sort_order' => 3, 'created_at' => now(), 'updated_at' => now()],
];
DB::table('violation_categories')->insert($categories);
}
@@ -155,9 +193,9 @@ class ResetController extends Controller
private function seedDefaultThresholds(): void
{
$thresholds = [
['name' => 'SP 1', 'slug' => 'sp-1', 'min_points' => 50, 'max_points' => 99, 'color' => '#eab308', 'is_active' => true, 'description' => 'Surat Peringatan 1 — poin mencapai 50', 'created_at' => now(), 'updated_at' => now()],
['name' => 'SP 2', 'slug' => 'sp-2', 'min_points' => 100, 'max_points' => 149, 'color' => '#f97316', 'is_active' => true, 'description' => 'Surat Peringatan 2 — poin mencapai 100', 'created_at' => now(), 'updated_at' => now()],
['name' => 'SP 3', 'slug' => 'sp-3', 'min_points' => 150, 'max_points' => null, 'color' => '#ef4444', 'is_active' => true, 'description' => 'Surat Peringatan 3 — poin mencapai 150', 'created_at' => now(), 'updated_at' => now()],
['name' => 'SP 1', 'slug' => 'sp-1', 'min_points' => 50, 'max_points' => 99, 'color' => '#eab308', 'is_active' => true, 'default_description' => 'Surat Peringatan 1 — poin mencapai 50', 'created_at' => now(), 'updated_at' => now()],
['name' => 'SP 2', 'slug' => 'sp-2', 'min_points' => 100, 'max_points' => 149, 'color' => '#f97316', 'is_active' => true, 'default_description' => 'Surat Peringatan 2 — poin mencapai 100', 'created_at' => now(), 'updated_at' => now()],
['name' => 'SP 3', 'slug' => 'sp-3', 'min_points' => 150, 'max_points' => null, 'color' => '#ef4444', 'is_active' => true, 'default_description' => 'Surat Peringatan 3 — poin mencapai 150', 'created_at' => now(), 'updated_at' => now()],
];
DB::table('sp_thresholds')->insert($thresholds);
}
@@ -0,0 +1,205 @@
<?php
namespace App\Http\Controllers;
use App\Models\Attendance;
use App\Models\Student;
use App\Models\ViolationType;
use App\Services\ViolationService;
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
{
return view('attendances.index');
}
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
{
$month = $request->input('month', now()->month);
$year = $request->input('year', now()->year);
$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'));
}
}