Update graduation workflow, promotions, and admin module improvements
This commit is contained in:
@@ -6,9 +6,15 @@ use App\Models\Department;
|
|||||||
use App\Models\StudentAcademicHistory;
|
use App\Models\StudentAcademicHistory;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||||
use RealRashid\SweetAlert\Facades\Alert;
|
use RealRashid\SweetAlert\Facades\Alert;
|
||||||
|
|
||||||
class DepartmentController extends Controller
|
class DepartmentController extends Controller
|
||||||
@@ -27,7 +33,11 @@ class DepartmentController extends Controller
|
|||||||
->paginate(10)
|
->paginate(10)
|
||||||
->withQueryString();
|
->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
|
public function create(): View
|
||||||
@@ -81,6 +91,118 @@ class DepartmentController extends Controller
|
|||||||
return redirect()->route('departments.index')->with('success', 'Program keahlian berhasil diperbarui.');
|
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
|
public function destroy(Department $department): RedirectResponse
|
||||||
{
|
{
|
||||||
$department->loadCount('students');
|
$department->loadCount('students');
|
||||||
@@ -114,4 +236,275 @@ class DepartmentController extends Controller
|
|||||||
|
|
||||||
return redirect()->route('departments.index')->with('success', $message);
|
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'),
|
'academicYears' => AcademicYear::orderBy('name')->pluck('name', 'id'),
|
||||||
'departments' => Department::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' => [
|
'documentStatusOptions' => [
|
||||||
'all' => 'Semua Dokumen',
|
'all' => 'Semua Dokumen',
|
||||||
'complete' => 'Dokumen Lengkap',
|
'complete' => 'Dokumen Lengkap',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\StudentGraduation;
|
use App\Models\StudentGraduation;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -32,6 +33,27 @@ class GraduationBulkUploadController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function preview(Request $request): RedirectResponse
|
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([
|
$validated = $request->validate([
|
||||||
'document_type' => ['required', 'in:skl,ijazah,transcript,ukk-certificate,lsp-certificate'],
|
'document_type' => ['required', 'in:skl,ijazah,transcript,ukk-certificate,lsp-certificate'],
|
||||||
@@ -115,9 +137,7 @@ class GraduationBulkUploadController extends Controller
|
|||||||
'summary' => $summary,
|
'summary' => $summary,
|
||||||
];
|
];
|
||||||
|
|
||||||
$request->session()->put('graduation_bulk_upload_preview', $preview);
|
return $preview;
|
||||||
|
|
||||||
return redirect()->route('graduated-students.bulk-upload.index');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(Request $request): RedirectResponse
|
||||||
|
|||||||
@@ -7,9 +7,15 @@ use App\Models\Department;
|
|||||||
use App\Models\SchoolClass;
|
use App\Models\SchoolClass;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||||
use RealRashid\SweetAlert\Facades\Alert;
|
use RealRashid\SweetAlert\Facades\Alert;
|
||||||
|
|
||||||
class SchoolClassController extends Controller
|
class SchoolClassController extends Controller
|
||||||
@@ -33,6 +39,8 @@ class SchoolClassController extends Controller
|
|||||||
'classes' => $classes,
|
'classes' => $classes,
|
||||||
'search' => $search,
|
'search' => $search,
|
||||||
'academicYears' => AcademicYear::orderByDesc('start_year')->orderByDesc('end_year')->get(),
|
'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.");
|
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
|
public function show(SchoolClass $class): RedirectResponse
|
||||||
{
|
{
|
||||||
return redirect()->route('classes.edit', $class);
|
return redirect()->route('classes.edit', $class);
|
||||||
@@ -159,7 +307,7 @@ class SchoolClassController extends Controller
|
|||||||
return view('classes.edit', [
|
return view('classes.edit', [
|
||||||
'class' => $class,
|
'class' => $class,
|
||||||
'academicYears' => $academicYears,
|
'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.');
|
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
|
protected function nextLevel(?string $level): ?string
|
||||||
{
|
{
|
||||||
return match (strtoupper((string) $level)) {
|
return match (strtoupper((string) $level)) {
|
||||||
|
|||||||
@@ -392,14 +392,42 @@ class StudentPromotionController extends Controller
|
|||||||
protected function pickRecommendedClass($classes, Student $student): ?SchoolClass
|
protected function pickRecommendedClass($classes, Student $student): ?SchoolClass
|
||||||
{
|
{
|
||||||
$sameDepartmentClasses = $classes->where('department_id', $student->department_id)->values();
|
$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 $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));
|
return str_contains(strtolower($classRoom->name), strtolower((string) $student->classRoom?->name));
|
||||||
})
|
})
|
||||||
?? $sameDepartmentClasses->first()
|
?? $sameDepartmentClasses->first()
|
||||||
?? $classes->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
|
protected function transformClassOptions(Collection $classes): Collection
|
||||||
{
|
{
|
||||||
return $classes->map(function (SchoolClass $classRoom) {
|
return $classes->map(function (SchoolClass $classRoom) {
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ class UserManagementController extends Controller
|
|||||||
'users' => $users,
|
'users' => $users,
|
||||||
'search' => $search,
|
'search' => $search,
|
||||||
'roleOptions' => $this->roleOptions(),
|
'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
|
public function destroy(Request $request, User $user): RedirectResponse
|
||||||
{
|
{
|
||||||
if ((int) $request->user()->id === (int) $user->id) {
|
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.']);
|
return back()->withErrors(['user' => 'Akun yang sedang dipakai login tidak bisa dihapus.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (User::query()->where('role', 'admin')->count() <= 1 && $user->role === 'admin') {
|
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.']);
|
return back()->withErrors(['user' => 'Minimal harus ada satu akun admin aktif di sistem.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasColumn('classes', 'promotion_target_class_id')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('classes', function (Blueprint $table) {
|
||||||
|
$table->dropConstrainedForeignId('promotion_target_class_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('classes', function (Blueprint $table) {
|
||||||
|
$table->foreignId('promotion_target_class_id')
|
||||||
|
->nullable()
|
||||||
|
->after('level')
|
||||||
|
->constrained('classes')
|
||||||
|
->nullOnDelete()
|
||||||
|
->cascadeOnUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -445,6 +445,243 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
renderBulkUploadFiles();
|
renderBulkUploadFiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bulkUploadOverlay = document.querySelector('[data-bulk-upload-overlay]');
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-bulk-upload-form]').forEach((form) => {
|
||||||
|
const submitButton = form.querySelector('[data-bulk-upload-submit-button]');
|
||||||
|
const submitLabel = form.querySelector('[data-bulk-upload-submit-label]');
|
||||||
|
const submitIcon = form.querySelector('[data-bulk-upload-submit-icon]');
|
||||||
|
const mode = form.dataset.bulkUploadMode || 'preview';
|
||||||
|
const useAjaxPreview = form.dataset.bulkUploadAjaxPreview === 'true';
|
||||||
|
const previewUrl = form.dataset.bulkUploadPreviewUrl || form.getAttribute('action') || '';
|
||||||
|
|
||||||
|
const showOverlay = (config = {}) => {
|
||||||
|
if (!bulkUploadOverlay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = bulkUploadOverlay.querySelector('[data-bulk-upload-overlay-title]');
|
||||||
|
const message = bulkUploadOverlay.querySelector('[data-bulk-upload-overlay-message]');
|
||||||
|
const fileCountLabel = bulkUploadOverlay.querySelector('[data-bulk-upload-overlay-file-count]');
|
||||||
|
const stageUpload = bulkUploadOverlay.querySelector('[data-bulk-upload-stage-upload]');
|
||||||
|
const stageMatch = bulkUploadOverlay.querySelector('[data-bulk-upload-stage-match]');
|
||||||
|
const stageSave = bulkUploadOverlay.querySelector('[data-bulk-upload-stage-save]');
|
||||||
|
const progressLabel = bulkUploadOverlay.querySelector('[data-bulk-upload-overlay-progress-label]');
|
||||||
|
const progressPercent = bulkUploadOverlay.querySelector('[data-bulk-upload-overlay-progress-percent]');
|
||||||
|
const progressBar = bulkUploadOverlay.querySelector('[data-bulk-upload-overlay-progress-bar]');
|
||||||
|
const fileCountSource = form.dataset.bulkUploadFileCountSource || 'input';
|
||||||
|
const inputFileCount = bulkUploadInput?.files?.length || 0;
|
||||||
|
const previewFileCount = Number(form.dataset.bulkUploadPreviewCount || 0);
|
||||||
|
const activeFileCount = fileCountSource === 'preview' ? previewFileCount : inputFileCount;
|
||||||
|
const percent = Math.max(0, Math.min(100, Number(config.percent ?? 0)));
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
title.textContent = config.title || (mode === 'process'
|
||||||
|
? 'Sedang memproses upload permanen...'
|
||||||
|
: 'Sedang menyiapkan preview dokumen...');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
message.textContent = config.message || (mode === 'process'
|
||||||
|
? 'Mohon tunggu. Sistem sedang menyimpan dokumen ke arsip kelulusan dan mencocokkannya ke data siswa. Jangan tutup tab ini agar proses tidak terputus.'
|
||||||
|
: 'Mohon tunggu. File sedang diunggah dan dipetakan oleh sistem untuk menyiapkan preview. Jangan tutup tab ini agar proses tidak terputus.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileCountLabel) {
|
||||||
|
fileCountLabel.textContent = activeFileCount > 0
|
||||||
|
? `${activeFileCount} file sedang diproses`
|
||||||
|
: 'Sedang menghitung jumlah file...';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stageUpload) {
|
||||||
|
stageUpload.textContent = config.stageUpload || (mode === 'process' ? 'Validasi file upload' : 'Upload file');
|
||||||
|
stageUpload.parentElement?.classList.add('ring-1', 'ring-indigo-300/50', 'bg-indigo-500/10');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stageMatch) {
|
||||||
|
stageMatch.textContent = config.stageMatch || (mode === 'process' ? 'Cocokkan ke siswa lulus' : 'Susun preview & matching');
|
||||||
|
stageMatch.parentElement?.classList.add('ring-1', 'ring-sky-300/40', 'bg-sky-500/10');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stageSave) {
|
||||||
|
stageSave.textContent = config.stageSave || (mode === 'process' ? 'Simpan ke arsip kelulusan' : 'Tunggu konfirmasi operator');
|
||||||
|
stageSave.parentElement?.classList.add('ring-1', 'ring-emerald-300/40', 'bg-emerald-500/10');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressLabel) {
|
||||||
|
progressLabel.textContent = config.progressLabel || 'Menunggu proses dimulai...';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressPercent) {
|
||||||
|
progressPercent.textContent = `${Math.round(percent)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressBar) {
|
||||||
|
progressBar.style.width = `${percent}%`;
|
||||||
|
progressBar.classList.toggle('animate-pulse', !!config.indeterminate);
|
||||||
|
}
|
||||||
|
|
||||||
|
bulkUploadOverlay.classList.remove('hidden');
|
||||||
|
bulkUploadOverlay.classList.add('flex');
|
||||||
|
document.body.classList.add('overflow-hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (event) => {
|
||||||
|
if (submitButton) {
|
||||||
|
submitButton.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submitIcon) {
|
||||||
|
submitIcon.classList.remove('fa-magnifying-glass', 'fa-upload', 'fa-cloud-arrow-up');
|
||||||
|
submitIcon.classList.add('fa-spinner', 'fa-spin');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submitLabel) {
|
||||||
|
submitLabel.textContent = mode === 'process'
|
||||||
|
? 'Sedang memproses upload...'
|
||||||
|
: 'Sedang menyiapkan preview...';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'preview' && useAjaxPreview) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
|
||||||
|
|
||||||
|
showOverlay({
|
||||||
|
title: 'Sedang mengunggah file preview...',
|
||||||
|
message: 'Mohon tunggu. Browser sedang mengirim file ke server untuk disiapkan preview dan pencocokan data.',
|
||||||
|
progressLabel: 'Mengunggah file ke server...',
|
||||||
|
percent: 0,
|
||||||
|
stageUpload: 'Upload file',
|
||||||
|
stageMatch: 'Menunggu upload selesai',
|
||||||
|
stageSave: 'Siapkan halaman preview',
|
||||||
|
indeterminate: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', previewUrl, true);
|
||||||
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||||
|
xhr.setRequestHeader('Accept', 'application/json');
|
||||||
|
if (csrfToken) {
|
||||||
|
xhr.setRequestHeader('X-CSRF-TOKEN', csrfToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', (progressEvent) => {
|
||||||
|
if (!progressEvent.lengthComputable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const percent = (progressEvent.loaded / progressEvent.total) * 100;
|
||||||
|
showOverlay({
|
||||||
|
title: 'Sedang mengunggah file preview...',
|
||||||
|
message: 'Mohon tunggu. Browser sedang mengirim file ke server untuk disiapkan preview dan pencocokan data.',
|
||||||
|
progressLabel: `Upload file ${Math.round(percent)}%`,
|
||||||
|
percent,
|
||||||
|
stageUpload: 'Upload file',
|
||||||
|
stageMatch: 'Menunggu upload selesai',
|
||||||
|
stageSave: 'Siapkan halaman preview',
|
||||||
|
indeterminate: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.onreadystatechange = () => {
|
||||||
|
if (xhr.readyState !== XMLHttpRequest.DONE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
let response = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = JSON.parse(xhr.responseText || '{}');
|
||||||
|
} catch (error) {
|
||||||
|
response = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
showOverlay({
|
||||||
|
title: 'Upload selesai, sistem sedang menyusun preview...',
|
||||||
|
message: 'File sudah terkirim. Sekarang sistem sedang mencocokkan dokumen dan menyiapkan halaman preview.',
|
||||||
|
progressLabel: 'Upload selesai, menyiapkan preview...',
|
||||||
|
percent: 100,
|
||||||
|
stageUpload: 'Upload selesai',
|
||||||
|
stageMatch: 'Mencocokkan ke siswa lulus',
|
||||||
|
stageSave: 'Membuka halaman preview',
|
||||||
|
indeterminate: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.location.href = response.redirect_url || window.location.href;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.classList.remove('overflow-hidden');
|
||||||
|
bulkUploadOverlay?.classList.add('hidden');
|
||||||
|
bulkUploadOverlay?.classList.remove('flex');
|
||||||
|
|
||||||
|
if (submitButton) {
|
||||||
|
submitButton.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submitIcon) {
|
||||||
|
submitIcon.classList.remove('fa-spinner', 'fa-spin');
|
||||||
|
submitIcon.classList.add('fa-magnifying-glass');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submitLabel) {
|
||||||
|
submitLabel.textContent = 'Preview Mapping Dokumen';
|
||||||
|
}
|
||||||
|
|
||||||
|
let errorMessage = 'Bulk upload gagal diproses. Silakan cek file lalu coba lagi.';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(xhr.responseText || '{}');
|
||||||
|
if (response.message) {
|
||||||
|
errorMessage = response.message;
|
||||||
|
} else if (response.errors) {
|
||||||
|
const firstError = Object.values(response.errors).flat()[0];
|
||||||
|
if (firstError) {
|
||||||
|
errorMessage = firstError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'error',
|
||||||
|
title: 'Bulk upload gagal',
|
||||||
|
text: errorMessage,
|
||||||
|
confirmButtonColor: '#dc2626',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send(formData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showOverlay({
|
||||||
|
progressLabel: mode === 'process' ? 'Menyimpan dokumen ke arsip...' : 'Menyiapkan proses...',
|
||||||
|
percent: mode === 'process' ? 100 : 15,
|
||||||
|
indeterminate: mode === 'process',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const graduationFilterForm = document.querySelector('[data-graduation-filter-form]');
|
||||||
|
const graduationAcademicYearFilter = document.querySelector('[data-graduation-academic-year-filter]');
|
||||||
|
|
||||||
|
if (graduationFilterForm && graduationAcademicYearFilter) {
|
||||||
|
graduationAcademicYearFilter.addEventListener('change', () => {
|
||||||
|
const classFilter = graduationFilterForm.querySelector('[name="class_id"]');
|
||||||
|
|
||||||
|
if (classFilter) {
|
||||||
|
classFilter.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
graduationFilterForm.submit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const promotionForm = document.querySelector('[data-student-promotion-form]');
|
const promotionForm = document.querySelector('[data-student-promotion-form]');
|
||||||
|
|
||||||
if (promotionForm) {
|
if (promotionForm) {
|
||||||
|
|||||||
@@ -4,6 +4,116 @@
|
|||||||
action-label="Tambah Kelas"
|
action-label="Tambah Kelas"
|
||||||
:action-url="route('classes.create')"
|
:action-url="route('classes.create')"
|
||||||
>
|
>
|
||||||
|
<x-master-card title="Import Data Kelas" subtitle="Upload Excel/CSV untuk menambah atau memperbarui data kelas secara massal.">
|
||||||
|
<form method="POST" action="{{ route('classes.import.preview') }}" enctype="multipart/form-data" class="space-y-5">
|
||||||
|
@csrf
|
||||||
|
<div>
|
||||||
|
<label for="import_file" class="mb-2 block text-sm font-medium text-slate-700">File Excel / CSV</label>
|
||||||
|
<input id="import_file" name="import_file" type="file" accept=".xlsx,.csv,.txt" class="block w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-700 file:mr-4 file:rounded-xl file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100">
|
||||||
|
<p class="mt-2 text-xs text-slate-500">Gunakan template agar format kolom sesuai. Import ini akan update data lama jika nama kelas pada tahun ajaran yang sama sudah ada.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-2xl bg-indigo-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-indigo-200 transition hover:bg-indigo-700">Preview Import</button>
|
||||||
|
<a href="{{ route('classes.import.template') }}" class="inline-flex items-center rounded-2xl border border-emerald-200 bg-emerald-50 px-5 py-3 text-sm font-semibold text-emerald-700 transition hover:bg-emerald-100">Download Template</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@if (!empty($importPreview))
|
||||||
|
<div class="mt-6 rounded-3xl border border-slate-200 bg-white p-5">
|
||||||
|
<div class="mb-5 grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-slate-50 px-5 py-4">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Total Baris</div>
|
||||||
|
<div class="mt-2 text-3xl font-bold text-slate-800">{{ $importPreview['summary']['total_rows'] }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-3xl border border-emerald-200 bg-emerald-50 px-5 py-4">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-emerald-600">Valid</div>
|
||||||
|
<div class="mt-2 text-3xl font-bold text-emerald-700">{{ $importPreview['summary']['valid_rows'] }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-3xl border border-sky-200 bg-sky-50 px-5 py-4">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-sky-600">Insert Baru</div>
|
||||||
|
<div class="mt-2 text-3xl font-bold text-sky-700">{{ $importPreview['summary']['insert_rows'] }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-3xl border border-amber-200 bg-amber-50 px-5 py-4">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-amber-600">Update Data</div>
|
||||||
|
<div class="mt-2 text-3xl font-bold text-amber-700">{{ $importPreview['summary']['update_rows'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!empty($importPreview['errors']))
|
||||||
|
<div class="mb-5 rounded-2xl border border-rose-200 bg-rose-50 px-5 py-4 text-sm text-rose-700">
|
||||||
|
<div class="font-semibold">Ada baris yang belum valid.</div>
|
||||||
|
<ul class="mt-2 list-disc space-y-1 pl-5">
|
||||||
|
@foreach (array_slice($importPreview['errors'], 0, 10) as $error)
|
||||||
|
<li>{{ $error }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="overflow-x-auto rounded-3xl border border-slate-200">
|
||||||
|
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||||
|
<thead class="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Baris</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Akademik</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Nama Kelas</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Detail</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Aksi</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Validasi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100 bg-white">
|
||||||
|
@foreach ($importPreview['rows'] as $row)
|
||||||
|
<tr class="align-top {{ $row['is_valid'] ? '' : 'bg-rose-50/50' }}">
|
||||||
|
<td class="px-4 py-4 font-semibold text-slate-700">{{ $row['row_number'] }}</td>
|
||||||
|
<td class="px-4 py-4 text-slate-600">
|
||||||
|
<div>Tahun: {{ $importReferenceData['academicYears'][$row['data']['academic_year_id']] ?? '-' }}</div>
|
||||||
|
<div>Jurusan: {{ $importReferenceData['departments'][$row['data']['department_id']] ?? '-' }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-4 text-slate-700">
|
||||||
|
<div class="font-medium">{{ $row['data']['name'] ?: '-' }}</div>
|
||||||
|
<div class="text-xs text-slate-400">Tingkat: {{ $row['data']['level'] ?: '-' }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-4 text-slate-600">
|
||||||
|
<div>Wali Kelas: {{ $row['data']['homeroom_teacher'] ?: '-' }}</div>
|
||||||
|
<div>Kapasitas: {{ $row['data']['capacity'] ?: '-' }}</div>
|
||||||
|
<div>Status: {{ ($row['data']['is_active'] ?? false) ? 'Aktif' : 'Nonaktif' }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-4">
|
||||||
|
<span class="rounded-full px-3 py-1 text-xs font-semibold {{ $row['action'] === 'update' ? 'bg-amber-100 text-amber-700' : 'bg-sky-100 text-sky-700' }}">{{ $row['action'] === 'update' ? 'Update' : 'Insert' }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-4 text-sm">
|
||||||
|
@if ($row['is_valid'])
|
||||||
|
<span class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700">Valid</span>
|
||||||
|
@else
|
||||||
|
<div class="space-y-1 text-rose-700">
|
||||||
|
<div class="inline-flex rounded-full bg-rose-100 px-3 py-1 text-xs font-semibold">Perlu perbaikan</div>
|
||||||
|
<ul class="list-disc pl-4 text-xs">
|
||||||
|
@foreach ($row['errors'] as $error)
|
||||||
|
<li>{{ $error }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($importPreview['summary']['valid_rows'] > 0)
|
||||||
|
<form method="POST" action="{{ route('classes.import') }}" class="mt-6 flex flex-wrap items-center gap-3">
|
||||||
|
@csrf
|
||||||
|
<input type="hidden" name="preview_token" value="{{ $importPreview['token'] }}">
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-2xl bg-emerald-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-emerald-200 transition hover:bg-emerald-700">Konfirmasi Import ke Database</button>
|
||||||
|
<span class="text-sm text-slate-500">Hanya baris valid yang akan diproses.</span>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</x-master-card>
|
||||||
<x-master-card title="Generate Kelas Tahun Ajaran Baru" subtitle="Salin kelas dari tahun ajaran sumber ke tahun ajaran target. Cocok untuk menyiapkan kelas tahun ajaran baru sebelum promosi siswa.">
|
<x-master-card title="Generate Kelas Tahun Ajaran Baru" subtitle="Salin kelas dari tahun ajaran sumber ke tahun ajaran target. Cocok untuk menyiapkan kelas tahun ajaran baru sebelum promosi siswa.">
|
||||||
<form method="POST" action="{{ route('classes.generate') }}" class="grid grid-cols-1 gap-4 xl:grid-cols-4 xl:items-end">
|
<form method="POST" action="{{ route('classes.generate') }}" class="grid grid-cols-1 gap-4 xl:grid-cols-4 xl:items-end">
|
||||||
@csrf
|
@csrf
|
||||||
|
|||||||
@@ -4,6 +4,109 @@
|
|||||||
action-label="Tambah Program Keahlian"
|
action-label="Tambah Program Keahlian"
|
||||||
:action-url="route('departments.create')"
|
:action-url="route('departments.create')"
|
||||||
>
|
>
|
||||||
|
<x-master-card title="Import Program Keahlian" subtitle="Upload Excel/CSV untuk menambah atau memperbarui data program keahlian secara massal.">
|
||||||
|
<form method="POST" action="{{ route('departments.import.preview') }}" enctype="multipart/form-data" class="space-y-5">
|
||||||
|
@csrf
|
||||||
|
<div>
|
||||||
|
<label for="import_file" class="mb-2 block text-sm font-medium text-slate-700">File Excel / CSV</label>
|
||||||
|
<input id="import_file" name="import_file" type="file" accept=".xlsx,.csv,.txt" class="block w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-700 file:mr-4 file:rounded-xl file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100">
|
||||||
|
<p class="mt-2 text-xs text-slate-500">Gunakan template agar format kolom sesuai. Import ini akan update data lama jika kode atau nama program keahlian sudah ada.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-2xl bg-indigo-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-indigo-200 transition hover:bg-indigo-700">Preview Import</button>
|
||||||
|
<a href="{{ route('departments.import.template') }}" class="inline-flex items-center rounded-2xl border border-emerald-200 bg-emerald-50 px-5 py-3 text-sm font-semibold text-emerald-700 transition hover:bg-emerald-100">Download Template</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@if (!empty($importPreview))
|
||||||
|
<div class="mt-6 rounded-3xl border border-slate-200 bg-white p-5">
|
||||||
|
<div class="mb-5 grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-slate-50 px-5 py-4">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Total Baris</div>
|
||||||
|
<div class="mt-2 text-3xl font-bold text-slate-800">{{ $importPreview['summary']['total_rows'] }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-3xl border border-emerald-200 bg-emerald-50 px-5 py-4">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-emerald-600">Valid</div>
|
||||||
|
<div class="mt-2 text-3xl font-bold text-emerald-700">{{ $importPreview['summary']['valid_rows'] }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-3xl border border-sky-200 bg-sky-50 px-5 py-4">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-sky-600">Insert Baru</div>
|
||||||
|
<div class="mt-2 text-3xl font-bold text-sky-700">{{ $importPreview['summary']['insert_rows'] }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-3xl border border-amber-200 bg-amber-50 px-5 py-4">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-amber-600">Update Data</div>
|
||||||
|
<div class="mt-2 text-3xl font-bold text-amber-700">{{ $importPreview['summary']['update_rows'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!empty($importPreview['errors']))
|
||||||
|
<div class="mb-5 rounded-2xl border border-rose-200 bg-rose-50 px-5 py-4 text-sm text-rose-700">
|
||||||
|
<div class="font-semibold">Ada baris yang belum valid.</div>
|
||||||
|
<ul class="mt-2 list-disc space-y-1 pl-5">
|
||||||
|
@foreach (array_slice($importPreview['errors'], 0, 10) as $error)
|
||||||
|
<li>{{ $error }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="overflow-x-auto rounded-3xl border border-slate-200">
|
||||||
|
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||||
|
<thead class="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Baris</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Kode</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Nama</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Status</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Aksi</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Validasi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100 bg-white">
|
||||||
|
@foreach ($importPreview['rows'] as $row)
|
||||||
|
<tr class="align-top {{ $row['is_valid'] ? '' : 'bg-rose-50/50' }}">
|
||||||
|
<td class="px-4 py-4 font-semibold text-slate-700">{{ $row['row_number'] }}</td>
|
||||||
|
<td class="px-4 py-4 text-slate-700">{{ $row['data']['code'] ?: '-' }}</td>
|
||||||
|
<td class="px-4 py-4 text-slate-700">
|
||||||
|
<div class="font-medium">{{ $row['data']['name'] ?: '-' }}</div>
|
||||||
|
<div class="text-xs text-slate-400">{{ $row['data']['description'] ?: 'Tanpa deskripsi' }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-4 text-slate-600">{{ ($row['data']['is_active'] ?? false) ? 'Aktif' : 'Nonaktif' }}</td>
|
||||||
|
<td class="px-4 py-4">
|
||||||
|
<span class="rounded-full px-3 py-1 text-xs font-semibold {{ $row['action'] === 'update' ? 'bg-amber-100 text-amber-700' : 'bg-sky-100 text-sky-700' }}">{{ $row['action'] === 'update' ? 'Update' : 'Insert' }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-4 text-sm">
|
||||||
|
@if ($row['is_valid'])
|
||||||
|
<span class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700">Valid</span>
|
||||||
|
@else
|
||||||
|
<div class="space-y-1 text-rose-700">
|
||||||
|
<div class="inline-flex rounded-full bg-rose-100 px-3 py-1 text-xs font-semibold">Perlu perbaikan</div>
|
||||||
|
<ul class="list-disc pl-4 text-xs">
|
||||||
|
@foreach ($row['errors'] as $error)
|
||||||
|
<li>{{ $error }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($importPreview['summary']['valid_rows'] > 0)
|
||||||
|
<form method="POST" action="{{ route('departments.import') }}" class="mt-6 flex flex-wrap items-center gap-3">
|
||||||
|
@csrf
|
||||||
|
<input type="hidden" name="preview_token" value="{{ $importPreview['token'] }}">
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-2xl bg-emerald-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-emerald-200 transition hover:bg-emerald-700">Konfirmasi Import ke Database</button>
|
||||||
|
<span class="text-sm text-slate-500">Hanya baris valid yang akan diproses.</span>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</x-master-card>
|
||||||
<x-master-card title="Filter Data" subtitle="Cari berdasarkan kode, nama, atau deskripsi program keahlian.">
|
<x-master-card title="Filter Data" subtitle="Cari berdasarkan kode, nama, atau deskripsi program keahlian.">
|
||||||
<form method="GET" action="{{ route('departments.index') }}" class="flex flex-col gap-4 md:flex-row md:items-end">
|
<form method="GET" action="{{ route('departments.index') }}" class="flex flex-col gap-4 md:flex-row md:items-end">
|
||||||
<div class="md:flex-1">
|
<div class="md:flex-1">
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="{{ route('graduated-students.bulk-upload.preview') }}" enctype="multipart/form-data" class="space-y-5">
|
<form method="POST" action="{{ route('graduated-students.bulk-upload.preview') }}" enctype="multipart/form-data" class="space-y-5" data-bulk-upload-form data-bulk-upload-mode="preview" data-bulk-upload-file-count-source="input" data-bulk-upload-ajax-preview="true" data-bulk-upload-preview-url="{{ route('graduated-students.bulk-upload.preview-ajax') }}">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||||
@@ -130,9 +130,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<button type="submit" class="inline-flex items-center gap-2 rounded-2xl bg-indigo-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-indigo-200 transition hover:bg-indigo-700">
|
<button type="submit" class="inline-flex items-center gap-2 rounded-2xl bg-indigo-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-indigo-200 transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70" data-bulk-upload-submit-button>
|
||||||
<i class="fa-solid fa-magnifying-glass"></i>
|
<i class="fa-solid fa-magnifying-glass" data-bulk-upload-submit-icon></i>
|
||||||
<span>Preview Mapping Dokumen</span>
|
<span data-bulk-upload-submit-label>Preview Mapping Dokumen</span>
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ route('graduated-students.index') }}" class="inline-flex items-center gap-2 rounded-2xl border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">
|
<a href="{{ route('graduated-students.index') }}" class="inline-flex items-center gap-2 rounded-2xl border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">
|
||||||
<i class="fa-solid fa-arrow-left"></i>
|
<i class="fa-solid fa-arrow-left"></i>
|
||||||
@@ -262,10 +262,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="{{ route('graduated-students.bulk-upload.store') }}" class="mt-6 flex flex-wrap items-center gap-3">
|
<form method="POST" action="{{ route('graduated-students.bulk-upload.store') }}" class="mt-6 flex flex-wrap items-center gap-3" data-bulk-upload-form data-bulk-upload-mode="process" data-bulk-upload-file-count-source="preview" data-bulk-upload-preview-count="{{ $preview['summary']['ready_to_upload'] ?? $preview['summary']['total_files'] ?? 0 }}">
|
||||||
@csrf
|
@csrf
|
||||||
<input type="hidden" name="preview_token" value="{{ $preview['token'] }}">
|
<input type="hidden" name="preview_token" value="{{ $preview['token'] }}">
|
||||||
<button type="submit" class="inline-flex items-center gap-2 rounded-2xl bg-emerald-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-emerald-200 transition hover:bg-emerald-700">
|
<button type="submit" class="inline-flex items-center gap-2 rounded-2xl bg-emerald-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-emerald-200 transition hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-70" data-bulk-upload-submit-button>
|
||||||
<i class="fa-solid fa-cloud-arrow-up"></i>
|
<i class="fa-solid fa-cloud-arrow-up"></i>
|
||||||
<span>Proses Bulk Upload</span>
|
<span>Proses Bulk Upload</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -273,4 +273,55 @@
|
|||||||
</x-master-card>
|
</x-master-card>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="fixed inset-0 z-[11000] hidden items-center justify-center bg-slate-950/60 px-4" data-bulk-upload-overlay>
|
||||||
|
<div class="w-full max-w-lg rounded-3xl border border-white/10 bg-slate-900/95 p-6 text-white shadow-2xl backdrop-blur">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-indigo-500/15 text-indigo-200">
|
||||||
|
<i class="fa-solid fa-arrows-rotate animate-spin text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-bold" data-bulk-upload-overlay-title>Sedang memproses dokumen...</div>
|
||||||
|
<div class="mt-2 text-sm leading-6 text-slate-300" data-bulk-upload-overlay-message>Mohon tunggu. File sedang diunggah dan dipetakan oleh sistem. Jangan tutup tab ini agar proses tidak terputus.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5">
|
||||||
|
<div class="mb-2 flex items-center justify-between gap-3 text-xs text-slate-300">
|
||||||
|
<span class="font-semibold" data-bulk-upload-overlay-progress-label>Menunggu proses dimulai...</span>
|
||||||
|
<span class="font-semibold" data-bulk-upload-overlay-progress-percent>0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden rounded-full bg-white/10">
|
||||||
|
<div class="h-2 w-0 rounded-full bg-gradient-to-r from-indigo-400 via-sky-300 to-emerald-300 transition-all duration-300" data-bulk-upload-overlay-progress-bar></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
|
||||||
|
<div class="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">Tahap 1</div>
|
||||||
|
<div class="mt-1 text-sm font-semibold text-white" data-bulk-upload-stage-upload>Upload file</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
|
||||||
|
<div class="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">Tahap 2</div>
|
||||||
|
<div class="mt-1 text-sm font-semibold text-white" data-bulk-upload-stage-match>Cocokkan data</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
|
||||||
|
<div class="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">Tahap 3</div>
|
||||||
|
<div class="mt-1 text-sm font-semibold text-white" data-bulk-upload-stage-save>Simpan hasil</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex flex-wrap items-center gap-3 text-xs text-slate-300">
|
||||||
|
<div class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5">
|
||||||
|
<i class="fa-solid fa-files"></i>
|
||||||
|
<span data-bulk-upload-overlay-file-count>Menyiapkan jumlah file...</span>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5">
|
||||||
|
<i class="fa-solid fa-hourglass-half"></i>
|
||||||
|
<span>Durasi tergantung jumlah dan ukuran file</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-xs leading-5 text-slate-300">
|
||||||
|
Proses bulk upload bisa butuh beberapa detik, terutama jika file banyak atau ukuran file mendekati batas maksimum.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</x-master-page>
|
</x-master-page>
|
||||||
|
|||||||
@@ -81,11 +81,11 @@
|
|||||||
|
|
||||||
<x-master-card title="Filter Arsip Kelulusan" subtitle="Cari lulusan berdasarkan nama, identitas siswa, jurusan, kelas terakhir, tahun ajaran, dan status dokumen.">
|
<x-master-card title="Filter Arsip Kelulusan" subtitle="Cari lulusan berdasarkan nama, identitas siswa, jurusan, kelas terakhir, tahun ajaran, dan status dokumen.">
|
||||||
<div class="rounded-3xl border border-slate-200 bg-gradient-to-br from-white via-slate-50 to-sky-50/30 p-4 shadow-sm ring-1 ring-white/70">
|
<div class="rounded-3xl border border-slate-200 bg-gradient-to-br from-white via-slate-50 to-sky-50/30 p-4 shadow-sm ring-1 ring-white/70">
|
||||||
<form method="GET" action="{{ $indexRoute }}" class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-5">
|
<form method="GET" action="{{ $indexRoute }}" class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-5" data-graduation-filter-form>
|
||||||
<x-form-input name="search" label="Pencarian" :value="$search" placeholder="Nama / NIS / NISN" />
|
<x-form-input name="search" label="Pencarian" :value="$search" placeholder="Nama / NIS / NISN" />
|
||||||
<x-form-select name="academic_year_id" label="Tahun Ajaran" :options="$academicYears->all()" :value="$filters['academic_year_id']" />
|
<x-form-select name="academic_year_id" label="Tahun Ajaran" :options="$academicYears->all()" :value="$filters['academic_year_id']" data-graduation-academic-year-filter />
|
||||||
<x-form-select name="department_id" label="Program Keahlian" :options="$departments->all()" :value="$filters['department_id']" />
|
<x-form-select name="department_id" label="Program Keahlian" :options="$departments->all()" :value="$filters['department_id']" />
|
||||||
<x-form-select name="class_id" label="Kelas Terakhir" :options="$classes->all()" :value="$filters['class_id']" />
|
<x-form-select name="class_id" label="Kelas Terakhir" :options="$classes->all()" :value="$filters['class_id']" :placeholder="$filters['academic_year_id'] ? 'Pilih kelas terakhir' : 'Pilih tahun ajaran dulu'" />
|
||||||
<x-form-select name="document_status" label="Status Dokumen" :options="$documentStatusOptions" :value="$filters['document_status']" />
|
<x-form-select name="document_status" label="Status Dokumen" :options="$documentStatusOptions" :value="$filters['document_status']" />
|
||||||
|
|
||||||
<input type="hidden" name="sort" value="{{ $sort }}">
|
<input type="hidden" name="sort" value="{{ $sort }}">
|
||||||
|
|||||||
@@ -222,8 +222,8 @@
|
|||||||
$errors->first($targetClassField),
|
$errors->first($targetClassField),
|
||||||
$errors->first($notesField),
|
$errors->first($notesField),
|
||||||
])->filter();
|
])->filter();
|
||||||
$selectedPromote = old($targetClassField, $promoteClasses->firstWhere('id', $recommendation['target_class_id'])?->id ?? ($promoteClasses->first()?->id ?? ''));
|
$selectedPromote = old($targetClassField, $recommendation['action'] === 'promote' ? ($recommendation['target_class_id'] ?? '') : '');
|
||||||
$selectedRepeat = old($targetClassField, $repeatClasses->firstWhere('id', $recommendation['target_class_id'])?->id ?? ($repeatClasses->first()?->id ?? ''));
|
$selectedRepeat = old($targetClassField, $recommendation['action'] === 'repeat' ? ($recommendation['target_class_id'] ?? '') : '');
|
||||||
$promoteClassOptions = $promoteClasses->map(fn ($classRoom) => [
|
$promoteClassOptions = $promoteClasses->map(fn ($classRoom) => [
|
||||||
'id' => $classRoom->id,
|
'id' => $classRoom->id,
|
||||||
'name' => $classRoom->name,
|
'name' => $classRoom->name,
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||||
<x-form-input name="name" label="Nama Lengkap" :value="$user->name ?? ''" required />
|
<x-form-input name="name" label="Nama Lengkap" :value="$user->name ?? ''" required />
|
||||||
<x-form-input name="email" label="Email" type="email" :value="$user->email ?? ''" required />
|
<x-form-input name="email" label="Email" type="email" :value="$user->email ?? ''" required />
|
||||||
|
|
||||||
|
<div>
|
||||||
<x-form-select name="role" label="Role" :options="$roleOptions" :value="$user->role ?? 'staff'" placeholder="-- Pilih role --" required />
|
<x-form-select name="role" label="Role" :options="$roleOptions" :value="$user->role ?? 'staff'" placeholder="-- Pilih role --" required />
|
||||||
|
<p class="mt-2 text-xs text-slate-500">Administrator punya akses penuh ke pengaturan sistem, staff hanya untuk operasional harian.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-3xl border border-slate-200 bg-slate-50 p-5 md:col-span-2">
|
<div class="rounded-3xl border border-slate-200 bg-slate-50 p-5 md:col-span-2">
|
||||||
|
<div class="mb-4 flex items-start gap-3 rounded-2xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm text-indigo-700">
|
||||||
|
<i class="fa-solid fa-shield-halved mt-0.5"></i>
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold">Keamanan akun</div>
|
||||||
|
<div class="mt-1 text-xs text-indigo-700/80">Gunakan password minimal 8 karakter. Untuk akun yang sedang diedit, password boleh dikosongkan jika tidak ingin diubah.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||||
<x-form-input name="password" label="Password {{ isset($user) ? '(opsional jika tidak diubah)' : '' }}" type="password" />
|
<x-form-input name="password" label="Password {{ isset($user) ? '(opsional jika tidak diubah)' : '' }}" type="password" />
|
||||||
<x-form-input name="password_confirmation" label="Konfirmasi Password" type="password" />
|
<x-form-input name="password_confirmation" label="Konfirmasi Password" type="password" />
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-3 text-xs text-slate-500">Password minimal 8 karakter.</p>
|
<p class="mt-3 text-xs text-slate-500">Tips: kombinasikan huruf besar, huruf kecil, angka, dan simbol untuk password yang lebih kuat.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,34 @@
|
|||||||
<x-master-page title="Manajemen Pengguna" description="Kelola akun administrator dan staff yang dapat mengakses panel Database Kesiswaan." action-label="Tambah Pengguna" :action-url="route('settings.users.create')">
|
<x-master-page title="Manajemen Pengguna" description="Kelola akun administrator dan staff yang dapat mengakses panel Database Kesiswaan." action-label="Tambah Pengguna" :action-url="route('settings.users.create')">
|
||||||
|
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-slate-50 px-5 py-4">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Total Pengguna</div>
|
||||||
|
<div class="mt-2 text-3xl font-bold text-slate-800">{{ $stats['total'] }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-3xl border border-indigo-200 bg-indigo-50 px-5 py-4">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600">Administrator</div>
|
||||||
|
<div class="mt-2 text-3xl font-bold text-indigo-700">{{ $stats['admins'] }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-white px-5 py-4">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Staff</div>
|
||||||
|
<div class="mt-2 text-3xl font-bold text-slate-700">{{ $stats['staff'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<x-master-card title="Daftar Pengguna" subtitle="Gunakan halaman ini untuk menambah, mengubah, dan menghapus akun admin internal.">
|
<x-master-card title="Daftar Pengguna" subtitle="Gunakan halaman ini untuk menambah, mengubah, dan menghapus akun admin internal.">
|
||||||
<form method="GET" class="mb-5">
|
<form method="GET" class="mb-5">
|
||||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-[1fr_auto]">
|
<div class="rounded-3xl border border-slate-200 bg-slate-50/80 p-4">
|
||||||
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
|
||||||
<x-form-input name="search" label="Cari Pengguna" :value="$search" placeholder="Cari nama atau email..." />
|
<x-form-input name="search" label="Cari Pengguna" :value="$search" placeholder="Cari nama atau email..." />
|
||||||
<div class="flex items-end">
|
<div class="flex flex-col gap-2 sm:flex-row md:justify-end">
|
||||||
<button type="submit" class="inline-flex h-12 items-center justify-center rounded-2xl bg-indigo-600 px-5 text-sm font-semibold text-white shadow-lg shadow-indigo-200 transition hover:bg-indigo-700">Cari</button>
|
<button type="submit" class="inline-flex h-12 items-center justify-center rounded-2xl bg-indigo-600 px-5 text-sm font-semibold text-white shadow-lg shadow-indigo-200 transition hover:bg-indigo-700">
|
||||||
|
<i class="fa-solid fa-magnifying-glass mr-2 text-xs"></i>
|
||||||
|
Cari
|
||||||
|
</button>
|
||||||
|
<a href="{{ route('settings.users.index') }}" class="inline-flex h-12 items-center justify-center rounded-2xl border border-slate-200 bg-white px-5 text-sm font-semibold text-slate-700 transition hover:bg-slate-100">
|
||||||
|
<i class="fa-solid fa-rotate-left mr-2 text-xs"></i>
|
||||||
|
Reset
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -23,22 +47,39 @@
|
|||||||
<tbody class="divide-y divide-slate-100 bg-white">
|
<tbody class="divide-y divide-slate-100 bg-white">
|
||||||
@forelse ($users as $user)
|
@forelse ($users as $user)
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-4 py-4 font-medium text-slate-800">{{ $user->name }}</td>
|
<td class="px-4 py-4">
|
||||||
|
<div class="font-medium text-slate-800">{{ $user->name }}</div>
|
||||||
|
@if (auth()->id() === $user->id)
|
||||||
|
<div class="mt-1 text-xs font-semibold text-emerald-600">Akun yang sedang dipakai</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
<td class="px-4 py-4 text-slate-600">{{ $user->email }}</td>
|
<td class="px-4 py-4 text-slate-600">{{ $user->email }}</td>
|
||||||
<td class="px-4 py-4">
|
<td class="px-4 py-4">
|
||||||
<span class="rounded-full px-3 py-1 text-xs font-semibold {{ $user->role === 'admin' ? 'bg-indigo-100 text-indigo-700' : 'bg-slate-100 text-slate-700' }}">
|
<span class="rounded-full px-3 py-1 text-xs font-semibold {{ $user->role === 'admin' ? 'bg-indigo-100 text-indigo-700' : 'bg-slate-100 text-slate-700' }}">
|
||||||
{{ $roleOptions[$user->role] ?? ucfirst($user->role) }}
|
{{ $roleOptions[$user->role] ?? ucfirst($user->role) }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-4 text-slate-500">{{ auth()->id() === $user->id ? 'Sedang dipakai login' : 'Aktif' }}</td>
|
<td class="px-4 py-4">
|
||||||
|
@if (auth()->id() === $user->id)
|
||||||
|
<span class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700">Login saat ini</span>
|
||||||
|
@else
|
||||||
|
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">Aktif</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
<td class="px-4 py-4">
|
<td class="px-4 py-4">
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<a href="{{ route('settings.users.edit', $user) }}" class="inline-flex items-center rounded-xl border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-700 transition hover:bg-slate-50">Edit</a>
|
<a href="{{ route('settings.users.edit', $user) }}" class="inline-flex items-center rounded-xl border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-700 transition hover:bg-slate-50">Edit</a>
|
||||||
<form method="POST" action="{{ route('settings.users.destroy', $user) }}" onsubmit="return confirm('Hapus akun ini?')">
|
@php($isCurrentUser = auth()->id() === $user->id)
|
||||||
|
@php($isLastAdmin = $user->role === 'admin' && ($stats['admins'] ?? 0) <= 1)
|
||||||
|
@if ($isCurrentUser || $isLastAdmin)
|
||||||
|
<button type="button" disabled class="cursor-not-allowed rounded-xl border border-slate-200 bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-400" title="{{ $isCurrentUser ? 'Akun yang sedang dipakai login tidak bisa dihapus' : 'Minimal harus ada satu akun admin aktif' }}">Hapus</button>
|
||||||
|
@else
|
||||||
|
<form method="POST" action="{{ route('settings.users.destroy', $user) }}" class="delete-form" data-entity="akun pengguna {{ $user->name }}" data-confirm-title="Hapus akun pengguna?" data-confirm-text="Akun {{ $user->name }} akan dihapus dari sistem. Tindakan ini tidak bisa dibatalkan.">
|
||||||
@csrf
|
@csrf
|
||||||
@method('DELETE')
|
@method('DELETE')
|
||||||
<button type="submit" class="inline-flex items-center rounded-xl border border-rose-200 px-3 py-2 text-xs font-semibold text-rose-600 transition hover:bg-rose-50">Hapus</button>
|
<button type="submit" class="inline-flex items-center rounded-xl border border-rose-200 px-3 py-2 text-xs font-semibold text-rose-600 transition hover:bg-rose-50">Hapus</button>
|
||||||
</form>
|
</form>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -45,7 +45,13 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
|
|
||||||
Route::middleware('role:admin')->group(function () {
|
Route::middleware('role:admin')->group(function () {
|
||||||
Route::resource('academic-years', AcademicYearController::class);
|
Route::resource('academic-years', AcademicYearController::class);
|
||||||
|
Route::get('departments/import/template', [DepartmentController::class, 'downloadImportTemplate'])->name('departments.import.template');
|
||||||
|
Route::post('departments/import/preview', [DepartmentController::class, 'previewImport'])->name('departments.import.preview');
|
||||||
|
Route::post('departments/import', [DepartmentController::class, 'import'])->name('departments.import');
|
||||||
Route::resource('departments', DepartmentController::class);
|
Route::resource('departments', DepartmentController::class);
|
||||||
|
Route::get('classes/import/template', [SchoolClassController::class, 'downloadImportTemplate'])->name('classes.import.template');
|
||||||
|
Route::post('classes/import/preview', [SchoolClassController::class, 'previewImport'])->name('classes.import.preview');
|
||||||
|
Route::post('classes/import', [SchoolClassController::class, 'import'])->name('classes.import');
|
||||||
Route::post('classes/generate', [SchoolClassController::class, 'generate'])->name('classes.generate');
|
Route::post('classes/generate', [SchoolClassController::class, 'generate'])->name('classes.generate');
|
||||||
Route::delete('classes/bulk-delete', [SchoolClassController::class, 'bulkDestroy'])->name('classes.bulk-destroy');
|
Route::delete('classes/bulk-delete', [SchoolClassController::class, 'bulkDestroy'])->name('classes.bulk-destroy');
|
||||||
Route::resource('classes', SchoolClassController::class);
|
Route::resource('classes', SchoolClassController::class);
|
||||||
@@ -74,6 +80,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
Route::get('students/lulus', [GraduatedStudentController::class, 'index'])->name('graduated-students.index');
|
Route::get('students/lulus', [GraduatedStudentController::class, 'index'])->name('graduated-students.index');
|
||||||
Route::get('students/lulus/bulk-upload', [GraduationBulkUploadController::class, 'index'])->name('graduated-students.bulk-upload.index');
|
Route::get('students/lulus/bulk-upload', [GraduationBulkUploadController::class, 'index'])->name('graduated-students.bulk-upload.index');
|
||||||
Route::post('students/lulus/bulk-upload/preview', [GraduationBulkUploadController::class, 'preview'])->name('graduated-students.bulk-upload.preview');
|
Route::post('students/lulus/bulk-upload/preview', [GraduationBulkUploadController::class, 'preview'])->name('graduated-students.bulk-upload.preview');
|
||||||
|
Route::post('students/lulus/bulk-upload/preview-ajax', [GraduationBulkUploadController::class, 'previewAjax'])->name('graduated-students.bulk-upload.preview-ajax');
|
||||||
Route::post('students/lulus/bulk-upload', [GraduationBulkUploadController::class, 'store'])->name('graduated-students.bulk-upload.store');
|
Route::post('students/lulus/bulk-upload', [GraduationBulkUploadController::class, 'store'])->name('graduated-students.bulk-upload.store');
|
||||||
Route::get('students/lulus/{graduatedStudent}', [GraduatedStudentController::class, 'show'])->name('graduated-students.show');
|
Route::get('students/lulus/{graduatedStudent}', [GraduatedStudentController::class, 'show'])->name('graduated-students.show');
|
||||||
Route::patch('students/lulus/{graduatedStudent}', [GraduatedStudentController::class, 'update'])->name('graduated-students.update');
|
Route::patch('students/lulus/{graduatedStudent}', [GraduatedStudentController::class, 'update'])->name('graduated-students.update');
|
||||||
@@ -104,3 +111,4 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
require __DIR__.'/auth.php';
|
require __DIR__.'/auth.php';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user