Update graduation workflow, promotions, and admin module improvements

This commit is contained in:
2026-05-02 13:09:26 +07:00
parent 700c24209d
commit 96d6e364a6
16 changed files with 1593 additions and 32 deletions
+394 -1
View File
@@ -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,
};
}
}
@@ -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',
@@ -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
+502 -1
View File
@@ -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)) {
@@ -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) {
@@ -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.']);
}