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 .= '| ';
+
+ if (isset($pageRows[$slot]) && is_array($pageRows[$slot])) {
+ $badgesHtml .= $this->buildBadgeHtml($pageRows[$slot], $schoolName, $motto, $footerBlock, $logoDataUri);
+ }
+
+ $badgesHtml .= ' | ';
+ }
+
+ $badgesHtml .= '
';
+ }
+
+ $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 '
+
+
+
+
+
' . mb_strtoupper($title, 'UTF-8') . '
+
+
+
+
+
+
' . mb_strtoupper($role, 'UTF-8') . '
+
+
+
+ |
+ ' . $infoRows . '
+ |
+
+
+ '
+ . ($qrBase64 !== ''
+ ? '  '
+ : '') .
+ '
+ 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