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:
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user