add badge logic

This commit is contained in:
root
2026-04-25 01:23:21 -04:00
parent eafe4eb134
commit 5c544f93b9
13 changed files with 1468 additions and 169 deletions
+67 -16
View File
@@ -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,
];
}
}
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';
}
}
+701 -127
View File
@@ -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 .= '<div class="page"><table class="grid">';
for ($rowIndex = 0; $rowIndex < 2; $rowIndex++) {
$badgesHtml .= '<tr>';
for ($colIndex = 0; $colIndex < 4; $colIndex++) {
$slot = ($rowIndex * 4) + $colIndex;
$badgesHtml .= '<td>';
if (isset($pageRows[$slot]) && is_array($pageRows[$slot])) {
$badgesHtml .= $this->buildBadgeHtml($pageRows[$slot], $schoolName, $motto, $footerBlock, $logoDataUri);
}
$badgesHtml .= '</td>';
}
$badgesHtml .= '</tr>';
}
$badgesHtml .= '</table></div>';
}
return '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Badges PDF</title><style>'
. $this->badgeCss()
. '</style></head><body>'
. $badgesHtml
. '</body></html>';
}
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 .= '
<div class="info-row">
<span class="icon-badge">G</span>
<span class="info-copy">
<span class="label">' . $gradeLabel . '</span>
<span class="value">' . $grade . '</span>
</span>
</div>';
}
$infoRows .= '
<div class="info-row">
<span class="icon-badge">Y</span>
<span class="info-copy">
<span class="label">Academic Year</span>
<span class="value">' . $year . '</span>
</span>
</div>
<div class="info-row">
<span class="icon-badge">ID</span>
<span class="info-copy">
<span class="label">' . $this->escapeHtml($idLabel) . '</span>
<span class="value">' . $schoolId . '</span>
</span>
</div>';
return '
<div class="badge">
<div class="header-wrap">
<div class="header">
<div class="header-mark">'
. ($logoDataUri !== null
? '<img src="' . $logoDataUri . '" alt="School Logo">'
: '<span>AS</span>') .
'</div>
<div class="header-copy">
<div class="school-name">' . $this->escapeHtml($schoolName) . '</div>
<div class="sub-school">High School</div>
<div class="motto">' . $this->escapeHtml($motto) . '</div>
</div>
</div>
<div class="header-accent"></div>
<div class="header-tail"></div>
</div>
<div class="body">
<div class="student-name">' . mb_strtoupper($title, 'UTF-8') . '</div>
<div class="name-divider">
<span class="divider-line"></span>
<span class="divider-dot"></span>
<span class="divider-line"></span>
</div>
<div class="role">' . mb_strtoupper($role, 'UTF-8') . '</div>
<table class="content">
<tr>
<td class="info">
' . $infoRows . '
</td>
<td class="qr-col">
<div class="qr-box">'
. ($qrBase64 !== ''
? '<img src="data:image/png;base64,' . $qrBase64 . '" alt="QR Code">'
: '') .
'</div>
<div class="scan-pill">Scan to verify</div>
</td>
</tr>
</table>
<div class="barcode-rule"></div>
<div class="barcode">' . $this->escapeHtml($barcodeDigits) . '</div>
</div>
<div class="footer">
<span class="footer-mark">'
. ($logoDataUri !== null
? '<img src="' . $logoDataUri . '" alt="School Logo">'
: '<span>S</span>') .
'</span>
<span class="footer-copy">' . $footer . '</span>
</div>
</div>
';
}
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<string, mixed> $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', ''));
@@ -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<int, array<string, mixed>>
*/
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.
*
+29 -6
View File
@@ -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;
}
}
}