From 96d6e364a6a046e908509862de42c7017bcab426 Mon Sep 17 00:00:00 2001 From: novysan87 Date: Sat, 2 May 2026 13:09:26 +0700 Subject: [PATCH] Update graduation workflow, promotions, and admin module improvements --- app/Http/Controllers/DepartmentController.php | 395 +++++++++++++- .../GraduatedStudentController.php | 20 +- .../GraduationBulkUploadController.php | 26 +- .../Controllers/SchoolClassController.php | 503 +++++++++++++++++- .../StudentPromotionController.php | 32 +- .../Controllers/UserManagementController.php | 9 + ...ion_target_class_id_from_classes_table.php | 31 ++ resources/js/app.js | 237 +++++++++ resources/views/classes/index.blade.php | 110 ++++ resources/views/departments/index.blade.php | 103 ++++ .../graduated-students/bulk-upload.blade.php | 63 ++- .../views/graduated-students/index.blade.php | 6 +- .../views/student-promotions/index.blade.php | 4 +- resources/views/users/_form.blade.php | 15 +- resources/views/users/index.blade.php | 63 ++- routes/web.php | 8 + 16 files changed, 1593 insertions(+), 32 deletions(-) create mode 100644 database/migrations/2026_05_02_122100_drop_promotion_target_class_id_from_classes_table.php diff --git a/app/Http/Controllers/DepartmentController.php b/app/Http/Controllers/DepartmentController.php index e2e3011..7722a0b 100644 --- a/app/Http/Controllers/DepartmentController.php +++ b/app/Http/Controllers/DepartmentController.php @@ -6,9 +6,15 @@ use App\Models\Department; use App\Models\StudentAcademicHistory; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; use Illuminate\View\View; +use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Writer\Xlsx; use RealRashid\SweetAlert\Facades\Alert; class DepartmentController extends Controller @@ -27,7 +33,11 @@ class DepartmentController extends Controller ->paginate(10) ->withQueryString(); - return view('departments.index', compact('departments', 'search')); + return view('departments.index', [ + 'departments' => $departments, + 'search' => $search, + 'importPreview' => $request->session()->get('department_import_preview'), + ]); } public function create(): View @@ -81,6 +91,118 @@ class DepartmentController extends Controller return redirect()->route('departments.index')->with('success', 'Program keahlian berhasil diperbarui.'); } + public function previewImport(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'import_file' => ['required', 'file', 'mimes:xlsx,csv,txt'], + ], [ + 'import_file.mimes' => 'File import harus berformat Excel (.xlsx) atau CSV.', + ]); + + $rows = $this->parseImportFile($validated['import_file']->getRealPath(), $validated['import_file']->getClientOriginalName()); + + if (count($rows) === 0) { + throw ValidationException::withMessages([ + 'import_file' => 'File tidak berisi data program keahlian yang bisa diproses.', + ]); + } + + $preview = $this->buildImportPreview($rows); + + if ($preview['summary']['valid_rows'] === 0) { + throw ValidationException::withMessages([ + 'import_file' => array_merge(['Tidak ada baris valid yang bisa dipreview.'], array_slice($preview['errors'], 0, 10)), + ]); + } + + $request->session()->put('department_import_preview', $preview); + + return redirect()->route('departments.index'); + } + + public function import(Request $request): RedirectResponse + { + $request->validate([ + 'preview_token' => ['required', 'string'], + ]); + + $preview = $request->session()->get('department_import_preview'); + + if (! $preview || ($preview['token'] ?? null) !== $request->string('preview_token')->value()) { + throw ValidationException::withMessages([ + 'preview_token' => 'Preview import sudah tidak valid. Silakan upload ulang file import.', + ]); + } + + $inserted = 0; + $updated = 0; + + DB::transaction(function () use ($preview, &$inserted, &$updated) { + foreach ($preview['valid_rows'] as $row) { + $payload = Arr::only($row['normalized'], ['code', 'name', 'description', 'is_active']); + $department = Department::query()->where('code', $payload['code'])->first(); + + if (! $department) { + $department = Department::query()->where('name', $payload['name'])->first(); + } + + if ($department) { + $department->update($payload); + $updated++; + continue; + } + + Department::create($payload); + $inserted++; + } + }); + + $request->session()->forget('department_import_preview'); + + Alert::success('Berhasil', "Import program keahlian selesai. {$inserted} data baru ditambahkan, {$updated} data diperbarui."); + + return redirect()->route('departments.index')->with('success', "Import program keahlian selesai. {$inserted} data baru ditambahkan, {$updated} data diperbarui."); + } + + public function downloadImportTemplate() + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('Import Program Keahlian'); + + $headers = ['Kode', 'Nama Program Keahlian', 'Deskripsi', 'Status']; + $sheet->fromArray($headers, null, 'A1'); + $sheet->fromArray(['TKJ', 'Teknik Komputer dan Jaringan', 'Jurusan jaringan dan komputer', 'Aktif'], null, 'A2'); + + foreach (range(1, count($headers)) as $columnIndex) { + $sheet->getColumnDimensionByColumn($columnIndex)->setAutoSize(true); + } + + $guideSheet = $spreadsheet->createSheet(); + $guideSheet->setTitle('Panduan'); + $guideSheet->fromArray([ + ['Kolom', 'Keterangan'], + ['Kode', 'Wajib, unik. Akan disimpan dalam huruf besar.'], + ['Nama Program Keahlian', 'Wajib diisi.'], + ['Deskripsi', 'Opsional.'], + ['Status', 'Isi Aktif/Nonaktif, Yes/No, 1/0, atau Ya/Tidak. Default aktif jika kosong.'], + ], null, 'A1'); + + foreach (range('A', 'B') as $column) { + $guideSheet->getColumnDimension($column)->setAutoSize(true); + } + + $writer = new Xlsx($spreadsheet); + ob_start(); + $writer->save('php://output'); + $content = ob_get_clean(); + + return response($content, 200, [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition' => 'attachment; filename="template-import-program-keahlian.xlsx"', + ]); + } + public function destroy(Department $department): RedirectResponse { $department->loadCount('students'); @@ -114,4 +236,275 @@ class DepartmentController extends Controller return redirect()->route('departments.index')->with('success', $message); } + + protected function buildImportPreview(array $rows): array + { + $previewRows = []; + $errors = []; + $validRows = []; + + foreach ($rows as $rowNumber => $row) { + if ($this->isImportRowEmpty($row)) { + continue; + } + + $normalized = $this->normalizeImportRow($row); + $validator = Validator::make($normalized, [ + 'code' => ['required', 'string', 'max:20'], + 'name' => ['required', 'string', 'max:100'], + 'description' => ['nullable', 'string'], + 'is_active' => ['required', 'boolean'], + ], [], [ + 'code' => 'kode', + 'name' => 'nama program keahlian', + 'description' => 'deskripsi', + 'is_active' => 'status', + ]); + + $errorMessages = []; + $action = 'insert'; + + if ($validator->fails()) { + $errorMessages = $validator->errors()->all(); + $errors[] = 'Baris ' . $rowNumber . ': ' . implode(' ', $errorMessages); + } else { + $payload = $validator->validated(); + $lookup = Department::query()->where('code', $payload['code'])->first(); + + if (! $lookup) { + $lookup = Department::query()->where('name', $payload['name'])->first(); + } + + if ($lookup) { + $action = 'update'; + } + + $validRows[] = [ + 'row_number' => $rowNumber, + 'normalized' => $payload, + 'action' => $action, + ]; + } + + $previewRows[] = [ + 'row_number' => $rowNumber, + 'data' => $normalized, + 'errors' => $errorMessages, + 'is_valid' => empty($errorMessages), + 'action' => $action, + ]; + } + + return [ + 'token' => Str::uuid()->toString(), + 'rows' => $previewRows, + 'valid_rows' => $validRows, + 'errors' => $errors, + 'summary' => [ + 'total_rows' => count($previewRows), + 'valid_rows' => count($validRows), + 'invalid_rows' => count(array_filter($previewRows, fn ($row) => ! $row['is_valid'])), + 'insert_rows' => count(array_filter($previewRows, fn ($row) => $row['is_valid'] && $row['action'] === 'insert')), + 'update_rows' => count(array_filter($previewRows, fn ($row) => $row['is_valid'] && $row['action'] === 'update')), + ], + ]; + } + + protected function normalizeImportRow(array $row): array + { + $normalized = []; + + foreach ($row as $key => $value) { + $normalizedKey = Str::of((string) $key) + ->trim() + ->lower() + ->replaceMatches('/[^a-z0-9]+/u', '_') + ->trim('_') + ->value(); + + $normalized[$normalizedKey] = is_string($value) ? trim($value) : $value; + } + + return [ + 'code' => strtoupper((string) ($this->nullIfBlank($normalized['code'] ?? ($normalized['kode'] ?? null)) ?? '')), + 'name' => $this->nullIfBlank($normalized['name'] ?? ($normalized['nama_program_keahlian'] ?? null)), + 'description' => $this->nullIfBlank($normalized['description'] ?? ($normalized['deskripsi'] ?? null)), + 'is_active' => $this->normalizeImportBoolean($normalized['is_active'] ?? ($normalized['status'] ?? null)), + ]; + } + + protected function parseImportFile(string $path, string $originalName): array + { + $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); + + return match ($extension) { + 'xlsx' => $this->parseXlsxRows($path), + default => $this->parseCsvRows($path), + }; + } + + protected function parseCsvRows(string $path): array + { + $handle = fopen($path, 'r'); + + if (! $handle) { + return []; + } + + $rows = []; + $headers = null; + $rowNumber = 1; + + while (($data = fgetcsv($handle, 0, ',')) !== false) { + $rowNumber++; + + if ($headers === null) { + $headers = array_map(fn ($header) => trim((string) $header), $data); + continue; + } + + if (count(array_filter($data, fn ($value) => trim((string) $value) !== '')) === 0) { + continue; + } + + $rows[$rowNumber] = array_combine($headers, array_pad($data, count($headers), null)); + } + + fclose($handle); + + return $rows; + } + + protected function parseXlsxRows(string $path): array + { + $zip = new \ZipArchive(); + + if ($zip->open($path) !== true) { + throw ValidationException::withMessages([ + 'import_file' => 'File Excel tidak bisa dibuka.', + ]); + } + + $sharedStrings = []; + $sharedStringsXml = $zip->getFromName('xl/sharedStrings.xml'); + if ($sharedStringsXml) { + $xml = simplexml_load_string($sharedStringsXml); + if ($xml && isset($xml->si)) { + foreach ($xml->si as $item) { + $text = ''; + if (isset($item->t)) { + $text = (string) $item->t; + } elseif (isset($item->r)) { + foreach ($item->r as $run) { + $text .= (string) $run->t; + } + } + $sharedStrings[] = $text; + } + } + } + + $sheetXml = $zip->getFromName('xl/worksheets/sheet1.xml'); + $zip->close(); + + if (! $sheetXml) { + throw ValidationException::withMessages([ + 'import_file' => 'Worksheet pertama pada file Excel tidak ditemukan.', + ]); + } + + $xml = simplexml_load_string($sheetXml); + $xml->registerXPathNamespace('main', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'); + $rows = []; + $headers = null; + + foreach ($xml->xpath('//main:sheetData/main:row') as $rowIndex => $row) { + $values = []; + + foreach ($row->c as $cell) { + $reference = (string) $cell['r']; + $column = preg_replace('/\d+/', '', $reference); + $index = $this->columnLetterToIndex($column); + $type = (string) $cell['t']; + $value = ''; + + if ($type === 'inlineStr' && isset($cell->is->t)) { + $value = (string) $cell->is->t; + } elseif ($type === 's') { + $sharedIndex = (int) ($cell->v ?? 0); + $value = $sharedStrings[$sharedIndex] ?? ''; + } else { + $value = (string) ($cell->v ?? ''); + } + + $values[$index] = $value; + } + + if (empty($values)) { + continue; + } + + ksort($values); + $maxIndex = max(array_keys($values)); + $ordered = []; + + for ($index = 0; $index <= $maxIndex; $index++) { + $ordered[$index] = $values[$index] ?? null; + } + + if ($headers === null) { + $headers = array_map(fn ($header) => trim((string) $header), $ordered); + continue; + } + + $rows[$rowIndex + 1] = array_combine($headers, array_pad($ordered, count($headers), null)); + } + + return $rows; + } + + protected function columnLetterToIndex(string $letters): int + { + $letters = strtoupper($letters); + $index = 0; + + for ($i = 0; $i < strlen($letters); $i++) { + $index = ($index * 26) + (ord($letters[$i]) - 64); + } + + return $index - 1; + } + + protected function isImportRowEmpty(array $row): bool + { + return count(array_filter($row, fn ($value) => ! is_null($value) && $value !== '')) === 0; + } + + protected function nullIfBlank(mixed $value): mixed + { + if (is_string($value) && trim($value) === '') { + return null; + } + + return $value; + } + + protected function normalizeImportBoolean(mixed $value): bool + { + if ($value === null || $value === '') { + return true; + } + + if (is_bool($value)) { + return $value; + } + + $normalized = strtolower(trim((string) $value)); + + return match ($normalized) { + '1', 'true', 'ya', 'y', 'yes', 'aktif' => true, + '0', 'false', 'tidak', 't', 'no', 'nonaktif', 'inactive' => false, + default => true, + }; + } } diff --git a/app/Http/Controllers/GraduatedStudentController.php b/app/Http/Controllers/GraduatedStudentController.php index 316f708..3341c8f 100644 --- a/app/Http/Controllers/GraduatedStudentController.php +++ b/app/Http/Controllers/GraduatedStudentController.php @@ -110,7 +110,25 @@ class GraduatedStudentController extends Controller ], 'academicYears' => AcademicYear::orderBy('name')->pluck('name', 'id'), 'departments' => Department::orderBy('name')->pluck('name', 'id'), - 'classes' => SchoolClass::orderBy('name')->pluck('name', 'id'), + 'classes' => SchoolClass::query() + ->when($academicYearId, function ($query) use ($academicYearId, $classId) { + $query->where('academic_year_id', $academicYearId) + ->where(function ($innerQuery) use ($classId) { + $innerQuery->where('level', 'XII'); + + if ($classId) { + $innerQuery->orWhere('id', $classId); + } + }); + }, function ($query) use ($classId) { + if ($classId) { + $query->whereKey($classId); + } else { + $query->whereRaw('1 = 0'); + } + }) + ->orderBy('name') + ->pluck('name', 'id'), 'documentStatusOptions' => [ 'all' => 'Semua Dokumen', 'complete' => 'Dokumen Lengkap', diff --git a/app/Http/Controllers/GraduationBulkUploadController.php b/app/Http/Controllers/GraduationBulkUploadController.php index 6eeff86..94ef714 100644 --- a/app/Http/Controllers/GraduationBulkUploadController.php +++ b/app/Http/Controllers/GraduationBulkUploadController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\StudentGraduation; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -32,6 +33,27 @@ class GraduationBulkUploadController extends Controller } public function preview(Request $request): RedirectResponse + { + $preview = $this->buildPreviewPayload($request); + + return $preview; + } + + public function previewAjax(Request $request): JsonResponse + { + $preview = $this->buildPreviewPayload($request); + + $request->session()->put('graduation_bulk_upload_preview', $preview); + + return response()->json([ + 'success' => true, + 'redirect_url' => route('graduated-students.bulk-upload.index'), + 'summary' => $preview['summary'], + 'message' => 'Preview bulk upload berhasil disiapkan.', + ]); + } + + protected function buildPreviewPayload(Request $request): array { $validated = $request->validate([ 'document_type' => ['required', 'in:skl,ijazah,transcript,ukk-certificate,lsp-certificate'], @@ -115,9 +137,7 @@ class GraduationBulkUploadController extends Controller 'summary' => $summary, ]; - $request->session()->put('graduation_bulk_upload_preview', $preview); - - return redirect()->route('graduated-students.bulk-upload.index'); + return $preview; } public function store(Request $request): RedirectResponse diff --git a/app/Http/Controllers/SchoolClassController.php b/app/Http/Controllers/SchoolClassController.php index 1b82c57..c22f7d2 100644 --- a/app/Http/Controllers/SchoolClassController.php +++ b/app/Http/Controllers/SchoolClassController.php @@ -7,9 +7,15 @@ use App\Models\Department; use App\Models\SchoolClass; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; use Illuminate\View\View; +use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Writer\Xlsx; use RealRashid\SweetAlert\Facades\Alert; class SchoolClassController extends Controller @@ -33,6 +39,8 @@ class SchoolClassController extends Controller 'classes' => $classes, 'search' => $search, 'academicYears' => AcademicYear::orderByDesc('start_year')->orderByDesc('end_year')->get(), + 'importPreview' => $request->session()->get('class_import_preview'), + 'importReferenceData' => $this->importReferenceData(), ]); } @@ -131,6 +139,146 @@ class SchoolClassController extends Controller return redirect()->route('classes.index')->with('success', "Generate kelas selesai. {$created} kelas dibuat, {$skipped} dilewati."); } + public function previewImport(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'import_file' => ['required', 'file', 'mimes:xlsx,csv,txt'], + ], [ + 'import_file.mimes' => 'File import harus berformat Excel (.xlsx) atau CSV.', + ]); + + $rows = $this->parseImportFile($validated['import_file']->getRealPath(), $validated['import_file']->getClientOriginalName()); + + if (count($rows) === 0) { + throw ValidationException::withMessages([ + 'import_file' => 'File tidak berisi data kelas yang bisa diproses.', + ]); + } + + $preview = $this->buildImportPreview($rows); + + if ($preview['summary']['valid_rows'] === 0) { + throw ValidationException::withMessages([ + 'import_file' => array_merge(['Tidak ada baris valid yang bisa dipreview.'], array_slice($preview['errors'], 0, 10)), + ]); + } + + $request->session()->put('class_import_preview', $preview); + + return redirect()->route('classes.index'); + } + + public function import(Request $request): RedirectResponse + { + $request->validate([ + 'preview_token' => ['required', 'string'], + ]); + + $preview = $request->session()->get('class_import_preview'); + + if (! $preview || ($preview['token'] ?? null) !== $request->string('preview_token')->value()) { + throw ValidationException::withMessages([ + 'preview_token' => 'Preview import sudah tidak valid. Silakan upload ulang file import.', + ]); + } + + $inserted = 0; + $updated = 0; + + DB::transaction(function () use ($preview, &$inserted, &$updated) { + foreach ($preview['valid_rows'] as $row) { + $payload = Arr::only($row['normalized'], [ + 'academic_year_id', + 'department_id', + 'name', + 'level', + 'homeroom_teacher', + 'capacity', + 'is_active', + ]); + + $class = SchoolClass::query() + ->where('academic_year_id', $payload['academic_year_id']) + ->where('name', $payload['name']) + ->first(); + + if ($class) { + $class->update($payload); + $updated++; + continue; + } + + SchoolClass::create($payload); + $inserted++; + } + }); + + $request->session()->forget('class_import_preview'); + + Alert::success('Berhasil', "Import kelas selesai. {$inserted} data baru ditambahkan, {$updated} data diperbarui."); + + return redirect()->route('classes.index')->with('success', "Import kelas selesai. {$inserted} data baru ditambahkan, {$updated} data diperbarui."); + } + + public function downloadImportTemplate() + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('Import Kelas'); + + $headers = ['Tahun Ajaran', 'Program Keahlian', 'Nama Kelas', 'Tingkat', 'Wali Kelas', 'Kapasitas', 'Status']; + $sheet->fromArray($headers, null, 'A1'); + $sheet->fromArray([ + AcademicYear::where('is_active', true)->orderBy('name')->value('name') ?? '2025/2026', + Department::where('is_active', true)->orderBy('name')->value('name') ?? 'TKJ', + 'X TKJ 1', + 'X', + 'Budi Santoso', + '36', + 'Aktif', + ], null, 'A2'); + + for ($columnIndex = 1; $columnIndex <= count($headers); $columnIndex++) { + $sheet->getColumnDimensionByColumn($columnIndex)->setAutoSize(true); + } + + $referenceSheet = $spreadsheet->createSheet(); + $referenceSheet->setTitle('Referensi'); + $referenceSheet->fromArray(['Tahun Ajaran'], null, 'A1'); + $referenceSheet->fromArray(AcademicYear::orderBy('name')->pluck('name')->all(), null, 'A2'); + $referenceSheet->fromArray(['Program Keahlian'], null, 'C1'); + $referenceSheet->fromArray(Department::orderBy('name')->pluck('name')->all(), null, 'C2'); + $referenceSheet->fromArray(['Status'], null, 'E1'); + $referenceSheet->fromArray([['Aktif'], ['Nonaktif']], null, 'E2'); + + $guideSheet = $spreadsheet->createSheet(); + $guideSheet->setTitle('Panduan'); + $guideSheet->fromArray([ + ['Kolom', 'Keterangan'], + ['Tahun Ajaran', 'Wajib. Isi nama tahun ajaran persis seperti sheet Referensi.'], + ['Program Keahlian', 'Wajib. Bisa isi nama jurusan atau kode jurusan.'], + ['Nama Kelas', 'Wajib. Unik per tahun ajaran.'], + ['Tingkat', 'Wajib. Contoh: X, XI, XII.'], + ['Wali Kelas', 'Opsional.'], + ['Kapasitas', 'Opsional. Angka bulat minimal 1.'], + ['Status', 'Isi Aktif/Nonaktif, Yes/No, 1/0, atau Ya/Tidak. Default aktif jika kosong.'], + ], null, 'A1'); + + foreach (range('A', 'B') as $column) { + $guideSheet->getColumnDimension($column)->setAutoSize(true); + } + + $writer = new Xlsx($spreadsheet); + ob_start(); + $writer->save('php://output'); + $content = ob_get_clean(); + + return response($content, 200, [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition' => 'attachment; filename="template-import-kelas.xlsx"', + ]); + } + public function show(SchoolClass $class): RedirectResponse { return redirect()->route('classes.edit', $class); @@ -159,7 +307,7 @@ class SchoolClassController extends Controller return view('classes.edit', [ 'class' => $class, 'academicYears' => $academicYears, - 'departments' => $departments, + 'departments' => $departments ]); } @@ -244,6 +392,359 @@ class SchoolClassController extends Controller return redirect()->route('classes.index')->with('success', $deletedCount . ' kelas berhasil dihapus.'); } + protected function importReferenceData(): array + { + return [ + 'academicYears' => AcademicYear::orderBy('name')->pluck('name', 'id'), + 'departments' => Department::orderBy('name')->pluck('name', 'id'), + ]; + } + + protected function buildImportPreview(array $rows): array + { + $previewRows = []; + $errors = []; + $validRows = []; + + foreach ($rows as $rowNumber => $row) { + if ($this->isImportRowEmpty($row)) { + continue; + } + + $normalized = $this->normalizeImportRow($row); + $validator = Validator::make($normalized, [ + 'academic_year_id' => ['required', Rule::exists('academic_years', 'id')], + 'department_id' => ['required', Rule::exists('departments', 'id')], + 'name' => ['required', 'string', 'max:100'], + 'level' => ['required', 'string', 'max:10'], + 'homeroom_teacher' => ['nullable', 'string', 'max:100'], + 'capacity' => ['nullable', 'integer', 'min:1'], + 'is_active' => ['required', 'boolean'], + ], [], [ + 'academic_year_id' => 'tahun ajaran', + 'department_id' => 'program keahlian', + 'name' => 'nama kelas', + 'level' => 'tingkat', + 'homeroom_teacher' => 'wali kelas', + 'capacity' => 'kapasitas', + 'is_active' => 'status', + ]); + + $errorMessages = []; + $action = 'insert'; + + if ($validator->fails()) { + $errorMessages = $validator->errors()->all(); + $errors[] = 'Baris ' . $rowNumber . ': ' . implode(' ', $errorMessages); + } else { + $payload = $validator->validated(); + $lookup = SchoolClass::query() + ->where('academic_year_id', $payload['academic_year_id']) + ->where('name', $payload['name']) + ->first(); + + if ($lookup) { + $action = 'update'; + } + + $validRows[] = [ + 'row_number' => $rowNumber, + 'normalized' => $payload, + 'action' => $action, + ]; + } + + $previewRows[] = [ + 'row_number' => $rowNumber, + 'data' => $normalized, + 'errors' => $errorMessages, + 'is_valid' => empty($errorMessages), + 'action' => $action, + ]; + } + + return [ + 'token' => Str::uuid()->toString(), + 'rows' => $previewRows, + 'valid_rows' => $validRows, + 'errors' => $errors, + 'summary' => [ + 'total_rows' => count($previewRows), + 'valid_rows' => count($validRows), + 'invalid_rows' => count(array_filter($previewRows, fn ($row) => ! $row['is_valid'])), + 'insert_rows' => count(array_filter($previewRows, fn ($row) => $row['is_valid'] && $row['action'] === 'insert')), + 'update_rows' => count(array_filter($previewRows, fn ($row) => $row['is_valid'] && $row['action'] === 'update')), + ], + ]; + } + + protected function normalizeImportRow(array $row): array + { + $normalized = []; + + foreach ($row as $key => $value) { + $normalizedKey = Str::of((string) $key) + ->trim() + ->lower() + ->replaceMatches('/[^a-z0-9]+/u', '_') + ->trim('_') + ->value(); + + $normalized[$normalizedKey] = is_string($value) ? trim($value) : $value; + } + + $academicYearId = $this->resolveAcademicYearId($normalized['academic_year_id'] ?? ($normalized['tahun_ajaran'] ?? null)); + $departmentId = $this->resolveDepartmentId($normalized['department_id'] ?? ($normalized['program_keahlian'] ?? null)); + + return [ + 'academic_year_id' => $academicYearId, + 'department_id' => $departmentId, + 'name' => $this->nullIfBlank($normalized['name'] ?? ($normalized['nama_kelas'] ?? null)), + 'level' => strtoupper((string) ($this->nullIfBlank($normalized['level'] ?? ($normalized['tingkat'] ?? null)) ?? '')), + 'homeroom_teacher' => $this->nullIfBlank($normalized['homeroom_teacher'] ?? ($normalized['wali_kelas'] ?? null)), + 'capacity' => $this->normalizeImportInteger($normalized['capacity'] ?? ($normalized['kapasitas'] ?? null)), + 'is_active' => $this->normalizeImportBoolean($normalized['is_active'] ?? ($normalized['status'] ?? null)), + ]; + } + + protected function resolveAcademicYearId(mixed $value): mixed + { + if ($value === null || $value === '') { + return null; + } + + if (is_numeric($value)) { + return $value; + } + + return AcademicYear::where('name', trim((string) $value))->value('id'); + } + + protected function resolveDepartmentId(mixed $value): mixed + { + if ($value === null || $value === '') { + return null; + } + + if (is_numeric($value)) { + return $value; + } + + $normalizedValue = trim((string) $value); + + return Department::query() + ->where('name', $normalizedValue) + ->orWhere('code', strtoupper($normalizedValue)) + ->value('id'); + } + + protected function parseImportFile(string $path, string $originalName): array + { + $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); + + return match ($extension) { + 'xlsx' => $this->parseXlsxRows($path), + default => $this->parseCsvRows($path), + }; + } + + protected function parseCsvRows(string $path): array + { + $handle = fopen($path, 'r'); + + if (! $handle) { + return []; + } + + $rows = []; + $headers = null; + $rowNumber = 1; + + while (($data = fgetcsv($handle, 0, ',')) !== false) { + $rowNumber++; + + if ($headers === null) { + $headers = array_map(fn ($header) => trim((string) $header), $data); + continue; + } + + if (count(array_filter($data, fn ($value) => trim((string) $value) !== '')) === 0) { + continue; + } + + $rows[$rowNumber] = array_combine($headers, array_pad($data, count($headers), null)); + } + + fclose($handle); + + return $rows; + } + + protected function parseXlsxRows(string $path): array + { + $zip = new \ZipArchive(); + + if ($zip->open($path) !== true) { + throw ValidationException::withMessages([ + 'import_file' => 'File Excel tidak bisa dibuka.', + ]); + } + + $sharedStrings = []; + $sharedStringsXml = $zip->getFromName('xl/sharedStrings.xml'); + if ($sharedStringsXml) { + $xml = simplexml_load_string($sharedStringsXml); + if ($xml && isset($xml->si)) { + foreach ($xml->si as $item) { + $text = ''; + if (isset($item->t)) { + $text = (string) $item->t; + } elseif (isset($item->r)) { + foreach ($item->r as $run) { + $text .= (string) $run->t; + } + } + $sharedStrings[] = $text; + } + } + } + + $sheetXml = $zip->getFromName('xl/worksheets/sheet1.xml'); + $zip->close(); + + if (! $sheetXml) { + throw ValidationException::withMessages([ + 'import_file' => 'Worksheet pertama pada file Excel tidak ditemukan.', + ]); + } + + $xml = simplexml_load_string($sheetXml); + $xml->registerXPathNamespace('main', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'); + $rows = []; + $headers = null; + + foreach ($xml->xpath('//main:sheetData/main:row') as $rowIndex => $row) { + $values = []; + + foreach ($row->c as $cell) { + $reference = (string) $cell['r']; + $column = preg_replace('/\d+/', '', $reference); + $index = $this->columnLetterToIndex($column); + $type = (string) $cell['t']; + $value = ''; + + if ($type === 'inlineStr' && isset($cell->is->t)) { + $value = (string) $cell->is->t; + } elseif ($type === 's') { + $sharedIndex = (int) ($cell->v ?? 0); + $value = $sharedStrings[$sharedIndex] ?? ''; + } else { + $value = (string) ($cell->v ?? ''); + } + + $values[$index] = $value; + } + + if (empty($values)) { + continue; + } + + ksort($values); + $maxIndex = max(array_keys($values)); + $ordered = []; + + for ($index = 0; $index <= $maxIndex; $index++) { + $ordered[$index] = $values[$index] ?? null; + } + + if ($headers === null) { + $headers = array_map(fn ($header) => trim((string) $header), $ordered); + continue; + } + + $rows[$rowIndex + 1] = array_combine($headers, array_pad($ordered, count($headers), null)); + } + + return $rows; + } + + protected function columnLetterToIndex(string $letters): int + { + $letters = strtoupper($letters); + $index = 0; + + for ($i = 0; $i < strlen($letters); $i++) { + $index = ($index * 26) + (ord($letters[$i]) - 64); + } + + return $index - 1; + } + + protected function isImportRowEmpty(array $row): bool + { + return count(array_filter($row, fn ($value) => ! is_null($value) && $value !== '')) === 0; + } + + protected function nullIfBlank(mixed $value): mixed + { + if (is_string($value) && trim($value) === '') { + return null; + } + + return $value; + } + + protected function normalizeImportInteger(mixed $value): mixed + { + $value = $this->nullIfBlank($value); + + if ($value === null) { + return null; + } + + if (is_int($value)) { + return $value; + } + + if (is_float($value)) { + return (int) round($value); + } + + if (is_numeric($value)) { + return (int) round((float) $value); + } + + if (is_string($value)) { + $normalized = trim($value); + $normalized = str_replace([',', ' '], '', $normalized); + + if (preg_match('/^\d+(?:\.0+)?$/', $normalized) === 1) { + return (int) round((float) $normalized); + } + } + + return $value; + } + + protected function normalizeImportBoolean(mixed $value): bool + { + if ($value === null || $value === '') { + return true; + } + + if (is_bool($value)) { + return $value; + } + + $normalized = strtolower(trim((string) $value)); + + return match ($normalized) { + '1', 'true', 'ya', 'y', 'yes', 'aktif' => true, + '0', 'false', 'tidak', 't', 'no', 'nonaktif', 'inactive' => false, + default => true, + }; + } + protected function nextLevel(?string $level): ?string { return match (strtoupper((string) $level)) { diff --git a/app/Http/Controllers/StudentPromotionController.php b/app/Http/Controllers/StudentPromotionController.php index 6be4f18..c068db2 100644 --- a/app/Http/Controllers/StudentPromotionController.php +++ b/app/Http/Controllers/StudentPromotionController.php @@ -392,14 +392,42 @@ class StudentPromotionController extends Controller protected function pickRecommendedClass($classes, Student $student): ?SchoolClass { $sameDepartmentClasses = $classes->where('department_id', $student->department_id)->values(); + $currentClassName = (string) $student->classRoom?->name; + $currentNormalized = $this->normalizeClassNameForComparison($currentClassName); + $currentParallel = $this->extractClassParallel($currentClassName); - return $sameDepartmentClasses->first(function (SchoolClass $classRoom) use ($student) { - return str_contains(strtolower($classRoom->name), strtolower((string) $student->classRoom?->name)); + return $sameDepartmentClasses->first(function (SchoolClass $classRoom) use ($currentParallel) { + return $currentParallel !== null + && $this->extractClassParallel((string) $classRoom->name) === $currentParallel; }) + ?? $sameDepartmentClasses->first(function (SchoolClass $classRoom) use ($currentNormalized) { + return $currentNormalized !== '' + && $this->normalizeClassNameForComparison((string) $classRoom->name) === $currentNormalized; + }) + ?? $sameDepartmentClasses->first(function (SchoolClass $classRoom) use ($student) { + return str_contains(strtolower($classRoom->name), strtolower((string) $student->classRoom?->name)); + }) ?? $sameDepartmentClasses->first() ?? $classes->first(); } + protected function normalizeClassNameForComparison(string $className): string + { + $normalized = strtoupper(trim($className)); + $normalized = preg_replace('/^XII?I?\s+/i', '', $normalized) ?? $normalized; + + return preg_replace('/\s+/', ' ', trim($normalized)) ?? trim($normalized); + } + + protected function extractClassParallel(string $className): ?string + { + if (! preg_match('/(\d+)\s*$/', trim($className), $matches)) { + return null; + } + + return ltrim($matches[1], '0') ?: '0'; + } + protected function transformClassOptions(Collection $classes): Collection { return $classes->map(function (SchoolClass $classRoom) { diff --git a/app/Http/Controllers/UserManagementController.php b/app/Http/Controllers/UserManagementController.php index f6bcebc..664e3fa 100644 --- a/app/Http/Controllers/UserManagementController.php +++ b/app/Http/Controllers/UserManagementController.php @@ -30,6 +30,11 @@ class UserManagementController extends Controller 'users' => $users, 'search' => $search, 'roleOptions' => $this->roleOptions(), + 'stats' => [ + 'total' => User::count(), + 'admins' => User::where('role', 'admin')->count(), + 'staff' => User::where('role', 'staff')->count(), + ], ]); } @@ -77,10 +82,14 @@ class UserManagementController extends Controller public function destroy(Request $request, User $user): RedirectResponse { if ((int) $request->user()->id === (int) $user->id) { + Alert::error('Tidak bisa dihapus', 'Akun yang sedang dipakai login tidak bisa dihapus.'); + return back()->withErrors(['user' => 'Akun yang sedang dipakai login tidak bisa dihapus.']); } if (User::query()->where('role', 'admin')->count() <= 1 && $user->role === 'admin') { + Alert::error('Tidak bisa dihapus', 'Minimal harus ada satu akun admin aktif di sistem.'); + return back()->withErrors(['user' => 'Minimal harus ada satu akun admin aktif di sistem.']); } diff --git a/database/migrations/2026_05_02_122100_drop_promotion_target_class_id_from_classes_table.php b/database/migrations/2026_05_02_122100_drop_promotion_target_class_id_from_classes_table.php new file mode 100644 index 0000000..d31b426 --- /dev/null +++ b/database/migrations/2026_05_02_122100_drop_promotion_target_class_id_from_classes_table.php @@ -0,0 +1,31 @@ +dropConstrainedForeignId('promotion_target_class_id'); + }); + } + + public function down(): void + { + Schema::table('classes', function (Blueprint $table) { + $table->foreignId('promotion_target_class_id') + ->nullable() + ->after('level') + ->constrained('classes') + ->nullOnDelete() + ->cascadeOnUpdate(); + }); + } +}; diff --git a/resources/js/app.js b/resources/js/app.js index d036a74..56fb648 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -445,6 +445,243 @@ document.addEventListener('DOMContentLoaded', () => { renderBulkUploadFiles(); } + const bulkUploadOverlay = document.querySelector('[data-bulk-upload-overlay]'); + + document.querySelectorAll('[data-bulk-upload-form]').forEach((form) => { + const submitButton = form.querySelector('[data-bulk-upload-submit-button]'); + const submitLabel = form.querySelector('[data-bulk-upload-submit-label]'); + const submitIcon = form.querySelector('[data-bulk-upload-submit-icon]'); + const mode = form.dataset.bulkUploadMode || 'preview'; + const useAjaxPreview = form.dataset.bulkUploadAjaxPreview === 'true'; + const previewUrl = form.dataset.bulkUploadPreviewUrl || form.getAttribute('action') || ''; + + const showOverlay = (config = {}) => { + if (!bulkUploadOverlay) { + return; + } + + const title = bulkUploadOverlay.querySelector('[data-bulk-upload-overlay-title]'); + const message = bulkUploadOverlay.querySelector('[data-bulk-upload-overlay-message]'); + const fileCountLabel = bulkUploadOverlay.querySelector('[data-bulk-upload-overlay-file-count]'); + const stageUpload = bulkUploadOverlay.querySelector('[data-bulk-upload-stage-upload]'); + const stageMatch = bulkUploadOverlay.querySelector('[data-bulk-upload-stage-match]'); + const stageSave = bulkUploadOverlay.querySelector('[data-bulk-upload-stage-save]'); + const progressLabel = bulkUploadOverlay.querySelector('[data-bulk-upload-overlay-progress-label]'); + const progressPercent = bulkUploadOverlay.querySelector('[data-bulk-upload-overlay-progress-percent]'); + const progressBar = bulkUploadOverlay.querySelector('[data-bulk-upload-overlay-progress-bar]'); + const fileCountSource = form.dataset.bulkUploadFileCountSource || 'input'; + const inputFileCount = bulkUploadInput?.files?.length || 0; + const previewFileCount = Number(form.dataset.bulkUploadPreviewCount || 0); + const activeFileCount = fileCountSource === 'preview' ? previewFileCount : inputFileCount; + const percent = Math.max(0, Math.min(100, Number(config.percent ?? 0))); + + if (title) { + title.textContent = config.title || (mode === 'process' + ? 'Sedang memproses upload permanen...' + : 'Sedang menyiapkan preview dokumen...'); + } + + if (message) { + message.textContent = config.message || (mode === 'process' + ? 'Mohon tunggu. Sistem sedang menyimpan dokumen ke arsip kelulusan dan mencocokkannya ke data siswa. Jangan tutup tab ini agar proses tidak terputus.' + : 'Mohon tunggu. File sedang diunggah dan dipetakan oleh sistem untuk menyiapkan preview. Jangan tutup tab ini agar proses tidak terputus.'); + } + + if (fileCountLabel) { + fileCountLabel.textContent = activeFileCount > 0 + ? `${activeFileCount} file sedang diproses` + : 'Sedang menghitung jumlah file...'; + } + + if (stageUpload) { + stageUpload.textContent = config.stageUpload || (mode === 'process' ? 'Validasi file upload' : 'Upload file'); + stageUpload.parentElement?.classList.add('ring-1', 'ring-indigo-300/50', 'bg-indigo-500/10'); + } + + if (stageMatch) { + stageMatch.textContent = config.stageMatch || (mode === 'process' ? 'Cocokkan ke siswa lulus' : 'Susun preview & matching'); + stageMatch.parentElement?.classList.add('ring-1', 'ring-sky-300/40', 'bg-sky-500/10'); + } + + if (stageSave) { + stageSave.textContent = config.stageSave || (mode === 'process' ? 'Simpan ke arsip kelulusan' : 'Tunggu konfirmasi operator'); + stageSave.parentElement?.classList.add('ring-1', 'ring-emerald-300/40', 'bg-emerald-500/10'); + } + + if (progressLabel) { + progressLabel.textContent = config.progressLabel || 'Menunggu proses dimulai...'; + } + + if (progressPercent) { + progressPercent.textContent = `${Math.round(percent)}%`; + } + + if (progressBar) { + progressBar.style.width = `${percent}%`; + progressBar.classList.toggle('animate-pulse', !!config.indeterminate); + } + + bulkUploadOverlay.classList.remove('hidden'); + bulkUploadOverlay.classList.add('flex'); + document.body.classList.add('overflow-hidden'); + }; + + form.addEventListener('submit', async (event) => { + if (submitButton) { + submitButton.disabled = true; + } + + if (submitIcon) { + submitIcon.classList.remove('fa-magnifying-glass', 'fa-upload', 'fa-cloud-arrow-up'); + submitIcon.classList.add('fa-spinner', 'fa-spin'); + } + + if (submitLabel) { + submitLabel.textContent = mode === 'process' + ? 'Sedang memproses upload...' + : 'Sedang menyiapkan preview...'; + } + + if (mode === 'preview' && useAjaxPreview) { + event.preventDefault(); + + const formData = new FormData(form); + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; + + showOverlay({ + title: 'Sedang mengunggah file preview...', + message: 'Mohon tunggu. Browser sedang mengirim file ke server untuk disiapkan preview dan pencocokan data.', + progressLabel: 'Mengunggah file ke server...', + percent: 0, + stageUpload: 'Upload file', + stageMatch: 'Menunggu upload selesai', + stageSave: 'Siapkan halaman preview', + indeterminate: false, + }); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', previewUrl, true); + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + xhr.setRequestHeader('Accept', 'application/json'); + if (csrfToken) { + xhr.setRequestHeader('X-CSRF-TOKEN', csrfToken); + } + + xhr.upload.addEventListener('progress', (progressEvent) => { + if (!progressEvent.lengthComputable) { + return; + } + + const percent = (progressEvent.loaded / progressEvent.total) * 100; + showOverlay({ + title: 'Sedang mengunggah file preview...', + message: 'Mohon tunggu. Browser sedang mengirim file ke server untuk disiapkan preview dan pencocokan data.', + progressLabel: `Upload file ${Math.round(percent)}%`, + percent, + stageUpload: 'Upload file', + stageMatch: 'Menunggu upload selesai', + stageSave: 'Siapkan halaman preview', + indeterminate: false, + }); + }); + + xhr.onreadystatechange = () => { + if (xhr.readyState !== XMLHttpRequest.DONE) { + return; + } + + if (xhr.status >= 200 && xhr.status < 300) { + let response = {}; + + try { + response = JSON.parse(xhr.responseText || '{}'); + } catch (error) { + response = {}; + } + + showOverlay({ + title: 'Upload selesai, sistem sedang menyusun preview...', + message: 'File sudah terkirim. Sekarang sistem sedang mencocokkan dokumen dan menyiapkan halaman preview.', + progressLabel: 'Upload selesai, menyiapkan preview...', + percent: 100, + stageUpload: 'Upload selesai', + stageMatch: 'Mencocokkan ke siswa lulus', + stageSave: 'Membuka halaman preview', + indeterminate: true, + }); + + window.location.href = response.redirect_url || window.location.href; + return; + } + + document.body.classList.remove('overflow-hidden'); + bulkUploadOverlay?.classList.add('hidden'); + bulkUploadOverlay?.classList.remove('flex'); + + if (submitButton) { + submitButton.disabled = false; + } + + if (submitIcon) { + submitIcon.classList.remove('fa-spinner', 'fa-spin'); + submitIcon.classList.add('fa-magnifying-glass'); + } + + if (submitLabel) { + submitLabel.textContent = 'Preview Mapping Dokumen'; + } + + let errorMessage = 'Bulk upload gagal diproses. Silakan cek file lalu coba lagi.'; + + try { + const response = JSON.parse(xhr.responseText || '{}'); + if (response.message) { + errorMessage = response.message; + } else if (response.errors) { + const firstError = Object.values(response.errors).flat()[0]; + if (firstError) { + errorMessage = firstError; + } + } + } catch (error) { + // noop + } + + Swal.fire({ + icon: 'error', + title: 'Bulk upload gagal', + text: errorMessage, + confirmButtonColor: '#dc2626', + }); + }; + + xhr.send(formData); + return; + } + + showOverlay({ + progressLabel: mode === 'process' ? 'Menyimpan dokumen ke arsip...' : 'Menyiapkan proses...', + percent: mode === 'process' ? 100 : 15, + indeterminate: mode === 'process', + }); + }); + }); + + const graduationFilterForm = document.querySelector('[data-graduation-filter-form]'); + const graduationAcademicYearFilter = document.querySelector('[data-graduation-academic-year-filter]'); + + if (graduationFilterForm && graduationAcademicYearFilter) { + graduationAcademicYearFilter.addEventListener('change', () => { + const classFilter = graduationFilterForm.querySelector('[name="class_id"]'); + + if (classFilter) { + classFilter.value = ''; + } + + graduationFilterForm.submit(); + }); + } + const promotionForm = document.querySelector('[data-student-promotion-form]'); if (promotionForm) { diff --git a/resources/views/classes/index.blade.php b/resources/views/classes/index.blade.php index 44f680b..17cce5f 100644 --- a/resources/views/classes/index.blade.php +++ b/resources/views/classes/index.blade.php @@ -4,6 +4,116 @@ action-label="Tambah Kelas" :action-url="route('classes.create')" > + +
+ @csrf +
+ + +

Gunakan template agar format kolom sesuai. Import ini akan update data lama jika nama kelas pada tahun ajaran yang sama sudah ada.

+
+ +
+ + Download Template +
+
+ + @if (!empty($importPreview)) +
+
+
+
Total Baris
+
{{ $importPreview['summary']['total_rows'] }}
+
+
+
Valid
+
{{ $importPreview['summary']['valid_rows'] }}
+
+
+
Insert Baru
+
{{ $importPreview['summary']['insert_rows'] }}
+
+
+
Update Data
+
{{ $importPreview['summary']['update_rows'] }}
+
+
+ + @if (!empty($importPreview['errors'])) +
+
Ada baris yang belum valid.
+
    + @foreach (array_slice($importPreview['errors'], 0, 10) as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + +
+ + + + + + + + + + + + + @foreach ($importPreview['rows'] as $row) + + + + + + + + + @endforeach + +
BarisAkademikNama KelasDetailAksiValidasi
{{ $row['row_number'] }} +
Tahun: {{ $importReferenceData['academicYears'][$row['data']['academic_year_id']] ?? '-' }}
+
Jurusan: {{ $importReferenceData['departments'][$row['data']['department_id']] ?? '-' }}
+
+
{{ $row['data']['name'] ?: '-' }}
+
Tingkat: {{ $row['data']['level'] ?: '-' }}
+
+
Wali Kelas: {{ $row['data']['homeroom_teacher'] ?: '-' }}
+
Kapasitas: {{ $row['data']['capacity'] ?: '-' }}
+
Status: {{ ($row['data']['is_active'] ?? false) ? 'Aktif' : 'Nonaktif' }}
+
+ {{ $row['action'] === 'update' ? 'Update' : 'Insert' }} + + @if ($row['is_valid']) + Valid + @else +
+
Perlu perbaikan
+
    + @foreach ($row['errors'] as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif +
+
+ + @if ($importPreview['summary']['valid_rows'] > 0) +
+ @csrf + + + Hanya baris valid yang akan diproses. +
+ @endif +
+ @endif +
@csrf diff --git a/resources/views/departments/index.blade.php b/resources/views/departments/index.blade.php index 116e363..69bfa50 100644 --- a/resources/views/departments/index.blade.php +++ b/resources/views/departments/index.blade.php @@ -4,6 +4,109 @@ action-label="Tambah Program Keahlian" :action-url="route('departments.create')" > + + + @csrf +
+ + +

Gunakan template agar format kolom sesuai. Import ini akan update data lama jika kode atau nama program keahlian sudah ada.

+
+ +
+ + Download Template +
+ + + @if (!empty($importPreview)) +
+
+
+
Total Baris
+
{{ $importPreview['summary']['total_rows'] }}
+
+
+
Valid
+
{{ $importPreview['summary']['valid_rows'] }}
+
+
+
Insert Baru
+
{{ $importPreview['summary']['insert_rows'] }}
+
+
+
Update Data
+
{{ $importPreview['summary']['update_rows'] }}
+
+
+ + @if (!empty($importPreview['errors'])) +
+
Ada baris yang belum valid.
+
    + @foreach (array_slice($importPreview['errors'], 0, 10) as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + +
+ + + + + + + + + + + + + @foreach ($importPreview['rows'] as $row) + + + + + + + + + @endforeach + +
BarisKodeNamaStatusAksiValidasi
{{ $row['row_number'] }}{{ $row['data']['code'] ?: '-' }} +
{{ $row['data']['name'] ?: '-' }}
+
{{ $row['data']['description'] ?: 'Tanpa deskripsi' }}
+
{{ ($row['data']['is_active'] ?? false) ? 'Aktif' : 'Nonaktif' }} + {{ $row['action'] === 'update' ? 'Update' : 'Insert' }} + + @if ($row['is_valid']) + Valid + @else +
+
Perlu perbaikan
+
    + @foreach ($row['errors'] as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif +
+
+ + @if ($importPreview['summary']['valid_rows'] > 0) +
+ @csrf + + + Hanya baris valid yang akan diproses. +
+ @endif +
+ @endif +
diff --git a/resources/views/graduated-students/bulk-upload.blade.php b/resources/views/graduated-students/bulk-upload.blade.php index ae9a573..df079d1 100644 --- a/resources/views/graduated-students/bulk-upload.blade.php +++ b/resources/views/graduated-students/bulk-upload.blade.php @@ -78,7 +78,7 @@
- + @csrf
@@ -130,9 +130,9 @@
- @@ -262,10 +262,10 @@
- + @csrf - @@ -273,4 +273,55 @@ @endif + + + diff --git a/resources/views/graduated-students/index.blade.php b/resources/views/graduated-students/index.blade.php index acafc3e..22116bd 100644 --- a/resources/views/graduated-students/index.blade.php +++ b/resources/views/graduated-students/index.blade.php @@ -81,11 +81,11 @@
- + - + - + diff --git a/resources/views/student-promotions/index.blade.php b/resources/views/student-promotions/index.blade.php index 29ba69d..bc61b01 100644 --- a/resources/views/student-promotions/index.blade.php +++ b/resources/views/student-promotions/index.blade.php @@ -222,8 +222,8 @@ $errors->first($targetClassField), $errors->first($notesField), ])->filter(); - $selectedPromote = old($targetClassField, $promoteClasses->firstWhere('id', $recommendation['target_class_id'])?->id ?? ($promoteClasses->first()?->id ?? '')); - $selectedRepeat = old($targetClassField, $repeatClasses->firstWhere('id', $recommendation['target_class_id'])?->id ?? ($repeatClasses->first()?->id ?? '')); + $selectedPromote = old($targetClassField, $recommendation['action'] === 'promote' ? ($recommendation['target_class_id'] ?? '') : ''); + $selectedRepeat = old($targetClassField, $recommendation['action'] === 'repeat' ? ($recommendation['target_class_id'] ?? '') : ''); $promoteClassOptions = $promoteClasses->map(fn ($classRoom) => [ 'id' => $classRoom->id, 'name' => $classRoom->name, diff --git a/resources/views/users/_form.blade.php b/resources/views/users/_form.blade.php index 605f054..261fe3c 100644 --- a/resources/views/users/_form.blade.php +++ b/resources/views/users/_form.blade.php @@ -1,13 +1,24 @@
- + +
+ +

Administrator punya akses penuh ke pengaturan sistem, staff hanya untuk operasional harian.

+
+
+ +
+
Keamanan akun
+
Gunakan password minimal 8 karakter. Untuk akun yang sedang diedit, password boleh dikosongkan jika tidak ingin diubah.
+
+
-

Password minimal 8 karakter.

+

Tips: kombinasikan huruf besar, huruf kecil, angka, dan simbol untuk password yang lebih kuat.

diff --git a/resources/views/users/index.blade.php b/resources/views/users/index.blade.php index 5e46b51..171ab8a 100644 --- a/resources/views/users/index.blade.php +++ b/resources/views/users/index.blade.php @@ -1,10 +1,34 @@ +
+
+
Total Pengguna
+
{{ $stats['total'] }}
+
+
+
Administrator
+
{{ $stats['admins'] }}
+
+
+
Staff
+
{{ $stats['staff'] }}
+
+
+ -
- -
- + @@ -23,22 +47,39 @@ @forelse ($users as $user) - {{ $user->name }} + +
{{ $user->name }}
+ @if (auth()->id() === $user->id) +
Akun yang sedang dipakai
+ @endif + {{ $user->email }} {{ $roleOptions[$user->role] ?? ucfirst($user->role) }} - {{ auth()->id() === $user->id ? 'Sedang dipakai login' : 'Aktif' }} + + @if (auth()->id() === $user->id) + Login saat ini + @else + Aktif + @endif +
Edit -
- @csrf - @method('DELETE') - -
+ @php($isCurrentUser = auth()->id() === $user->id) + @php($isLastAdmin = $user->role === 'admin' && ($stats['admins'] ?? 0) <= 1) + @if ($isCurrentUser || $isLastAdmin) + + @else +
+ @csrf + @method('DELETE') + +
+ @endif
diff --git a/routes/web.php b/routes/web.php index 062b3e2..4ff09d6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -45,7 +45,13 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::middleware('role:admin')->group(function () { Route::resource('academic-years', AcademicYearController::class); + Route::get('departments/import/template', [DepartmentController::class, 'downloadImportTemplate'])->name('departments.import.template'); + Route::post('departments/import/preview', [DepartmentController::class, 'previewImport'])->name('departments.import.preview'); + Route::post('departments/import', [DepartmentController::class, 'import'])->name('departments.import'); Route::resource('departments', DepartmentController::class); + Route::get('classes/import/template', [SchoolClassController::class, 'downloadImportTemplate'])->name('classes.import.template'); + Route::post('classes/import/preview', [SchoolClassController::class, 'previewImport'])->name('classes.import.preview'); + Route::post('classes/import', [SchoolClassController::class, 'import'])->name('classes.import'); Route::post('classes/generate', [SchoolClassController::class, 'generate'])->name('classes.generate'); Route::delete('classes/bulk-delete', [SchoolClassController::class, 'bulkDestroy'])->name('classes.bulk-destroy'); Route::resource('classes', SchoolClassController::class); @@ -74,6 +80,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('students/lulus', [GraduatedStudentController::class, 'index'])->name('graduated-students.index'); Route::get('students/lulus/bulk-upload', [GraduationBulkUploadController::class, 'index'])->name('graduated-students.bulk-upload.index'); Route::post('students/lulus/bulk-upload/preview', [GraduationBulkUploadController::class, 'preview'])->name('graduated-students.bulk-upload.preview'); + Route::post('students/lulus/bulk-upload/preview-ajax', [GraduationBulkUploadController::class, 'previewAjax'])->name('graduated-students.bulk-upload.preview-ajax'); Route::post('students/lulus/bulk-upload', [GraduationBulkUploadController::class, 'store'])->name('graduated-students.bulk-upload.store'); Route::get('students/lulus/{graduatedStudent}', [GraduatedStudentController::class, 'show'])->name('graduated-students.show'); Route::patch('students/lulus/{graduatedStudent}', [GraduatedStudentController::class, 'update'])->name('graduated-students.update'); @@ -104,3 +111,4 @@ Route::middleware(['auth', 'verified'])->group(function () { }); require __DIR__.'/auth.php'; +