diff --git a/app/Http/Controllers/Api/Badges/BadgeController.php b/app/Http/Controllers/Api/Badges/BadgeController.php index e29ad842..9a2d1345 100644 --- a/app/Http/Controllers/Api/Badges/BadgeController.php +++ b/app/Http/Controllers/Api/Badges/BadgeController.php @@ -149,6 +149,7 @@ class BadgeController extends Controller 'data' => $this->badgeFormDataService->build( schoolYear: $request->input('school_year'), selectedUserIds: $this->parseStaffUserIds($request), + selectedStudentIds: $this->parseStudentIds($request), activeRole: $request->input('active_role') ), ]); diff --git a/app/Http/Requests/Roles/RoleUpdateRequest.php b/app/Http/Requests/Roles/RoleUpdateRequest.php index e5ee1f32..0611a35c 100644 --- a/app/Http/Requests/Roles/RoleUpdateRequest.php +++ b/app/Http/Requests/Roles/RoleUpdateRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests\Roles; use App\Http\Requests\ApiFormRequest; +use App\Models\Role; use Illuminate\Validation\Rule; class RoleUpdateRequest extends ApiFormRequest @@ -14,11 +15,32 @@ class RoleUpdateRequest extends ApiFormRequest public function rules(): array { - $roleId = (int) $this->route('roleId'); + $roleId = (int) ( + $this->route('roleId') + ?? $this->route('id') + ?? $this->input('id') + ?? 0 + ); + $current = $roleId > 0 ? Role::query()->find($roleId) : null; + + $currentName = strtolower(trim((string) ($current?->name ?? ''))); + $currentSlug = strtolower(trim((string) ($current?->slug ?? ''))); + $incomingName = strtolower(trim((string) $this->input('name', ''))); + $incomingSlug = strtolower(trim((string) $this->input('slug', ''))); + + $nameRules = ['sometimes', 'string', 'min:3', 'max:255']; + if ($incomingName !== '' && $incomingName !== $currentName) { + $nameRules[] = Rule::unique('roles', 'name')->ignore($roleId); + } + + $slugRules = ['nullable', 'string', 'max:64']; + if ($incomingSlug !== '' && $incomingSlug !== $currentSlug) { + $slugRules[] = Rule::unique('roles', 'slug')->ignore($roleId); + } return [ - 'name' => ['sometimes', 'string', 'min:3', 'max:255', Rule::unique('roles', 'name')->ignore($roleId)], - 'slug' => ['nullable', 'string', 'max:64', Rule::unique('roles', 'slug')->ignore($roleId)], + 'name' => $nameRules, + 'slug' => $slugRules, 'description' => ['nullable', 'string', 'max:500'], 'dashboard_route' => ['sometimes', 'string', 'min:1', 'max:255', 'regex:/^[a-z0-9_\\/\\-]+$/'], 'priority' => ['sometimes', 'integer', 'min:0', 'max:100000'], diff --git a/app/Http/Resources/Roles/RolePermissionResource.php b/app/Http/Resources/Roles/RolePermissionResource.php index ae51ecf1..83bef099 100644 --- a/app/Http/Resources/Roles/RolePermissionResource.php +++ b/app/Http/Resources/Roles/RolePermissionResource.php @@ -9,14 +9,14 @@ class RolePermissionResource extends JsonResource public function toArray($request): array { return [ - 'permission_id' => (int) ($this['id'] ?? $this['permission_id'] ?? 0), - 'name' => (string) ($this['name'] ?? ''), - 'description' => $this['description'] ?? null, - 'can_create' => (int) ($this['can_create'] ?? 0), - 'can_read' => (int) ($this['can_read'] ?? 0), - 'can_update' => (int) ($this['can_update'] ?? 0), - 'can_delete' => (int) ($this['can_delete'] ?? 0), - 'can_manage' => (int) ($this['can_manage'] ?? 0), + 'permission_id' => (int) ($this->id ?? $this->permission_id ?? $this['id'] ?? $this['permission_id'] ?? 0), + 'name' => (string) ($this->name ?? $this['name'] ?? ''), + 'description' => $this->description ?? $this['description'] ?? null, + 'can_create' => (int) ($this->can_create ?? $this['can_create'] ?? 0), + 'can_read' => (int) ($this->can_read ?? $this['can_read'] ?? 0), + 'can_update' => (int) ($this->can_update ?? $this['can_update'] ?? 0), + 'can_delete' => (int) ($this->can_delete ?? $this['can_delete'] ?? 0), + 'can_manage' => (int) ($this->can_manage ?? $this['can_manage'] ?? 0), ]; } } diff --git a/app/Models/Configuration.php b/app/Models/Configuration.php index e21e8d96..7c827fca 100644 --- a/app/Models/Configuration.php +++ b/app/Models/Configuration.php @@ -9,8 +9,8 @@ class Configuration extends BaseModel { protected $table = 'configuration'; - // CI model didn't use timestamps - public $timestamps = true; + // The legacy table only has id/config_key/config_value. + public $timestamps = false; protected $fillable = [ 'config_key', diff --git a/app/Services/Attendance/StaffAttendanceService.php b/app/Services/Attendance/StaffAttendanceService.php index a1662857..378f4019 100644 --- a/app/Services/Attendance/StaffAttendanceService.php +++ b/app/Services/Attendance/StaffAttendanceService.php @@ -303,6 +303,108 @@ class StaffAttendanceService ]; } + $adminStatusByUser = []; + $adminIds = []; + if (!empty($days)) { + $query = DB::table('staff_attendance') + ->select('user_id', 'date', 'status', 'reason') + ->where('school_year', $schoolYear) + ->whereIn('date', $days); + + if ($semesterNorm !== '') { + $query->where('semester', $semesterNorm); + } + + $rows = $query->get(); + foreach ($rows as $row) { + $uid = (int) ($row->user_id ?? 0); + if ($uid <= 0) { + continue; + } + $adminIds[$uid] = true; + $adminStatusByUser[$uid][(string) $row->date] = [ + 'status' => strtolower((string) $row->status), + 'reason' => $row->reason, + ]; + } + } + + $adminIds = array_keys($adminIds); + $admins = []; + if (!empty($adminIds)) { + $rolesRows = DB::table('user_roles as ur') + ->select('ur.user_id', 'r.name as role_name', 'r.slug as role_slug', 'r.priority') + ->join('roles as r', 'r.id', '=', 'ur.role_id') + ->whereIn('ur.user_id', $adminIds) + ->whereNull('ur.deleted_at') + ->where('r.is_active', 1) + ->get(); + + $rolesByUser = []; + foreach ($rolesRows as $row) { + $rolesByUser[(int) $row->user_id][] = (array) $row; + } + + foreach ($rolesByUser as &$list) { + usort($list, fn ($a, $b) => ((int) ($a['priority'] ?? 100)) <=> ((int) ($b['priority'] ?? 100))); + } + unset($list); + + $excludeSlugs = ['parent', 'guest', 'teacher', 'teacher_assistant', 'ta', 'assistant_teacher']; + + $userRows = DB::table('users') + ->select('id', 'firstname', 'lastname') + ->whereIn('id', $adminIds) + ->get(); + + $namesByUser = []; + foreach ($userRows as $row) { + $namesByUser[(int) $row->id] = trim(((string) ($row->firstname ?? '')) . ' ' . ((string) ($row->lastname ?? ''))); + } + + foreach ($adminIds as $uid) { + $picked = null; + foreach (($rolesByUser[$uid] ?? []) as $role) { + $slug = strtolower((string) ($role['role_slug'] ?? $role['role_name'] ?? '')); + if (!in_array($slug, $excludeSlugs, true)) { + $picked = $role; + break; + } + } + + if (!$picked) { + continue; + } + + $totals = ['p' => 0, 'a' => 0, 'l' => 0]; + $cells = []; + foreach ($days as $date) { + $cell = $adminStatusByUser[$uid][$date] ?? null; + if (!$cell || empty($cell['status'])) { + continue; + } + $cells[$date] = $cell; + if ($cell['status'] === 'present') { + $totals['p']++; + } elseif ($cell['status'] === 'absent') { + $totals['a']++; + } elseif ($cell['status'] === 'late') { + $totals['l']++; + } + } + + $admins[] = [ + 'user_id' => $uid, + 'name' => $namesByUser[$uid] !== '' ? $namesByUser[$uid] : ('User#' . $uid), + 'role' => (string) ($picked['role_name'] ?? 'Admin'), + 'cells' => $cells, + 'totals' => $totals, + ]; + } + + usort($admins, fn ($a, $b) => strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''))); + } + return [ 'filters' => [ 'semester' => $semester, @@ -313,7 +415,7 @@ class StaffAttendanceService 'sundays' => $days, 'noSchoolDays' => $noSchoolDays, 'sections' => $sections, - 'admins' => [], + 'admins' => $admins, ]; } @@ -570,4 +672,4 @@ class StaffAttendanceService fclose($out); }, $filename); } -} \ No newline at end of file +} diff --git a/app/Services/Badges/BadgeFormDataService.php b/app/Services/Badges/BadgeFormDataService.php index 914746ec..cd76097c 100644 --- a/app/Services/Badges/BadgeFormDataService.php +++ b/app/Services/Badges/BadgeFormDataService.php @@ -6,21 +6,26 @@ class BadgeFormDataService { public function __construct( protected BadgeUserLookupService $lookupService, + protected BadgeStudentLookupService $studentLookupService, protected BadgeTextFormatter $formatter ) { } - public function build(?string $schoolYear, array $selectedUserIds = [], ?string $activeRole = null): array - { + public function build( + ?string $schoolYear, + array $selectedUserIds = [], + array $selectedStudentIds = [], + ?string $activeRole = null + ): array { $selectedUserIds = $this->formatter->normalizeIds($selectedUserIds); + $selectedStudentIds = $this->formatter->normalizeIds($selectedStudentIds); $users = $this->lookupService->fetchStaffList($schoolYear, $selectedUserIds); + $students = $this->studentLookupService->fetchStudentList($schoolYear, $selectedStudentIds); foreach ($users as &$u) { - if (!isset($u['user_id'])) { - if (isset($u['id'])) { - $u['user_id'] = (int) $u['id']; - } + if (!isset($u['user_id']) && isset($u['id'])) { + $u['user_id'] = (int) $u['id']; } $rolesStr = $u['roles'] ?? ''; @@ -38,14 +43,18 @@ class BadgeFormDataService $u['role_name_raw'] = $u['role_name']; $u['role_name'] = $this->formatter->formatRole((string) $u['role_name']); - $u['class_section_id'] = $u['class_section_id'] ?? null; $u['class_section_name'] = $u['class_section_name'] ?? null; + $u['entity_type'] = 'user'; + $u['row_id'] = 'user:' . (int) ($u['user_id'] ?? 0); + $u['role_group'] = $this->detectRoleGroup( + strtolower((string) ($u['roles_raw'] ?? ($u['role_name_raw'] ?? ''))) + ); } unset($u); foreach ($users as &$u) { - $rolesDetect = strtolower($u['roles_raw'] ?? ($u['role_name_raw'] ?? '')); + $rolesDetect = strtolower((string) ($u['roles_raw'] ?? ($u['role_name_raw'] ?? ''))); $isTeacherish = str_contains($rolesDetect, 'teacher') || preg_match('/\bta\b/', $rolesDetect); if ($isTeacherish && !empty($u['user_id'])) { @@ -58,24 +67,66 @@ class BadgeFormDataService } unset($u); + foreach ($students as &$student) { + $student['entity_type'] = 'student'; + $student['row_id'] = 'student:' . (int) ($student['student_id'] ?? 0); + $student['role_group'] = 'student'; + $student['class_section_id'] = null; + + if (empty($student['class_section_name'])) { + $student['class_section_name'] = $this->formatter->formatClass( + (string) ($student['registration_grade'] ?? '') + ); + } + } + unset($student); + + $rows = array_merge($users, $students); + usort($rows, static function (array $a, array $b): int { + return strcasecmp( + trim((string) ($a['lastname'] ?? '')) . ' ' . trim((string) ($a['firstname'] ?? '')), + trim((string) ($b['lastname'] ?? '')) . ' ' . trim((string) ($b['firstname'] ?? '')) + ); + }); + $rolesTabs = [ + 'all' => 'All', 'teacher' => 'Teachers', 'ta' => 'Teacher Assistants', 'admin' => 'Admins', 'staff' => 'Staff', + 'student' => 'Students', ]; $activeRole = array_key_exists((string) $activeRole, $rolesTabs) ? (string) $activeRole - : 'teacher'; + : 'all'; return [ - 'users' => $users, - 'schoolYears' => $this->lookupService->getAvailableSchoolYears(), - 'selectedYear' => $schoolYear, - 'selectedUserIds' => $selectedUserIds, - 'rolesTabs' => $rolesTabs, - 'active_role' => $activeRole, + 'users' => $rows, + 'schoolYears' => $this->lookupService->getAvailableSchoolYears(), + 'selectedYear' => $schoolYear, + 'selectedUserIds' => $selectedUserIds, + 'selectedStudentIds' => $selectedStudentIds, + 'rolesTabs' => $rolesTabs, + 'active_role' => $activeRole, ]; } -} \ No newline at end of file + + protected function detectRoleGroup(string $rolesDetect): string + { + if (str_contains($rolesDetect, 'assistant') || preg_match('/\bta\b/', $rolesDetect)) { + return 'ta'; + } + + if (str_contains($rolesDetect, 'teacher')) { + return 'teacher'; + } + + if (str_contains($rolesDetect, 'admin')) { + return 'admin'; + } + + return 'staff'; + } +} diff --git a/app/Services/Badges/BadgePdfService.php b/app/Services/Badges/BadgePdfService.php index 15498267..17f71b98 100644 --- a/app/Services/Badges/BadgePdfService.php +++ b/app/Services/Badges/BadgePdfService.php @@ -7,6 +7,8 @@ namespace App\Services\Badges; use chillerlan\QRCode\Output\QRGdImagePNG; use chillerlan\QRCode\QRCode; use chillerlan\QRCode\QROptions; +use Dompdf\Dompdf; +use Dompdf\Options; use Illuminate\Http\Response; use Throwable; @@ -47,6 +49,109 @@ class BadgePdfService array $classesMap = [], ?int $actorId = null ): Response { + $studentIds = $this->formatter->normalizeIds($studentIds); + $userIds = $this->formatter->normalizeIds($userIds); + + $schoolName = (string) config('badges.school_name', 'Al Rahma Sunday School'); + $motto = (string) config('badges.motto', ''); + $studentRoleLabel = (string) config('badges.role_label', 'Student'); + $footerBlock = $this->footerBlock($schoolName); + $logoPath = $this->logoFilePath(); + + $rows = []; + + $students = $this->studentLookupService->fetchForBadges($studentIds, $schoolYear); + foreach ($students as $row) { + try { + $rows[] = array_merge($row, [ + '_badge_kind' => 'student', + 'role_subline' => $studentRoleLabel, + 'show_grade' => true, + 'grade_label' => 'Grade', + '_qr_base64' => base64_encode($this->renderQrPng((string) $row['school_id'])), + ]); + } catch (Throwable) { + continue; + } + } + + foreach ($userIds as $uid) { + $staffRow = $this->buildStaffBadgeRow((int) $uid, $schoolYear, $rolesMap, $classesMap); + if ($staffRow === null) { + continue; + } + + try { + $qrPayload = (string) ($staffRow['user_id_for_qr'] ?? $staffRow['user_id'] ?? ''); + if ($qrPayload === '' || !preg_match('/^[A-Za-z0-9_-]+$/', $qrPayload)) { + $qrPayload = (string) (int) ($staffRow['user_id'] ?? 0); + } + + $rows[] = array_merge($staffRow, [ + '_badge_kind' => 'staff', + '_qr_base64' => base64_encode($this->renderQrPng($qrPayload)), + ]); + } catch (Throwable) { + continue; + } + } + + // Use the fixed-coordinate FPDF renderer for badges so the output + // always stays at a strict 4x2 layout (8 badges per page). + $binary = $this->renderWithFpdf( + $rows, + $schoolName, + $motto, + $studentRoleLabel, + $footerBlock, + $logoPath + ); + + $logIds = array_values(array_unique(array_merge($studentIds, $userIds))); + $this->printLogService->logSafely( + userIds: $logIds, + actorId: $actorId, + schoolYear: $schoolYear, + rolesMap: $rolesMap, + classesMap: $classesMap, + copies: 1 + ); + + return response((string) $binary, 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="Badges.pdf"', + ]); + } + + protected function renderWithDompdf( + array $rows, + string $schoolName, + string $motto, + string $footerBlock + ): string { + $html = $this->buildHtmlDocument($rows, $schoolName, $motto, $footerBlock); + + $options = new Options(); + $options->set('isHtml5ParserEnabled', true); + $options->set('isRemoteEnabled', true); + $options->set('defaultFont', 'DejaVu Sans'); + + $dompdf = new Dompdf($options); + $dompdf->loadHtml($html, 'UTF-8'); + $dompdf->setPaper('A4', 'landscape'); + $dompdf->render(); + + return $dompdf->output(); + } + + protected function renderWithFpdf( + array $rows, + string $schoolName, + string $motto, + string $studentRoleLabel, + string $footerBlock, + ?string $logoPath + ): string { $this->tempQrFiles = []; try { @@ -54,67 +159,10 @@ class BadgePdfService require_once base_path('app/ThirdParty/fpdf/fpdf.php'); } - $studentIds = $this->formatter->normalizeIds($studentIds); - $userIds = $this->formatter->normalizeIds($userIds); - - $schoolName = (string) config('badges.school_name', 'Al Rahma Sunday School'); - $motto = (string) config('badges.motto', ''); - $studentRoleLabel = (string) config('badges.role_label', 'Student'); - $footerBlock = $this->footerBlock($schoolName); - $logoPath = $this->logoFilePath(); - - $rowsWithQr = []; - - $students = $this->studentLookupService->fetchForBadges($studentIds, $schoolYear); - foreach ($students as $row) { - try { - $png = $this->renderQrPng((string) $row['school_id']); - $tmpPath = $this->writeTempQrPng($png); - if ($tmpPath === null) { - continue; - } - - $rowsWithQr[] = array_merge($row, [ - '_badge_kind' => 'student', - 'role_subline' => $studentRoleLabel, - '_qr_tmp' => $tmpPath, - ]); - } catch (Throwable) { - continue; - } - } - - foreach ($userIds as $uid) { - $staffRow = $this->buildStaffBadgeRow((int) $uid, $schoolYear, $rolesMap, $classesMap); - if ($staffRow === null) { - continue; - } - - try { - $qrPayload = (string) ($staffRow['user_id_for_qr'] ?? $staffRow['user_id'] ?? ''); - if ($qrPayload === '' || !preg_match('/^[A-Za-z0-9_-]+$/', $qrPayload)) { - $qrPayload = (string) (int) ($staffRow['user_id'] ?? 0); - } - - $png = $this->renderQrPng($qrPayload); - $tmpPath = $this->writeTempQrPng($png); - if ($tmpPath === null) { - continue; - } - - $rowsWithQr[] = array_merge($staffRow, [ - '_badge_kind' => 'staff', - '_qr_tmp' => $tmpPath, - ]); - } catch (Throwable) { - continue; - } - } - $pdf = new \FPDF('L', 'in', 'Letter'); $pdf->SetAutoPageBreak(false); - $pages = array_chunk($rowsWithQr, 8); + $pages = array_chunk($rows, 8); if ($pages === []) { $pdf->AddPage('L', 'Letter'); @@ -125,7 +173,7 @@ class BadgePdfService $pdf->GetPageWidth() - 2 * self::PAGE_MARGIN_IN, 0.3, $this->formatter->toPdf( - 'No valid badges: check user/student ids, roles, or (for students) school id for QR.' + 'No valid badges: check user/student ids, roles, or school ids for QR.' ), 0, 'L' @@ -139,13 +187,24 @@ class BadgePdfService for ($gridCol = 0; $gridCol < 4; $gridCol++) { $idx = ($gridRow * 4) + $gridCol; $row = $slots[$idx]; - if (!is_array($row) || empty($row['_qr_tmp'])) { + if (!is_array($row)) { continue; } + $qrBase64 = trim((string) ($row['_qr_base64'] ?? '')); + if ($qrBase64 === '') { + continue; + } + + $tmpPath = $this->writeTempQrPng(base64_decode($qrBase64, true) ?: ''); + if ($tmpPath === null) { + continue; + } + + $row['_qr_tmp'] = $tmpPath; + $cellX = self::PAGE_MARGIN_IN + $gridCol * self::CELL_WIDTH_IN; $cellY = self::PAGE_MARGIN_IN + $gridRow * self::CELL_HEIGHT_IN; - $badgeX = $cellX + (self::CELL_WIDTH_IN - self::BADGE_WIDTH_IN) / 2; $badgeY = $cellY + (self::CELL_HEIGHT_IN - self::BADGE_HEIGHT_IN) / 2; @@ -167,22 +226,7 @@ class BadgePdfService } } - $binary = $pdf->Output('S'); - - $logIds = array_values(array_unique(array_merge($studentIds, $userIds))); - $this->printLogService->logSafely( - userIds: $logIds, - actorId: $actorId, - schoolYear: $schoolYear, - rolesMap: $rolesMap, - classesMap: $classesMap, - copies: 1 - ); - - return response((string) $binary, 200, [ - 'Content-Type' => 'application/pdf', - 'Content-Disposition' => 'inline; filename="Badges.pdf"', - ]); + return $pdf->Output('S'); } finally { foreach ($this->tempQrFiles as $path) { if (is_string($path) && $path !== '' && is_file($path)) { @@ -230,6 +274,7 @@ class BadgePdfService ($info['roles'] ?? '') !== '' ? (string) $info['roles'] : $roleResolved )); $isTeacherish = str_contains($detectSrc, 'teacher') || preg_match('/\bta\b/', $detectSrc); + $isAdmin = str_contains($detectSrc, 'admin'); if ($isTeacherish && $class !== '') { if (strtolower($class) === 'youth') { @@ -249,20 +294,544 @@ class BadgePdfService $gradeCol = $class !== '' ? $class : '—'; $year = $this->formatter->norm((string) ($info['school_year'] ?? ($schoolYear ?? ''))); + $schoolId = trim((string) ($info['school_id'] ?? '')); + + if ($schoolId === '' || !preg_match('/^[A-Za-z0-9_-]+$/', $schoolId)) { + return null; + } return [ 'user_id' => $resolvedId, - 'user_id_for_qr' => (string) $resolvedId, + 'user_id_for_qr' => $schoolId, 'fullname' => $info['name'] ?? 'STAFF', 'grade' => $gradeCol, 'academic_year' => $year !== '' ? $year : '—', - 'school_id' => (string) $resolvedId, + 'school_id' => $schoolId, 'role_subline' => $display, + 'show_grade' => $isTeacherish && !$isAdmin && $gradeCol !== '—', + 'grade_label' => 'Class / Grade', ]; } + protected function buildHtmlDocument( + array $rows, + string $schoolName, + string $motto, + string $footerBlock + ): string { + $logoDataUri = $this->logoDataUri(); + $badgesHtml = ''; + $pages = array_chunk($rows, 8); + + if ($pages === []) { + $pages = [[]]; + } + + foreach ($pages as $pageRows) { + $badgesHtml .= '
'; + + for ($rowIndex = 0; $rowIndex < 2; $rowIndex++) { + $badgesHtml .= ''; + + for ($colIndex = 0; $colIndex < 4; $colIndex++) { + $slot = ($rowIndex * 4) + $colIndex; + $badgesHtml .= ''; + } + + $badgesHtml .= ''; + } + + $badgesHtml .= '
'; + + if (isset($pageRows[$slot]) && is_array($pageRows[$slot])) { + $badgesHtml .= $this->buildBadgeHtml($pageRows[$slot], $schoolName, $motto, $footerBlock, $logoDataUri); + } + + $badgesHtml .= '
'; + } + + return 'Badges PDF' + . $badgesHtml + . ''; + } + + protected function buildBadgeHtml( + array $row, + string $schoolName, + string $motto, + string $footerBlock, + ?string $logoDataUri + ): string + { + $kind = (string) ($row['_badge_kind'] ?? 'student'); + $idLabel = $kind === 'staff' ? 'User ID' : 'School ID'; + $title = $this->escapeHtml((string) ($row['fullname'] ?? '')); + $role = $this->escapeHtml((string) ($row['role_subline'] ?? 'Student')); + $gradeLabel = $this->escapeHtml((string) ($row['grade_label'] ?? ($kind === 'staff' ? 'Class / Assignment' : 'Grade'))); + $grade = $this->escapeHtml((string) ($row['grade'] ?? '—')); + $year = $this->escapeHtml((string) ($row['academic_year'] ?? '—')); + $schoolId = $this->escapeHtml((string) ($row['school_id'] ?? '—')); + $qrBase64 = trim((string) ($row['_qr_base64'] ?? '')); + $footer = nl2br($this->escapeHtml($footerBlock)); + $barcodeDigits = preg_replace('/[^A-Za-z0-9]/', '', (string) ($row['school_id'] ?? '')); + $barcodeDigits = $barcodeDigits !== '' ? str_repeat($barcodeDigits, 4) : '12345678901234567890'; + $showGrade = (bool) ($row['show_grade'] ?? ($kind === 'student')); + + $infoRows = ''; + if ($showGrade) { + $infoRows .= ' +
+ G + + ' . $gradeLabel . ' + ' . $grade . ' + +
'; + } + + $infoRows .= ' +
+ Y + + Academic Year + ' . $year . ' + +
+ +
+ ID + + ' . $this->escapeHtml($idLabel) . ' + ' . $schoolId . ' + +
'; + + return ' +
+
+
+
' + . ($logoDataUri !== null + ? 'School Logo' + : 'AS') . + '
+
+
' . $this->escapeHtml($schoolName) . '
+
High School
+
' . $this->escapeHtml($motto) . '
+
+
+
+
+
+ +
+
' . mb_strtoupper($title, 'UTF-8') . '
+
+ + + +
+
' . mb_strtoupper($role, 'UTF-8') . '
+ + + + + + + +
+ ' . $infoRows . ' + +
' + . ($qrBase64 !== '' + ? 'QR Code' + : '') . + '
+
Scan to verify
+
+ +
+
' . $this->escapeHtml($barcodeDigits) . '
+
+ + +
+ '; + } + + protected function badgeCss(): string + { + return <<<'CSS' +@page { + size: A4 landscape; + margin: 0.18in; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: DejaVu Sans, Arial, Helvetica, sans-serif; + color: #111; +} + +.page { + width: 100%; + page-break-after: always; +} + +.page:last-child { + page-break-after: auto; +} + +.grid { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.grid td { + width: 25%; + vertical-align: top; + padding: 0.03in; + height: 3.32in; +} + +.badge { + width: 2.12in; + height: 3.18in; + border: 1.2px solid #1f6b3a; + border-radius: 0.18in; + overflow: hidden; + position: relative; + margin: 0 auto; + background: #ffffff; + box-shadow: 0 0.03in 0.10in rgba(0, 0, 0, 0.15); +} + +.header-wrap { + position: relative; + height: 0.92in; +} + +.header { + background: #1f6b3a; + color: #ffffff; + padding: 0.10in 0.10in 0.06in; + height: 0.64in; + border-radius: 0.18in 0.18in 0 0; +} + +.header-accent { + position: absolute; + left: 0.28in; + right: 0.28in; + top: 0.64in; + height: 0.02in; + background: #9fd6b0; +} + +.header-tail { + width: 0; + height: 0; + margin: 0 auto; + border-left: 0.92in solid transparent; + border-right: 0.92in solid transparent; + border-top: 0.22in solid #1f6b3a; +} + +.header-mark { + float: left; + width: 0.40in; + height: 0.40in; + border: 2px solid #ffffff; + border-radius: 50%; + text-align: center; + overflow: hidden; + background: rgba(255,255,255,0.08); +} + +.header-mark img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.header-mark span { + display: block; + line-height: 0.36in; + font-size: 12pt; + font-weight: 800; +} + +.header-copy { + margin-left: 0.48in; + text-align: center; +} + +.school-name { + font-size: 8.8pt; + font-weight: 800; + line-height: 1.1; + margin: 0; + text-transform: uppercase; + letter-spacing: 0.8px; +} + +.sub-school { + font-size: 5.8pt; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 2px; + margin-top: 0.02in; +} + +.motto { + font-size: 4.8pt; + font-weight: 700; + margin-top: 0.03in; + letter-spacing: 0.4px; + min-height: 0.08in; + text-transform: uppercase; + color: #f4d34c; +} + +.body { + padding: 0.00in 0.10in 0.40in; +} + +.student-name { + text-align: center; + color: #15512c; + font-size: 10.6pt; + font-weight: 800; + line-height: 1.05; + text-transform: uppercase; + margin: 0.08in 0 0.03in; +} + +.name-divider { + text-align: center; + margin-bottom: 0.03in; +} + +.divider-line { + display: inline-block; + width: 0.60in; + border-top: 1px solid #8fc7a0; + vertical-align: middle; +} + +.divider-dot { + display: inline-block; + width: 0.05in; + height: 0.05in; + margin: 0 0.04in; + background: #1f6b3a; + transform: rotate(45deg); + vertical-align: middle; +} + +.role { + text-align: center; + color: #1f6b3a; + font-size: 6.2pt; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1.1px; + margin-bottom: 0.08in; +} + +.content { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.content td { + vertical-align: top; +} + +.info { + width: 58%; + padding-right: 0.06in; + border-right: 1px solid #d6d6d6; +} + +.info-row { + margin-bottom: 0.07in; + padding-bottom: 0.04in; + border-bottom: 1px solid #d9d9d9; +} + +.info-row:last-child { + border-bottom: 0; + margin-bottom: 0; + padding-bottom: 0; +} + +.icon-badge { + display: inline-block; + width: 0.22in; + height: 0.22in; + line-height: 0.22in; + margin-right: 0.04in; + border-radius: 50%; + background: #1f6b3a; + color: #ffffff; + text-align: center; + font-size: 5pt; + font-weight: 800; + vertical-align: top; +} + +.info-copy { + display: inline-block; + width: 0.92in; +} + +.label { + display: block; + font-size: 4.8pt; + font-weight: 700; + text-transform: uppercase; + color: #333333; + margin-bottom: 0.01in; +} + +.value { + display: block; + font-size: 7.6pt; + font-weight: 800; + color: #1f6b3a; + word-wrap: break-word; +} + +.qr-col { + width: 42%; + text-align: center; + padding-left: 0.06in; +} + +.qr-box { + display: inline-block; + border: 1px solid #1f6b3a; + border-radius: 0.08in; + padding: 0.04in; + background: #ffffff; + min-width: 0.92in; + min-height: 0.92in; +} + +.qr-box img { + width: 0.84in; + height: 0.84in; + display: block; +} + +.scan { + margin-top: 0.04in; + font-size: 5.5pt; + font-weight: 700; + text-transform: uppercase; + color: #1f6b3a; +} + +.scan-pill { + margin-top: 0.03in; + display: inline-block; + background: #1f6b3a; + color: #ffffff; + border-radius: 0.06in; + font-size: 5pt; + font-weight: 700; + text-transform: uppercase; + padding: 0.025in 0.08in; +} + +.barcode-rule { + margin-top: 0.07in; + border-top: 1px solid #8fc7a0; +} + +.barcode { + margin: 0.04in auto 0; + text-align: center; + font-family: "Courier New", monospace; + font-size: 9pt; + letter-spacing: 0.2px; + color: #111111; +} + +.footer { + position: absolute; + left: 0; + right: 0; + bottom: 0; + background: #1f6b3a; + color: #ffffff; + padding: 0.06in 0.06in; + font-size: 4.8pt; + font-weight: 700; + line-height: 1.2; +} + +.footer-mark { + display: inline-block; + width: 0.18in; + height: 0.18in; + line-height: 0.18in; + margin-right: 0.05in; + border: 1.5px solid #ffffff; + border-radius: 50%; + text-align: center; + vertical-align: top; + font-size: 6pt; + overflow: hidden; + background: rgba(255,255,255,0.08); +} + +.footer-mark img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.footer-mark span { + display: block; +} + +.footer-copy { + display: inline-block; + width: 1.64in; + vertical-align: top; +} +CSS; + } + + protected function escapeHtml(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); + } + protected function writeTempQrPng(string $png): ?string { + if ($png === '') { + return null; + } + $tmpPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'badge_qr_' . bin2hex(random_bytes(8)) . '.png'; if (@file_put_contents($tmpPath, $png) === false) { return null; @@ -273,9 +842,6 @@ class BadgePdfService return $tmpPath; } - /** - * @param array $student - */ protected function drawBadge( \FPDF $pdf, array $student, @@ -291,9 +857,11 @@ class BadgePdfService ): void { $kind = (string) ($student['_badge_kind'] ?? 'student'); $idLabel = $kind === 'staff' ? 'USER ID' : 'SCHOOL ID'; + $showGrade = (bool) ($student['show_grade'] ?? ($kind === 'student')); + $gradeLabel = (string) ($student['grade_label'] ?? ($kind === 'staff' ? 'CLASS / ASSIGNMENT' : 'GRADE')); - $blue = [11, 58, 130]; - $blueLight = [27, 76, 160]; + $blue = [31, 107, 58]; + $blueLight = [53, 140, 81]; $qrPath = (string) ($student['_qr_tmp'] ?? ''); $fullName = $this->formatter->toPdf((string) ($student['fullname'] ?? '')); @@ -315,17 +883,9 @@ class BadgePdfService $pdf->SetFillColor($blue[0], $blue[1], $blue[2]); $pdf->Rect($x, $y, $w, $headerH, 'F'); - $logoSize = 0.12; - if ($logoPath !== null && is_readable($logoPath)) { - try { - $pdf->Image($logoPath, $x + $w / 2 - $logoSize / 2, $y + 0.035, $logoSize, $logoSize); - } catch (Throwable) { - } - } - $pdf->SetTextColor(255, 255, 255); $pdf->SetFont('Helvetica', 'B', 8.5); - $pdf->SetXY($x + 0.06, $y + 0.035 + $logoSize + 0.01); + $pdf->SetXY($x + 0.06, $y + 0.12); $pdf->Cell($w - 0.12, 0.1, $schoolNamePdf, 0, 0, 'C'); if ($mottoPdf !== '') { @@ -335,7 +895,7 @@ class BadgePdfService } $bodyTop = $y + $headerH + 0.06; - $pdf->SetTextColor(11, 47, 115); + $pdf->SetTextColor(21, 81, 44); $pdf->SetFont('Helvetica', 'B', 9.5); $pdf->SetXY($x + 0.07, $bodyTop); $pdf->Cell($w - 0.14, 0.11, strtoupper($fullName), 0, 1, 'C'); @@ -352,38 +912,30 @@ class BadgePdfService $infoLeft = $x + 0.08; $infoW = $w * 0.52; $labelY = $ruleY + 0.05; + $infoRows = []; + if ($showGrade) { + $infoRows[] = [$gradeLabel, $grade]; + } + $infoRows[] = ['ACADEMIC YEAR', $year]; + $infoRows[] = [$idLabel, $schoolId]; - $gradeLabel = $kind === 'staff' ? 'CLASS / ASSIGNMENT' : 'GRADE'; + foreach ($infoRows as $index => [$label, $value]) { + $pdf->SetTextColor(51, 51, 51); + $pdf->SetFont('Helvetica', 'B', 5); + $pdf->SetXY($infoLeft, $labelY); + $pdf->Cell($infoW, 0.05, $label, 0, 1, 'L'); - $pdf->SetTextColor(51, 51, 51); - $pdf->SetFont('Helvetica', 'B', 5); - $pdf->SetXY($infoLeft, $labelY); - $pdf->Cell($infoW, 0.05, $gradeLabel, 0, 1, 'L'); - - $pdf->SetTextColor($blueLight[0], $blueLight[1], $blueLight[2]); - $pdf->SetFont('Helvetica', 'B', 7.5); - $pdf->SetX($infoLeft); - $pdf->Cell($infoW, 0.08, $grade, 0, 1, 'L'); - - $pdf->SetTextColor(51, 51, 51); - $pdf->SetFont('Helvetica', 'B', 5); - $pdf->SetX($infoLeft); - $pdf->Cell($infoW, 0.05, 'ACADEMIC YEAR', 0, 1, 'L'); - - $pdf->SetTextColor($blueLight[0], $blueLight[1], $blueLight[2]); - $pdf->SetFont('Helvetica', 'B', 7.5); - $pdf->SetX($infoLeft); - $pdf->Cell($infoW, 0.08, $year, 0, 1, 'L'); - - $pdf->SetTextColor(51, 51, 51); - $pdf->SetFont('Helvetica', 'B', 5); - $pdf->SetX($infoLeft); - $pdf->Cell($infoW, 0.05, $idLabel, 0, 1, 'L'); - - $pdf->SetTextColor($blueLight[0], $blueLight[1], $blueLight[2]); - $pdf->SetFont('Helvetica', 'B', 7.5); - $pdf->SetX($infoLeft); - $pdf->MultiCell($infoW, 0.09, $schoolId, 0, 'L'); + $pdf->SetTextColor($blueLight[0], $blueLight[1], $blueLight[2]); + $pdf->SetFont('Helvetica', 'B', 7.5); + $pdf->SetX($infoLeft); + if ($index === array_key_last($infoRows)) { + $pdf->MultiCell($infoW, 0.09, $value, 0, 'L'); + $labelY = $pdf->GetY() + 0.015; + } else { + $pdf->Cell($infoW, 0.08, $value, 0, 1, 'L'); + $labelY = $pdf->GetY() + 0.01; + } + } $qrSize = 0.38; $qrX = $x + $w - 0.08 - $qrSize; @@ -407,9 +959,9 @@ class BadgePdfService $pdf->SetFillColor($blue[0], $blue[1], $blue[2]); $pdf->Rect($x, $fy, $w, $footerH, 'F'); - $smallLogo = 0.1; if ($logoPath !== null && is_readable($logoPath)) { try { + $smallLogo = 0.1; $pdf->Image($logoPath, $x + $w / 2 - $smallLogo / 2, $fy + 0.03, $smallLogo, $smallLogo); } catch (Throwable) { } @@ -418,13 +970,12 @@ class BadgePdfService $pdf->SetTextColor(255, 255, 255); $pdf->SetFont('Helvetica', 'B', 4.5); $lines = preg_split('/\r\n|\r|\n/', $footerBlock) ?: [$footerBlock]; - $lineY = $fy + 0.03 + $smallLogo + 0.015; + $lineY = $fy + 0.15; foreach ($lines as $line) { $line = trim($line); if ($line === '') { continue; } - $pdf->SetXY($x + 0.04, $lineY); $pdf->Cell($w - 0.08, 0.055, $this->formatter->toPdf($line), 0, 1, 'C'); $lineY += 0.055; @@ -454,6 +1005,29 @@ class BadgePdfService ]); } + protected function logoDataUri(): ?string + { + $path = $this->logoFilePath(); + if ($path === null || !is_readable($path)) { + return null; + } + + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + $mime = match ($ext) { + 'jpg', 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + default => 'image/png', + }; + + $bytes = @file_get_contents($path); + if ($bytes === false || $bytes === '') { + return null; + } + + return 'data:' . $mime . ';base64,' . base64_encode($bytes); + } + protected function footerBlock(string $schoolName): string { $address = trim((string) config('badges.footer_address', '')); diff --git a/app/Services/Badges/BadgeStudentLookupService.php b/app/Services/Badges/BadgeStudentLookupService.php index cebbcc54..ea519b0a 100644 --- a/app/Services/Badges/BadgeStudentLookupService.php +++ b/app/Services/Badges/BadgeStudentLookupService.php @@ -6,6 +6,7 @@ namespace App\Services\Badges; use App\Models\Student; use App\Models\StudentClass; +use Illuminate\Support\Facades\DB; class BadgeStudentLookupService { @@ -14,6 +15,85 @@ class BadgeStudentLookupService ) { } + /** + * Active student roster for the badge picker. + * + * @param int[] $selectedStudentIds + * @return array> + */ + public function fetchStudentList(?string $schoolYear, array $selectedStudentIds = []): array + { + $selectedStudentIds = $this->formatter->normalizeIds($selectedStudentIds); + + $query = DB::table('students') + ->leftJoin('student_class as sc', function ($join) use ($schoolYear) { + $join->on('sc.student_id', '=', 'students.id'); + + if (!empty($schoolYear)) { + $join->where('sc.school_year', '=', $schoolYear); + } + }) + ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id') + ->select([ + 'students.id as student_id', + 'students.firstname', + 'students.lastname', + 'students.registration_grade', + 'students.school_id', + 'students.school_year', + DB::raw("GROUP_CONCAT(DISTINCT cs.class_section_name ORDER BY cs.class_section_name SEPARATOR ', ') as class_section_name"), + ]) + ->where('students.is_active', 1) + ->groupBy( + 'students.id', + 'students.firstname', + 'students.lastname', + 'students.registration_grade', + 'students.school_id', + 'students.school_year' + ) + ->orderBy('students.lastname') + ->orderBy('students.firstname'); + + if (!empty($selectedStudentIds)) { + $query->whereIn('students.id', $selectedStudentIds); + } elseif (!empty($schoolYear)) { + $query->where(function ($q) use ($schoolYear) { + $q->where('students.school_year', $schoolYear) + ->orWhereNotNull('sc.student_id'); + }); + } + + $rows = array_map(static fn ($row) => (array) $row, $query->get()->all()); + + $out = []; + foreach ($rows as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + if ($studentId <= 0) { + continue; + } + + $schoolId = trim((string) ($row['school_id'] ?? '')); + if ($schoolId === '' || !preg_match('/^[A-Za-z0-9_-]+$/', $schoolId)) { + continue; + } + + $out[] = [ + 'student_id' => $studentId, + 'firstname' => (string) ($row['firstname'] ?? ''), + 'lastname' => (string) ($row['lastname'] ?? ''), + 'role_name' => 'Student', + 'roles' => 'Student', + 'registration_grade' => (string) ($row['registration_grade'] ?? ''), + 'class_section_name' => $this->formatter->norm((string) ($row['class_section_name'] ?? '')), + 'school_id' => $schoolId, + 'school_year' => (string) ($row['school_year'] ?? $schoolYear ?? ''), + ]; + } + + return $out; + } + /** * Load students for badge printing: valid school_id (QR payload), ordered by name. * diff --git a/app/Services/Badges/BadgeUserLookupService.php b/app/Services/Badges/BadgeUserLookupService.php index 297e5487..f9ff53e6 100644 --- a/app/Services/Badges/BadgeUserLookupService.php +++ b/app/Services/Badges/BadgeUserLookupService.php @@ -29,11 +29,30 @@ class BadgeUserLookupService $query->whereIn('users.id', $selectedUserIds); } - if (!empty($schoolYear) && Schema::hasColumn('user_roles', 'school_year')) { - $query->where('user_roles.school_year', $schoolYear); + if (Schema::hasColumn('user_roles', 'deleted_at')) { + $query->whereNull('user_roles.deleted_at'); } - return array_map(static fn ($row) => (array) $row, $query->get()->all()); + $rows = array_map(static fn ($row) => (array) $row, $query->get()->all()); + + return array_values(array_filter($rows, static function (array $row): bool { + $roles = strtolower(trim((string) ($row['roles'] ?? ''))); + + if ($roles === '') { + return false; + } + + $parts = array_values(array_filter(array_map('trim', explode(',', $roles)))); + foreach ($parts as $part) { + if (in_array($part, ['parent', 'student'], true)) { + continue; + } + + return true; + } + + return false; + })); } public function getLatestClassForUser(int $userId, ?string $schoolYear = null): ?array @@ -69,7 +88,7 @@ class BadgeUserLookupService public function getUserInfoById(int $id, ?string $schoolYear = null): ?array { $u = DB::table('users') - ->select(['id as user_id', 'firstname', 'lastname']) + ->select(['id as user_id', 'firstname', 'lastname', 'school_id', 'account_id']) ->where('id', $id) ->first(); @@ -79,6 +98,10 @@ class BadgeUserLookupService $userId = (int) $u->user_id; $fullname = trim(($u->firstname ?? '') . ' ' . ($u->lastname ?? '')); + $schoolId = trim((string) ($u->school_id ?? '')); + if ($schoolId === '') { + $schoolId = trim((string) ($u->account_id ?? '')); + } $rolesRows = DB::table('user_roles as ur') ->join('roles as r', 'r.id', '=', 'ur.role_id') @@ -155,7 +178,7 @@ class BadgeUserLookupService 'class_section_name' => $className, 'job_title' => $jobTitle, 'school_name' => 'Al Rahma Sunday School', - 'school_id' => $userId, + 'school_id' => $schoolId, 'school_logo' => $schoolLogo, 'isgl_logo' => $isglLogo, 'school_year' => $assignment->school_year ?? $schoolYear, @@ -208,4 +231,4 @@ class BadgeUserLookupService return $row->class_section_name ?? null; } -} \ No newline at end of file +} diff --git a/composer.json b/composer.json index ff7b9110..e4410a6e 100644 --- a/composer.json +++ b/composer.json @@ -7,12 +7,13 @@ "license": "MIT", "require": { "php": "^8.2", + "chillerlan/php-qrcode": "^5.0", + "dompdf/dompdf": "^3.1", "laravel/framework": "^12.0", "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1", "php-open-source-saver/jwt-auth": "^2.8", - "setasign/fpdf": "*", - "chillerlan/php-qrcode": "^5.0" + "setasign/fpdf": "*" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index d1106d97..dc398f12 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b2966364d6a0f8b7a445116ac851814b", + "content-hash": "661306f5e5cfc18e168ca8a287b7097a", "packages": [ { "name": "brick/math", @@ -539,6 +539,161 @@ ], "time": "2024-02-05T11:56:58+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v3.1.5", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" + }, + "time": "2026-03-03T13:54:37+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, { "name": "dragonmantank/cron-expression", "version": "v3.6.0", @@ -2318,6 +2473,73 @@ ], "time": "2026-03-08T20:05:35+00:00" }, + { + "name": "masterminds/html5", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fcf91eb64359852f00d921887b219479b4f21251" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + }, + "time": "2025-07-25T09:04:22+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -3756,6 +3978,86 @@ }, "time": "2025-12-14T04:43:48+00:00" }, + { + "name": "sabberworm/php-css-parser", + "version": "v9.3.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", + "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.32 || 2.1.32", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.8", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7", + "phpunit/phpunit": "8.5.52", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.2.8", + "rector/type-perfect": "1.0.0 || 2.1.0", + "squizlabs/php_codesniffer": "4.0.1", + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.4.x-dev" + } + }, + "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0" + }, + "time": "2026-03-03T17:31:43+00:00" + }, { "name": "setasign/fpdf", "version": "1.8.6", @@ -6371,6 +6673,149 @@ ], "time": "2026-02-15T10:53:20+00:00" }, + { + "name": "thecodingmachine/safe", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2026-02-04T18:08:13+00:00" + }, { "name": "tijsverkoyen/css-to-inline-styles", "version": "v2.4.0", @@ -8970,5 +9415,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/config/badges.php b/config/badges.php index 51817475..68bf8a3a 100644 --- a/config/badges.php +++ b/config/badges.php @@ -7,7 +7,7 @@ return [ |-------------------------------------------------------------------------- */ 'school_name' => env('BADGE_SCHOOL_NAME', 'Al Rahma Sunday School'), - 'motto' => env('BADGE_MOTTO', 'Learn • Lead • Succeed'), + 'motto' => env('BADGE_MOTTO', ''), 'role_label' => env('BADGE_ROLE_LABEL', 'Student'), 'footer_address' => env('BADGE_FOOTER_ADDRESS', ''), ]; diff --git a/public/badge_example.jpeg b/public/badge_example.jpeg new file mode 100644 index 00000000..67649a45 Binary files /dev/null and b/public/badge_example.jpeg differ