1886 lines
78 KiB
PHP
1886 lines
78 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\AcademicYear;
|
|
use App\Models\Department;
|
|
use App\Models\District;
|
|
use App\Models\Province;
|
|
use App\Models\Regency;
|
|
use App\Models\SchoolClass;
|
|
use App\Models\Student;
|
|
use App\Models\Village;
|
|
use App\Models\StudentAcademicHistory;
|
|
use App\Models\StudentExit;
|
|
use App\Models\StudentTransfer;
|
|
use App\Models\StudentGraduation;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Response;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
|
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\Rule;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Illuminate\View\View;
|
|
use RealRashid\SweetAlert\Facades\Alert;
|
|
use App\Models\DocumentType;
|
|
|
|
class StudentController extends Controller
|
|
{
|
|
public function index(Request $request): View
|
|
{
|
|
$search = trim((string) $request->get('search'));
|
|
$academicYearId = $request->get('academic_year_id');
|
|
$departmentId = $request->get('department_id');
|
|
$classId = $request->get('class_id');
|
|
$statusFilter = $request->get('status');
|
|
$viewMode = (string) $request->get('view', 'all');
|
|
|
|
$viewConfig = $this->resolveStudentViewConfig($viewMode, null);
|
|
$status = $viewConfig['status'];
|
|
$sort = (string) $request->get('sort', 'full_name');
|
|
|
|
$direction = strtolower((string) $request->get('direction', $sort === 'full_name' ? 'asc' : 'desc')) === 'asc' ? 'asc' : 'desc';
|
|
|
|
$allowedSorts = [
|
|
'full_name' => 'students.full_name',
|
|
'student_number' => 'students.student_number',
|
|
'academic_year' => 'academic_years.name',
|
|
'department' => 'departments.name',
|
|
'class' => 'classes.name',
|
|
'phone_number' => 'students.phone_number',
|
|
'status' => 'students.status',
|
|
'created_at' => 'students.created_at',
|
|
];
|
|
|
|
if (! array_key_exists($sort, $allowedSorts)) {
|
|
$sort = 'created_at';
|
|
}
|
|
|
|
$requiredDocumentTypeIds = DocumentType::where('is_active', true)->where('is_required', true)->pluck('id');
|
|
|
|
$students = Student::query()
|
|
->select('students.*')
|
|
->with(['academicYear', 'department', 'classRoom', 'documents.documentType', 'achievements'])
|
|
->leftJoin('academic_years', 'academic_years.id', '=', 'students.academic_year_id')
|
|
->leftJoin('departments', 'departments.id', '=', 'students.department_id')
|
|
->leftJoin('classes', 'classes.id', '=', 'students.class_id')
|
|
->when($search !== '', function ($query) use ($search) {
|
|
$query->where(function ($subQuery) use ($search) {
|
|
$subQuery->where('students.full_name', 'like', "%{$search}%")
|
|
->orWhere('students.student_number', 'like', "%{$search}%")
|
|
->orWhere('students.nisn', 'like', "%{$search}%")
|
|
->orWhere('students.nik', 'like', "%{$search}%")
|
|
->orWhere('students.phone_number', 'like', "%{$search}%");
|
|
});
|
|
})
|
|
->when($academicYearId, fn ($query) => $query->where('students.academic_year_id', $academicYearId))
|
|
->when($departmentId, fn ($query) => $query->where('students.department_id', $departmentId))
|
|
->when($classId, fn ($query) => $query->where('students.class_id', $classId))
|
|
->when($viewConfig['view'] === 'all' && blank($statusFilter), fn ($query) => $query->where('students.status', 'active'))
|
|
->when($viewConfig['view'] === 'all' && $statusFilter === 'active', fn ($query) => $query->where('students.status', 'active'))
|
|
->when($viewConfig['view'] === 'all' && $statusFilter === 'non_active', fn ($query) => $query->whereIn('students.status', ['inactive', 'graduated', 'dropped', 'transferred']))
|
|
->when($viewConfig['view'] !== 'all' && $status, fn ($query) => $query->where('students.status', $status))
|
|
->orderBy($allowedSorts[$sort], $direction)
|
|
->orderBy('students.id', $direction)
|
|
->paginate(10)
|
|
->withQueryString();
|
|
|
|
$students->getCollection()->transform(function ($student) use ($requiredDocumentTypeIds) {
|
|
$requiredDocuments = $student->documents->whereIn('document_type_id', $requiredDocumentTypeIds);
|
|
|
|
$photoDocument = $student->documents
|
|
->filter(function ($document) {
|
|
$code = strtoupper((string) ($document->documentType?->code ?? ''));
|
|
$name = strtolower((string) ($document->documentType?->name ?? ''));
|
|
|
|
return $code === 'PAS_FOTO'
|
|
|| str_contains($name, 'pas foto')
|
|
|| str_contains($name, 'pasfoto');
|
|
})
|
|
->sortByDesc('created_at')
|
|
->first();
|
|
|
|
$presentRequired = $requiredDocuments
|
|
->pluck('document_type_id')
|
|
->unique()
|
|
->count();
|
|
|
|
$verifiedRequired = $requiredDocuments
|
|
->where('verification_status', 'valid')
|
|
->pluck('document_type_id')
|
|
->unique()
|
|
->count();
|
|
|
|
$student->required_documents_total = $requiredDocumentTypeIds->count();
|
|
$student->required_documents_present = $presentRequired;
|
|
$student->required_documents_verified = $verifiedRequired;
|
|
$student->required_documents_missing = max($student->required_documents_total - $presentRequired, 0);
|
|
$student->required_documents_unverified = max($student->required_documents_present - $student->required_documents_verified, 0);
|
|
$student->document_completion_percentage = $student->required_documents_total > 0
|
|
? (int) round(($presentRequired / $student->required_documents_total) * 100)
|
|
: 100;
|
|
$student->document_verification_percentage = $student->required_documents_total > 0
|
|
? (int) round(($verifiedRequired / $student->required_documents_total) * 100)
|
|
: 100;
|
|
$student->hover_photo_url = $photoDocument?->file_path ? asset('storage/' . $photoDocument->file_path) : null;
|
|
|
|
$achievementPriority = [
|
|
'Internasional' => 6,
|
|
'Nasional' => 5,
|
|
'Provinsi' => 4,
|
|
'Kabupaten/Kota' => 3,
|
|
'Kecamatan' => 2,
|
|
'Sekolah' => 1,
|
|
];
|
|
|
|
$topAchievement = $student->achievements
|
|
->sortByDesc(fn ($achievement) => $achievementPriority[$achievement->level] ?? 0)
|
|
->first();
|
|
|
|
$student->achievement_badge_level = $topAchievement?->level;
|
|
$student->achievement_badge_count = $student->achievements->count();
|
|
$student->achievement_badge_title = $topAchievement
|
|
? sprintf('Siswa berprestasi · %s · %s prestasi', $topAchievement->level ?: 'Prestasi', $student->achievement_badge_count)
|
|
: null;
|
|
|
|
return $student;
|
|
});
|
|
|
|
$summary = [
|
|
'total' => $students->total(),
|
|
'byAcademicYear' => Student::query()
|
|
->select('academic_years.name', 'academic_years.start_year', DB::raw('count(*) as total'))
|
|
->join('academic_years', 'academic_years.id', '=', 'students.academic_year_id')
|
|
->when($search !== '', function ($query) use ($search) {
|
|
$query->where(function ($subQuery) use ($search) {
|
|
$subQuery->where('students.full_name', 'like', "%{$search}%")
|
|
->orWhere('students.student_number', 'like', "%{$search}%")
|
|
->orWhere('students.nisn', 'like', "%{$search}%")
|
|
->orWhere('students.nik', 'like', "%{$search}%")
|
|
->orWhere('students.phone_number', 'like', "%{$search}%");
|
|
});
|
|
})
|
|
->when($academicYearId, fn ($query) => $query->where('students.academic_year_id', $academicYearId))
|
|
->when($departmentId, fn ($query) => $query->where('students.department_id', $departmentId))
|
|
->when($classId, fn ($query) => $query->where('students.class_id', $classId))
|
|
->where('students.status', $status ?: 'graduated')
|
|
->groupBy('academic_years.name', 'academic_years.start_year')
|
|
->orderByDesc('academic_years.start_year')
|
|
->limit(3)
|
|
->get(),
|
|
];
|
|
|
|
return view('students.index', [
|
|
'students' => $students,
|
|
'search' => $search,
|
|
'filters' => [
|
|
'academic_year_id' => $academicYearId,
|
|
'department_id' => $departmentId,
|
|
'class_id' => $classId,
|
|
'status' => $statusFilter,
|
|
],
|
|
'academicYears' => AcademicYear::orderBy('name')->pluck('name', 'id'),
|
|
'departments' => Department::where('is_active', true)->orderBy('name')->pluck('name', 'id'),
|
|
'classes' => SchoolClass::when(
|
|
$academicYearId,
|
|
fn ($query) => $query->where('academic_year_id', $academicYearId),
|
|
fn ($query) => $query->forActiveAcademicYear()
|
|
)->orderBy('name')->pluck('name', 'id'),
|
|
'statuses' => $viewConfig['view'] === 'all'
|
|
? [
|
|
'active' => 'Aktif',
|
|
'non_active' => 'Non Aktif',
|
|
]
|
|
: [],
|
|
'studentStatusLabels' => $this->statusOptions(),
|
|
'sort' => $sort,
|
|
'direction' => $direction,
|
|
'pageTitle' => $viewConfig['title'],
|
|
'pageDescription' => $viewConfig['description'],
|
|
'viewMode' => $viewConfig['view'],
|
|
'summary' => $summary,
|
|
]);
|
|
}
|
|
|
|
public function create(): View
|
|
{
|
|
return view('students.create', $this->formData());
|
|
}
|
|
|
|
public function store(Request $request): RedirectResponse
|
|
{
|
|
$data = $this->validateStudent($request);
|
|
|
|
$student = Student::create($data);
|
|
$this->syncAcademicHistory($student, $data);
|
|
$this->syncExitRecord($student, $data);
|
|
$this->syncTransferRecord($student, $data);
|
|
$this->syncGraduationRecord($student, $data);
|
|
|
|
Alert::success('Berhasil', 'Data siswa berhasil ditambahkan.');
|
|
|
|
return redirect()->route('students.index')->with('success', 'Data siswa berhasil ditambahkan.');
|
|
}
|
|
|
|
public function show(Student $student): View
|
|
{
|
|
$student->load(['academicYear', 'department', 'classRoom', 'parents', 'documents.documentType', 'academicHistories.academicYear', 'academicHistories.department', 'academicHistories.classRoom', 'achievements']);
|
|
|
|
$requiredDocumentTypes = DocumentType::where('is_active', true)
|
|
->where('is_required', true)
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
$requiredDocuments = $student->documents->whereIn('document_type_id', $requiredDocumentTypes->pluck('id'));
|
|
|
|
$uploadedRequiredDocumentTypeIds = $requiredDocuments
|
|
->pluck('document_type_id')
|
|
->unique();
|
|
|
|
$verifiedRequiredDocumentTypeIds = $requiredDocuments
|
|
->where('verification_status', 'valid')
|
|
->pluck('document_type_id')
|
|
->unique();
|
|
|
|
$requiredDocumentSummary = [
|
|
'total' => $requiredDocumentTypes->count(),
|
|
'uploaded' => $uploadedRequiredDocumentTypeIds->count(),
|
|
'verified' => $verifiedRequiredDocumentTypeIds->count(),
|
|
'missing' => max($requiredDocumentTypes->count() - $uploadedRequiredDocumentTypeIds->count(), 0),
|
|
'unverified' => max($uploadedRequiredDocumentTypeIds->count() - $verifiedRequiredDocumentTypeIds->count(), 0),
|
|
'percentage' => $requiredDocumentTypes->count() > 0
|
|
? (int) round(($uploadedRequiredDocumentTypeIds->count() / $requiredDocumentTypes->count()) * 100)
|
|
: 100,
|
|
'verification_percentage' => $requiredDocumentTypes->count() > 0
|
|
? (int) round(($verifiedRequiredDocumentTypeIds->count() / $requiredDocumentTypes->count()) * 100)
|
|
: 100,
|
|
'missing_items' => $requiredDocumentTypes
|
|
->whereNotIn('id', $uploadedRequiredDocumentTypeIds)
|
|
->pluck('name'),
|
|
'unverified_items' => $requiredDocumentTypes
|
|
->whereIn('id', $uploadedRequiredDocumentTypeIds)
|
|
->whereNotIn('id', $verifiedRequiredDocumentTypeIds)
|
|
->pluck('name'),
|
|
];
|
|
|
|
return view('students.show', [
|
|
'student' => $student,
|
|
'relationLabels' => [
|
|
'father' => 'Ayah',
|
|
'mother' => 'Ibu',
|
|
'guardian' => 'Wali',
|
|
],
|
|
'requiredDocumentSummary' => $requiredDocumentSummary,
|
|
]);
|
|
}
|
|
|
|
public function edit(Student $student): View
|
|
{
|
|
$student->loadMissing('classRoom');
|
|
|
|
return view('students.edit', array_merge(
|
|
$this->formData($student),
|
|
[
|
|
'student' => $student,
|
|
]
|
|
));
|
|
}
|
|
|
|
public function update(Request $request, Student $student): RedirectResponse
|
|
{
|
|
$data = $this->validateStudent($request, $student);
|
|
|
|
$student->update($data);
|
|
$this->syncAcademicHistory($student, $data);
|
|
$this->syncExitRecord($student, $data);
|
|
$this->syncTransferRecord($student, $data);
|
|
$this->syncGraduationRecord($student, $data);
|
|
|
|
Alert::success('Berhasil', 'Data siswa berhasil diperbarui.');
|
|
|
|
return redirect()->route('students.index')->with('success', 'Data siswa berhasil diperbarui.');
|
|
}
|
|
|
|
public function destroy(Student $student): RedirectResponse
|
|
{
|
|
$this->ensureAdmin();
|
|
|
|
$student->delete();
|
|
|
|
Alert::success('Berhasil', 'Data siswa berhasil dihapus.');
|
|
|
|
return redirect()->route('students.index')->with('success', 'Data siswa berhasil dihapus.');
|
|
}
|
|
|
|
public function bulkDestroy(Request $request): RedirectResponse
|
|
{
|
|
$this->ensureAdmin();
|
|
|
|
$validated = $request->validate([
|
|
'student_ids' => ['required', 'array', 'min:1'],
|
|
'student_ids.*' => ['integer', 'exists:students,id'],
|
|
], [
|
|
'student_ids.required' => 'Pilih minimal satu siswa untuk dihapus.',
|
|
]);
|
|
|
|
$students = Student::whereIn('id', $validated['student_ids'])->get();
|
|
$deletedCount = 0;
|
|
|
|
foreach ($students as $student) {
|
|
$student->delete();
|
|
$deletedCount++;
|
|
}
|
|
|
|
Alert::success('Berhasil', $deletedCount . ' data siswa berhasil dihapus.');
|
|
|
|
return redirect()->route('students.index')->with('success', $deletedCount . ' data siswa berhasil dihapus.');
|
|
}
|
|
|
|
public function importForm(Request $request): View
|
|
{
|
|
$this->ensureAdmin();
|
|
|
|
$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,
|
|
]));
|
|
}
|
|
|
|
public function previewImport(Request $request): RedirectResponse
|
|
{
|
|
$this->ensureAdmin();
|
|
|
|
$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 siswa 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('student_import_preview', $preview);
|
|
|
|
return redirect()->route('students.import.form');
|
|
}
|
|
|
|
public function import(Request $request): RedirectResponse
|
|
{
|
|
$this->ensureAdmin();
|
|
|
|
$request->validate([
|
|
'preview_token' => ['required', 'string'],
|
|
]);
|
|
|
|
$preview = $request->session()->get('student_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'], [
|
|
'student_number',
|
|
'nisn',
|
|
'nik',
|
|
'full_name',
|
|
'gender',
|
|
'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',
|
|
'academic_year_id',
|
|
'department_id',
|
|
'class_id',
|
|
'entry_date',
|
|
'status',
|
|
'notes',
|
|
]);
|
|
|
|
$payload = $this->hydrateRegionNamesFromIds($payload);
|
|
|
|
$lookup = null;
|
|
|
|
if (! empty($payload['student_number'])) {
|
|
$lookup = Student::withTrashed()->where('student_number', $payload['student_number'])->first();
|
|
}
|
|
|
|
if (! $lookup && ! empty($payload['nisn'])) {
|
|
$lookup = Student::withTrashed()->where('nisn', $payload['nisn'])->first();
|
|
}
|
|
|
|
if ($lookup) {
|
|
if (method_exists($lookup, 'trashed') && $lookup->trashed()) {
|
|
$lookup->restore();
|
|
}
|
|
|
|
$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;
|
|
}
|
|
|
|
$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++;
|
|
}
|
|
});
|
|
|
|
$request->session()->forget('student_import_preview');
|
|
|
|
Alert::success('Berhasil', "Import data siswa selesai. {$inserted} data baru ditambahkan, {$updated} data diperbarui.");
|
|
|
|
return redirect()
|
|
->route('students.index')
|
|
->with('success', "Import data siswa selesai. {$inserted} data baru ditambahkan, {$updated} data diperbarui.");
|
|
}
|
|
|
|
public function downloadImportTemplate()
|
|
{
|
|
$this->ensureAdmin();
|
|
|
|
$spreadsheet = new Spreadsheet();
|
|
$sheet = $spreadsheet->getActiveSheet();
|
|
$sheet->setTitle('Import Siswa');
|
|
|
|
$headers = [
|
|
'NIS',
|
|
'NISN',
|
|
'NIK',
|
|
'Nama Lengkap',
|
|
'Jenis Kelamin (L/P)',
|
|
'Tempat Lahir',
|
|
'Tanggal Lahir (YYYY-MM-DD)',
|
|
'Agama',
|
|
'Kewarganegaraan',
|
|
'Nomor HP',
|
|
'Email',
|
|
'Alamat',
|
|
'Provinsi',
|
|
'Kabupaten/Kota',
|
|
'Kecamatan',
|
|
'Kelurahan/Desa',
|
|
'Kode Pos',
|
|
'Tahun Ajaran',
|
|
'Program Keahlian',
|
|
'Kelas',
|
|
'Tanggal Masuk (YYYY-MM-DD)',
|
|
'Status',
|
|
'Catatan',
|
|
];
|
|
|
|
$sheet->fromArray($headers, null, 'A1');
|
|
$sheet->fromArray([
|
|
'2026-001',
|
|
'9988776655',
|
|
'3512345678901234',
|
|
'Contoh Nama Siswa',
|
|
'L',
|
|
'Malang',
|
|
'2010-01-15',
|
|
'Islam',
|
|
'Indonesia',
|
|
'081234567890',
|
|
'contoh@siswa.local',
|
|
'Jl. Contoh No. 1',
|
|
'Jawa Timur',
|
|
'Kota Malang',
|
|
'Klojen',
|
|
'Kauman',
|
|
'65119',
|
|
AcademicYear::orderBy('name')->value('name') ?? '2026/2027',
|
|
Department::orderBy('name')->value('name') ?? 'DKV',
|
|
SchoolClass::orderBy('name')->value('name') ?? 'X DKV 1',
|
|
'2026-07-15',
|
|
'active',
|
|
'Catatan opsional',
|
|
], 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(['Kelas'], null, 'E1');
|
|
$referenceSheet->fromArray(
|
|
SchoolClass::with(['academicYear', 'department'])->orderBy('name')->get()->map(function ($classRoom) {
|
|
return [$classRoom->name, $classRoom->academicYear?->name, $classRoom->department?->name];
|
|
})->all(),
|
|
null,
|
|
'E2'
|
|
);
|
|
$referenceSheet->fromArray(['Nama Kelas', 'Tahun Ajaran', 'Program Keahlian'], null, 'E1');
|
|
|
|
$guideSheet = $spreadsheet->createSheet();
|
|
$guideSheet->setTitle('Panduan');
|
|
$guideSheet->fromArray([
|
|
['Kolom', 'Keterangan'],
|
|
['NIS', 'Boleh kosong, tapi idealnya diisi untuk identitas lokal sekolah.'],
|
|
['NISN', 'Boleh kosong jika belum ada.'],
|
|
['NIK', 'Opsional, tetapi jika diisi harus unik.'],
|
|
['Nama Lengkap', 'Wajib diisi.'],
|
|
['Jenis Kelamin (L/P)', 'Isi L atau P.'],
|
|
['Tempat Lahir', 'Opsional.'],
|
|
['Tanggal Lahir (YYYY-MM-DD)', 'Format contoh: 2010-01-15.'],
|
|
['Agama', 'Opsional, contoh: Islam, Kristen, Katolik, Hindu, Buddha, Konghucu.'],
|
|
['Kewarganegaraan', 'Opsional, default aman: Indonesia.'],
|
|
['Nomor HP', 'Opsional.'],
|
|
['Email', 'Opsional, harus format email valid bila diisi.'],
|
|
['Alamat', 'Opsional.'],
|
|
['Provinsi', 'Opsional, isi nama persis seperti data master wilayah.'],
|
|
['Kabupaten/Kota', 'Opsional, isi nama persis seperti data master wilayah.'],
|
|
['Kecamatan', 'Opsional, isi nama persis seperti data master wilayah.'],
|
|
['Kelurahan/Desa', 'Opsional, isi nama persis seperti data master wilayah.'],
|
|
['Kode Pos', 'Opsional.'],
|
|
['Tahun Ajaran', 'Isi dengan nama tahun ajaran persis seperti sheet Referensi.'],
|
|
['Program Keahlian', 'Isi dengan nama jurusan persis seperti sheet Referensi.'],
|
|
['Kelas', 'Isi dengan nama kelas persis seperti sheet Referensi.'],
|
|
['Tanggal Masuk (YYYY-MM-DD)', 'Format contoh: 2026-07-15.'],
|
|
['Status', 'Gunakan: active, inactive, transferred, graduated, dropped.'],
|
|
], 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-siswa.xlsx"',
|
|
]);
|
|
}
|
|
|
|
public function downloadImportExample()
|
|
{
|
|
$this->ensureAdmin();
|
|
|
|
$path = storage_path('app/private/student-import-example.xlsx');
|
|
|
|
if (! file_exists($path)) {
|
|
$response = $this->downloadImportTemplate();
|
|
$response->headers->set('Content-Disposition', 'attachment; filename="contoh-import-siswa.xlsx"');
|
|
|
|
return $response;
|
|
}
|
|
|
|
return response()->file($path, [
|
|
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'Content-Disposition' => 'attachment; filename="contoh-import-siswa.xlsx"',
|
|
]);
|
|
}
|
|
|
|
protected function validateStudent(Request $request, ?Student $student = null): array
|
|
{
|
|
$data = $request->validate([
|
|
'student_number' => ['nullable', 'string', 'max:30', Rule::unique('students', 'student_number')->ignore($student?->id)],
|
|
'nisn' => ['nullable', 'string', 'max:30', Rule::unique('students', 'nisn')->ignore($student?->id)],
|
|
'nik' => ['nullable', 'string', 'max:30', Rule::unique('students', 'nik')->ignore($student?->id)],
|
|
'full_name' => ['required', 'string', 'max:150'],
|
|
'gender' => ['required', Rule::in(['L', 'P'])],
|
|
'place_of_birth' => ['nullable', 'string', 'max:100'],
|
|
'date_of_birth' => ['nullable', 'date'],
|
|
'religion' => ['nullable', 'string', 'max:50'],
|
|
'nationality' => ['nullable', 'string', 'max:50'],
|
|
'family_card_number' => ['nullable', 'string', 'max:30'],
|
|
'child_order' => ['nullable', 'integer', 'min:1'],
|
|
'number_of_siblings' => ['nullable', 'integer', 'min:0'],
|
|
'phone_number' => ['nullable', 'string', 'max:30'],
|
|
'email' => ['nullable', 'email', 'max:100'],
|
|
'address' => ['nullable', 'string'],
|
|
'province_id' => ['nullable', 'string', 'exists:provinces,id'],
|
|
'regency_id' => ['nullable', 'string', 'exists:regencies,id'],
|
|
'district_id' => ['nullable', 'string', 'exists:districts,id'],
|
|
'village_id' => ['nullable', 'string', 'exists:villages,id'],
|
|
'village' => ['nullable', 'string', 'max:100'],
|
|
'district' => ['nullable', 'string', 'max:100'],
|
|
'city' => ['nullable', 'string', 'max:100'],
|
|
'province' => ['nullable', 'string', 'max:100'],
|
|
'postal_code' => ['nullable', 'string', 'max:10'],
|
|
'academic_year_id' => ['required', Rule::exists('academic_years', 'id')->where(fn ($query) => $student ? $query->where('is_active', true)->orWhere('id', $student->academic_year_id) : $query->where('is_active', true))],
|
|
'department_id' => ['required', Rule::exists('departments', 'id')->where(fn ($query) => $student ? $query->where('is_active', true)->orWhere('id', $student->department_id) : $query->where('is_active', true))],
|
|
'class_id' => [
|
|
'required',
|
|
Rule::exists('classes', 'id')->where(fn ($query) => $student ? $query->where(function ($subQuery) use ($student) {
|
|
$subQuery->where('is_active', true)
|
|
->orWhere('id', $student->class_id);
|
|
}) : $query->where('is_active', true)),
|
|
],
|
|
'entry_date' => ['nullable', 'date'],
|
|
'status' => ['required', Rule::in(array_keys($this->statusOptions()))],
|
|
'notes' => ['nullable', 'string'],
|
|
'exit_letter' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png,webp', 'max:5120'],
|
|
'transfer_letter' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png,webp', 'max:5120'],
|
|
]);
|
|
|
|
$classRoom = SchoolClass::query()
|
|
->select(['id', 'academic_year_id', 'department_id'])
|
|
->find($data['class_id']);
|
|
|
|
if (! $classRoom) {
|
|
throw ValidationException::withMessages([
|
|
'class_id' => 'Kelas yang dipilih tidak ditemukan.',
|
|
]);
|
|
}
|
|
|
|
if ((int) $classRoom->academic_year_id !== (int) $data['academic_year_id']) {
|
|
throw ValidationException::withMessages([
|
|
'class_id' => 'Kelas tidak sesuai dengan Tahun Angkatan yang dipilih.',
|
|
]);
|
|
}
|
|
|
|
if ((int) $classRoom->department_id !== (int) $data['department_id']) {
|
|
throw ValidationException::withMessages([
|
|
'class_id' => 'Kelas tidak sesuai dengan Program Keahlian yang dipilih.',
|
|
]);
|
|
}
|
|
|
|
$data['province'] = !empty($data['province_id']) ? Province::where('id', $data['province_id'])->value('name') : null;
|
|
$data['city'] = !empty($data['regency_id']) ? Regency::where('id', $data['regency_id'])->value('name') : null;
|
|
$data['district'] = !empty($data['district_id']) ? District::where('id', $data['district_id'])->value('name') : null;
|
|
$data['village'] = !empty($data['village_id']) ? Village::where('id', $data['village_id'])->value('name') : null;
|
|
|
|
return $data;
|
|
}
|
|
|
|
protected function formData(?Student $student = null): array
|
|
{
|
|
$classOptionsQuery = SchoolClass::query()
|
|
->forActiveAcademicYear()
|
|
->when($student?->class_id, fn ($query) => $query->orWhere('id', $student->class_id))
|
|
->orderBy('name');
|
|
|
|
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::where('is_active', true)->orderBy('name')->pluck('name', 'id'),
|
|
'provinces' => Province::orderBy('name')->pluck('name', 'id'),
|
|
'classOptions' => $classOptionsQuery
|
|
->get(['id', 'name', 'department_id', 'academic_year_id'])
|
|
->map(fn ($classRoom) => [
|
|
'id' => $classRoom->id,
|
|
'name' => $classRoom->name,
|
|
'department_id' => $classRoom->department_id,
|
|
'academic_year_id' => $classRoom->academic_year_id,
|
|
])
|
|
->values(),
|
|
'statuses' => $this->statusOptions(),
|
|
'religions' => [
|
|
'Islam' => 'Islam',
|
|
'Kristen' => 'Kristen',
|
|
'Katolik' => 'Katolik',
|
|
'Hindu' => 'Hindu',
|
|
'Buddha' => 'Buddha',
|
|
'Konghucu' => 'Konghucu',
|
|
],
|
|
'genders' => [
|
|
'L' => 'Laki-laki',
|
|
'P' => 'Perempuan',
|
|
],
|
|
];
|
|
}
|
|
|
|
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'])->forActiveAcademicYear()->where('is_active', true)->orderBy('name')->get();
|
|
|
|
return [
|
|
'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 [
|
|
'active' => 'Aktif',
|
|
'graduated' => 'Lulus',
|
|
'transferred' => 'Pindah',
|
|
'dropped' => 'Keluar',
|
|
'inactive' => 'Nonaktif',
|
|
];
|
|
}
|
|
|
|
protected function syncAcademicHistory(Student $student, array $data): void
|
|
{
|
|
$currentHistory = StudentAcademicHistory::query()
|
|
->where('student_id', $student->id)
|
|
->where('is_current', true)
|
|
->first();
|
|
|
|
$samePlacement = $currentHistory
|
|
&& (int) $currentHistory->academic_year_id === (int) $data['academic_year_id']
|
|
&& (int) $currentHistory->department_id === (int) $data['department_id']
|
|
&& (int) $currentHistory->class_id === (int) $data['class_id']
|
|
&& $currentHistory->status === $data['status'];
|
|
|
|
if ($samePlacement) {
|
|
$currentHistory->update([
|
|
'start_date' => $data['entry_date'] ?? $currentHistory->start_date,
|
|
]);
|
|
|
|
return;
|
|
}
|
|
|
|
StudentAcademicHistory::query()
|
|
->where('student_id', $student->id)
|
|
->where('is_current', true)
|
|
->update([
|
|
'is_current' => false,
|
|
'end_date' => now()->toDateString(),
|
|
]);
|
|
|
|
StudentAcademicHistory::updateOrCreate(
|
|
[
|
|
'student_id' => $student->id,
|
|
'academic_year_id' => $data['academic_year_id'],
|
|
],
|
|
[
|
|
'department_id' => $data['department_id'],
|
|
'class_id' => $data['class_id'],
|
|
'status' => $data['status'],
|
|
'start_date' => $data['entry_date'] ?? null,
|
|
'end_date' => null,
|
|
'notes' => null,
|
|
'is_current' => true,
|
|
]
|
|
);
|
|
}
|
|
|
|
protected function syncExitRecord(Student $student, array $data): void
|
|
{
|
|
if (($data['status'] ?? null) === 'dropped') {
|
|
$exitRecord = StudentExit::firstOrNew(['student_id' => $student->id]);
|
|
|
|
$exitRecord->fill([
|
|
'student_number' => $student->student_number,
|
|
'nisn' => $student->nisn,
|
|
'full_name' => $student->full_name,
|
|
'academic_year_id' => $student->academic_year_id,
|
|
'department_id' => $student->department_id,
|
|
'class_id' => $student->class_id,
|
|
'entry_date' => $student->entry_date,
|
|
'exit_date' => $exitRecord->exit_date ?? now()->toDateString(),
|
|
'exit_reason' => $student->notes,
|
|
'status' => 'dropped',
|
|
]);
|
|
|
|
if (request()->hasFile('exit_letter')) {
|
|
if ($exitRecord->exit_letter_file_path && Storage::disk('public')->exists($exitRecord->exit_letter_file_path)) {
|
|
Storage::disk('public')->delete($exitRecord->exit_letter_file_path);
|
|
}
|
|
|
|
$file = request()->file('exit_letter');
|
|
$path = $file->store("student-exit-letters/{$student->id}", 'public');
|
|
|
|
$exitRecord->exit_letter_file_name = $file->getClientOriginalName();
|
|
$exitRecord->exit_letter_file_path = $path;
|
|
$exitRecord->exit_letter_mime_type = $file->getMimeType();
|
|
$exitRecord->exit_letter_file_size = $file->getSize();
|
|
}
|
|
|
|
$exitRecord->save();
|
|
|
|
return;
|
|
}
|
|
|
|
StudentExit::query()->where('student_id', $student->id)->delete();
|
|
}
|
|
|
|
protected function syncTransferRecord(Student $student, array $data): void
|
|
{
|
|
if (($data['status'] ?? null) === 'transferred') {
|
|
$transferRecord = StudentTransfer::firstOrNew(['student_id' => $student->id]);
|
|
|
|
$transferRecord->fill([
|
|
'student_number' => $student->student_number,
|
|
'nisn' => $student->nisn,
|
|
'full_name' => $student->full_name,
|
|
'academic_year_id' => $student->academic_year_id,
|
|
'department_id' => $student->department_id,
|
|
'class_id' => $student->class_id,
|
|
'entry_date' => $student->entry_date,
|
|
'transfer_date' => $transferRecord->transfer_date ?? now()->toDateString(),
|
|
'transfer_reason' => $student->notes,
|
|
'status' => 'transferred',
|
|
]);
|
|
|
|
if (request()->hasFile('transfer_letter')) {
|
|
if ($transferRecord->transfer_letter_file_path && Storage::disk('public')->exists($transferRecord->transfer_letter_file_path)) {
|
|
Storage::disk('public')->delete($transferRecord->transfer_letter_file_path);
|
|
}
|
|
|
|
$file = request()->file('transfer_letter');
|
|
$path = $file->store("student-transfer-letters/{$student->id}", 'public');
|
|
|
|
$transferRecord->transfer_letter_file_name = $file->getClientOriginalName();
|
|
$transferRecord->transfer_letter_file_path = $path;
|
|
$transferRecord->transfer_letter_mime_type = $file->getMimeType();
|
|
$transferRecord->transfer_letter_file_size = $file->getSize();
|
|
}
|
|
|
|
$transferRecord->save();
|
|
|
|
return;
|
|
}
|
|
|
|
StudentTransfer::query()->where('student_id', $student->id)->delete();
|
|
}
|
|
|
|
protected function syncGraduationRecord(Student $student, array $data): void
|
|
{
|
|
if (($data['status'] ?? null) === 'graduated') {
|
|
$graduationRecord = StudentGraduation::firstOrNew(['student_id' => $student->id]);
|
|
|
|
$graduationRecord->fill([
|
|
'student_number' => $student->student_number,
|
|
'nisn' => $student->nisn,
|
|
'full_name' => $student->full_name,
|
|
'academic_year_id' => $student->academic_year_id,
|
|
'department_id' => $student->department_id,
|
|
'class_id' => $student->class_id,
|
|
'entry_date' => $student->entry_date,
|
|
'graduation_date' => $graduationRecord->graduation_date ?? now()->toDateString(),
|
|
'graduation_note' => $student->notes,
|
|
'status' => 'graduated',
|
|
]);
|
|
|
|
if (request()->hasFile('skl_file')) {
|
|
if ($graduationRecord->skl_file_path && Storage::disk('public')->exists($graduationRecord->skl_file_path)) {
|
|
Storage::disk('public')->delete($graduationRecord->skl_file_path);
|
|
}
|
|
|
|
$file = request()->file('skl_file');
|
|
$path = $file->store("student-graduation-documents/{$student->id}/skl", 'public');
|
|
|
|
$graduationRecord->skl_file_name = $file->getClientOriginalName();
|
|
$graduationRecord->skl_file_path = $path;
|
|
$graduationRecord->skl_mime_type = $file->getMimeType();
|
|
$graduationRecord->skl_file_size = $file->getSize();
|
|
}
|
|
|
|
if (request()->hasFile('ijazah_file')) {
|
|
if ($graduationRecord->ijazah_file_path && Storage::disk('public')->exists($graduationRecord->ijazah_file_path)) {
|
|
Storage::disk('public')->delete($graduationRecord->ijazah_file_path);
|
|
}
|
|
|
|
$file = request()->file('ijazah_file');
|
|
$path = $file->store("student-graduation-documents/{$student->id}/ijazah", 'public');
|
|
|
|
$graduationRecord->ijazah_file_name = $file->getClientOriginalName();
|
|
$graduationRecord->ijazah_file_path = $path;
|
|
$graduationRecord->ijazah_mime_type = $file->getMimeType();
|
|
$graduationRecord->ijazah_file_size = $file->getSize();
|
|
}
|
|
|
|
if (request()->hasFile('transcript_file')) {
|
|
if ($graduationRecord->transcript_file_path && Storage::disk('public')->exists($graduationRecord->transcript_file_path)) {
|
|
Storage::disk('public')->delete($graduationRecord->transcript_file_path);
|
|
}
|
|
|
|
$file = request()->file('transcript_file');
|
|
$path = $file->store("student-graduation-documents/{$student->id}/transcript", 'public');
|
|
|
|
$graduationRecord->transcript_file_name = $file->getClientOriginalName();
|
|
$graduationRecord->transcript_file_path = $path;
|
|
$graduationRecord->transcript_mime_type = $file->getMimeType();
|
|
$graduationRecord->transcript_file_size = $file->getSize();
|
|
}
|
|
|
|
if (request()->hasFile('ukk_certificate_file')) {
|
|
if ($graduationRecord->ukk_certificate_file_path && Storage::disk('public')->exists($graduationRecord->ukk_certificate_file_path)) {
|
|
Storage::disk('public')->delete($graduationRecord->ukk_certificate_file_path);
|
|
}
|
|
|
|
$file = request()->file('ukk_certificate_file');
|
|
$path = $file->store("student-graduation-documents/{$student->id}/ukk-certificate", 'public');
|
|
|
|
$graduationRecord->ukk_certificate_file_name = $file->getClientOriginalName();
|
|
$graduationRecord->ukk_certificate_file_path = $path;
|
|
$graduationRecord->ukk_certificate_mime_type = $file->getMimeType();
|
|
$graduationRecord->ukk_certificate_file_size = $file->getSize();
|
|
}
|
|
|
|
if (request()->hasFile('lsp_certificate_file')) {
|
|
if ($graduationRecord->lsp_certificate_file_path && Storage::disk('public')->exists($graduationRecord->lsp_certificate_file_path)) {
|
|
Storage::disk('public')->delete($graduationRecord->lsp_certificate_file_path);
|
|
}
|
|
|
|
$file = request()->file('lsp_certificate_file');
|
|
$path = $file->store("student-graduation-documents/{$student->id}/lsp-certificate", 'public');
|
|
|
|
$graduationRecord->lsp_certificate_file_name = $file->getClientOriginalName();
|
|
$graduationRecord->lsp_certificate_file_path = $path;
|
|
$graduationRecord->lsp_certificate_mime_type = $file->getMimeType();
|
|
$graduationRecord->lsp_certificate_file_size = $file->getSize();
|
|
}
|
|
|
|
$graduationRecord->save();
|
|
|
|
return;
|
|
}
|
|
|
|
StudentGraduation::query()->where('student_id', $student->id)->delete();
|
|
}
|
|
|
|
protected function importRules(): array
|
|
{
|
|
return [
|
|
'student_number' => ['nullable', 'string', 'max:30'],
|
|
'nisn' => ['nullable', 'string', 'max:30'],
|
|
'nik' => ['nullable', 'string', 'max:30'],
|
|
'full_name' => ['required', 'string', 'max:150'],
|
|
'gender' => ['required', Rule::in(['L', 'P'])],
|
|
'place_of_birth' => ['nullable', 'string', 'max:100'],
|
|
'date_of_birth' => ['nullable', 'date'],
|
|
'religion' => ['nullable', 'string', 'max:50'],
|
|
'nationality' => ['nullable', 'string', 'max:50'],
|
|
'family_card_number' => ['nullable', 'string', 'max:30'],
|
|
'child_order' => ['nullable', 'integer', 'min:1'],
|
|
'number_of_siblings' => ['nullable', 'integer', 'min:0'],
|
|
'phone_number' => ['nullable', 'string', 'max:30'],
|
|
'email' => ['nullable', 'email', 'max:100'],
|
|
'address' => ['nullable', 'string'],
|
|
'province_id' => ['nullable', 'string', 'exists:provinces,id'],
|
|
'regency_id' => ['nullable', 'string', 'exists:regencies,id'],
|
|
'district_id' => ['nullable', 'string', 'exists:districts,id'],
|
|
'village_id' => ['nullable', 'string', 'exists:villages,id'],
|
|
'postal_code' => ['nullable', 'string', 'max:10'],
|
|
'academic_year_id' => ['required', 'integer', Rule::exists('academic_years', 'id')->where(fn ($query) => $query->where('is_active', true))],
|
|
'department_id' => ['required', 'integer', Rule::exists('departments', 'id')->where(fn ($query) => $query->where('is_active', true))],
|
|
'class_id' => ['required', 'integer', Rule::exists('classes', 'id')->where(fn ($query) => $query->where('is_active', true))],
|
|
'entry_date' => ['nullable', 'date'],
|
|
'status' => ['required', Rule::in(array_keys($this->statusOptions()))],
|
|
'notes' => ['nullable', 'string'],
|
|
'parents.father.name' => ['nullable', 'string', 'max:150'],
|
|
'parents.father.nik' => ['nullable', 'string', 'max:30'],
|
|
'parents.father.birth_year' => ['nullable', 'integer', 'min:1900', 'max:' . now()->year],
|
|
'parents.father.education' => ['nullable', 'string', 'max:100'],
|
|
'parents.father.occupation' => ['nullable', 'string', 'max:100'],
|
|
'parents.father.phone_number' => ['nullable', 'string', 'max:30'],
|
|
'parents.father.address' => ['nullable', 'string'],
|
|
'parents.father.is_alive' => ['nullable', 'boolean'],
|
|
'parents.mother.name' => ['nullable', 'string', 'max:150'],
|
|
'parents.mother.nik' => ['nullable', 'string', 'max:30'],
|
|
'parents.mother.birth_year' => ['nullable', 'integer', 'min:1900', 'max:' . now()->year],
|
|
'parents.mother.education' => ['nullable', 'string', 'max:100'],
|
|
'parents.mother.occupation' => ['nullable', 'string', 'max:100'],
|
|
'parents.mother.phone_number' => ['nullable', 'string', 'max:30'],
|
|
'parents.mother.address' => ['nullable', 'string'],
|
|
'parents.mother.is_alive' => ['nullable', 'boolean'],
|
|
'parents.guardian.name' => ['nullable', 'string', 'max:150'],
|
|
'parents.guardian.nik' => ['nullable', 'string', 'max:30'],
|
|
'parents.guardian.birth_year' => ['nullable', 'integer', 'min:1900', 'max:' . now()->year],
|
|
'parents.guardian.education' => ['nullable', 'string', 'max:100'],
|
|
'parents.guardian.occupation' => ['nullable', 'string', 'max:100'],
|
|
'parents.guardian.phone_number' => ['nullable', 'string', 'max:30'],
|
|
'parents.guardian.address' => ['nullable', 'string'],
|
|
'parents.guardian.is_alive' => ['nullable', 'boolean'],
|
|
];
|
|
}
|
|
|
|
protected function importAttributes(): array
|
|
{
|
|
return [
|
|
'student_number' => 'NIS',
|
|
'nisn' => 'NISN',
|
|
'nik' => 'NIK',
|
|
'full_name' => 'nama lengkap',
|
|
'gender' => 'jenis kelamin',
|
|
'place_of_birth' => 'tempat lahir',
|
|
'date_of_birth' => 'tanggal lahir',
|
|
'religion' => 'agama',
|
|
'nationality' => 'kewarganegaraan',
|
|
'family_card_number' => 'nomor kartu keluarga',
|
|
'child_order' => 'anak ke',
|
|
'number_of_siblings' => 'jumlah saudara',
|
|
'phone_number' => 'nomor HP',
|
|
'email' => 'email',
|
|
'address' => 'alamat',
|
|
'province_id' => 'provinsi',
|
|
'regency_id' => 'kabupaten/kota',
|
|
'district_id' => 'kecamatan',
|
|
'village_id' => 'kelurahan/desa',
|
|
'postal_code' => 'kode pos',
|
|
'academic_year_id' => 'tahun ajaran',
|
|
'department_id' => 'program keahlian',
|
|
'class_id' => 'kelas',
|
|
'entry_date' => 'tanggal masuk',
|
|
'status' => 'status',
|
|
'notes' => 'catatan',
|
|
'parents.father.name' => 'nama ayah',
|
|
'parents.father.nik' => 'NIK ayah',
|
|
'parents.father.birth_year' => 'tahun lahir ayah',
|
|
'parents.father.education' => 'pendidikan ayah',
|
|
'parents.father.occupation' => 'pekerjaan ayah',
|
|
'parents.father.phone_number' => 'nomor HP ayah',
|
|
'parents.father.address' => 'alamat ayah',
|
|
'parents.father.is_alive' => 'status hidup ayah',
|
|
'parents.mother.name' => 'nama ibu',
|
|
'parents.mother.nik' => 'NIK ibu',
|
|
'parents.mother.birth_year' => 'tahun lahir ibu',
|
|
'parents.mother.education' => 'pendidikan ibu',
|
|
'parents.mother.occupation' => 'pekerjaan ibu',
|
|
'parents.mother.phone_number' => 'nomor HP ibu',
|
|
'parents.mother.address' => 'alamat ibu',
|
|
'parents.mother.is_alive' => 'status hidup ibu',
|
|
'parents.guardian.name' => 'nama wali',
|
|
'parents.guardian.nik' => 'NIK wali',
|
|
'parents.guardian.birth_year' => 'tahun lahir wali',
|
|
'parents.guardian.education' => 'pendidikan wali',
|
|
'parents.guardian.occupation' => 'pekerjaan wali',
|
|
'parents.guardian.phone_number' => 'nomor HP wali',
|
|
'parents.guardian.address' => 'alamat wali',
|
|
'parents.guardian.is_alive' => 'status hidup wali',
|
|
];
|
|
}
|
|
|
|
protected function buildImportPreview(array $rows): array
|
|
{
|
|
$previewRows = [];
|
|
$errors = [];
|
|
$validRows = [];
|
|
|
|
foreach ($rows as $rowNumber => $row) {
|
|
$normalized = $this->normalizeImportRow($row);
|
|
|
|
if ($this->isImportRowEmpty($normalized)) {
|
|
continue;
|
|
}
|
|
|
|
$validator = Validator::make($normalized, $this->importRules(), [], $this->importAttributes());
|
|
|
|
$validator->after(function ($validator) use ($normalized) {
|
|
$class = SchoolClass::query()->find($normalized['class_id']);
|
|
|
|
if (! $class) {
|
|
return;
|
|
}
|
|
|
|
if ((int) $class->academic_year_id !== (int) $normalized['academic_year_id']) {
|
|
$validator->errors()->add('class_id', 'Kelas harus berasal dari tahun ajaran yang dipilih.');
|
|
}
|
|
|
|
if ((int) $class->department_id !== (int) $normalized['department_id']) {
|
|
$validator->errors()->add('class_id', 'Kelas harus berasal dari program keahlian yang dipilih.');
|
|
}
|
|
});
|
|
|
|
$errorMessages = [];
|
|
$action = 'insert';
|
|
|
|
if ($validator->fails()) {
|
|
$errorMessages = $validator->errors()->all();
|
|
$errors[] = 'Baris ' . $rowNumber . ': ' . implode(' ', $errorMessages);
|
|
} else {
|
|
$lookup = null;
|
|
$payload = $validator->validated();
|
|
|
|
if (! empty($payload['student_number'])) {
|
|
$lookup = Student::withTrashed()->where('student_number', $payload['student_number'])->first();
|
|
}
|
|
|
|
if (! $lookup && ! empty($payload['nisn'])) {
|
|
$lookup = Student::withTrashed()->where('nisn', $payload['nisn'])->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 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 = [];
|
|
|
|
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;
|
|
}
|
|
|
|
$studentNumber = $this->nullIfBlank($normalized['student_number'] ?? ($normalized['nis'] ?? null));
|
|
$nisn = $this->nullIfBlank($normalized['nisn'] ?? null);
|
|
$nik = $this->nullIfBlank($normalized['nik'] ?? null);
|
|
$fullName = $this->nullIfBlank($normalized['full_name'] ?? ($normalized['nama_lengkap'] ?? null));
|
|
$gender = strtoupper((string) $this->nullIfBlank($normalized['gender'] ?? ($normalized['jenis_kelamin_l_p'] ?? null)));
|
|
$placeOfBirth = $this->nullIfBlank($normalized['place_of_birth'] ?? ($normalized['tempat_lahir'] ?? null));
|
|
$dateOfBirth = $this->normalizeImportDate($normalized['date_of_birth'] ?? ($normalized['tanggal_lahir_yyyy_mm_dd'] ?? null));
|
|
$religion = $this->nullIfBlank($normalized['religion'] ?? ($normalized['agama'] ?? null));
|
|
$nationality = $this->nullIfBlank($normalized['nationality'] ?? ($normalized['kewarganegaraan'] ?? null));
|
|
$familyCardNumber = $this->nullIfBlank($normalized['family_card_number'] ?? ($normalized['no_kartu_keluarga'] ?? null));
|
|
$childOrder = $this->normalizeImportInteger($normalized['child_order'] ?? ($normalized['anak_ke'] ?? null));
|
|
$numberOfSiblings = $this->normalizeImportInteger($normalized['number_of_siblings'] ?? ($normalized['jumlah_saudara'] ?? null));
|
|
$phoneNumber = $this->nullIfBlank($normalized['phone_number'] ?? ($normalized['nomor_hp'] ?? null));
|
|
$email = $this->nullIfBlank($normalized['email'] ?? null);
|
|
$address = $this->nullIfBlank($normalized['address'] ?? ($normalized['alamat'] ?? null));
|
|
$postalCode = $this->nullIfBlank($normalized['postal_code'] ?? ($normalized['kode_pos'] ?? null));
|
|
$status = strtolower((string) ($this->nullIfBlank($normalized['status'] ?? null) ?? 'active'));
|
|
$entryDate = $this->normalizeImportDate($normalized['entry_date'] ?? ($normalized['tanggal_masuk_yyyy_mm_dd'] ?? null));
|
|
$notes = $this->nullIfBlank($normalized['notes'] ?? ($normalized['catatan'] ?? null));
|
|
$academicYearSource = $this->nullIfBlank($normalized['academic_year_id'] ?? ($normalized['tahun_ajaran'] ?? null));
|
|
$departmentSource = $this->nullIfBlank($normalized['department_id'] ?? ($normalized['program_keahlian'] ?? null));
|
|
$classSource = $this->nullIfBlank($normalized['class_id'] ?? ($normalized['kelas'] ?? null));
|
|
$provinceSource = $this->nullIfBlank($normalized['province_id'] ?? ($normalized['provinsi'] ?? null));
|
|
$regencySource = $this->nullIfBlank($normalized['regency_id'] ?? ($normalized['kabupaten_kota'] ?? null));
|
|
$districtSource = $this->nullIfBlank($normalized['district_id'] ?? ($normalized['kecamatan'] ?? null));
|
|
$villageSource = $this->nullIfBlank($normalized['village_id'] ?? ($normalized['kelurahan_desa'] ?? null));
|
|
|
|
$academicYearId = $this->resolveAcademicYearId($academicYearSource);
|
|
$departmentId = $this->resolveDepartmentId($departmentSource);
|
|
$classId = $this->resolveClassId($classSource, $academicYearId, $departmentId);
|
|
$provinceId = $this->resolveProvinceId($provinceSource);
|
|
$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,
|
|
'nisn' => $nisn,
|
|
'nik' => $nik,
|
|
'full_name' => $fullName,
|
|
'gender' => $gender,
|
|
'place_of_birth' => $placeOfBirth,
|
|
'date_of_birth' => $dateOfBirth,
|
|
'religion' => $religion,
|
|
'nationality' => $nationality,
|
|
'family_card_number' => $familyCardNumber,
|
|
'child_order' => $childOrder,
|
|
'number_of_siblings' => $numberOfSiblings,
|
|
'phone_number' => $phoneNumber,
|
|
'email' => $email,
|
|
'address' => $address,
|
|
'province_id' => $provinceId,
|
|
'regency_id' => $regencyId,
|
|
'district_id' => $districtId,
|
|
'village_id' => $villageId,
|
|
'province' => $provinceName,
|
|
'city' => $regencyName,
|
|
'district' => $districtName,
|
|
'village' => $villageName,
|
|
'postal_code' => $postalCode,
|
|
'academic_year_id' => $academicYearId,
|
|
'department_id' => $departmentId,
|
|
'class_id' => $classId,
|
|
'entry_date' => $entryDate,
|
|
'status' => $status,
|
|
'notes' => $notes,
|
|
'parents' => [
|
|
'father' => $this->normalizeImportParentData($normalized, 'father', 'ayah'),
|
|
'mother' => $this->normalizeImportParentData($normalized, 'mother', 'ibu'),
|
|
'guardian' => $this->normalizeImportParentData($normalized, 'guardian', 'wali'),
|
|
],
|
|
];
|
|
}
|
|
|
|
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 resolveClassId(mixed $value, mixed $academicYearId = null, mixed $departmentId = null): mixed
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return null;
|
|
}
|
|
|
|
if (is_numeric($value)) {
|
|
return $value;
|
|
}
|
|
|
|
return SchoolClass::query()
|
|
->when($academicYearId, fn ($query) => $query->where('academic_year_id', $academicYearId))
|
|
->when($departmentId, fn ($query) => $query->where('department_id', $departmentId))
|
|
->where('name', trim((string) $value))
|
|
->value('id');
|
|
}
|
|
|
|
protected function resolveProvinceId(mixed $value): mixed
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return null;
|
|
}
|
|
|
|
if (is_numeric($value)) {
|
|
return (string) $value;
|
|
}
|
|
|
|
$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
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return null;
|
|
}
|
|
|
|
if (is_numeric($value)) {
|
|
return (string) $value;
|
|
}
|
|
|
|
$normalizedValue = $this->normalizeRegionLookupValue((string) $value);
|
|
|
|
return Regency::query()
|
|
->when($provinceId, fn ($query) => $query->where('province_id', $provinceId))
|
|
->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
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return null;
|
|
}
|
|
|
|
if (is_numeric($value)) {
|
|
return (string) $value;
|
|
}
|
|
|
|
$normalizedValue = $this->normalizeRegionLookupValue((string) $value);
|
|
|
|
return District::query()
|
|
->when($regencyId, fn ($query) => $query->where('regency_id', $regencyId))
|
|
->get(['id', 'name'])
|
|
->first(fn ($district) => $this->normalizeRegionLookupValue($district->name) === $normalizedValue)
|
|
?->id;
|
|
}
|
|
|
|
protected function resolveVillageId(mixed $value, mixed $districtId = null): mixed
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return null;
|
|
}
|
|
|
|
if (is_numeric($value)) {
|
|
return (string) $value;
|
|
}
|
|
|
|
$normalizedValue = $this->normalizeRegionLookupValue((string) $value);
|
|
|
|
return Village::query()
|
|
->when($districtId, fn ($query) => $query->where('district_id', $districtId))
|
|
->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
|
|
{
|
|
$value = $this->nullIfBlank($value);
|
|
|
|
if ($value === null) {
|
|
return null;
|
|
}
|
|
|
|
if (is_numeric($value)) {
|
|
try {
|
|
return \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject((float) $value)->format('Y-m-d');
|
|
} catch (\Throwable) {
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
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 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);
|
|
|
|
if ($value === null) {
|
|
return null;
|
|
}
|
|
|
|
if (is_bool($value)) {
|
|
return $value;
|
|
}
|
|
|
|
$normalized = strtolower(trim((string) $value));
|
|
|
|
return match ($normalized) {
|
|
'1', 'true', 'ya', 'y', 'yes', 'hidup', 'masih hidup' => true,
|
|
'0', 'false', 'tidak', 't', 'no', 'meninggal', 'wafat' => false,
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
protected function normalizeImportParentData(array $normalized, string $relationKey, string $legacyPrefix): array
|
|
{
|
|
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->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)),
|
|
'address' => $this->nullIfBlank($normalized["parent_{$relationKey}_address"] ?? ($normalized["alamat_{$legacyPrefix}"] ?? null)),
|
|
'is_alive' => $this->normalizeImportBoolean($normalized["parent_{$relationKey}_is_alive"] ?? ($normalized["status_hidup_{$legacyPrefix}"] ?? null)),
|
|
];
|
|
}
|
|
|
|
protected function syncImportedParents(Student $student, array $parents): void
|
|
{
|
|
foreach (['father', 'mother', 'guardian'] as $relationType) {
|
|
$parentData = $parents[$relationType] ?? [];
|
|
$hasMeaningfulValue = collect($parentData)
|
|
->except('is_alive')
|
|
->filter(fn ($value) => filled($value))
|
|
->isNotEmpty();
|
|
|
|
if (! $hasMeaningfulValue && ! array_key_exists('is_alive', $parentData)) {
|
|
continue;
|
|
}
|
|
|
|
$existingParent = $student->parents()->where('relation_type', $relationType)->first();
|
|
|
|
if (! $hasMeaningfulValue) {
|
|
if ($existingParent) {
|
|
$existingParent->delete();
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
$payload = [
|
|
'name' => $parentData['name'] ?? '-',
|
|
'nik' => $parentData['nik'] ?? null,
|
|
'birth_year' => $parentData['birth_year'] ?? null,
|
|
'education' => $parentData['education'] ?? null,
|
|
'occupation' => $parentData['occupation'] ?? null,
|
|
'phone_number' => $parentData['phone_number'] ?? null,
|
|
'address' => $parentData['address'] ?? null,
|
|
'is_alive' => $parentData['is_alive'] ?? false,
|
|
];
|
|
|
|
if ($existingParent) {
|
|
$existingParent->update($payload);
|
|
} else {
|
|
$student->parents()->create([
|
|
'relation_type' => $relationType,
|
|
...$payload,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 ensureAdmin(): void
|
|
{
|
|
abort_unless(auth()->user()?->isAdmin(), 403, 'Aksi ini hanya boleh dilakukan administrator.');
|
|
}
|
|
|
|
protected function resolveStudentViewConfig(string $viewMode, mixed $requestedStatus): array
|
|
{
|
|
return match ($viewMode) {
|
|
'transferred' => [
|
|
'view' => 'transferred',
|
|
'status' => 'transferred',
|
|
'title' => 'Data Siswa Mutasi',
|
|
'description' => 'Daftar siswa yang berstatus mutasi atau pindah sekolah.',
|
|
],
|
|
'dropped' => [
|
|
'view' => 'dropped',
|
|
'status' => 'dropped',
|
|
'title' => 'Data Siswa Keluar',
|
|
'description' => 'Daftar siswa yang berstatus keluar dari sekolah.',
|
|
],
|
|
default => [
|
|
'view' => 'all',
|
|
'status' => $requestedStatus,
|
|
'title' => 'Data Siswa',
|
|
'description' => 'Kelola data siswa lengkap berdasarkan master akademik yang tersedia.',
|
|
],
|
|
};
|
|
}
|
|
}
|