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'],
|
||||
]);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Attendance extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'student_id', 'date', 'lesson_hour',
|
||||
'status', 'notes', 'recorded_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'date' => 'date',
|
||||
'lesson_hour' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function student(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Student::class);
|
||||
}
|
||||
|
||||
public function recorder(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'recorded_by');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('attendances', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('student_id')->constrained()->cascadeOnDelete();
|
||||
$table->date('date');
|
||||
$table->unsignedTinyInteger('lesson_hour')->comment('Jam ke- 1 s.d 10');
|
||||
$table->string('status', 20)->default('hadir')->comment('hadir|sakit|izin|alpha|terlambat');
|
||||
$table->text('notes')->nullable();
|
||||
$table->foreignId('recorded_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['student_id', 'date', 'lesson_hour'], 'attendance_unique');
|
||||
$table->index('date');
|
||||
$table->index('status');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('attendances');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('violation_types', function (Blueprint $table) {
|
||||
$table->boolean('is_system')->default(false)->after('is_active');
|
||||
});
|
||||
|
||||
// Mark existing alpha & terlambat types as system
|
||||
DB::table('violation_types')
|
||||
->whereIn('id', function ($q) {
|
||||
$q->select('id')
|
||||
->from('violation_types')
|
||||
->where('name', 'like', '%alpha%')
|
||||
->orWhere('slug', 'terlambat-datang-ke-sekolah-1');
|
||||
})
|
||||
->update(['is_system' => true]);
|
||||
|
||||
// Set clean slugs for system types
|
||||
DB::table('violation_types')
|
||||
->where('name', 'like', '%alpha%')
|
||||
->update(['slug' => 'alpha']);
|
||||
|
||||
DB::table('violation_types')
|
||||
->where('slug', 'terlambat-datang-ke-sekolah-1')
|
||||
->update(['slug' => 'terlambat']);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Restore slugs
|
||||
DB::table('violation_types')
|
||||
->where('slug', 'alpha')
|
||||
->update(['slug' => 'tidak-masuk-tanpa-keterangan-alpha']);
|
||||
|
||||
DB::table('violation_types')
|
||||
->where('slug', 'terlambat')
|
||||
->update(['slug' => 'terlambat-datang-ke-sekolah']);
|
||||
|
||||
Schema::table('violation_types', function (Blueprint $table) {
|
||||
$table->dropColumn('is_system');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\ViolationCategory;
|
||||
use App\Models\ViolationType;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class ViolationTypeDefaultsSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
// Cari kategori ringan (ID 1 biasanya)
|
||||
$ringan = ViolationCategory::where('slug', 'ringan')
|
||||
->orWhere('name', 'like', '%ringan%')
|
||||
->first();
|
||||
|
||||
if (!$ringan) {
|
||||
$this->command->warn('Kategori "Ringan" tidak ditemukan. Buat kategori Ringan dulu.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Alpha
|
||||
ViolationType::updateOrCreate(
|
||||
['slug' => 'alpha'],
|
||||
[
|
||||
'name' => 'Alpha - Tidak hadir tanpa keterangan',
|
||||
'category_id' => $ringan->id,
|
||||
'points' => 15,
|
||||
'default_sanction' => 'Peringatan lisan / teguran BK',
|
||||
'description' => 'Pelanggaran otomatis dari sistem presensi ketika siswa tidak hadir tanpa keterangan.',
|
||||
'is_active' => true,
|
||||
'is_system' => true,
|
||||
]
|
||||
);
|
||||
|
||||
// Terlambat
|
||||
ViolationType::updateOrCreate(
|
||||
['slug' => 'terlambat'],
|
||||
[
|
||||
'name' => 'Terlambat',
|
||||
'category_id' => $ringan->id,
|
||||
'points' => 2,
|
||||
'default_sanction' => 'Peringatan lisan',
|
||||
'description' => 'Pelanggaran otomatis dari sistem presensi ketika siswa terlambat.',
|
||||
'is_active' => true,
|
||||
'is_system' => true,
|
||||
]
|
||||
);
|
||||
|
||||
$this->command->info('System violation types (alpha & terlambat) seeded/updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Input Presensi')
|
||||
|
||||
@section('content')
|
||||
<div>
|
||||
{{-- Header --}}
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<nav class="flex items-center gap-1.5 text-sm text-gray-400 mb-1">
|
||||
<a href="{{ route('attendances.index') }}" class="hover:text-gray-600 transition">Presensi</a>
|
||||
<span class="text-gray-300">/</span>
|
||||
<span class="text-gray-700 font-medium">Input</span>
|
||||
</nav>
|
||||
<h1 class="text-2xl font-bold text-gray-900 tracking-tight">Input Presensi Per Kelas</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Pilih kelas, tanggal, dan isi kehadiran per jam pelajaran</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Pilih Kelas + Tanggal --}}
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 mb-6">
|
||||
<form method="GET" class="p-5">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1.5">Kelas <span class="text-red-500">*</span></label>
|
||||
<select name="class_name" required
|
||||
class="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
<option value="">Pilih kelas...</option>
|
||||
@foreach($classNames as $cn)
|
||||
<option value="{{ $cn }}" @selected($className == $cn)>{{ $cn }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1.5">Tanggal <span class="text-red-500">*</span></label>
|
||||
<input type="date" name="date" value="{{ $date }}"
|
||||
class="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button type="submit"
|
||||
class="w-full px-5 py-2.5 text-sm font-semibold text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition shadow-sm inline-flex items-center justify-center gap-2">
|
||||
<i class="fa-solid fa-arrow-right text-xs"></i>
|
||||
Lanjutkan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if($errors->any() && $students->count() > 0)
|
||||
<div class="bg-red-50 border border-red-200 rounded-2xl p-4 mb-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="fa-solid fa-circle-exclamation text-red-400 mt-0.5"></i>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-red-700">Gagal menyimpan presensi</p>
|
||||
<ul class="mt-1 text-xs text-red-600 list-disc list-inside">
|
||||
@foreach($errors->all() as $err)
|
||||
<li>{{ $err }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($students->count() > 0)
|
||||
<form action="{{ route('attendances.store') }}" method="POST" x-data="{ saved: false }">
|
||||
@csrf
|
||||
<input type="hidden" name="date" value="{{ $date }}">
|
||||
<input type="hidden" name="class_name" value="{{ $className }}">
|
||||
<input type="hidden" name="auto_violation" value="1">
|
||||
|
||||
{{-- Info bar --}}
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden mb-4">
|
||||
<div class="px-6 py-3 flex flex-wrap items-center justify-between gap-3 bg-gradient-to-br from-gray-50 to-gray-100/50 border-b border-gray-100">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fa-solid fa-users text-gray-400"></i>
|
||||
<span class="text-sm font-semibold text-gray-700">{{ $students->count() }} siswa</span>
|
||||
<span class="w-px h-4 bg-gray-200"></span>
|
||||
<span class="text-xs text-gray-500">{{ \Carbon\Carbon::parse($date)->translatedFormat('l, d F Y') }}</span>
|
||||
<span class="w-px h-4 bg-gray-200"></span>
|
||||
<span class="text-xs text-gray-500">Kelas {{ $className }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ route('attendances.recap', ['class_name' => $className]) }}"
|
||||
class="text-xs font-semibold text-violet-600 hover:text-violet-800 transition inline-flex items-center gap-1">
|
||||
<i class="fa-solid fa-chart-simple"></i> Rekap
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Grid table --}}
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-100">
|
||||
<thead>
|
||||
<tr class="bg-gray-50/80">
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider sticky left-0 bg-gray-50/80 z-10 min-w-[150px]">Siswa</th>
|
||||
<th class="px-1 py-3 text-center text-[9px] font-semibold text-gray-300 uppercase tracking-wider min-w-[80px]">Set</th>
|
||||
@foreach($lessonHours as $lh)
|
||||
<th class="px-2 py-3 text-center text-xs font-semibold text-gray-400 uppercase tracking-wider min-w-[70px]">
|
||||
Jam ke-{{ $lh }}
|
||||
</th>
|
||||
@endforeach
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
@foreach($students as $s)
|
||||
<tr class="hover:bg-gray-50/50 transition">
|
||||
{{-- Student name --}}
|
||||
<td class="px-4 py-2.5 whitespace-nowrap sticky left-0 bg-white z-10">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-[10px] font-bold text-gray-500">{{ strtoupper(substr($s->full_name, 0, 1)) }}</span>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold text-gray-900 truncate max-w-[130px]">{{ $s->full_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{{-- Per-row bulk buttons --}}
|
||||
<td class="px-1 py-2.5 text-center whitespace-nowrap">
|
||||
<div class="flex items-center gap-px">
|
||||
<button type="button" onclick="studentBulkSet('{{ $s->id }}', 'hadir')" title="Semua Hadir"
|
||||
class="w-5 h-5 rounded text-[9px] font-bold bg-emerald-100 text-emerald-700 border border-emerald-300 hover:bg-emerald-200 transition">H</button>
|
||||
<button type="button" onclick="studentBulkSet('{{ $s->id }}', 'sakit')" title="Semua Sakit"
|
||||
class="w-5 h-5 rounded text-[9px] font-bold bg-purple-100 text-purple-700 border border-purple-300 hover:bg-purple-200 transition">S</button>
|
||||
<button type="button" onclick="studentBulkSet('{{ $s->id }}', 'izin')" title="Semua Izin"
|
||||
class="w-5 h-5 rounded text-[9px] font-bold bg-blue-100 text-blue-700 border border-blue-300 hover:bg-blue-200 transition">I</button>
|
||||
<button type="button" onclick="studentBulkSet('{{ $s->id }}', 'alpha')" title="Semua Alpha"
|
||||
class="w-5 h-5 rounded text-[9px] font-bold bg-red-100 text-red-700 border border-red-300 hover:bg-red-200 transition">A</button>
|
||||
<button type="button" onclick="studentBulkSet('{{ $s->id }}', 'terlambat')" title="Semua Terlambat"
|
||||
class="w-5 h-5 rounded text-[9px] font-bold bg-yellow-100 text-yellow-700 border border-yellow-300 hover:bg-yellow-200 transition">T</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{{-- Lesson hours cells --}}
|
||||
@foreach($lessonHours as $lh)
|
||||
@php
|
||||
$key = $s->id . '-' . $lh;
|
||||
$currentStatus = isset($existing[$key]) ? $existing[$key]->status : 'hadir';
|
||||
$statusColors = [
|
||||
'hadir' => 'bg-emerald-100 border-emerald-300 text-emerald-700',
|
||||
'sakit' => 'bg-purple-100 border-purple-300 text-purple-700',
|
||||
'izin' => 'bg-blue-100 border-blue-300 text-blue-700',
|
||||
'alpha' => 'bg-red-100 border-red-300 text-red-700',
|
||||
'terlambat' => 'bg-yellow-100 border-yellow-300 text-yellow-700',
|
||||
];
|
||||
$statusLabels = [
|
||||
'hadir' => 'H',
|
||||
'sakit' => 'S',
|
||||
'izin' => 'I',
|
||||
'alpha' => 'A',
|
||||
'terlambat' => 'T',
|
||||
];
|
||||
$statusFull = [
|
||||
'hadir' => 'Hadir',
|
||||
'sakit' => 'Sakit',
|
||||
'izin' => 'Izin',
|
||||
'alpha' => 'Alpha',
|
||||
'terlambat' => 'Terlambat',
|
||||
];
|
||||
$nextStatus = [
|
||||
'hadir' => 'sakit',
|
||||
'sakit' => 'izin',
|
||||
'izin' => 'alpha',
|
||||
'alpha' => 'terlambat',
|
||||
'terlambat' => 'hadir',
|
||||
];
|
||||
@endphp
|
||||
<td class="px-2 py-2.5 text-center">
|
||||
<input type="hidden"
|
||||
name="attendances[{{ $s->id }}][{{ $lh }}][student_id]"
|
||||
value="{{ $s->id }}">
|
||||
<input type="hidden"
|
||||
name="attendances[{{ $s->id }}][{{ $lh }}][lesson_hour]"
|
||||
value="{{ $lh }}">
|
||||
<input type="hidden"
|
||||
name="attendances[{{ $s->id }}][{{ $lh }}][status]"
|
||||
id="status-{{ $s->id }}-{{ $lh }}"
|
||||
value="{{ $currentStatus }}">
|
||||
|
||||
<button type="button"
|
||||
onclick="rotateStatus({{ $s->id }}, {{ $lh }})"
|
||||
class="status-btn w-9 h-9 rounded-lg text-xs font-bold border-2 transition-all duration-150 shadow-sm hover:shadow-md hover:scale-105 {{ $statusColors[$currentStatus] }}"
|
||||
id="btn-{{ $s->id }}-{{ $lh }}"
|
||||
title="{{ $statusFull[$currentStatus] }}">
|
||||
{{ $statusLabels[$currentStatus] }}
|
||||
</button>
|
||||
</td>
|
||||
@endforeach
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Submit --}}
|
||||
<div class="mt-6 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 text-xs text-gray-500">
|
||||
<span class="inline-flex items-center gap-1.5"><span class="w-4 h-4 rounded bg-emerald-100 border border-emerald-300 text-[9px] font-bold text-emerald-700 flex items-center justify-center">H</span> Hadir</span>
|
||||
<span class="inline-flex items-center gap-1.5"><span class="w-4 h-4 rounded bg-purple-100 border border-purple-300 text-[9px] font-bold text-purple-700 flex items-center justify-center">S</span> Sakit</span>
|
||||
<span class="inline-flex items-center gap-1.5"><span class="w-4 h-4 rounded bg-blue-100 border border-blue-300 text-[9px] font-bold text-blue-700 flex items-center justify-center">I</span> Izin</span>
|
||||
<span class="inline-flex items-center gap-1.5"><span class="w-4 h-4 rounded bg-red-100 border border-red-300 text-[9px] font-bold text-red-700 flex items-center justify-center">A</span> Alpha</span>
|
||||
<span class="inline-flex items-center gap-1.5"><span class="w-4 h-4 rounded bg-yellow-100 border border-yellow-300 text-[9px] font-bold text-yellow-700 flex items-center justify-center">T</span> Terlambat</span>
|
||||
<span class="w-px h-4 bg-gray-200"></span>
|
||||
<span>Klik tombol untuk ganti status</span>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 text-sm font-semibold text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition shadow-sm inline-flex items-center justify-center gap-2">
|
||||
<i class="fa-solid fa-floppy-disk text-xs"></i>
|
||||
Simpan Presensi
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Info --}}
|
||||
<div class="mt-4 bg-gradient-to-br from-emerald-50 to-teal-50/50 border border-emerald-100 rounded-2xl p-4">
|
||||
<div class="flex items-start gap-3 text-xs text-emerald-700">
|
||||
<i class="fa-solid fa-circle-info mt-0.5"></i>
|
||||
<span>Siswa dengan status <strong>Alpha (A)</strong> minimal 1 jam akan otomatis dicatat sebagai pelanggaran.</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
const STATUS_CYCLE = ['hadir', 'sakit', 'izin', 'alpha', 'terlambat'];
|
||||
const STATUS_COLORS = {
|
||||
'hadir': 'bg-emerald-100 border-emerald-300 text-emerald-700',
|
||||
'sakit': 'bg-purple-100 border-purple-300 text-purple-700',
|
||||
'izin': 'bg-blue-100 border-blue-300 text-blue-700',
|
||||
'alpha': 'bg-red-100 border-red-300 text-red-700',
|
||||
'terlambat': 'bg-yellow-100 border-yellow-300 text-yellow-700',
|
||||
};
|
||||
const STATUS_LABELS = {
|
||||
'hadir': 'H', 'sakit': 'S', 'izin': 'I',
|
||||
'alpha': 'A', 'terlambat': 'T',
|
||||
};
|
||||
const STATUS_FULL = {
|
||||
'hadir': 'Hadir', 'sakit': 'Sakit', 'izin': 'Izin',
|
||||
'alpha': 'Alpha', 'terlambat': 'Terlambat',
|
||||
};
|
||||
|
||||
function studentBulkSet(studentId, status) {
|
||||
for (let lh = 1; lh <= 10; lh++) {
|
||||
const hidden = document.getElementById('status-' + studentId + '-' + lh);
|
||||
const btn = document.getElementById('btn-' + studentId + '-' + lh);
|
||||
if (hidden && btn) {
|
||||
hidden.value = status;
|
||||
btn.className = 'status-btn w-9 h-9 rounded-lg text-xs font-bold border-2 transition-all duration-150 shadow-sm hover:shadow-md hover:scale-105 ' + STATUS_COLORS[status];
|
||||
btn.textContent = STATUS_LABELS[status];
|
||||
btn.title = STATUS_FULL[status];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rotateStatus(studentId, lessonHour) {
|
||||
const hiddenInput = document.getElementById('status-' + studentId + '-' + lessonHour);
|
||||
const btn = document.getElementById('btn-' + studentId + '-' + lessonHour);
|
||||
const current = hiddenInput.value;
|
||||
const nextIdx = (STATUS_CYCLE.indexOf(current) + 1) % STATUS_CYCLE.length;
|
||||
const next = STATUS_CYCLE[nextIdx];
|
||||
|
||||
hiddenInput.value = next;
|
||||
btn.className = 'status-btn w-9 h-9 rounded-lg text-xs font-bold border-2 transition-all duration-150 shadow-sm hover:shadow-md hover:scale-105 ' + STATUS_COLORS[next];
|
||||
btn.textContent = STATUS_LABELS[next];
|
||||
btn.title = STATUS_FULL[next];
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@@ -0,0 +1,118 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Presensi Siswa')
|
||||
|
||||
@section('content')
|
||||
<div>
|
||||
{{-- Header --}}
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 tracking-tight">Presensi Siswa</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Pencatatan kehadiran siswa per jam pelajaran</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ route('attendances.recap') }}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold text-violet-600 bg-violet-50 border border-violet-200 rounded-xl hover:bg-violet-100 transition shadow-sm">
|
||||
<i class="fa-solid fa-chart-simple text-xs"></i>
|
||||
Rekap Bulanan
|
||||
</a>
|
||||
<a href="{{ route('attendances.create') }}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition shadow-sm">
|
||||
<i class="fa-solid fa-plus text-xs"></i>
|
||||
Input Presensi
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Quick Stats --}}
|
||||
@php
|
||||
$today = now()->toDateString();
|
||||
$todayAlpha = \App\Models\Attendance::where('date', $today)->where('status', 'alpha')->count();
|
||||
$todayTotal = \App\Models\Attendance::where('date', $today)->count();
|
||||
$monthTotal = \App\Models\Attendance::whereMonth('date', now()->month)->whereYear('date', now()->year)->count();
|
||||
$monthAlpha = \App\Models\Attendance::whereMonth('date', now()->month)->whereYear('date', now()->year)->where('status', 'alpha')->count();
|
||||
$recentDates = \App\Models\Attendance::select('date')
|
||||
->distinct()
|
||||
->orderByDesc('date')
|
||||
->take(10)
|
||||
->get();
|
||||
@endphp
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="relative overflow-hidden rounded-2xl bg-gradient-to-br from-emerald-500 to-emerald-600 p-5 shadow-sm">
|
||||
<div class="absolute right-0 top-0 w-20 h-20 opacity-10"><i class="fa-solid fa-clipboard-check text-white text-6xl"></i></div>
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs font-semibold text-white/70 uppercase tracking-wider">Presensi Hari Ini</p>
|
||||
<p class="text-3xl font-bold text-white mt-1">{{ $todayTotal }}</p>
|
||||
<p class="text-[10px] text-white/50 mt-0.5">data tercatat</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative overflow-hidden rounded-2xl bg-gradient-to-br from-red-500 to-red-600 p-5 shadow-sm">
|
||||
<div class="absolute right-0 top-0 w-20 h-20 opacity-10"><i class="fa-solid fa-xmark text-white text-6xl"></i></div>
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs font-semibold text-white/70 uppercase tracking-wider">Alpha Hari Ini</p>
|
||||
<p class="text-3xl font-bold text-white mt-1">{{ $todayAlpha }}</p>
|
||||
<p class="text-[10px] text-white/50 mt-0.5">tanpa keterangan</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative overflow-hidden rounded-2xl bg-gradient-to-br from-blue-500 to-blue-600 p-5 shadow-sm">
|
||||
<div class="absolute right-0 top-0 w-20 h-20 opacity-10"><i class="fa-solid fa-calendar text-white text-6xl"></i></div>
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs font-semibold text-white/70 uppercase tracking-wider">Bulan Ini</p>
|
||||
<p class="text-3xl font-bold text-white mt-1">{{ $monthTotal }}</p>
|
||||
<p class="text-[10px] text-white/50 mt-0.5">total presensi</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative overflow-hidden rounded-2xl bg-gradient-to-br from-violet-500 to-violet-600 p-5 shadow-sm">
|
||||
<div class="absolute right-0 top-0 w-20 h-20 opacity-10"><i class="fa-solid fa-chart-bar text-white text-6xl"></i></div>
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs font-semibold text-white/70 uppercase tracking-wider">Alpha Bulan Ini</p>
|
||||
<p class="text-3xl font-bold text-white mt-1">{{ $monthAlpha }}</p>
|
||||
<p class="text-[10px] text-white/50 mt-0.5">pelanggaran</p>
|
||||
</div>
|
||||
</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">
|
||||
<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>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900">Riwayat Presensi</h3>
|
||||
<p class="text-xs text-gray-400">10 hari terakhir</p>
|
||||
</div>
|
||||
</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>
|
||||
<i class="fa-solid fa-arrow-right text-gray-300 group-hover:text-blue-500 transition"></i>
|
||||
</a>
|
||||
@endforeach
|
||||
</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>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,132 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Rekap Presensi')
|
||||
|
||||
@section('content')
|
||||
<div>
|
||||
{{-- Header --}}
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<nav class="flex items-center gap-1.5 text-sm text-gray-400 mb-1">
|
||||
<a href="{{ route('attendances.index') }}" class="hover:text-gray-600 transition">Presensi</a>
|
||||
<span class="text-gray-300">/</span>
|
||||
<span class="text-gray-700 font-medium">Rekap Bulanan</span>
|
||||
</nav>
|
||||
<h1 class="text-2xl font-bold text-gray-900 tracking-tight">Rekap Presensi Bulanan</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Rekapitulasi kehadiran siswa per jam pelajaran</p>
|
||||
</div>
|
||||
<a href="{{ route('attendances.create') }}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition shadow-sm">
|
||||
<i class="fa-solid fa-plus text-xs"></i>
|
||||
Input Presensi
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- Filter --}}
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 mb-6">
|
||||
<form method="GET">
|
||||
<div class="p-5">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1.5">Kelas</label>
|
||||
<select name="class_name"
|
||||
class="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
<option value="">Semua Kelas</option>
|
||||
@foreach($classNames as $cn)
|
||||
<option value="{{ $cn }}" @selected($className == $cn)>{{ $cn }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1.5">Bulan</label>
|
||||
<select name="month"
|
||||
class="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
@foreach(range(1, 12) as $m)
|
||||
<option value="{{ $m }}" @selected($month == $m)>{{ \Carbon\Carbon::create()->month($m)->format('F') }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1.5">Tahun</label>
|
||||
<div class="flex gap-2">
|
||||
<select name="year"
|
||||
class="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
@foreach(range(now()->year, now()->year - 2, -1) as $y)
|
||||
<option value="{{ $y }}" @selected($year == $y)>{{ $y }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="submit"
|
||||
class="px-4 py-2.5 text-sm font-semibold text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition shadow-sm">
|
||||
<i class="fa-solid fa-filter"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Table --}}
|
||||
<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">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-500 to-violet-600 flex items-center justify-center shadow-sm">
|
||||
<i class="fa-solid fa-chart-simple text-white text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900">
|
||||
Rekap {{ \Carbon\Carbon::create()->month($month)->format('F') }} {{ $year }}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-400">{{ count($recap) }} siswa</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(count($recap) > 0)
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-100">
|
||||
<thead>
|
||||
<tr class="bg-gray-50/80">
|
||||
<th class="px-5 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Siswa</th>
|
||||
<th class="px-3 py-3 text-center text-xs font-semibold text-emerald-600 uppercase tracking-wider">✅ Hadir</th>
|
||||
<th class="px-3 py-3 text-center text-xs font-semibold text-purple-600 uppercase tracking-wider">🟣 Sakit</th>
|
||||
<th class="px-3 py-3 text-center text-xs font-semibold text-blue-600 uppercase tracking-wider">🔵 Izin</th>
|
||||
<th class="px-3 py-3 text-center text-xs font-semibold text-red-600 uppercase tracking-wider">❌ Alpha</th>
|
||||
<th class="px-3 py-3 text-center text-xs font-semibold text-yellow-600 uppercase tracking-wider">🟡 Terlambat</th>
|
||||
<th class="px-3 py-3 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
@foreach($recap as $r)
|
||||
<tr class="hover:bg-gray-50/50 transition">
|
||||
<td class="px-5 py-3 whitespace-nowrap">
|
||||
<p class="text-sm font-semibold text-gray-900">{{ $r->student->full_name }}</p>
|
||||
<p class="text-xs text-gray-400">{{ $r->student->class_name }}</p>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-center text-sm font-bold text-emerald-600">{{ $r->hadir }}</td>
|
||||
<td class="px-3 py-3 text-center text-sm font-bold text-purple-600">{{ $r->sakit }}</td>
|
||||
<td class="px-3 py-3 text-center text-sm font-bold text-blue-600">{{ $r->izin }}</td>
|
||||
<td class="px-3 py-3 text-center text-sm font-bold {{ $r->alpha > 0 ? 'text-red-600' : 'text-gray-400' }}">{{ $r->alpha }}</td>
|
||||
<td class="px-3 py-3 text-center text-sm font-bold {{ $r->terlambat > 0 ? 'text-yellow-600' : 'text-gray-400' }}">{{ $r->terlambat }}</td>
|
||||
<td class="px-3 py-3 text-center text-sm font-bold text-gray-700">{{ $r->total }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="px-5 py-3 border-t border-gray-100 bg-gray-50/50 text-xs text-gray-400 text-center">
|
||||
Menampilkan rekap {{ \Carbon\Carbon::create()->month($month)->format('F') }} {{ $year }}
|
||||
@if($className) • Kelas {{ $className }} @endif
|
||||
</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-chart-simple text-gray-300 text-xl"></i>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-500 mb-0.5">Belum Ada Data Presensi</p>
|
||||
<p class="text-xs text-gray-400">Belum ada catatan presensi untuk bulan ini</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -11,6 +11,7 @@ $faIcons = [
|
||||
'plus-circle' => 'fa-solid fa-circle-plus',
|
||||
'exclamation-triangle' => 'fa-solid fa-triangle-exclamation',
|
||||
'users' => 'fa-solid fa-users',
|
||||
'clipboard-check' => 'fa-solid fa-clipboard-check',
|
||||
'document-text' => 'fa-solid fa-file-lines',
|
||||
'tag' => 'fa-solid fa-tag',
|
||||
'list' => 'fa-solid fa-list',
|
||||
@@ -18,6 +19,7 @@ $faIcons = [
|
||||
'refresh' => 'fa-solid fa-rotate',
|
||||
'cog' => 'fa-solid fa-gear',
|
||||
'users-cog' => 'fa-solid fa-user-gear',
|
||||
'database' => 'fa-solid fa-database',
|
||||
];
|
||||
$faClass = $faIcons[$icon] ?? 'fa-solid fa-circle';
|
||||
@endphp
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
<x-nav-item href="{{ route('violations.create') }}" icon="plus-circle" :active="request()->routeIs('violations.create')">Input Pelanggaran</x-nav-item>
|
||||
<x-nav-item href="{{ route('violations.index') }}" icon="exclamation-triangle" :active="request()->routeIs('violations.index')">Data Pelanggaran</x-nav-item>
|
||||
<x-nav-item href="{{ route('students.index') }}" icon="users" :active="request()->routeIs('students.index*')">Data Siswa</x-nav-item>
|
||||
<x-nav-item href="{{ route('attendances.index') }}" icon="clipboard-check" :active="request()->routeIs('attendances*')">Presensi Siswa</x-nav-item>
|
||||
<x-nav-item href="{{ route('sp-letters.index') }}" icon="document-text" :active="request()->routeIs('sp-letters.*')">Surat Peringatan</x-nav-item>
|
||||
|
||||
<div class="pt-4 mt-4 border-t border-gray-200">
|
||||
@@ -58,6 +59,7 @@
|
||||
<x-nav-item href="{{ route('settings.thresholds') }}" icon="chart-bar" :active="request()->routeIs('settings.thresholds*')">Ambang SP</x-nav-item>
|
||||
<x-nav-item href="{{ route('settings.sync') }}" icon="refresh" :active="request()->routeIs('settings.sync*')">Sinkronisasi</x-nav-item>
|
||||
<x-nav-item href="{{ route('users.index') }}" icon="users-cog" :active="request()->routeIs('users.*')">Manajemen User</x-nav-item>
|
||||
<x-nav-item href="{{ route('settings.backup') }}" icon="database" :active="request()->routeIs('settings.backup*')">Backup Database</x-nav-item>
|
||||
<x-nav-item href="{{ route('settings.reset') }}" icon="refresh" :active="request()->routeIs('settings.reset*')">Reset Aplikasi</x-nav-item>
|
||||
<x-nav-item href="{{ route('settings.index') }}" icon="cog" :active="request()->routeIs('settings.index')">Pengaturan</x-nav-item>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Backup Database')
|
||||
|
||||
@section('content')
|
||||
<div>
|
||||
{{-- Header --}}
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<nav class="flex items-center gap-1.5 text-sm text-gray-400 mb-1">
|
||||
<a href="{{ route('settings.index') }}" class="hover:text-gray-600 transition">Pengaturan</a>
|
||||
<span class="text-gray-300">/</span>
|
||||
<span class="text-gray-700 font-medium">Backup Database</span>
|
||||
</nav>
|
||||
<h1 class="text-2xl font-bold text-gray-900 tracking-tight">Backup Database</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Kelola backup dan restore database aplikasi</p>
|
||||
</div>
|
||||
<form action="{{ route('settings.backup.create') }}" method="POST">
|
||||
@csrf
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold text-white bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl hover:from-blue-600 hover:to-blue-700 transition shadow-sm">
|
||||
<i class="fa-solid fa-database text-xs"></i>
|
||||
Backup Sekarang
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Stats Cards --}}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="relative overflow-hidden rounded-2xl bg-gradient-to-br from-blue-500 to-blue-700 p-5 shadow-sm">
|
||||
<div class="absolute right-0 top-0 w-20 h-20 opacity-10">
|
||||
<i class="fa-solid fa-layer-group text-white text-6xl"></i>
|
||||
</div>
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs font-semibold text-white/70 uppercase tracking-wider">Total Backup</p>
|
||||
<p class="text-3xl font-bold text-white mt-1">{{ $stats['total_backups'] }}</p>
|
||||
<p class="text-[10px] text-white/50 mt-0.5">file tersimpan</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative overflow-hidden rounded-2xl bg-gradient-to-br from-emerald-500 to-emerald-700 p-5 shadow-sm">
|
||||
<div class="absolute right-0 top-0 w-20 h-20 opacity-10">
|
||||
<i class="fa-solid fa-clock-rotate-left text-white text-6xl"></i>
|
||||
</div>
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs font-semibold text-white/70 uppercase tracking-wider">Backup Terakhir</p>
|
||||
<p class="text-base font-bold text-white mt-1">{{ $stats['latest_backup'] }}</p>
|
||||
<p class="text-[10px] text-white/50 mt-0.5">terbaru</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative overflow-hidden rounded-2xl bg-gradient-to-br from-violet-500 to-violet-700 p-5 shadow-sm">
|
||||
<div class="absolute right-0 top-0 w-20 h-20 opacity-10">
|
||||
<i class="fa-solid fa-hard-drive text-white text-6xl"></i>
|
||||
</div>
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs font-semibold text-white/70 uppercase tracking-wider">Total Ukuran</p>
|
||||
<p class="text-3xl font-bold text-white mt-1">{{ $stats['total_size'] }}</p>
|
||||
<p class="text-[10px] text-white/50 mt-0.5">semua backup</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative overflow-hidden rounded-2xl bg-gradient-to-br from-amber-500 to-orange-600 p-5 shadow-sm">
|
||||
<div class="absolute right-0 top-0 w-20 h-20 opacity-10">
|
||||
<i class="fa-solid fa-server text-white text-6xl"></i>
|
||||
</div>
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs font-semibold text-white/70 uppercase tracking-wider">Ukuran Database</p>
|
||||
<p class="text-3xl font-bold text-white mt-1">{{ $stats['db_size'] }}</p>
|
||||
<p class="text-[10px] text-white/50 mt-0.5">saat ini</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Daftar Backup --}}
|
||||
<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 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-blue-500 to-blue-600 flex items-center justify-center shadow-sm">
|
||||
<i class="fa-solid fa-clock-rotate-left text-white text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900">Riwayat Backup</h3>
|
||||
<p class="text-xs text-gray-400">Daftar file backup database</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($backups->count() > 0)
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-100">
|
||||
<thead>
|
||||
<tr class="bg-gray-50/80">
|
||||
<th class="px-5 py-3.5 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Nama File</th>
|
||||
<th class="px-5 py-3.5 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider hidden sm:table-cell">Tanggal</th>
|
||||
<th class="px-5 py-3.5 text-right text-xs font-semibold text-gray-400 uppercase tracking-wider">Ukuran</th>
|
||||
<th class="px-5 py-3.5 text-right text-xs font-semibold text-gray-400 uppercase tracking-wider">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
@foreach($backups as $backup)
|
||||
<tr class="hover:bg-gray-50/50 transition">
|
||||
<td class="px-5 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fa-solid fa-file-zipper text-blue-500 text-xs"></i>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate max-w-[240px] sm:max-w-[300px]">{{ $backup->filename }}</p>
|
||||
<p class="text-xs text-gray-400 sm:hidden">{{ $backup->date }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-5 py-4 whitespace-nowrap hidden sm:table-cell">
|
||||
<span class="text-sm text-gray-600">{{ $backup->date }}</span>
|
||||
</td>
|
||||
<td class="px-5 py-4 whitespace-nowrap text-right">
|
||||
<span class="inline-flex items-center px-2.5 py-1 text-xs font-medium bg-gray-100 text-gray-600 rounded-lg">
|
||||
<i class="fa-solid fa-hard-drive mr-1 text-gray-400"></i>
|
||||
{{ $backup->size }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-4 whitespace-nowrap text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<a href="{{ route('settings.backup.download', $backup->filename) }}"
|
||||
class="p-2 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition"
|
||||
title="Download">
|
||||
<i class="fa-solid fa-download"></i>
|
||||
</a>
|
||||
|
||||
@if($loop->first)
|
||||
<form action="{{ route('settings.backup.restore') }}" method="POST" id="restore-form-{{ $loop->index }}">
|
||||
@csrf
|
||||
<input type="hidden" name="filename" value="{{ $backup->filename }}">
|
||||
<button type="button"
|
||||
x-data
|
||||
x-on:click="if(await window.confirmSwal({title:'Restore Database?',text:'Semua data saat ini akan diganti dengan data backup {{ $backup->filename }}. Backup otomatis akan dibuat sebelum restore.',icon:'warning',confirmText:'Ya, Restore!',cancelText:'Batal'})) document.getElementById('restore-form-{{ $loop->index }}').submit()"
|
||||
class="p-2 rounded-lg text-gray-400 hover:text-amber-600 hover:bg-amber-50 transition" title="Restore">
|
||||
<i class="fa-solid fa-rotate-left"></i>
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
<form action="{{ route('settings.backup.destroy', $backup->filename) }}" method="POST" id="delete-form-{{ $loop->index }}">
|
||||
@csrf @method('DELETE')
|
||||
<button type="button"
|
||||
x-data
|
||||
x-on:click="if(await window.confirmSwal({title:'Hapus Backup?',text:'Yakin ingin menghapus {{ $backup->filename }}?',icon:'question',confirmText:'Ya, Hapus!',cancelText:'Batal'})) document.getElementById('delete-form-{{ $loop->index }}').submit()"
|
||||
class="p-2 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 transition" title="Hapus">
|
||||
<i class="fa-solid fa-trash-can"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="px-5 py-3 border-t border-gray-100 bg-gray-50/50 flex items-center justify-between text-xs text-gray-400">
|
||||
<span>{{ $backups->count() }} file backup</span>
|
||||
<span>Total {{ $stats['total_size'] }}</span>
|
||||
</div>
|
||||
@else
|
||||
<div class="px-5 py-12 text-center">
|
||||
<div class="w-16 h-16 rounded-2xl bg-gray-50 border border-gray-100 flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fa-solid fa-database text-gray-300 text-2xl"></i>
|
||||
</div>
|
||||
<h4 class="text-sm font-semibold text-gray-500 mb-1">Belum Ada Backup</h4>
|
||||
<p class="text-xs text-gray-400 mb-4">Backup database pertama Anda untuk mengamankan data</p>
|
||||
<form action="{{ route('settings.backup.create') }}" method="POST">
|
||||
@csrf
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold text-blue-600 bg-blue-50 border border-blue-200 rounded-xl hover:bg-blue-100 transition shadow-sm">
|
||||
<i class="fa-solid fa-database text-xs"></i>
|
||||
Buat Backup Sekarang
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Info Card --}}
|
||||
<div class="mt-6 bg-gradient-to-br from-blue-50 to-indigo-50/50 border border-blue-100 rounded-2xl p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fa-solid fa-circle-info text-blue-600 text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-bold text-blue-800 mb-1">Informasi Backup</h4>
|
||||
<ul class="text-xs text-blue-700 space-y-1">
|
||||
<li><i class="fa-solid fa-check mr-1.5 text-blue-500"></i>Backup disimpan di <code class="bg-blue-100 px-1 rounded">storage/app/backups/</code></li>
|
||||
<li><i class="fa-solid fa-check mr-1.5 text-blue-500"></i>Format file: <code class="bg-blue-100 px-1 rounded">.sql.gz</code> (SQL + kompresi Gzip)</li>
|
||||
<li><i class="fa-solid fa-check mr-1.5 text-blue-500"></i>Restore akan membuat backup otomatis sebelum menjalankan restore</li>
|
||||
<li><i class="fa-solid fa-check mr-1.5 text-blue-500"></i>Hanya admin yang dapat mengakses fitur ini</li>
|
||||
<li><i class="fa-solid fa-triangle-exclamation mr-1.5 text-amber-500"></i>Pastikan ada cukup ruang penyimpanan di server</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -82,5 +82,42 @@
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Backup Database Card --}}
|
||||
<div class="mt-6 bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<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-blue-500 to-blue-600 flex items-center justify-center shadow-sm">
|
||||
<i class="fa-solid fa-database text-white text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900">Backup Database</h3>
|
||||
<p class="text-xs text-gray-400">Kelola backup dan restore database</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ route('settings.backup') }}"
|
||||
class="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-blue-600 bg-blue-50 border border-blue-200 rounded-lg hover:bg-blue-100 transition flex-shrink-0">
|
||||
Kelola Backup
|
||||
<i class="fa-solid fa-arrow-right text-[10px]"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="p-5 text-xs text-gray-500 leading-relaxed">
|
||||
<p>Fitur backup database memungkinkan Anda untuk:</p>
|
||||
<ul class="mt-2 space-y-1.5">
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="fa-solid fa-check text-emerald-500 mt-0.5"></i>
|
||||
Membuat backup database kapan saja
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="fa-solid fa-check text-emerald-500 mt-0.5"></i>
|
||||
Mendownload file backup untuk penyimpanan eksternal
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="fa-solid fa-check text-emerald-500 mt-0.5"></i>
|
||||
Merestore database dari backup dengan safety backup otomatis
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -159,6 +159,26 @@
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{{-- Penanganan --}}
|
||||
<label class="reset-card relative flex items-start gap-3 p-4 rounded-xl border border-gray-200 cursor-pointer hover:border-gray-300 has-[:checked]:border-amber-300 has-[:checked]:bg-amber-50/50 transition-all select-none">
|
||||
<input type="checkbox" name="reset_items[]" value="handlings" class="reset-checkbox mt-0.5 w-4 h-4 rounded border-gray-300 text-amber-500 focus:ring-amber-400 focus:ring-offset-0 flex-shrink-0">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-gray-900">Riwayat Penanganan</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ $stats['handlings'] }} catatan penanganan</p>
|
||||
<p class="text-xs text-gray-400">Status pelanggaran akan dikembalikan ke "Belum Ditangani"</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{{-- Backup Files --}}
|
||||
<label class="reset-card relative flex items-start gap-3 p-4 rounded-xl border border-gray-200 cursor-pointer hover:border-gray-300 has-[:checked]:border-sky-300 has-[:checked]:bg-sky-50/50 transition-all select-none">
|
||||
<input type="checkbox" name="reset_items[]" value="backups" class="reset-checkbox mt-0.5 w-4 h-4 rounded border-gray-300 text-sky-500 focus:ring-sky-400 focus:ring-offset-0 flex-shrink-0">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-gray-900">File Backup</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ $stats['backups'] }} file backup database</p>
|
||||
<p class="text-xs text-red-500 font-medium mt-1">Tidak bisa dikembalikan!</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{{-- User Lain --}}
|
||||
<label class="reset-card relative flex items-start gap-3 p-4 rounded-xl border border-gray-200 cursor-pointer hover:border-gray-300 has-[:checked]:border-red-300 has-[:checked]:bg-red-50/50 transition-all select-none">
|
||||
<input type="checkbox" name="reset_items[]" value="users" class="reset-checkbox mt-0.5 w-4 h-4 rounded border-gray-300 text-red-500 focus:ring-red-400 focus:ring-offset-0 flex-shrink-0">
|
||||
|
||||
@@ -1,54 +1,224 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Ambang Surat Peringatan')
|
||||
@section('title', 'Ambang SP')
|
||||
|
||||
@section('content')
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Ambang Surat Peringatan</h1>
|
||||
<p class="text-sm text-gray-500">Atur batas poin untuk setiap tingkat Surat Peringatan</p>
|
||||
{{-- Header --}}
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<nav class="flex items-center gap-1.5 text-sm text-gray-400 mb-1">
|
||||
<a href="{{ route('settings.index') }}" class="hover:text-gray-600 transition">Pengaturan</a>
|
||||
<span class="text-gray-300">/</span>
|
||||
<span class="text-gray-700 font-medium">Ambang SP</span>
|
||||
</nav>
|
||||
<h1 class="text-2xl font-bold text-gray-900 tracking-tight">Ambang Surat Peringatan</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Atur batas poin untuk setiap tingkat Surat Peringatan</p>
|
||||
</div>
|
||||
<button type="button" x-data @click="window.dispatchEvent(new CustomEvent('open-modal', {detail: 'create-threshold'}))"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition shadow-sm">
|
||||
<i class="fa-solid fa-plus text-xs"></i>
|
||||
Tambah Ambang SP
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<form action="{{ route('settings.thresholds.update') }}" method="POST" class="p-6 space-y-6">
|
||||
{{-- Threshold Cards --}}
|
||||
@if($thresholds->count() > 0)
|
||||
<form action="{{ route('settings.thresholds.update') }}" method="POST" class="space-y-4 mb-6">
|
||||
@csrf @method('PUT')
|
||||
|
||||
@foreach($thresholds as $t)
|
||||
<input type="hidden" name="thresholds[{{ $loop->index }}][id]" value="{{ $t->id }}">
|
||||
<div class="p-4 rounded-lg border-2" style="border-color: {{ $t->color }}20; background-color: {{ $t->color }}08">
|
||||
<div class="flex items-center space-x-2 mb-3">
|
||||
<span class="w-3 h-3 rounded-full" style="background-color: {{ $t->color }}"></span>
|
||||
<h3 class="text-lg font-semibold" style="color: {{ $t->color }}">{{ $t->name }}</h3>
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden transition hover:shadow-md">
|
||||
<div class="flex items-stretch">
|
||||
{{-- Left color bar --}}
|
||||
<div class="w-[5px] flex-shrink-0" style="background-color: {{ $t->color }}"></div>
|
||||
|
||||
<div class="flex-1 px-5 py-4">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-start justify-between gap-4 mb-4">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="w-9 h-9 rounded-xl flex items-center justify-center shadow-sm flex-shrink-0"
|
||||
style="background-color: {{ $t->color }}20; color: {{ $t->color }}">
|
||||
<i class="fa-solid fa-file-lines text-sm"></i>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-sm font-bold text-gray-900" style="color: {{ $t->color }}">{{ $t->name }}</h3>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" name="thresholds[{{ $loop->index }}][is_active]" value="1"
|
||||
{{ $t->is_active ? 'checked' : '' }}
|
||||
class="sr-only peer">
|
||||
<div class="w-8 h-4 bg-gray-200 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-3 after:w-3 after:transition-all"
|
||||
style="{{ $t->is_active ? 'background-color: ' . $t->color : '' }}"></div>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-0.5">Min {{ $t->min_points }} poin
|
||||
@if($t->max_points) – Maks {{ $t->max_points }} poin @else + (tak terbatas) @endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-shrink-0">
|
||||
<form action="{{ route('settings.thresholds.destroy', $t->id) }}" method="POST" id="delete-threshold-{{ $t->id }}">
|
||||
@csrf @method('DELETE')
|
||||
<button type="button"
|
||||
x-data
|
||||
x-on:click="if(await window.confirmSwal({title:'Hapus {{ $t->name }}?',text:'Yakin ingin menghapus ambang SP ini?',icon:'question',confirmText:'Ya, Hapus!',cancelText:'Batal'})) document.getElementById('delete-threshold-{{ $t->id }}').submit()"
|
||||
class="w-7 h-7 rounded-lg flex items-center justify-center text-gray-300 hover:text-red-500 hover:bg-red-50 transition"
|
||||
title="Hapus">
|
||||
<i class="fa-solid fa-trash-can text-xs"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Fields --}}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Nama</label>
|
||||
<label class="block text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-1">Nama</label>
|
||||
<input type="text" name="thresholds[{{ $loop->index }}][name]" value="{{ $t->name }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
|
||||
class="w-full px-3.5 py-2 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Minimal Poin</label>
|
||||
<label class="block text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-1">Min Poin</label>
|
||||
<input type="number" name="thresholds[{{ $loop->index }}][min_points]" value="{{ $t->min_points }}" min="0"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
|
||||
class="w-full px-3.5 py-2 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Maksimal Poin (kosongkan = tak terbatas)</label>
|
||||
<input type="number" name="thresholds[{{ $loop->index }}][max_points]" value="{{ $t->max_points }}" min="0"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
|
||||
<label class="block text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-1">Maks Poin</label>
|
||||
<input type="number" name="thresholds[{{ $loop->index }}][max_points]" value="{{ $t->max_points }}" min="0" placeholder="Tak terbatas"
|
||||
class="w-full px-3.5 py-2 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Deskripsi</label>
|
||||
<label class="block text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-1">Deskripsi</label>
|
||||
<input type="text" name="thresholds[{{ $loop->index }}][default_description]" value="{{ $t->default_description }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
|
||||
class="w-full px-3.5 py-2 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
<button type="submit" class="w-full px-4 py-2.5 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition">
|
||||
Simpan Perubahan
|
||||
<button type="submit"
|
||||
class="w-full px-5 py-3 text-sm font-semibold text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition shadow-sm inline-flex items-center justify-center gap-2">
|
||||
<i class="fa-solid fa-floppy-disk text-xs"></i>
|
||||
Simpan Semua Perubahan
|
||||
</button>
|
||||
</form>
|
||||
@else
|
||||
{{-- Empty state --}}
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 py-12 text-center">
|
||||
<div class="w-16 h-16 rounded-2xl bg-gray-50 border border-gray-100 flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fa-solid fa-chart-bar text-gray-300 text-2xl"></i>
|
||||
</div>
|
||||
<h4 class="text-sm font-semibold text-gray-500 mb-1">Belum Ada Ambang SP</h4>
|
||||
<p class="text-xs text-gray-400 mb-4">Tambahkan ambang Surat Peringatan untuk memantau level pelanggaran siswa</p>
|
||||
<button type="button" x-data @click="window.dispatchEvent(new CustomEvent('open-modal', {detail: 'create-threshold'}))"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold text-blue-600 bg-blue-50 border border-blue-200 rounded-xl hover:bg-blue-100 transition shadow-sm">
|
||||
<i class="fa-solid fa-plus text-xs"></i>
|
||||
Tambah Ambang SP
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Create Modal --}}
|
||||
<div x-data="{ open: false }"
|
||||
x-on:open-modal.window="if($event.detail === 'create-threshold') open = true"
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-50 overflow-y-auto" style="display: none;">
|
||||
<div class="flex items-center justify-center min-h-screen px-4 py-4">
|
||||
<div class="fixed inset-0 bg-gray-900/40 backdrop-blur-sm"></div>
|
||||
<div class="relative bg-white rounded-2xl shadow-xl border border-gray-200 w-full max-w-lg mx-4">
|
||||
<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-blue-500 to-blue-600 flex items-center justify-center shadow-sm">
|
||||
<i class="fa-solid fa-plus text-white text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900">Tambah Ambang SP</h3>
|
||||
<p class="text-xs text-gray-400">Batas poin Surat Peringatan baru</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="open = false" class="w-8 h-8 rounded-lg flex items-center justify-center text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form action="{{ route('settings.thresholds.store') }}" method="POST" class="p-6">
|
||||
@csrf
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">Nama Threshold <span class="text-red-500">*</span></label>
|
||||
<input type="text" name="name" placeholder="SP 4, SP 5, dll" required
|
||||
class="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">Minimal Poin <span class="text-red-500">*</span></label>
|
||||
<input type="number" name="min_points" min="0" value="200" required
|
||||
class="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">Maksimal Poin</label>
|
||||
<input type="number" name="max_points" min="0" placeholder="Tak terbatas"
|
||||
class="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">Deskripsi</label>
|
||||
<input type="text" name="default_description" placeholder="Contoh: SP 4 — poin mencapai 200"
|
||||
class="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">Warna</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="color" name="color" value="#8b5cf6"
|
||||
class="w-10 h-10 rounded-lg border border-gray-200 cursor-pointer p-0.5">
|
||||
<span class="text-xs text-gray-400">Pilih warna identitas threshold</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-gray-100">
|
||||
<button type="button" @click="open = false"
|
||||
class="px-5 py-2.5 text-sm font-medium text-gray-600 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 transition">
|
||||
Batal
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-5 py-2.5 text-sm font-semibold text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition shadow-sm inline-flex items-center gap-2">
|
||||
<i class="fa-solid fa-floppy-disk text-xs"></i>
|
||||
Simpan
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Info Card --}}
|
||||
<div class="mt-6 bg-gradient-to-br from-blue-50 to-indigo-50/50 border border-blue-100 rounded-2xl p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fa-solid fa-circle-info text-blue-600 text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-bold text-blue-800 mb-1">Cara Kerja Ambang SP</h4>
|
||||
<ul class="text-xs text-blue-700 space-y-1">
|
||||
<li><i class="fa-solid fa-check mr-1.5 text-blue-500"></i>SP1 (≥50 poin) — peringatan pertama</li>
|
||||
<li><i class="fa-solid fa-check mr-1.5 text-blue-500"></i>SP2 (≥100 poin) — peringatan kedua</li>
|
||||
<li><i class="fa-solid fa-check mr-1.5 text-blue-500"></i>SP3 (≥150 poin) — peringatan ketiga</li>
|
||||
<li><i class="fa-solid fa-star mr-1.5 text-amber-500"></i>Setiap poin dihitung dari akumulasi semua pelanggaran siswa</li>
|
||||
<li><i class="fa-solid fa-star mr-1.5 text-amber-500"></i>Toggle aktif/nonaktif untuk mengontrol threshold yang digunakan</li>
|
||||
<li><i class="fa-solid fa-star mr-1.5 text-amber-500"></i>Threshold baru otomatis aktif saat dibuat</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -162,6 +162,7 @@
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
@if (!$type->is_system)
|
||||
<form action="{{ route('settings.violation-types.destroy', $type->id) }}" method="POST" class="inline"
|
||||
x-data x-on:submit.prevent="if(await window.confirmSwal({text:'Hapus jenis pelanggaran ini?'})) $el.submit()">
|
||||
@csrf @method('DELETE')
|
||||
@@ -170,6 +171,12 @@
|
||||
<i class="fa-solid fa-trash-can text-xs"></i>
|
||||
</button>
|
||||
</form>
|
||||
@else
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<i class="fa-solid fa-lock text-[10px]"></i>
|
||||
<span class="hidden sm:inline">Sistem</span>
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -29,6 +29,11 @@ Route::middleware('auth')->group(function () {
|
||||
// Notifications
|
||||
Route::get('/notifications/unread-count', [DashboardController::class, 'getUnreadCount'])->name('notifications.unread-count');
|
||||
Route::get('/calendar/data', [DashboardController::class, 'getCalendarData'])->name('calendar.data');
|
||||
Route::get('/attendances', [\App\Http\Controllers\AttendanceController::class, 'index'])->name('attendances.index');
|
||||
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('/notifications/recent', [DashboardController::class, 'getRecentNotifications'])->name('notifications.recent');
|
||||
Route::post('/notifications/{id}/read', [DashboardController::class, 'markNotificationRead'])->name('notifications.read');
|
||||
Route::post('/notifications/read-all', [DashboardController::class, 'markAllNotificationsRead'])->name('notifications.read-all');
|
||||
@@ -74,11 +79,18 @@ Route::middleware('auth')->group(function () {
|
||||
// Import
|
||||
Route::get('/settings/import/template', [ImportController::class, 'downloadTemplate'])->name('settings.import.template');
|
||||
Route::get('/settings/export/violation-types', [ImportController::class, 'exportViolationTypes'])->name('settings.export.violation-types');
|
||||
Route::get('/settings/backup', [\App\Http\Controllers\Admin\BackupController::class, 'index'])->name('settings.backup');
|
||||
Route::post('/settings/backup/create', [\App\Http\Controllers\Admin\BackupController::class, 'create'])->name('settings.backup.create');
|
||||
Route::get('/settings/backup/download/{filename}', [\App\Http\Controllers\Admin\BackupController::class, 'download'])->name('settings.backup.download');
|
||||
Route::delete('/settings/backup/{filename}', [\App\Http\Controllers\Admin\BackupController::class, 'destroy'])->name('settings.backup.destroy');
|
||||
Route::post('/settings/backup/restore', [\App\Http\Controllers\Admin\BackupController::class, 'restore'])->name('settings.backup.restore');
|
||||
Route::get('/export/violations', [\App\Http\Controllers\Admin\ViolationExportController::class, 'export'])->name('violations.export');
|
||||
Route::post('/settings/import/violation-types', [ImportController::class, 'importViolationTypes'])->name('settings.import.violation-types');
|
||||
|
||||
Route::get('/settings/thresholds', [MasterDataController::class, 'thresholds'])->name('settings.thresholds');
|
||||
Route::put('/settings/thresholds', [MasterDataController::class, 'updateThresholds'])->name('settings.thresholds.update');
|
||||
Route::post('/settings/thresholds', [MasterDataController::class, 'storeThreshold'])->name('settings.thresholds.store');
|
||||
Route::delete('/settings/thresholds/{threshold}', [MasterDataController::class, 'destroyThreshold'])->name('settings.thresholds.destroy');
|
||||
|
||||
// Sync
|
||||
Route::get('/settings/sync', [SyncController::class, 'index'])->name('settings.sync');
|
||||
|
||||
Reference in New Issue
Block a user