220 lines
7.9 KiB
PHP
220 lines
7.9 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\DocumentType;
|
|
use App\Models\Student;
|
|
use App\Models\StudentDocument;
|
|
use App\Models\StudentGraduation;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\View\View;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
class PublicStudentLookupController extends Controller
|
|
{
|
|
public function index(Request $request): View
|
|
{
|
|
$query = trim((string) $request->get('q'));
|
|
$students = collect();
|
|
$selectedStudent = null;
|
|
$requiredDocumentSummary = null;
|
|
$matchedBy = null;
|
|
$selectedStudentId = $request->integer('student');
|
|
|
|
if ($query !== '') {
|
|
$students = Student::query()
|
|
->with([
|
|
'academicYear',
|
|
'department',
|
|
'classRoom',
|
|
'documents.documentType',
|
|
'achievements',
|
|
])
|
|
->where(function ($builder) use ($query) {
|
|
$builder->where('student_number', 'like', "%{$query}%")
|
|
->orWhere('nisn', 'like', "%{$query}%")
|
|
->orWhere('full_name', 'like', "%{$query}%");
|
|
})
|
|
->orderByRaw('CASE WHEN student_number = ? THEN 0 WHEN nisn = ? THEN 1 WHEN full_name = ? THEN 2 ELSE 3 END', [$query, $query, $query])
|
|
->orderBy('full_name')
|
|
->limit(10)
|
|
->get();
|
|
|
|
$selectedStudent = $students->firstWhere('id', $selectedStudentId) ?? $students->first();
|
|
|
|
if ($selectedStudent) {
|
|
$requiredDocumentSummary = $this->buildRequiredDocumentSummary($selectedStudent);
|
|
$matchedBy = $this->detectMatchedBy($selectedStudent, $query);
|
|
$selectedStudent->public_graduation_record = $selectedStudent->status === 'graduated'
|
|
? StudentGraduation::query()->where('student_id', $selectedStudent->id)->first()
|
|
: null;
|
|
}
|
|
|
|
$students->each(function (Student $student) {
|
|
$student->public_photo_url = $this->resolvePhotoUrl($student);
|
|
});
|
|
}
|
|
|
|
return view('public.student-lookup', [
|
|
'query' => $query,
|
|
'students' => $students,
|
|
'student' => $selectedStudent,
|
|
'requiredDocumentSummary' => $requiredDocumentSummary,
|
|
'matchedBy' => $matchedBy,
|
|
'selectedStudentId' => $selectedStudent?->id,
|
|
]);
|
|
}
|
|
|
|
protected function detectMatchedBy(Student $student, string $query): ?string
|
|
{
|
|
if ($student->student_number && strcasecmp($student->student_number, $query) === 0) {
|
|
return 'NIS';
|
|
}
|
|
|
|
if ($student->nisn && strcasecmp($student->nisn, $query) === 0) {
|
|
return 'NISN';
|
|
}
|
|
|
|
return 'Nama';
|
|
}
|
|
|
|
protected function resolvePhotoUrl(Student $student): ?string
|
|
{
|
|
$photoDocument = $student->documents
|
|
->filter(fn ($document) => $this->isPhotoDocument($document))
|
|
->sortByDesc(function ($document) {
|
|
return sprintf(
|
|
'%d-%s',
|
|
$document->verification_status === 'valid' ? 1 : 0,
|
|
optional($document->created_at)?->format('YmdHis') ?? '00000000000000'
|
|
);
|
|
})
|
|
->first();
|
|
|
|
if (! $photoDocument && $student->relationLoaded('documents') === false) {
|
|
$photoDocument = StudentDocument::query()
|
|
->with('documentType')
|
|
->where('student_id', $student->id)
|
|
->get()
|
|
->filter(fn ($document) => $this->isPhotoDocument($document))
|
|
->sortByDesc(function ($document) {
|
|
return sprintf(
|
|
'%d-%s',
|
|
$document->verification_status === 'valid' ? 1 : 0,
|
|
optional($document->created_at)?->format('YmdHis') ?? '00000000000000'
|
|
);
|
|
})
|
|
->first();
|
|
}
|
|
|
|
if (! $photoDocument?->file_path) {
|
|
return null;
|
|
}
|
|
|
|
if (! Storage::disk('public')->exists($photoDocument->file_path)) {
|
|
return null;
|
|
}
|
|
|
|
return asset('storage/' . ltrim($photoDocument->file_path, '/'));
|
|
}
|
|
|
|
protected function isPhotoDocument(StudentDocument $document): bool
|
|
{
|
|
$code = strtoupper((string) ($document->documentType?->code ?? ''));
|
|
$name = strtolower((string) ($document->documentType?->name ?? ''));
|
|
$fileName = strtolower((string) ($document->file_name ?? ''));
|
|
$mimeType = strtolower((string) ($document->mime_type ?? ''));
|
|
|
|
$looksLikePhoto = $code === 'PAS_FOTO'
|
|
|| $code === 'PASFOTO'
|
|
|| str_contains($name, 'pas foto')
|
|
|| str_contains($name, 'pasfoto')
|
|
|| str_contains($fileName, 'pas foto')
|
|
|| str_contains($fileName, 'pasfoto');
|
|
|
|
if ($looksLikePhoto) {
|
|
return true;
|
|
}
|
|
|
|
return str_starts_with($mimeType, 'image/')
|
|
&& (str_contains($name, 'foto') || str_contains($fileName, 'foto'));
|
|
}
|
|
|
|
public function downloadGraduationDocument(Student $student, string $document): StreamedResponse
|
|
{
|
|
abort_unless($student->status === 'graduated', 404);
|
|
abort_unless(in_array($document, ['skl', 'ijazah'], true), 404);
|
|
|
|
$graduation = StudentGraduation::query()->where('student_id', $student->id)->firstOrFail();
|
|
|
|
$filePathField = $document === 'skl' ? 'skl_file_path' : 'ijazah_file_path';
|
|
$fileNameField = $document === 'skl' ? 'skl_file_name' : 'ijazah_file_name';
|
|
|
|
abort_unless($graduation->{$filePathField} && Storage::disk('public')->exists($graduation->{$filePathField}), 404);
|
|
|
|
return Storage::disk('public')->download($graduation->{$filePathField}, $graduation->{$fileNameField} ?: basename($graduation->{$filePathField}));
|
|
}
|
|
|
|
protected function buildRequiredDocumentSummary(Student $student): array
|
|
{
|
|
$requiredTypes = DocumentType::query()
|
|
->where('is_active', true)
|
|
->where('is_required', true)
|
|
->get();
|
|
|
|
if ($requiredTypes->isEmpty()) {
|
|
return [
|
|
'total' => 0,
|
|
'uploaded' => 0,
|
|
'verified' => 0,
|
|
'missing' => 0,
|
|
'unverified' => 0,
|
|
'percentage' => 100,
|
|
'verification_percentage' => 100,
|
|
'missing_items' => collect(),
|
|
'unverified_items' => collect(),
|
|
];
|
|
}
|
|
|
|
$documents = $student->documents;
|
|
|
|
$uploaded = 0;
|
|
$verified = 0;
|
|
$missingItems = collect();
|
|
$unverifiedItems = collect();
|
|
|
|
foreach ($requiredTypes as $type) {
|
|
$matchingDocuments = $documents->where('document_type_id', $type->id);
|
|
$latestDocument = $matchingDocuments->sortByDesc('created_at')->first();
|
|
|
|
if (! $latestDocument) {
|
|
$missingItems->push($type->name);
|
|
continue;
|
|
}
|
|
|
|
$uploaded++;
|
|
|
|
if ($latestDocument->verification_status === 'valid') {
|
|
$verified++;
|
|
} else {
|
|
$unverifiedItems->push($type->name);
|
|
}
|
|
}
|
|
|
|
$total = $requiredTypes->count();
|
|
|
|
return [
|
|
'total' => $total,
|
|
'uploaded' => $uploaded,
|
|
'verified' => $verified,
|
|
'missing' => max($total - $uploaded, 0),
|
|
'unverified' => max($total - $verified, 0),
|
|
'percentage' => $total > 0 ? (int) round(($uploaded / $total) * 100) : 100,
|
|
'verification_percentage' => $total > 0 ? (int) round(($verified / $total) * 100) : 100,
|
|
'missing_items' => $missingItems,
|
|
'unverified_items' => $unverifiedItems,
|
|
];
|
|
}
|
|
}
|