diff --git a/app/Http/Controllers/DocumentTypeController.php b/app/Http/Controllers/DocumentTypeController.php index a268e21..a658eec 100644 --- a/app/Http/Controllers/DocumentTypeController.php +++ b/app/Http/Controllers/DocumentTypeController.php @@ -3,9 +3,11 @@ namespace App\Http\Controllers; use App\Models\DocumentType; +use App\Models\StudentDocument; use Illuminate\Database\QueryException; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; use Illuminate\Validation\Rule; use Illuminate\View\View; use RealRashid\SweetAlert\Facades\Alert; @@ -17,6 +19,9 @@ class DocumentTypeController extends Controller $search = trim((string) $request->get('search')); $documentTypes = DocumentType::withCount('studentDocuments') + ->withCount([ + 'studentDocuments as student_documents_with_trashed_count' => fn ($query) => $query->withTrashed(), + ]) ->when($search !== '', function ($query) use ($search) { $query->where('code', 'like', "%{$search}%") ->orWhere('name', 'like', "%{$search}%") @@ -93,23 +98,67 @@ class DocumentTypeController extends Controller public function destroy(DocumentType $documentType): RedirectResponse { - $documentType->loadCount('studentDocuments'); + $documentType->loadCount([ + 'studentDocuments', + 'studentDocuments as student_documents_with_trashed_count' => fn ($query) => $query->withTrashed(), + ]); if ($documentType->student_documents_count > 0) { - Alert::error('Tidak bisa dihapus', 'Jenis dokumen ini masih digunakan oleh data dokumen siswa.'); + Alert::error('Tidak bisa dihapus', 'Jenis dokumen ini masih digunakan oleh data dokumen siswa aktif.'); return redirect()->route('document-types.index')->withErrors([ - 'delete' => "Jenis dokumen '{$documentType->name}' tidak bisa dihapus karena masih digunakan oleh {$documentType->student_documents_count} dokumen siswa.", + 'delete' => "Jenis dokumen '{$documentType->name}' tidak bisa dihapus karena masih digunakan oleh {$documentType->student_documents_count} dokumen siswa aktif.", ]); } + $trashedDocumentsCount = $documentType->student_documents_with_trashed_count - $documentType->student_documents_count; + + if ($trashedDocumentsCount > 0) { + Alert::error('Tidak bisa dihapus', 'Jenis dokumen ini masih dipakai oleh dokumen siswa yang sudah diarsipkan.'); + + return redirect()->route('document-types.index')->withErrors([ + 'delete' => "Jenis dokumen '{$documentType->name}' tidak bisa dihapus karena masih direferensikan oleh {$trashedDocumentsCount} dokumen siswa yang sudah diarsipkan. Bersihkan arsip dokumen terkait terlebih dahulu atau gunakan aksi bersihkan arsip dari modul ini.", + ]); + } + + return $this->deleteDocumentType($documentType); + } + + public function purgeArchivedDocuments(DocumentType $documentType): RedirectResponse + { + $trashedDocuments = StudentDocument::withTrashed() + ->where('document_type_id', $documentType->id) + ->whereNotNull('deleted_at') + ->get(); + + if ($trashedDocuments->isEmpty()) { + Alert::info('Tidak ada arsip', 'Tidak ditemukan dokumen siswa arsip untuk jenis dokumen ini.'); + + return $this->deleteDocumentType($documentType); + } + + foreach ($trashedDocuments as $document) { + if ($document->file_path && Storage::disk('public')->exists($document->file_path)) { + Storage::disk('public')->delete($document->file_path); + } + + $document->forceDelete(); + } + + Alert::success('Arsip dibersihkan', 'Arsip dokumen siswa yang terkait berhasil dibersihkan.'); + + return $this->deleteDocumentType($documentType); + } + + protected function deleteDocumentType(DocumentType $documentType): RedirectResponse + { try { $documentType->delete(); } catch (QueryException $exception) { Alert::error('Tidak bisa dihapus', 'Jenis dokumen ini masih direferensikan oleh data lain di sistem.'); return redirect()->route('document-types.index')->withErrors([ - 'delete' => "Jenis dokumen '{$documentType->name}' tidak bisa dihapus karena masih direferensikan oleh data lain di sistem. Untuk kasus ini, cek dokumen siswa yang masih memakai jenis dokumen tersebut terlebih dahulu.", + 'delete' => "Jenis dokumen '{$documentType->name}' tidak bisa dihapus karena masih direferensikan oleh data lain di sistem. Cek relasi data lain yang masih memakai jenis dokumen ini terlebih dahulu.", ]); } diff --git a/app/Http/Controllers/StudentController.php b/app/Http/Controllers/StudentController.php index b4a04d9..39e640d 100644 --- a/app/Http/Controllers/StudentController.php +++ b/app/Http/Controllers/StudentController.php @@ -342,8 +342,15 @@ class StudentController extends Controller { $this->ensureAdmin(); - return view('students.import', array_merge($this->importReferenceData(), [ - 'preview' => $request->session()->get('student_import_preview'), + $referenceData = $this->importReferenceData(); + $preview = $request->session()->get('student_import_preview'); + + if ($preview) { + $preview = $this->hydrateImportPreviewDisplay($preview, $referenceData); + } + + return view('students.import', array_merge($referenceData, [ + 'preview' => $preview, ])); } @@ -432,6 +439,8 @@ class StudentController extends Controller 'notes', ]); + $payload = $this->hydrateRegionNamesFromIds($payload); + $lookup = null; if (! empty($payload['student_number'])) { @@ -447,8 +456,14 @@ class StudentController extends Controller $lookup->restore(); } - $lookup->update($payload); - $this->syncAcademicHistory($lookup, $payload); + $updatePayload = $this->buildStudentImportUpdatePayload($lookup, $payload); + $historyPayload = $this->buildStudentImportHistoryPayload($lookup, $payload); + + $lookup->update($updatePayload); + $this->syncAcademicHistory($lookup, $historyPayload); + $this->syncExitRecord($lookup, $historyPayload); + $this->syncTransferRecord($lookup, $historyPayload); + $this->syncGraduationRecord($lookup, $historyPayload); $this->syncImportedParents($lookup, $row['normalized']['parents'] ?? []); $updated++; continue; @@ -456,6 +471,9 @@ class StudentController extends Controller $student = Student::create($payload); $this->syncAcademicHistory($student, $payload); + $this->syncExitRecord($student, $payload); + $this->syncTransferRecord($student, $payload); + $this->syncGraduationRecord($student, $payload); $this->syncImportedParents($student, $row['normalized']['parents'] ?? []); $inserted++; } @@ -804,14 +822,92 @@ class StudentController extends Controller protected function importReferenceData(): array { + $academicYears = AcademicYear::where('is_active', true)->orderBy('name')->pluck('name', 'id'); + $departments = Department::where('is_active', true)->orderBy('name')->pluck('name', 'id'); + $classes = SchoolClass::with(['academicYear', 'department'])->where('is_active', true)->orderBy('name')->get(); + return [ - 'academicYears' => AcademicYear::where('is_active', true)->orderBy('name')->pluck('name', 'id'), - 'departments' => Department::where('is_active', true)->orderBy('name')->pluck('name', 'id'), - 'classes' => SchoolClass::with(['academicYear', 'department'])->where('is_active', true)->orderBy('name')->get(), + 'academicYears' => $academicYears, + 'departments' => $departments, + 'classes' => $classes, 'statuses' => $this->statusOptions(), + 'academicYearsById' => $academicYears->all(), + 'departmentsById' => $departments->all(), + 'classesById' => $classes->keyBy('id')->map(fn ($classRoom) => [ + 'name' => $classRoom->name, + 'academic_year' => $classRoom->academicYear?->name, + 'department' => $classRoom->department?->name, + ])->all(), ]; } + protected function hydrateRegionNamesFromIds(array $payload): array + { + $payload['province'] = ! empty($payload['province_id']) ? Province::where('id', $payload['province_id'])->value('name') : null; + $payload['city'] = ! empty($payload['regency_id']) ? Regency::where('id', $payload['regency_id'])->value('name') : null; + $payload['district'] = ! empty($payload['district_id']) ? District::where('id', $payload['district_id'])->value('name') : null; + $payload['village'] = ! empty($payload['village_id']) ? Village::where('id', $payload['village_id'])->value('name') : null; + + return $payload; + } + + protected function buildStudentImportUpdatePayload(Student $student, array $payload): array + { + $preserveWhenBlank = [ + 'student_number', + 'nisn', + 'nik', + 'place_of_birth', + 'date_of_birth', + 'religion', + 'nationality', + 'family_card_number', + 'child_order', + 'number_of_siblings', + 'phone_number', + 'email', + 'address', + 'province_id', + 'regency_id', + 'district_id', + 'village_id', + 'province', + 'city', + 'district', + 'village', + 'postal_code', + 'entry_date', + 'notes', + ]; + + foreach ($preserveWhenBlank as $field) { + if (array_key_exists($field, $payload) && blank($payload[$field])) { + $payload[$field] = $student->{$field}; + } + } + + return $payload; + } + + protected function buildStudentImportHistoryPayload(Student $student, array $payload): array + { + $historyPayload = Arr::only($payload, [ + 'academic_year_id', + 'department_id', + 'class_id', + 'status', + 'entry_date', + ]); + + foreach (['academic_year_id', 'department_id', 'class_id', 'status', 'entry_date'] as $field) { + if (! array_key_exists($field, $historyPayload) || blank($historyPayload[$field])) { + $historyPayload[$field] = $student->{$field}; + } + } + + return $historyPayload; + } + protected function statusOptions(): array { return [ @@ -1241,6 +1337,40 @@ class StudentController extends Controller ]; } + protected function hydrateImportPreviewDisplay(array $preview, array $referenceData): array + { + $academicYearsById = $referenceData['academicYearsById'] ?? []; + $departmentsById = $referenceData['departmentsById'] ?? []; + $classesById = $referenceData['classesById'] ?? []; + $statuses = $referenceData['statuses'] ?? []; + + $preview['rows'] = array_map(function (array $row) use ($academicYearsById, $departmentsById, $classesById, $statuses) { + $academicYearId = $row['data']['academic_year_id'] ?? null; + $departmentId = $row['data']['department_id'] ?? null; + $classId = $row['data']['class_id'] ?? null; + $status = $row['data']['status'] ?? null; + $classMeta = $classId ? ($classesById[$classId] ?? null) : null; + + $row['display'] = [ + 'academic_year' => $academicYearId ? ($academicYearsById[$academicYearId] ?? 'ID ' . $academicYearId) : '-', + 'department' => $departmentId ? ($departmentsById[$departmentId] ?? 'ID ' . $departmentId) : '-', + 'class' => $classMeta['name'] ?? ($classId ? 'ID ' . $classId : '-'), + 'class_context' => $classMeta + ? collect([$classMeta['academic_year'] ?? null, $classMeta['department'] ?? null])->filter()->implode(' · ') + : null, + 'status' => $status ? ($statuses[$status] ?? strtoupper((string) $status)) : '-', + 'province' => $row['data']['province'] ?? '-', + 'city' => $row['data']['city'] ?? '-', + 'district' => $row['data']['district'] ?? '-', + 'village' => $row['data']['village'] ?? '-', + ]; + + return $row; + }, $preview['rows'] ?? []); + + return $preview; + } + protected function normalizeImportRow(array $row): array { $normalized = []; @@ -1292,6 +1422,10 @@ class StudentController extends Controller $regencyId = $this->resolveRegencyId($regencySource, $provinceId); $districtId = $this->resolveDistrictId($districtSource, $regencyId); $villageId = $this->resolveVillageId($villageSource, $districtId); + $provinceName = $provinceId ? Province::where('id', $provinceId)->value('name') : null; + $regencyName = $regencyId ? Regency::where('id', $regencyId)->value('name') : null; + $districtName = $districtId ? District::where('id', $districtId)->value('name') : null; + $villageName = $villageId ? Village::where('id', $villageId)->value('name') : null; return [ 'student_number' => $studentNumber, @@ -1313,10 +1447,10 @@ class StudentController extends Controller 'regency_id' => $regencyId, 'district_id' => $districtId, 'village_id' => $villageId, - 'province' => $provinceId ? Province::where('id', $provinceId)->value('name') : null, - 'city' => $regencyId ? Regency::where('id', $regencyId)->value('name') : null, - 'district' => $districtId ? District::where('id', $districtId)->value('name') : null, - 'village' => $villageId ? Village::where('id', $villageId)->value('name') : null, + 'province' => $provinceName, + 'city' => $regencyName, + 'district' => $districtName, + 'village' => $villageName, 'postal_code' => $postalCode, 'academic_year_id' => $academicYearId, 'department_id' => $departmentId, @@ -1355,7 +1489,12 @@ class StudentController extends Controller return $value; } - return Department::where('name', trim((string) $value))->value('id'); + $normalizedValue = trim((string) $value); + + return Department::query() + ->where('name', $normalizedValue) + ->orWhere('code', strtoupper($normalizedValue)) + ->value('id'); } protected function resolveClassId(mixed $value, mixed $academicYearId = null, mixed $departmentId = null): mixed @@ -1385,7 +1524,12 @@ class StudentController extends Controller return (string) $value; } - return Province::where('name', trim((string) $value))->value('id'); + $normalizedValue = $this->normalizeRegionLookupValue((string) $value); + + return Province::query() + ->get(['id', 'name']) + ->first(fn ($province) => $this->normalizeRegionLookupValue($province->name) === $normalizedValue) + ?->id; } protected function resolveRegencyId(mixed $value, mixed $provinceId = null): mixed @@ -1398,10 +1542,19 @@ class StudentController extends Controller return (string) $value; } + $normalizedValue = $this->normalizeRegionLookupValue((string) $value); + return Regency::query() ->when($provinceId, fn ($query) => $query->where('province_id', $provinceId)) - ->where('name', trim((string) $value)) - ->value('id'); + ->get(['id', 'name']) + ->first(function ($regency) use ($normalizedValue) { + $candidate = $this->normalizeRegionLookupValue($regency->name); + + return $candidate === $normalizedValue + || $candidate === 'KABUPATEN ' . $normalizedValue + || $candidate === 'KOTA ' . $normalizedValue; + }) + ?->id; } protected function resolveDistrictId(mixed $value, mixed $regencyId = null): mixed @@ -1414,10 +1567,13 @@ class StudentController extends Controller return (string) $value; } + $normalizedValue = $this->normalizeRegionLookupValue((string) $value); + return District::query() ->when($regencyId, fn ($query) => $query->where('regency_id', $regencyId)) - ->where('name', trim((string) $value)) - ->value('id'); + ->get(['id', 'name']) + ->first(fn ($district) => $this->normalizeRegionLookupValue($district->name) === $normalizedValue) + ?->id; } protected function resolveVillageId(mixed $value, mixed $districtId = null): mixed @@ -1430,10 +1586,22 @@ class StudentController extends Controller return (string) $value; } + $normalizedValue = $this->normalizeRegionLookupValue((string) $value); + return Village::query() ->when($districtId, fn ($query) => $query->where('district_id', $districtId)) - ->where('name', trim((string) $value)) - ->value('id'); + ->get(['id', 'name']) + ->first(fn ($village) => $this->normalizeRegionLookupValue($village->name) === $normalizedValue) + ?->id; + } + + protected function normalizeRegionLookupValue(string $value): string + { + $normalized = strtoupper(trim($value)); + $normalized = preg_replace('/\s+/', ' ', $normalized) ?? $normalized; + $normalized = str_replace(['KAB.', 'KABUPATEN.', 'KOTA.'], ['KABUPATEN', 'KABUPATEN', 'KOTA'], $normalized); + + return trim($normalized); } protected function normalizeImportDate(mixed $value): mixed @@ -1463,13 +1631,68 @@ class StudentController extends Controller return null; } + if (is_int($value)) { + return $value; + } + + if (is_float($value)) { + return (int) round($value); + } + if (is_numeric($value)) { - return (int) $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 normalizeImportYear(mixed $value): mixed + { + $value = $this->nullIfBlank($value); + + if ($value === null) { + return null; + } + + if ($value instanceof \DateTimeInterface) { + return (int) $value->format('Y'); + } + + if (is_string($value)) { + $normalized = trim($value); + + if (preg_match('/^(\d{4})[-\/]/', $normalized, $matches) === 1) { + return (int) $matches[1]; + } + } + + if (is_numeric($value)) { + $numericValue = (float) $value; + $intValue = (int) round($numericValue); + + if ($intValue >= 1900 && $intValue <= now()->year) { + return $intValue; + } + + try { + return (int) \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject($numericValue)->format('Y'); + } catch (\Throwable) { + return $intValue; + } + } + + return $this->normalizeImportInteger($value); + } + protected function normalizeImportBoolean(mixed $value): ?bool { $value = $this->nullIfBlank($value); @@ -1496,7 +1719,7 @@ class StudentController extends Controller return [ 'name' => $this->nullIfBlank($normalized["parent_{$relationKey}_name"] ?? ($normalized["nama_{$legacyPrefix}"] ?? null)), 'nik' => $this->nullIfBlank($normalized["parent_{$relationKey}_nik"] ?? ($normalized["nik_{$legacyPrefix}"] ?? null)), - 'birth_year' => $this->normalizeImportInteger($normalized["parent_{$relationKey}_birth_year"] ?? ($normalized["tahun_lahir_{$legacyPrefix}"] ?? null)), + 'birth_year' => $this->normalizeImportYear($normalized["parent_{$relationKey}_birth_year"] ?? ($normalized["tahun_lahir_{$legacyPrefix}"] ?? null)), 'education' => $this->nullIfBlank($normalized["parent_{$relationKey}_education"] ?? ($normalized["pendidikan_{$legacyPrefix}"] ?? null)), 'occupation' => $this->nullIfBlank($normalized["parent_{$relationKey}_occupation"] ?? ($normalized["pekerjaan_{$legacyPrefix}"] ?? null)), 'phone_number' => $this->nullIfBlank($normalized["parent_{$relationKey}_phone_number"] ?? ($normalized["no_hp_{$legacyPrefix}"] ?? null)), @@ -1662,7 +1885,13 @@ class StudentController extends Controller } ksort($values); - $ordered = array_values($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); diff --git a/resources/js/app.js b/resources/js/app.js index e0911aa..d036a74 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -14,15 +14,19 @@ document.addEventListener('DOMContentLoaded', () => { event.preventDefault(); const entity = form.dataset.entity || 'data ini'; + const title = form.dataset.confirmTitle || 'Hapus data?'; + const text = form.dataset.confirmText || `Yakin ingin menghapus ${entity}? Tindakan ini tidak bisa dibatalkan.`; + const confirmButtonColor = form.dataset.confirmButtonColor || '#e11d48'; + const confirmButtonText = form.dataset.confirmButtonText || 'Ya, hapus'; Swal.fire({ - title: 'Hapus data?', - text: `Yakin ingin menghapus ${entity}? Tindakan ini tidak bisa dibatalkan.`, + title, + text, icon: 'warning', showCancelButton: true, - confirmButtonColor: '#e11d48', + confirmButtonColor, cancelButtonColor: '#475569', - confirmButtonText: 'Ya, hapus', + confirmButtonText, cancelButtonText: 'Batal', reverseButtons: true, }).then((result) => { diff --git a/resources/views/document-types/index.blade.php b/resources/views/document-types/index.blade.php index 2df5d5a..54bb40b 100644 --- a/resources/views/document-types/index.blade.php +++ b/resources/views/document-types/index.blade.php @@ -31,7 +31,13 @@ {{ $documentType->max_file_size_kb ? number_format($documentType->max_file_size_kb / 1024, 2) . ' MB' : 'Default 5 MB' }}