reconstruction of the project

This commit is contained in:
root
2026-03-08 16:33:24 -04:00
parent 23b7db1107
commit c8de5f7edc
9157 changed files with 77877 additions and 1073823 deletions
@@ -1,330 +0,0 @@
<?php
namespace App\Services\Admin;
use Illuminate\Support\Facades\DB;
use Throwable;
class AdminNotificationService
{
public function alertsPayload(): array
{
$admins = $this->fetchAdminNotificationUsers();
$subjects = $this->notificationSubjectOptions();
$assignedSubjects = [];
$tableReady = $this->hasTable('admin_notification_subjects');
if ($tableReady && !empty($admins)) {
$adminIds = array_map('intval', array_column($admins, 'id'));
$rows = DB::table('admin_notification_subjects')
->select('id', 'admin_id', 'subject')
->whereIn('admin_id', $adminIds)
->get();
foreach ($rows as $row) {
$adminId = (int) ($row->admin_id ?? 0);
$subject = (string) ($row->subject ?? '');
if ($adminId <= 0 || $subject === '') {
continue;
}
$assignedSubjects[$adminId] ??= [];
$assignedSubjects[$adminId][$subject] = true;
}
}
return [
'admins' => $admins,
'subjects' => $subjects,
'assigned_subjects' => $assignedSubjects,
'table_ready' => $tableReady,
];
}
/**
* Input shape (API):
* [
* 'subjects' => [
* 12 => ['finance' => true, 'attendance' => true],
* 15 => ['general', 'events']
* ]
* ]
*/
public function saveSubjects(array $posted): array
{
if (!$this->hasTable('admin_notification_subjects')) {
return [
'ok' => false,
'message' => 'Notification subject storage is missing. Run migrations first.',
'updated' => 0,
];
}
$subjectsMap = $this->notificationSubjectOptions();
$allowed = array_keys($subjectsMap);
$admins = $this->fetchAdminNotificationUsers();
$adminIds = array_map('intval', array_column($admins, 'id'));
if (empty($adminIds)) {
return [
'ok' => true,
'message' => 'No admins found to update.',
'updated' => 0,
];
}
$existing = DB::table('admin_notification_subjects')
->select('id', 'admin_id', 'subject')
->whereIn('admin_id', $adminIds)
->get();
$existingMap = [];
foreach ($existing as $row) {
$adminId = (int) ($row->admin_id ?? 0);
$subject = (string) ($row->subject ?? '');
if ($adminId <= 0 || $subject === '') {
continue;
}
$existingMap[$adminId] ??= [];
$existingMap[$adminId][$subject] = (int) ($row->id ?? 0);
}
$updates = 0;
DB::transaction(function () use ($posted, $adminIds, $allowed, $existingMap, &$updates) {
foreach ($adminIds as $adminId) {
$subjectRaw = $posted[$adminId] ?? [];
$selected = [];
if (is_array($subjectRaw)) {
foreach ($subjectRaw as $key => $value) {
$candidate = is_string($key) ? $key : $value;
if (!is_string($candidate)) {
continue;
}
$candidate = trim($candidate);
if ($candidate !== '') {
$selected[] = $candidate;
}
}
}
$selected = array_values(array_unique(array_filter(
$selected,
fn ($value) => in_array($value, $allowed, true)
)));
$current = array_keys($existingMap[$adminId] ?? []);
$toDelete = array_values(array_diff($current, $selected));
$toInsert = array_values(array_diff($selected, $current));
if (!empty($toDelete)) {
DB::table('admin_notification_subjects')
->where('admin_id', $adminId)
->whereIn('subject', $toDelete)
->delete();
$updates += count($toDelete);
}
if (!empty($toInsert)) {
$batch = [];
foreach ($toInsert as $subject) {
$batch[] = [
'admin_id' => $adminId,
'subject' => $subject,
];
}
DB::table('admin_notification_subjects')->insert($batch);
$updates += count($toInsert);
}
}
});
return [
'ok' => true,
'message' => $updates > 0 ? 'Notification subjects updated.' : 'No changes were made.',
'updated' => $updates,
];
}
public function printRecipientsPayload(): array
{
$admins = $this->fetchAdminNotificationUsers();
$tableReady = $this->hasTable('admin_notification_subjects');
$assigned = [];
if ($tableReady && !empty($admins)) {
$adminIds = array_map('intval', array_column($admins, 'id'));
$rows = DB::table('admin_notification_subjects')
->select('admin_id')
->where('subject', 'print_requests')
->whereIn('admin_id', $adminIds)
->get();
foreach ($rows as $row) {
$adminId = (int) ($row->admin_id ?? 0);
if ($adminId > 0) {
$assigned[$adminId] = true;
}
}
}
return [
'admins' => $admins,
'assigned' => $assigned,
'table_ready' => $tableReady,
];
}
/**
* Input shape (API):
* ['notify' => [12 => true, 15 => true]]
*/
public function savePrintRecipients(array $postedNotify): array
{
if (!$this->hasTable('admin_notification_subjects')) {
return [
'ok' => false,
'message' => 'Notification subject storage is missing. Run migrations first.',
'updated' => 0,
];
}
$admins = $this->fetchAdminNotificationUsers();
$adminIds = array_map('intval', array_column($admins, 'id'));
if (empty($adminIds)) {
return [
'ok' => true,
'message' => 'No admins found to update.',
'updated' => 0,
];
}
$selected = [];
foreach ((array) $postedNotify as $key => $value) {
$adminId = (int) $key;
if ($adminId <= 0) continue;
if (!in_array($adminId, $adminIds, true)) continue;
$selected[] = $adminId;
}
$selected = array_values(array_unique($selected));
$existingRows = DB::table('admin_notification_subjects')
->select('admin_id')
->where('subject', 'print_requests')
->whereIn('admin_id', $adminIds)
->get();
$current = array_values(array_unique(array_map(
fn ($row) => (int) ($row->admin_id ?? 0),
$existingRows->all()
)));
$toDelete = array_values(array_diff($current, $selected));
$toInsert = array_values(array_diff($selected, $current));
DB::transaction(function () use ($toDelete, $toInsert) {
if (!empty($toDelete)) {
DB::table('admin_notification_subjects')
->where('subject', 'print_requests')
->whereIn('admin_id', $toDelete)
->delete();
}
if (!empty($toInsert)) {
$batch = [];
foreach ($toInsert as $adminId) {
$batch[] = [
'admin_id' => $adminId,
'subject' => 'print_requests',
];
}
DB::table('admin_notification_subjects')->insert($batch);
}
});
$changes = count($toDelete) + count($toInsert);
return [
'ok' => true,
'message' => $changes > 0 ? 'Print notification recipients updated.' : 'No changes were made.',
'updated' => $changes,
'selected_admin_ids' => $selected,
];
}
public function notificationSubjectOptions(): array
{
return [
'academics' => 'Academics',
'attendance' => 'Attendance',
'events' => 'Events',
'finance' => 'Finance',
'general' => 'General',
'print_requests' => 'Print Requests',
];
}
private function getAdminNotificationExcludedRoles(): array
{
return [
'parent',
'student',
'guest',
'teacher',
'assistant teacher',
'teacher assistant',
'teacher_assistant',
'assistant_teacher',
'ta',
'authorized_user',
];
}
public function fetchAdminNotificationUsers(): array
{
$excluded = array_map(
fn ($value) => strtolower(trim((string) $value)),
$this->getAdminNotificationExcludedRoles()
);
// Safer than raw NOT IN string: use bindings + lower(name)
$rows = DB::table('users as u')
->select('u.id', 'u.firstname', 'u.lastname', 'u.email')
->join('user_roles as ur', 'ur.user_id', '=', 'u.id')
->join('roles as r', 'r.id', '=', 'ur.role_id')
->whereNull('ur.deleted_at')
->whereNotNull('r.name')
->whereNotIn(DB::raw('LOWER(r.name)'), $excluded)
->groupBy('u.id', 'u.firstname', 'u.lastname', 'u.email')
->orderBy('u.lastname')
->orderBy('u.firstname')
->get();
return $rows->map(function ($row) {
return [
'id' => (int) $row->id,
'firstname' => (string) ($row->firstname ?? ''),
'lastname' => (string) ($row->lastname ?? ''),
'email' => (string) ($row->email ?? ''),
'full_name' => trim((string)($row->firstname ?? '') . ' ' . (string)($row->lastname ?? '')),
];
})->all();
}
private function hasTable(string $table): bool
{
try {
return DB::getSchemaBuilder()->hasTable($table);
} catch (Throwable) {
return false;
}
}
}
@@ -1,83 +0,0 @@
<?php
namespace App\Services\Admin;
use App\Models\ClassProgressReport;
use Illuminate\Database\Eloquent\Builder;
class AdminProgressQueryService
{
public function baseIndexQuery(): Builder
{
return ClassProgressReport::query()
->from('class_progress_reports')
->select([
'class_progress_reports.*',
'cs.class_section_name',
])
->selectRaw("CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.lastname, '')) AS teacher_name")
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'class_progress_reports.class_section_id')
->leftJoin('users as u', 'u.id', '=', 'class_progress_reports.teacher_id');
}
public function applyFilters(Builder $query, array $filters): Builder
{
if (!empty($filters['from'])) {
$query->where('week_start', '>=', $filters['from']);
}
if (!empty($filters['to'])) {
$query->where('week_end', '<=', $filters['to']);
}
if (!empty($filters['class_section_id'])) {
$query->where('class_progress_reports.class_section_id', (int) $filters['class_section_id']);
}
if (!empty($filters['status'])) {
$query->where('class_progress_reports.status', $filters['status']);
}
return $query;
}
public function baseShowQuery(): Builder
{
return ClassProgressReport::query()
->from('class_progress_reports')
->select([
'class_progress_reports.*',
'cs.class_section_name',
])
->selectRaw("CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.lastname, '')) AS teacher_name")
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'class_progress_reports.class_section_id')
->leftJoin('users as u', 'u.id', '=', 'class_progress_reports.teacher_id');
}
public function groupReportsByWeekAndSection(array $rows): array
{
$groups = [];
foreach ($rows as $row) {
$key = ($row['week_start'] ?? '') . '_' . ($row['class_section_id'] ?? '');
if ($key === '_') {
continue;
}
if (!isset($groups[$key])) {
$groups[$key] = [
'week_start' => $row['week_start'] ?? null,
'week_end' => $row['week_end'] ?? null,
'class_section_id' => $row['class_section_id'] ?? null,
'class_section_name' => $row['class_section_name'] ?? '',
'reports' => [],
];
}
$subject = (string) ($row['subject'] ?? 'unknown');
$groups[$key]['reports'][$subject] = $row;
}
return $groups;
}
}
@@ -1,606 +0,0 @@
<?php
namespace App\Services\Admin;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Throwable;
class TeacherSubmissionService
{
public function __construct(
private AdminContextService $context
) {}
public function report(): array
{
$cfg = $this->context->currentConfig();
$semester = (string) ($cfg['semester'] ?? '');
$schoolYear = (string) ($cfg['school_year'] ?? '');
$assignmentRows = DB::table('teacher_class as tc')
->select([
'tc.class_section_id',
'cs.class_section_name',
'tc.teacher_id',
'u.firstname',
'u.lastname',
'tc.position',
])
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'tc.class_section_id')
->leftJoin('users as u', 'u.id', '=', 'tc.teacher_id')
->where('tc.school_year', $schoolYear)
->where('tc.semester', $semester)
->orderBy('cs.class_section_name')
->get();
$teachersBySection = [];
foreach ($assignmentRows as $assignment) {
$sectionId = (int) ($assignment->class_section_id ?? 0);
if ($sectionId <= 0) {
continue;
}
$positionKey = strtolower(trim((string) ($assignment->position ?? '')));
$roleKey = $positionKey !== '' ? $positionKey : 'teacher';
$positionLabel = match ($roleKey) {
'ta' => 'TA',
'main' => 'Main',
default => $roleKey !== '' ? ucfirst($roleKey) : 'Teacher',
};
$teacherFullName = trim(
(string)($assignment->firstname ?? '') . ' ' . (string)($assignment->lastname ?? '')
);
$teacherId = (int) ($assignment->teacher_id ?? 0);
if ($teacherId <= 0 || $teacherFullName === '') {
continue;
}
if (!isset($teachersBySection[$sectionId])) {
$teachersBySection[$sectionId] = [
'class_section' => (string) ($assignment->class_section_name ?? "Section {$sectionId}"),
'teachers' => [],
];
}
$teachersBySection[$sectionId]['teachers'][] = [
'id' => $teacherId,
'label' => "{$positionLabel}: {$teacherFullName}",
'role_key' => $roleKey,
];
}
$today = Carbon::now()->format('Y-m-d');
$rows = [];
$totalStatuses = 0;
$missingItemCount = 0;
$allTeacherIds = [];
$allClassSectionIds = [];
foreach ($teachersBySection as $classSectionId => $section) {
$classSectionId = (int) $classSectionId;
if ($classSectionId <= 0) {
continue;
}
$studentIds = DB::table('student_class')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->pluck('student_id')
->map(fn ($id) => (int) $id)
->filter(fn ($id) => $id > 0)
->values()
->all();
$expected = count($studentIds);
// semester_scores
$midtermStudents = [];
$participationStudents = [];
$scoreRecords = DB::table('semester_scores')
->select('student_id', 'midterm_exam_score', 'participation_score')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get();
foreach ($scoreRecords as $score) {
$sid = (int) ($score->student_id ?? 0);
if ($sid <= 0 || ($expected > 0 && !in_array($sid, $studentIds, true))) {
continue;
}
$midtermValue = trim((string) ($score->midterm_exam_score ?? ''));
if ($midtermValue !== '') {
$midtermStudents[$sid] = true;
}
$participationValue = trim((string) ($score->participation_score ?? ''));
if ($participationValue !== '') {
$participationStudents[$sid] = true;
}
}
// score_comments
$midtermCommentStudents = [];
$ptapCommentStudents = [];
if (!empty($studentIds)) {
$comments = DB::table('score_comments')
->select('student_id', 'score_type', 'comment')
->whereIn('student_id', $studentIds)
->where('semester', $semester)
->where('school_year', $schoolYear)
->whereIn('score_type', ['midterm', 'ptap'])
->get();
foreach ($comments as $comment) {
$sid = (int) ($comment->student_id ?? 0);
if ($sid <= 0) {
continue;
}
$text = trim((string) ($comment->comment ?? ''));
if ($text === '') {
continue;
}
$type = strtolower(trim((string) ($comment->score_type ?? '')));
if ($type === 'midterm') {
$midtermCommentStudents[$sid] = true;
}
if ($type === 'ptap') {
$ptapCommentStudents[$sid] = true;
}
}
}
// attendance_days (today)
$attendanceRow = DB::table('attendance_days')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->where('date', $today)
->first();
$attendanceSubmitted = $attendanceRow
&& in_array(
strtolower((string) ($attendanceRow->status ?? '')),
['submitted', 'published', 'finalized'],
true
);
$teacherList = $section['teachers'] ?? [];
if (!empty($teacherList)) {
usort($teacherList, function (array $a, array $b) {
return $this->teacherRolePriority($a['role_key'] ?? 'teacher')
<=> $this->teacherRolePriority($b['role_key'] ?? 'teacher');
});
$teacherList = array_values($teacherList);
}
foreach ($teacherList as $teacherEntry) {
if (!empty($teacherEntry['id'])) {
$allTeacherIds[] = (int) $teacherEntry['id'];
}
}
$allClassSectionIds[] = $classSectionId;
$midtermScoreStatus = $this->submissionStatus(count($midtermStudents), $expected);
$midtermCommentStatus = $this->submissionStatus(count($midtermCommentStudents), $expected);
$participationStatus = $this->submissionStatus(count($participationStudents), $expected);
$ptapCommentStatus = $this->submissionStatus(count($ptapCommentStudents), $expected);
$attendanceStatus = $this->attendanceStatus($attendanceSubmitted);
// CI original intentionally only counted score/comment statuses in summary (not attendance)
$statusDetails = [
'midterm_score_status' => $midtermScoreStatus,
'midterm_comment_status' => $midtermCommentStatus,
'participation_status' => $participationStatus,
'ptap_comment_status' => $ptapCommentStatus,
];
$missingItemsForSection = $this->buildMissingItems($statusDetails);
$missingItemCount += count($missingItemsForSection);
$totalStatuses += count($statusDetails);
$rows[] = [
'class_section' => $section['class_section'] ?? "Section {$classSectionId}",
'class_section_id' => $classSectionId,
'teachers' => $teacherList,
'midterm_score_status' => $midtermScoreStatus,
'midterm_comment_status' => $midtermCommentStatus,
'participation_status' => $participationStatus,
'ptap_comment_status' => $ptapCommentStatus,
'attendance_status' => $attendanceStatus,
'missing_items' => $missingItemsForSection,
'student_count' => $expected,
];
}
$historyMap = [];
$teacherIds = array_values(array_unique($allTeacherIds));
$classSectionIds = array_values(array_unique($allClassSectionIds));
if (!empty($teacherIds) && !empty($classSectionIds) && $this->hasTable('teacher_submission_notification_history')) {
$historyRecords = DB::table('teacher_submission_notification_history as h')
->select([
'h.*',
'u.firstname',
'u.lastname',
])
->leftJoin('users as u', 'u.id', '=', 'h.admin_id')
->where('h.notification_category', 'teacher_submissions')
->whereIn('h.teacher_id', $teacherIds)
->whereIn('h.class_section_id', $classSectionIds)
->orderByDesc('h.sent_at')
->get();
foreach ($historyRecords as $record) {
$sectionId = (int) ($record->class_section_id ?? 0);
$teacherId = (int) ($record->teacher_id ?? 0);
if ($sectionId <= 0 || $teacherId <= 0) {
continue;
}
$sentAt = $record->sent_at ?? null;
$sentAtText = $sentAt ? Carbon::parse($sentAt)->format('M j, Y g:i A') : '';
$adminName = trim((string)($record->firstname ?? '') . ' ' . (string)($record->lastname ?? ''));
if ($adminName === '') {
$adminName = 'Administrator';
}
$historyMap[$sectionId][$teacherId][] = [
'sent_at_text' => $sentAtText,
'admin_name' => $adminName,
'status' => strtolower((string) ($record->status ?? 'sent')),
];
}
// keep latest 3 per section/teacher, same as CI
foreach ($historyMap as &$teachersHistory) {
foreach ($teachersHistory as &$entries) {
$entries = array_slice($entries, 0, 3);
}
unset($entries);
}
unset($teachersHistory);
}
$summary = [
'total_items' => $totalStatuses,
'missing_items' => $missingItemCount,
'submitted_items' => max(0, $totalStatuses - $missingItemCount),
'submission_percentage' => $totalStatuses > 0
? (int) round((($totalStatuses - $missingItemCount) / $totalStatuses) * 100)
: 100,
];
return [
'semester' => $semester,
'school_year' => $schoolYear,
'rows' => $rows,
'notification_history' => $historyMap,
'summary' => $summary,
];
}
/**
* API payload format expected:
* [
* 'notify' => [sectionId => [teacherId => 1, ...], ...],
* 'missing_items' => [sectionId => [teacherId => base64(json(items))]]
* ]
*/
public function sendNotifications(int $adminId, array $payload): array
{
$notify = $payload['notify'] ?? null;
if (!is_array($notify)) {
return [
'message' => 'Select at least one teacher to notify.',
'sent' => 0,
'failed' => 0,
'results' => [],
];
}
$missingItemsPayload = is_array($payload['missing_items'] ?? null) ? $payload['missing_items'] : [];
$targets = [];
foreach ($notify as $sectionIdRaw => $teachers) {
$sectionId = (int) $sectionIdRaw;
if ($sectionId <= 0 || !is_array($teachers)) {
continue;
}
foreach ($teachers as $teacherIdRaw => $value) {
$teacherId = (int) $teacherIdRaw;
if ($teacherId <= 0 || $value === null || $value === '') {
continue;
}
$targets["{$sectionId}_{$teacherId}"] = [
'class_section_id' => $sectionId,
'teacher_id' => $teacherId,
];
}
}
if (empty($targets)) {
return [
'message' => 'Select at least one teacher to notify.',
'sent' => 0,
'failed' => 0,
'results' => [],
];
}
$targets = array_values($targets);
$teacherIds = array_values(array_unique(array_map(
fn ($t) => (int) $t['teacher_id'],
$targets
)));
$classSectionIds = array_values(array_unique(array_map(
fn ($t) => (int) $t['class_section_id'],
$targets
)));
$classSectionMap = DB::table('classSection')
->select('class_section_id', 'class_section_name')
->whereIn('class_section_id', $classSectionIds)
->get()
->mapWithKeys(fn ($row) => [(int)$row->class_section_id => (string)($row->class_section_name ?? '')])
->all();
$teacherLookup = DB::table('users')
->select('id', 'firstname', 'lastname', 'email')
->whereIn('id', $teacherIds)
->get()
->keyBy(fn ($row) => (int) $row->id);
$adminUser = DB::table('users')->select('id', 'firstname', 'lastname')->where('id', $adminId)->first();
$adminName = $adminUser
? trim((string)($adminUser->firstname ?? '') . ' ' . (string)($adminUser->lastname ?? ''))
: '';
if ($adminName === '') {
$adminName = 'Administrator';
}
$cfg = $this->context->currentConfig();
$scoreUrl = rtrim((string) config('app.url'), '/') . '/';
$sentCount = 0;
$failCount = 0;
$results = [];
foreach ($targets as $target) {
$classSectionId = (int) $target['class_section_id'];
$teacherId = (int) $target['teacher_id'];
$teacher = $teacherLookup->get($teacherId);
$sectionName = $classSectionMap[$classSectionId] ?? "Section {$classSectionId}";
$teacherName = $teacher
? trim((string)($teacher->firstname ?? '') . ' ' . (string)($teacher->lastname ?? ''))
: '';
if ($teacherName === '') {
$teacherName = 'Teacher';
}
$subject = "Reminder: Complete submissions for {$sectionName}";
$missingPayload = $missingItemsPayload[$classSectionId][$teacherId] ?? '';
$missingItems = $this->parseMissingItemsPayload((string) $missingPayload);
if (!empty($missingItems)) {
$missingText = e($this->formatMissingItemsText($missingItems));
$missingNote = "<p>Outstanding items: {$missingText}.</p>";
} else {
$missingNote = "<p>Our records show no outstanding submissions for this section, but please verify if anything still needs attention.</p>";
}
$body = "<p>Dear {$teacherName},</p>"
. "<p>Administration is gently reminding you to wrap up any remaining score submissions and/or comments for {$sectionName}.</p>"
. $missingNote
. "<p>Visit <a href=\"{$scoreUrl}\">Teacher Score Submission</a> to address any remaining items.</p>"
. "<p>Thank you,<br>Al Rahma Administration</p>";
$email = (string) ($teacher->email ?? '');
$status = 'failed';
$error = null;
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
try {
Mail::html($body, function ($message) use ($email, $subject) {
$message->to($email)->subject($subject);
});
$status = 'sent';
} catch (Throwable $e) {
$status = 'failed';
$error = $e->getMessage();
}
} else {
$error = 'Missing or invalid email';
}
if ($status === 'sent') {
$sentCount++;
} else {
$failCount++;
}
if ($this->hasTable('teacher_submission_notification_history')) {
DB::table('teacher_submission_notification_history')->insert([
'teacher_id' => $teacherId,
'class_section_id' => $classSectionId,
'admin_id' => $adminId,
'notification_category' => 'teacher_submissions',
'message' => $this->truncateNotificationMessage($body),
'status' => $status,
'school_year' => $cfg['school_year'] ?? null,
'semester' => $cfg['semester'] ?? null,
'sent_at' => Carbon::now('UTC'),
]);
}
$results[] = [
'teacher_id' => $teacherId,
'class_section_id' => $classSectionId,
'teacher_name' => $teacherName,
'section_name' => $sectionName,
'email' => $email,
'status' => $status,
'missing_items' => $missingItems,
'error' => $error,
];
}
$parts = [];
if ($sentCount > 0) {
$parts[] = $sentCount . ' reminder' . ($sentCount === 1 ? '' : 's') . ' sent';
}
if ($failCount > 0) {
$parts[] = $failCount . ' reminder' . ($failCount === 1 ? '' : 's') . ' failed';
}
return [
'message' => !empty($parts) ? implode(' and ', $parts) : 'No notifications were sent.',
'sent' => $sentCount,
'failed' => $failCount,
'results' => $results,
];
}
private function submissionStatus(int $filled, int $expected): array
{
if ($expected <= 0) {
return [
'label' => 'No students',
'badge' => 'bg-secondary',
'detail' => '',
'completed' => true,
];
}
$completed = $filled >= $expected;
return [
'label' => $completed ? 'Submitted' : 'Missing',
'badge' => $completed ? 'bg-success' : 'bg-danger',
'detail' => "{$filled}/{$expected}",
'completed' => $completed,
];
}
private function attendanceStatus(bool $submitted): array
{
return [
'label' => $submitted ? 'Submitted' : 'Missing',
'badge' => $submitted ? 'bg-success' : 'bg-danger',
'completed' => $submitted,
];
}
private function buildMissingItems(array $statusMap): array
{
$labels = [
'midterm_score_status' => 'midterm scores',
'midterm_comment_status' => 'midterm comments',
'participation_status' => 'participation',
'ptap_comment_status' => 'PTAP comments',
'attendance_status' => 'attendance',
];
$items = [];
foreach ($statusMap as $key => $status) {
$completed = (bool) ($status['completed'] ?? true);
if (!$completed && isset($labels[$key])) {
$items[] = $labels[$key];
}
}
return array_values($items);
}
private function formatMissingItemsText(array $items): string
{
$items = array_values(array_unique(array_filter(array_map(
fn ($v) => trim((string)$v),
$items
))));
$count = count($items);
if ($count === 0) return '';
if ($count === 1) return $items[0];
if ($count === 2) return $items[0] . ' and ' . $items[1];
$last = array_pop($items);
return implode(', ', $items) . ' and ' . $last;
}
private function teacherRolePriority(string $roleKey): int
{
return match (strtolower($roleKey)) {
'main' => 1,
'ta' => 2,
default => 3,
};
}
private function truncateNotificationMessage(string $html, int $limit = 1000): string
{
$text = trim(strip_tags($html));
if ($text === '') {
return '';
}
return mb_strlen($text) <= $limit
? $text
: mb_substr($text, 0, $limit) . '…';
}
private function parseMissingItemsPayload(string $payload): array
{
if ($payload === '') {
return [];
}
$decodedRaw = base64_decode($payload, true);
if ($decodedRaw === false) {
return [];
}
$decoded = json_decode($decodedRaw, true);
if (!is_array($decoded)) {
return [];
}
$items = [];
foreach ($decoded as $item) {
$item = trim((string) $item);
if ($item !== '') {
$items[] = $item;
}
}
return array_values(array_unique($items));
}
private function hasTable(string $table): bool
{
try {
return DB::getSchemaBuilder()->hasTable($table);
} catch (Throwable) {
return false;
}
}
}
@@ -0,0 +1,150 @@
<?php
namespace App\Services\Administrator;
use App\Models\AdminNotificationSubject;
use Illuminate\Support\Facades\DB;
class AdminNotificationSubjectService
{
public function __construct(
protected AdminNotificationUserService $userService
) {
}
public function subjectOptions(): array
{
return [
'academics' => 'Academics',
'attendance' => 'Attendance',
'events' => 'Events',
'finance' => 'Finance',
'general' => 'General',
'print_requests' => 'Print Requests',
];
}
public function alertsData(): array
{
$admins = $this->userService->fetchAdminNotificationUsers();
$subjects = $this->subjectOptions();
$assignedSubjects = [];
$tableReady = DB::getSchemaBuilder()->hasTable('admin_notification_subjects');
if ($tableReady && !empty($admins)) {
$adminIds = array_map('intval', array_column($admins, 'id'));
$rows = AdminNotificationSubject::query()
->select('id', 'admin_id', 'subject')
->whereIn('admin_id', $adminIds)
->get();
foreach ($rows as $row) {
$adminId = (int) ($row->admin_id ?? 0);
$subject = (string) ($row->subject ?? '');
if ($adminId > 0 && $subject !== '') {
$assignedSubjects[$adminId][$subject] = true;
}
}
}
return [
'admins' => $admins,
'subjects' => $subjects,
'assignedSubjects' => $assignedSubjects,
'tableReady' => $tableReady,
];
}
public function save(array $posted): array
{
if (!DB::getSchemaBuilder()->hasTable('admin_notification_subjects')) {
return [
'success' => false,
'message' => 'Notification subject storage is missing. Run migrations first.',
'status' => 422,
];
}
$allowed = array_keys($this->subjectOptions());
$admins = $this->userService->fetchAdminNotificationUsers();
$adminIds = array_map('intval', array_column($admins, 'id'));
if (empty($adminIds)) {
return [
'success' => false,
'message' => 'No admins found to update.',
'status' => 404,
];
}
$existing = AdminNotificationSubject::query()
->select('id', 'admin_id', 'subject')
->whereIn('admin_id', $adminIds)
->get();
$existingMap = [];
foreach ($existing as $row) {
$adminId = (int) ($row->admin_id ?? 0);
$subject = (string) ($row->subject ?? '');
if ($adminId > 0 && $subject !== '') {
$existingMap[$adminId][$subject] = (int) ($row->id ?? 0);
}
}
$updates = 0;
foreach ($adminIds as $adminId) {
$subjectRaw = $posted[$adminId] ?? [];
$selected = [];
if (is_array($subjectRaw)) {
foreach ($subjectRaw as $key => $value) {
$candidate = is_string($key) ? $key : $value;
if (!is_string($candidate)) {
continue;
}
$candidate = trim($candidate);
if ($candidate !== '' && in_array($candidate, $allowed, true)) {
$selected[] = $candidate;
}
}
}
$selected = array_values(array_unique($selected));
$current = array_keys($existingMap[$adminId] ?? []);
$toDelete = array_values(array_diff($current, $selected));
$toInsert = array_values(array_diff($selected, $current));
if (!empty($toDelete)) {
AdminNotificationSubject::query()
->where('admin_id', $adminId)
->whereIn('subject', $toDelete)
->delete();
$updates += count($toDelete);
}
if (!empty($toInsert)) {
$batch = [];
foreach ($toInsert as $subject) {
$batch[] = [
'admin_id' => $adminId,
'subject' => $subject,
'created_at' => now(),
'updated_at' => now(),
];
}
AdminNotificationSubject::insert($batch);
$updates += count($toInsert);
}
}
return [
'success' => true,
'message' => $updates > 0 ? 'Notification subjects updated.' : 'No changes were made.',
'status' => 200,
];
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Services\Administrator;
use Illuminate\Support\Facades\DB;
class AdminNotificationUserService
{
public function excludedRoles(): array
{
return [
'parent',
'student',
'guest',
'teacher',
'assistant teacher',
'teacher assistant',
'teacher_assistant',
'assistant_teacher',
'ta',
'authorized_user',
];
}
public function fetchAdminNotificationUsers(): array
{
$excluded = $this->excludedRoles();
return DB::table('users as u')
->select('u.id', 'u.firstname', 'u.lastname', 'u.email')
->join('user_roles as ur', 'ur.user_id', '=', 'u.id')
->join('roles as r', 'r.id', '=', 'ur.role_id')
->whereNotNull('r.name')
->whereNull('ur.deleted_at')
->whereNotIn(DB::raw('LOWER(r.name)'), array_map('strtolower', $excluded))
->groupBy('u.id', 'u.firstname', 'u.lastname', 'u.email')
->orderBy('u.lastname')
->orderBy('u.firstname')
->get()
->map(fn($r) => (array) $r)
->all();
}
}
@@ -0,0 +1,116 @@
<?php
namespace App\Services\Administrator;
use App\Models\AdminNotificationSubject;
use Illuminate\Support\Facades\DB;
class AdminPrintRecipientService
{
public function __construct(
protected AdminNotificationUserService $userService
) {
}
public function data(): array
{
$admins = $this->userService->fetchAdminNotificationUsers();
$tableReady = DB::getSchemaBuilder()->hasTable('admin_notification_subjects');
$assigned = [];
if ($tableReady && !empty($admins)) {
$adminIds = array_map('intval', array_column($admins, 'id'));
$rows = AdminNotificationSubject::query()
->select('admin_id')
->where('subject', 'print_requests')
->whereIn('admin_id', $adminIds)
->get();
foreach ($rows as $row) {
$adminId = (int) ($row->admin_id ?? 0);
if ($adminId > 0) {
$assigned[$adminId] = true;
}
}
}
return [
'admins' => $admins,
'assigned' => $assigned,
'tableReady' => $tableReady,
];
}
public function save(array $posted): array
{
if (!DB::getSchemaBuilder()->hasTable('admin_notification_subjects')) {
return [
'success' => false,
'message' => 'Notification subject storage is missing. Run migrations first.',
'status' => 422,
];
}
$admins = $this->userService->fetchAdminNotificationUsers();
$adminIds = array_map('intval', array_column($admins, 'id'));
if (empty($adminIds)) {
return [
'success' => false,
'message' => 'No admins found to update.',
'status' => 404,
];
}
$selected = [];
foreach ($posted as $key => $value) {
$adminId = (int) $key;
if ($adminId > 0 && in_array($adminId, $adminIds, true)) {
$selected[] = $adminId;
}
}
$selected = array_values(array_unique($selected));
$current = AdminNotificationSubject::query()
->where('subject', 'print_requests')
->whereIn('admin_id', $adminIds)
->pluck('admin_id')
->map(fn($id) => (int) $id)
->unique()
->values()
->all();
$toDelete = array_values(array_diff($current, $selected));
$toInsert = array_values(array_diff($selected, $current));
if (!empty($toDelete)) {
AdminNotificationSubject::query()
->where('subject', 'print_requests')
->whereIn('admin_id', $toDelete)
->delete();
}
if (!empty($toInsert)) {
$batch = [];
foreach ($toInsert as $adminId) {
$batch[] = [
'admin_id' => $adminId,
'subject' => 'print_requests',
'created_at' => now(),
'updated_at' => now(),
];
}
AdminNotificationSubject::insert($batch);
}
$changes = count($toDelete) + count($toInsert);
return [
'success' => true,
'message' => $changes > 0 ? 'Print notification recipients updated.' : 'No changes were made.',
'status' => 200,
];
}
}
@@ -0,0 +1,227 @@
<?php
namespace App\Services\Administrator;
use App\Libraries\StaffTimeOffLinkService;
use App\Models\StaffAttendance;
use App\Models\User;
use App\Services\SemesterRangeService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class AdministratorAbsenceService
{
public function __construct(
protected AdministratorSharedService $shared,
protected User $userModel,
protected StaffAttendance $staffAttendanceModel,
protected SemesterRangeService $semesterRangeService,
protected StaffTimeOffLinkService $staffTimeOffLinkService,
) {
}
public function getAbsenceFormData(int $userId): array
{
$admin = $this->userModel->find($userId);
$displayName = $admin
? trim(($admin->firstname ?? '') . ' ' . ($admin->lastname ?? ''))
: 'Administrator';
$semester = $this->shared->getSemester();
$schoolYear = $this->shared->getSchoolYear();
$existing = $this->staffAttendanceModel
->where('user_id', $userId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->orderByDesc('date')
->get()
->toArray();
return [
'admin_name' => $displayName,
'semester' => $semester,
'schoolYear' => $schoolYear,
'existing' => $existing,
'availableDates' => $this->shared->allowedAbsenceDates(),
];
}
public function submit(Request $request, int $userId): array
{
$semester = $this->shared->getSemester();
$schoolYear = $this->shared->getSchoolYear();
if ($schoolYear === '') {
return [
'success' => false,
'message' => 'Semester or school year not configured.',
'status' => 422,
];
}
$dates = (array) $request->input('dates', []);
$reasonType = trim((string) $request->input('reason_type', ''));
$reasonText = trim((string) $request->input('reason', ''));
if ($reasonText === '') {
return [
'success' => false,
'message' => 'Reason is required.',
'status' => 422,
];
}
$reasonBase = $reasonType !== '' ? strtolower($reasonType) : '';
$reason = $reasonBase !== '' ? ($reasonBase . ': ' . $reasonText) : $reasonText;
$allowedSet = array_fill_keys($this->shared->allowedAbsenceDates(), true);
$roleName = method_exists($this->userModel, 'getUserRole')
? ($this->userModel->getUserRole($userId) ?: null)
: null;
$saved = 0;
$invalid = [];
$savedDates = [];
$dates = array_values(array_unique(array_map('strval', $dates)));
foreach ($dates as $d) {
$d = trim($d);
if ($d === '') {
continue;
}
try {
$parsed = \Carbon\Carbon::createFromFormat('Y-m-d', $d);
if ($parsed->format('Y-m-d') !== $d || empty($allowedSet[$d])) {
$invalid[] = $d;
continue;
}
} catch (\Throwable) {
$invalid[] = $d;
continue;
}
$semesterForDate = $this->semesterRangeService->getSemesterForDate($d);
if ($semesterForDate === '') {
$semesterForDate = $semester;
}
$ok = $this->staffAttendanceModel->upsertOne(
userId: $userId,
roleName: $roleName,
date: $d,
semester: $semesterForDate,
schoolYear: $schoolYear,
status: StaffAttendance::STATUS_ABSENT,
reason: $reason,
editorId: $userId
);
if ($ok) {
$saved++;
$savedDates[] = $d;
}
}
if (!empty($invalid)) {
return [
'success' => false,
'message' => 'Invalid dates: ' . implode(', ', $invalid),
'status' => 422,
];
}
$this->sendPrincipalNotification(
userId: $userId,
roleName: $roleName,
semester: $semester,
schoolYear: $schoolYear,
reasonType: $reasonType,
reasonText: $reasonText,
dates: $dates,
savedDates: $savedDates
);
return [
'success' => true,
'message' => $saved . ' day(s) saved as absent.',
'saved' => $saved,
'dates' => $savedDates,
'status' => 200,
];
}
protected function sendPrincipalNotification(
int $userId,
?string $roleName,
string $semester,
string $schoolYear,
string $reasonType,
string $reasonText,
array $dates,
array $savedDates
): void {
try {
$user = $this->userModel->find($userId);
$fullName = trim(($user->firstname ?? '') . ' ' . ($user->lastname ?? '')) ?: 'Administrator';
$userEmail = $user->email ?? '';
$role = $roleName ?: 'admin';
$dateList = !empty($savedDates) ? implode(', ', $savedDates) : implode(', ', $dates);
$subject = sprintf(
'TimeOff Request: %s (%s) — %s',
$fullName,
ucfirst((string) $role),
$dateList ?: 'No dates'
);
$submittedAt = now()->toDateTimeString();
$body = '<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">'
. '<h3 style="margin:0 0 8px;">New TimeOff Request</h3>'
. '<p style="margin:0 0 12px;">A staff time-off request was submitted from the administrator portal.</p>'
. '<table cellpadding="6" cellspacing="0" style="border-collapse:collapse;">'
. '<tr><td><strong>Name</strong></td><td>' . e($fullName) . '</td></tr>'
. '<tr><td><strong>Email</strong></td><td>' . e($userEmail) . '</td></tr>'
. '<tr><td><strong>Role</strong></td><td>' . e((string) $role) . '</td></tr>'
. '<tr><td><strong>Semester</strong></td><td>' . e($semester) . '</td></tr>'
. '<tr><td><strong>School Year</strong></td><td>' . e($schoolYear) . '</td></tr>'
. '<tr><td><strong>Reason Type</strong></td><td>' . e($reasonType ?: '-') . '</td></tr>'
. '<tr><td><strong>Reason</strong></td><td>' . e($reasonText) . '</td></tr>'
. '<tr><td><strong>Dates</strong></td><td>' . e($dateList ?: '-') . '</td></tr>'
. '<tr><td><strong>Submitted At</strong></td><td>' . e($submittedAt) . '</td></tr>'
. '</table>'
. '</div>';
$token = $this->staffTimeOffLinkService->createToken([
'uid' => $userId,
'email' => $userEmail,
'name' => $fullName,
'role' => $role,
'dates' => $dateList ?: '-',
'reason' => $reasonText,
'reason_type' => $reasonType,
'submitted_at' => $submittedAt,
'origin' => 'administrator portal',
]);
$notifyUrl = url('/timeoff/notify/' . rawurlencode($token));
$body .= '<p style="margin-top:16px;">Click <a href="' . e($notifyUrl) . '">Send confirmation email to '
. e($fullName)
. '</a> so the staff member is notified automatically. This link expires in 14 days.</p>';
$principalEmail = env('PRINCIPAL_EMAIL', 'principal@alrahmaisgl.org');
Mail::html($body, function ($message) use ($principalEmail, $subject) {
$message->to($principalEmail)->subject($subject);
});
} catch (\Throwable $e) {
Log::error('Failed to send TimeOff email (admin): ' . $e->getMessage());
}
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Services\Administrator;
class AdministratorDashboardService
{
public function __construct(
protected AdministratorMetricsService $metricsService,
protected AdministratorUserSearchService $userSearchService,
) {
}
public function metrics(): array
{
return $this->metricsService->metrics();
}
public function userSearch(string $query): array
{
return $this->userSearchService->search($query);
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Services\Administrator;
use App\Models\Invoice;
use Illuminate\Support\Facades\Event;
class AdministratorEnrollmentEventService
{
public function dispatchGroupedEvents(
array $groupsByParentStatus,
array $parentInfo,
array $refundAmountByParent,
string $schoolYear
): void {
$eventMap = [
'admission under review' => 'admissionUnderReview',
'payment pending' => 'paymentPending',
'enrolled' => 'studentEnrolled',
'withdraw under review' => 'withdrawUnderReview',
'refund pending' => 'refundPending',
'withdrawn' => 'withdrawn',
'denied' => 'denied',
'waitlist' => 'waitlist',
];
foreach ($groupsByParentStatus as $pid => $byStatus) {
$p = $parentInfo[$pid] ?? [
'user_id' => $pid,
'email' => null,
'firstname' => '',
'lastname' => '',
];
$invoice = Invoice::query()
->where('parent_id', $pid)
->where('school_year', $schoolYear)
->latest('created_at')
->first();
foreach ($byStatus as $status => $studentsArr) {
if (!isset($eventMap[$status])) {
continue;
}
$studentData = array_map(
fn ($s) => [
'name' => $s['student_name'],
'student_id' => $s['student_id'],
],
$studentsArr
);
$parentData = [
'user_id' => $p['user_id'],
'email' => $p['email'],
'firstname' => $p['firstname'],
'lastname' => $p['lastname'],
'school_year' => $schoolYear,
'portalLink' => url('/login'),
];
if ($status === 'payment pending' && $invoice) {
$parentData['amount'] = (float) ($invoice->balance ?? $invoice->amount_due ?? $invoice->total_amount ?? 0);
$parentData['due_date'] = $invoice->due_date ?? null;
} elseif ($status === 'refund pending') {
$parentData['amount'] = $refundAmountByParent[$pid] ?? null;
}
Event::dispatch($eventMap[$status], [$parentData, $studentData]);
}
}
}
}
@@ -0,0 +1,194 @@
<?php
namespace App\Services\Administrator;
use App\Models\ClassSection;
use App\Models\Enrollment;
use App\Models\Student;
use App\Models\StudentClass;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class AdministratorEnrollmentQueryService
{
public function __construct(
protected AdministratorSharedService $shared,
protected Student $studentModel,
protected StudentClass $studentClassModel,
protected Enrollment $enrollmentModel,
protected ClassSection $classSectionModel,
) {
}
public function enrollmentWithdrawalData(?string $selectedYear = null): array
{
$selectedYear = trim((string) ($selectedYear ?: $this->shared->getSchoolYear()));
$schoolYears = DB::table('enrollments')
->distinct()
->orderByDesc('school_year')
->pluck('school_year')
->map(fn ($year) => (string) $year)
->filter()
->values()
->all();
$students = method_exists($this->studentModel, 'getStudentsWithClassAndEnrollment')
? ($this->studentModel->getStudentsWithClassAndEnrollment() ?? [])
: [];
$selectedStartYear = $this->shared->getSchoolYearStartYear($selectedYear);
$removedPriorIds = [];
if ($selectedStartYear !== null) {
$removedRows = DB::table('enrollments')
->select('student_id', 'school_year')
->where('is_withdrawn', 1)
->get();
foreach ($removedRows as $row) {
$rowYear = $this->shared->getSchoolYearStartYear((string) ($row->school_year ?? ''));
if ($rowYear !== null && $rowYear < $selectedStartYear) {
$removedPriorIds[(int) ($row->student_id ?? 0)] = true;
}
}
}
foreach ($students as &$s) {
$studentId = (int) ($s['id'] ?? 0);
$s['student_id'] = $studentId;
$s['removed_previous_year'] = isset($removedPriorIds[$studentId]) ? 'Yes' : 'No';
if (empty($s['parent_id']) && !empty($s['secondparent_user_id'])) {
$s['parent_id'] = (int) $s['secondparent_user_id'];
} else {
$s['parent_id'] = (int) ($s['parent_id'] ?? 0);
}
$pf = trim((string) ($s['parent_firstname'] ?? ''));
$pl = trim((string) ($s['parent_lastname'] ?? ''));
if ($pf === '' && $pl === '' && !empty($s['parent_fullname'])) {
$parts = preg_split('/\s+/', trim((string) $s['parent_fullname']), 2);
$pf = $parts[0] ?? '';
$pl = $parts[1] ?? '';
}
$s['parent_label'] = trim($pf . ' ' . $pl) ?: 'Unknown Parent';
$s['parent_sort'] = trim(($pl !== '' ? $pl : $pf) . ' ' . $pf) ?: 'ZZZ Unknown Parent';
$s['is_new'] = (int) ($s['is_new'] ?? 0);
$s['new_student'] = $s['is_new'] === 1 ? 'Yes' : 'No';
$statusForYear = method_exists($this->enrollmentModel, 'getEnrollmentStatus')
? $this->enrollmentModel->getEnrollmentStatus($studentId, $selectedYear)
: null;
if (!empty($statusForYear)) {
$s['enrollment_status'] = $statusForYear;
} elseif (($s['admission_status'] ?? null) === 'denied') {
$s['enrollment_status'] = 'denied';
}
$className = method_exists($this->studentClassModel, 'getClassSectionsByStudentId')
? $this->studentClassModel->getClassSectionsByStudentId($studentId, $selectedYear)
: null;
$s['class_section'] = $className ?: 'Class not Assigned';
$s['registration_date_order'] = !empty($s['registration_date'])
? Carbon::parse($s['registration_date'])->format('Y-m-d')
: '';
}
unset($s);
usort($students, function (array $a, array $b) {
$pa = $a['parent_sort'] ?? '';
$pb = $b['parent_sort'] ?? '';
if (strcasecmp($pa, $pb) === 0) {
$la = $a['lastname'] ?? '';
$lb = $b['lastname'] ?? '';
$cmp = strcasecmp($la, $lb);
if ($cmp !== 0) {
return $cmp;
}
return strcasecmp($a['firstname'] ?? '', $b['firstname'] ?? '');
}
return strcasecmp($pa, $pb);
});
$classes = ClassSection::query()
->select('id', 'class_section_id', 'class_section_name', 'school_year', 'semester')
->where('school_year', $selectedYear)
->where('semester', $this->shared->getSemester())
->orderBy('class_section_name')
->get()
->toArray();
if (empty($classes)) {
$classes = ClassSection::query()
->select('id', 'class_section_id', 'class_section_name')
->orderBy('class_section_name')
->get()
->toArray();
}
return [
'students' => $students,
'classes' => $classes,
'semester' => $this->shared->getSemester(),
'school_year' => $selectedYear,
'schoolYears' => $schoolYears,
];
}
public function newStudentsData(): array
{
$schoolYear = $this->shared->getSchoolYear();
$rows = method_exists($this->studentModel, 'getStudentsWithParentsAndEmergency')
? ($this->studentModel->getStudentsWithParentsAndEmergency($schoolYear) ?? [])
: [];
$newStudents = [];
foreach ($rows as $r) {
$studentId = (int) ($r['id'] ?? 0);
$classSection = method_exists($this->studentClassModel, 'getClassSectionsByStudentId')
? $this->studentClassModel->getClassSectionsByStudentId($studentId, $schoolYear)
: null;
$enrollmentStatus = method_exists($this->enrollmentModel, 'getEnrollmentStatus')
? $this->enrollmentModel->getEnrollmentStatus($studentId, $schoolYear)
: null;
$r['class_section'] = $classSection && trim((string) $classSection) !== ''
? $classSection
: 'Class not Assigned';
$r['is_new'] = (int) ($r['is_new'] ?? 0);
$r['new_student'] = $r['is_new'] === 1 ? 'Yes' : 'No';
$r['modalIdContact'] = 'contact_' . $studentId;
$r['enrollment_status'] = $enrollmentStatus;
if (!empty($r['registration_date'])) {
try {
$r['registration_date'] = Carbon::parse($r['registration_date'])->format('Y-m-d');
} catch (\Throwable) {
$r['registration_date'] = '';
}
}
$newStudents[] = $r;
}
return [
'new_students' => $newStudents,
'total_new' => count($newStudents),
];
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Services\Administrator;
use App\Models\Enrollment;
use App\Models\Invoice;
use App\Models\RefundModel as Refund;
use App\Services\FeeCalculationService;
class AdministratorEnrollmentRefundService
{
public function __construct(
protected FeeCalculationService $feeCalculationService
) {
}
public function processRefunds(array $parentIds, string $schoolYear, int $editorUserId): array
{
$errors = [];
$refundAmountByParent = [];
foreach ($parentIds as $pid) {
$students = Enrollment::query()
->where('parent_id', $pid)
->where('school_year', $schoolYear)
->get()
->toArray();
if (empty($students)) {
continue;
}
$invoice = Invoice::query()
->where('parent_id', $pid)
->where('school_year', $schoolYear)
->latest('created_at')
->first();
if (!$invoice) {
$errors[] = "No invoice found for parent ID {$pid} (for refund calc).";
continue;
}
$refundAmount = $this->feeCalculationService->calculateRefund($students, $pid);
$refundAmountByParent[$pid] = $refundAmount;
$existingRefund = Refund::query()
->where('invoice_id', $invoice->id)
->first();
if ($existingRefund) {
$existingRefund->update([
'refund_amount' => $refundAmount,
'status' => 'Pending',
'updated_by' => $editorUserId,
]);
} else {
Refund::query()->create([
'parent_id' => $pid,
'school_year' => $invoice->school_year,
'invoice_id' => $invoice->id,
'refund_amount' => $refundAmount,
'refund_paid_amount' => 0.0,
'status' => 'Pending',
'requested_at' => now(),
'updated_by' => $editorUserId,
]);
}
}
return [
'errors' => $errors,
'refundAmountByParent' => $refundAmountByParent,
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Services\Administrator;
class AdministratorEnrollmentService
{
public function __construct(
protected AdministratorEnrollmentQueryService $queryService,
protected AdministratorEnrollmentStatusService $statusService,
) {
}
public function enrollmentWithdrawalData(?string $selectedYear = null): array
{
return $this->queryService->enrollmentWithdrawalData($selectedYear);
}
public function showNewStudentsData(): array
{
return $this->queryService->newStudentsData();
}
public function updateStatuses(array $enrollmentStatuses, int $editorUserId): array
{
return $this->statusService->updateStatuses($enrollmentStatuses, $editorUserId);
}
}
@@ -0,0 +1,273 @@
<?php
namespace App\Services\Administrator;
use App\Models\Student;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class AdministratorEnrollmentStatusService
{
public function __construct(
protected AdministratorSharedService $shared,
protected Student $studentModel,
protected User $userModel,
protected AdministratorEnrollmentRefundService $refundService,
protected AdministratorEnrollmentEventService $eventService,
) {
}
public function updateStatuses(array $enrollmentStatuses, int $editorUserId): array
{
$schoolYear = $this->shared->getSchoolYear();
$semester = $this->shared->getSemester();
DB::beginTransaction();
try {
if (empty($enrollmentStatuses)) {
return [
'success' => false,
'message' => 'No enrollment statuses were submitted.',
'status' => 422,
];
}
$errors = [];
$groupsByParentStatus = [];
$parentInfo = [];
$refundParents = [];
$validStatuses = [
'admission under review',
'payment pending',
'enrolled',
'withdraw under review',
'refund pending',
'withdrawn',
'denied',
'waitlist',
];
foreach ($enrollmentStatuses as $studentId => $newEnrollmentStatus) {
$studentId = (int) $studentId;
if (!in_array($newEnrollmentStatus, $validStatuses, true)) {
$errors[] = "Invalid enrollment status '{$newEnrollmentStatus}' for student ID {$studentId}.";
continue;
}
$result = $this->upsertSingleStatus(
$studentId,
$newEnrollmentStatus,
$schoolYear,
$semester,
$parentInfo,
$groupsByParentStatus,
$refundParents
);
if (!$result['success']) {
$errors[] = $result['message'];
}
}
$refundResult = $this->refundService->processRefunds(
array_keys($refundParents),
$schoolYear,
$editorUserId
);
$errors = array_merge($errors, $refundResult['errors']);
DB::commit();
$this->eventService->dispatchGroupedEvents(
$groupsByParentStatus,
$parentInfo,
$refundResult['refundAmountByParent'],
$schoolYear
);
return [
'success' => empty($errors),
'message' => empty($errors)
? 'Enrollment statuses updated and notifications sent.'
: implode(' ', $errors),
'status' => empty($errors) ? 200 : 207,
];
} catch (\Throwable $e) {
DB::rollBack();
Log::error('Enrollment withdrawal error: ' . $e->getMessage());
return [
'success' => false,
'message' => 'An unexpected error occurred while processing enrollments.',
'status' => 500,
];
}
}
protected function upsertSingleStatus(
int $studentId,
string $newEnrollmentStatus,
string $schoolYear,
string $semester,
array &$parentInfo,
array &$groupsByParentStatus,
array &$refundParents
): array {
$admissionStatus = match ($newEnrollmentStatus) {
'denied' => 'denied',
'enrolled', 'payment pending' => 'accepted',
default => 'pending',
};
$enrollmentRow = DB::table('enrollments')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->first();
if (!$enrollmentRow) {
return $this->createEnrollmentRow(
$studentId,
$newEnrollmentStatus,
$admissionStatus,
$schoolYear,
$semester,
$parentInfo,
$groupsByParentStatus,
$refundParents
);
}
$oldStatus = $enrollmentRow->enrollment_status ?? null;
$parentId = (int) ($enrollmentRow->parent_id ?? 0);
if (!$parentId) {
return [
'success' => false,
'message' => "No parent ID found for student ID {$studentId}.",
];
}
if ($oldStatus === $newEnrollmentStatus) {
return ['success' => true, 'message' => 'No change'];
}
$updated = DB::table('enrollments')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->update([
'enrollment_status' => $newEnrollmentStatus,
'admission_status' => $admissionStatus,
'updated_at' => now(),
]);
if (!$updated) {
return [
'success' => false,
'message' => "Failed to update enrollment for student ID {$studentId}.",
];
}
$studentName = $this->resolveStudentName($studentId);
$this->ensureParentInfoLoaded($parentId, $parentInfo);
$groupsByParentStatus[$parentId][$newEnrollmentStatus][] = [
'student_id' => $studentId,
'student_name' => $studentName,
];
if ($newEnrollmentStatus === 'refund pending') {
$refundParents[$parentId] = true;
}
return ['success' => true, 'message' => 'Updated'];
}
protected function createEnrollmentRow(
int $studentId,
string $newEnrollmentStatus,
string $admissionStatus,
string $schoolYear,
string $semester,
array &$parentInfo,
array &$groupsByParentStatus,
array &$refundParents
): array {
$stu = $this->studentModel->find($studentId);
$stu = is_array($stu) ? $stu : ($stu?->toArray() ?? []);
$parentId = (int) ($stu['parent_id'] ?? ($stu['secondparent_user_id'] ?? 0));
if (!$parentId) {
return [
'success' => false,
'message' => "No parent ID found for student ID {$studentId}.",
];
}
$isWithdrawn = in_array($newEnrollmentStatus, ['withdrawn', 'refund pending', 'withdraw under review'], true) ? 1 : 0;
$ok = DB::table('enrollments')->insert([
'student_id' => $studentId,
'parent_id' => $parentId,
'school_year' => $schoolYear,
'semester' => $semester,
'enrollment_date' => now()->toDateString(),
'is_withdrawn' => $isWithdrawn,
'enrollment_status' => $newEnrollmentStatus,
'admission_status' => $admissionStatus,
'created_at' => now(),
'updated_at' => now(),
]);
if (!$ok) {
return [
'success' => false,
'message' => "Failed to create enrollment for student ID {$studentId}.",
];
}
$studentName = $this->resolveStudentName($studentId);
$this->ensureParentInfoLoaded($parentId, $parentInfo);
$groupsByParentStatus[$parentId][$newEnrollmentStatus][] = [
'student_id' => $studentId,
'student_name' => $studentName,
];
if ($newEnrollmentStatus === 'refund pending') {
$refundParents[$parentId] = true;
}
return ['success' => true, 'message' => 'Created'];
}
protected function resolveStudentName(int $studentId): string
{
$studentRow = $this->studentModel->find($studentId);
$studentData = is_array($studentRow) ? $studentRow : ($studentRow?->toArray() ?? []);
return trim(($studentData['firstname'] ?? '') . ' ' . ($studentData['lastname'] ?? ''))
?: "Student #{$studentId}";
}
protected function ensureParentInfoLoaded(int $parentId, array &$parentInfo): void
{
if (isset($parentInfo[$parentId])) {
return;
}
$p = $this->userModel->find($parentId);
$parentData = is_array($p) ? $p : ($p?->toArray() ?? []);
$parentInfo[$parentId] = [
'user_id' => $parentData['id'] ?? $parentId,
'email' => $parentData['email'] ?? null,
'firstname' => $parentData['firstname'] ?? '',
'lastname' => $parentData['lastname'] ?? '',
];
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Services\Administrator;
use App\Models\LoginActivity;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class AdministratorMetricsService
{
public function __construct(
protected AdministratorSharedService $shared,
protected User $userModel,
protected LoginActivity $loginActivityModel,
) {
}
public function metrics(): array
{
$schoolYear = $this->shared->getSchoolYear();
$semester = $this->shared->getSemester();
$recentActivities = method_exists($this->loginActivityModel, 'getLastActivities')
? ($this->loginActivityModel->getLastActivities(4) ?? [])
: [];
$totalAdmins = method_exists($this->userModel, 'countAdminsBySchoolYear')
? (int) ($this->userModel->countAdminsBySchoolYear($schoolYear) ?? 0)
: 0;
$teachers = method_exists($this->userModel, 'getUsersByRoleAndSchoolYear')
? ($this->userModel->getUsersByRoleAndSchoolYear('teacher', $schoolYear) ?? [])
: [];
$teacherAssistants = method_exists($this->userModel, 'getUsersByRoleAndSchoolYear')
? ($this->userModel->getUsersByRoleAndSchoolYear('teacher_assistant', $schoolYear) ?? [])
: [];
$parents = method_exists($this->userModel, 'getUsersByRoleAndSchoolYear')
? ($this->userModel->getUsersByRoleAndSchoolYear('parent', $schoolYear) ?? [])
: [];
$totalStudents = (int) (
DB::table('student_class')
->join('students', 'students.id', '=', 'student_class.student_id')
->where('student_class.school_year', $schoolYear)
->whereNotNull('student_class.class_section_id')
->where('students.is_active', 1)
->distinct('student_class.student_id')
->count('student_class.student_id')
);
return [
'counts' => [
'students' => $totalStudents,
'teachers' => $this->shared->countUniqueEntities($teachers),
'teacherAssistants' => $this->shared->countUniqueEntities($teacherAssistants),
'admins' => $totalAdmins,
'parents' => $this->shared->countUniqueEntities($parents),
],
'recentActivities' => collect($recentActivities)->map(function ($activity) {
return [
'login_time' => is_array($activity) ? ($activity['login_time'] ?? null) : ($activity->login_time ?? null),
'email' => is_array($activity) ? ($activity['email'] ?? null) : ($activity->email ?? null),
];
})->values()->all(),
'meta' => [
'schoolYear' => $schoolYear,
'semester' => $semester,
],
];
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Services\Administrator;
class AdministratorNotificationService
{
public function __construct(
protected AdminNotificationSubjectService $subjectService,
protected AdminPrintRecipientService $printRecipientService,
) {
}
public function notificationsAlertsData(): array
{
return $this->subjectService->alertsData();
}
public function saveNotificationSubjects(array $posted): array
{
return $this->subjectService->save($posted);
}
public function printNotificationRecipientsData(): array
{
return $this->printRecipientService->data();
}
public function savePrintNotificationRecipients(array $posted): array
{
return $this->printRecipientService->save($posted);
}
}
@@ -0,0 +1,149 @@
<?php
namespace App\Services\Administrator;
use App\Models\Configuration;
use Carbon\Carbon;
class AdministratorSharedService
{
public function __construct(
protected Configuration $configuration
) {
}
public function getSemester(): string
{
return (string) ($this->configuration->getConfig('semester') ?? '');
}
public function getSchoolYear(): string
{
return (string) ($this->configuration->getConfig('school_year') ?? '');
}
public function getPreviousSchoolYear(string $schoolYear): string
{
$schoolYear = trim($schoolYear);
if ($schoolYear === '') {
return '';
}
if (preg_match('/^(\d{4})\s*-\s*(\d{4})$/', $schoolYear, $m)) {
return ((int) $m[1] - 1) . '-' . ((int) $m[2] - 1);
}
if (preg_match('/^(\d{4})\s*-\s*(\d{2})$/', $schoolYear, $m)) {
$start = (int) $m[1] - 1;
$end = (int) $m[2] - 1;
if ($end < 0) {
$end += 100;
}
return sprintf('%04d-%02d', $start, $end);
}
if (preg_match('/^\d{4}$/', $schoolYear)) {
return (string) ((int) $schoolYear - 1);
}
return '';
}
public function getSchoolYearStartYear(string $schoolYear): ?int
{
$schoolYear = trim($schoolYear);
if ($schoolYear === '') {
return null;
}
if (preg_match('/^(\d{4})\s*-\s*(\d{4})$/', $schoolYear, $m)) {
return (int) $m[1];
}
if (preg_match('/^(\d{4})\s*-\s*(\d{2})$/', $schoolYear, $m)) {
return (int) $m[1];
}
if (preg_match('/^\d{4}$/', $schoolYear)) {
return (int) $schoolYear;
}
return null;
}
public function allowedAbsenceDates(): array
{
$today = Carbon::today();
$schoolYear = $this->getSchoolYear();
$startYear = null;
$endYear = null;
if (preg_match('/^(\d{4})\D+(\d{4})$/', $schoolYear, $m)) {
$startYear = (int) $m[1];
$endYear = (int) $m[2];
} else {
$cy = (int) now()->format('Y');
$cm = (int) now()->format('n');
if ($cm >= 9) {
$startYear = $cy;
$endYear = $cy + 1;
} else {
$startYear = $cy - 1;
$endYear = $cy;
}
}
try {
$start = Carbon::create($startYear, 9, 1)->startOfDay();
$end = Carbon::create($endYear, 5, 31)->startOfDay();
} catch (\Throwable) {
return [];
}
if ($start->lt($today)) {
$start = $today->copy();
}
$dates = [];
$cursor = $start->copy();
while ($cursor->lte($end)) {
if ($cursor->dayOfWeek === Carbon::SUNDAY) {
$dates[] = $cursor->format('Y-m-d');
}
$cursor->addDay();
}
return $dates;
}
public function countUniqueEntities($rows): int
{
if (!is_iterable($rows)) {
return 0;
}
$ids = [];
foreach ($rows as $row) {
if (is_array($row)) {
if (isset($row['id'])) {
$ids[] = (int) $row['id'];
} elseif (isset($row['user_id'])) {
$ids[] = (int) $row['user_id'];
}
} elseif (is_object($row)) {
if (isset($row->id)) {
$ids[] = (int) $row->id;
} elseif (isset($row->user_id)) {
$ids[] = (int) $row->user_id;
}
}
}
return count(array_unique($ids));
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Services\Administrator;
use Illuminate\Http\Request;
class AdministratorTeacherSubmissionService
{
public function __construct(
protected TeacherSubmissionReportService $reportService,
protected TeacherSubmissionNotificationService $notificationService,
) {
}
public function report(): array
{
return $this->reportService->report();
}
public function sendNotifications(Request $request, int $adminId): array
{
return $this->notificationService->send($request, $adminId);
}
}
@@ -0,0 +1,128 @@
<?php
namespace App\Services\Administrator;
use Illuminate\Support\Facades\DB;
class AdministratorUserSearchService
{
public function search(string $query): array
{
$q = trim($query);
if ($q === '') {
return [
'query' => '',
'results' => [],
'scope_used' => 'unscoped-raw',
'scope_label' => 'all years/semesters (raw)',
'total_found' => 0,
];
}
$rawTokens = preg_split('/[,\s]+/u', $q, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$tokens = array_values(array_filter(array_map('trim', $rawTokens)));
$phoneMap = [];
foreach ($tokens as $t) {
$digits = preg_replace('/\D+/', '', $t);
if ($digits === '') {
continue;
}
$v = [];
if (strlen($digits) >= 7) {
$v[] = $digits;
if (strlen($digits) === 10) {
$v[] = sprintf('(%s)-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
$v[] = sprintf('%s-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
$v[] = sprintf('%s %s %s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
$v[] = '1' . $digits;
$v[] = '+1' . $digits;
$v[] = '+1 ' . sprintf('(%s) %s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
$v[] = '+1-' . sprintf('%s-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
} elseif (strlen($digits) === 11 && str_starts_with($digits, '1')) {
$ten = substr($digits, 1);
$v[] = $ten;
$v[] = sprintf('(%s)-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
$v[] = sprintf('%s-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
$v[] = sprintf('%s %s %s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
$v[] = '+1' . $ten;
$v[] = '+1 ' . sprintf('(%s) %s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
$v[] = '+1-' . sprintf('%s-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
}
}
if (!empty($v)) {
$phoneMap[$t] = array_values(array_unique($v));
}
}
$applyMultiTokenLike = function ($qb, array $columns, array $tokens, array $phoneCols = []) use ($phoneMap) {
foreach ($tokens as $t) {
$qb->where(function ($sub) use ($columns, $phoneCols, $phoneMap, $t) {
foreach ($columns as $col) {
$sub->orWhere($col, 'like', '%' . $t . '%');
}
if (!empty($phoneMap[$t]) && !empty($phoneCols)) {
foreach ($phoneMap[$t] as $pv) {
foreach ($phoneCols as $pcol) {
$sub->orWhere($pcol, 'like', '%' . $pv . '%');
}
}
}
});
}
return $qb;
};
$uQB = DB::table('users')
->select('id', 'firstname', 'lastname', 'email', 'cellphone', 'school_id', 'city', 'state', 'school_year', 'semester');
$applyMultiTokenLike($uQB, ['firstname', 'lastname', 'email', 'cellphone', 'school_id', 'city', 'state'], $tokens, ['cellphone']);
$users = $uQB->limit(150)->get()->map(fn($r) => (array) $r)->all();
$sQB = DB::table('students')
->select('id', 'parent_id', 'school_id', 'firstname', 'lastname', 'dob', 'gender', 'school_year', 'semester', 'rfid_tag');
$applyMultiTokenLike($sQB, ['firstname', 'lastname', 'school_id', 'rfid_tag', 'dob', 'gender'], $tokens);
$students = $sQB->limit(150)->get()->map(fn($r) => (array) $r)->all();
$pQB = DB::table('parents')
->select('id', 'firstparent_id', 'secondparent_firstname', 'secondparent_lastname', 'secondparent_email', 'secondparent_phone', 'school_year', 'semester');
$applyMultiTokenLike($pQB, ['secondparent_firstname', 'secondparent_lastname', 'secondparent_email', 'secondparent_phone'], $tokens, ['secondparent_phone']);
foreach ($tokens as $t) {
if (ctype_digit($t)) {
$pQB->orWhere('firstparent_id', (int) $t)->orWhere('id', (int) $t);
}
}
$parents = $pQB->limit(150)->get()->map(fn($r) => (array) $r)->all();
$stQB = DB::table('staff')
->select('id', 'user_id', 'firstname', 'lastname', 'email', 'phone', 'role_name', 'school_year', 'active_role');
$applyMultiTokenLike($stQB, ['firstname', 'lastname', 'email', 'role_name', 'phone'], $tokens, ['phone']);
$staff = $stQB->limit(150)->get()->map(fn($r) => (array) $r)->all();
$ecQB = DB::table('emergency_contacts')
->select('id', 'parent_id', 'emergency_contact_name', 'relation', 'cellphone', 'email', 'school_year', 'semester');
$applyMultiTokenLike($ecQB, ['emergency_contact_name', 'relation', 'email', 'cellphone'], $tokens, ['cellphone']);
$emergency = $ecQB->limit(150)->get()->map(fn($r) => (array) $r)->all();
return [
'query' => $q,
'results' => [
'users' => $users,
'students' => $students,
'parents' => $parents,
'staff' => $staff,
'emergency_contacts' => $emergency,
],
'scope_used' => 'unscoped-raw',
'scope_label' => 'all years/semesters (raw, tokenized)',
'total_found' => count($users) + count($students) + count($parents) + count($staff) + count($emergency),
];
}
}
@@ -0,0 +1,161 @@
<?php
namespace App\Services\Administrator;
use App\Models\ClassSection;
use App\Models\TeacherSubmissionNotificationHistory;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class TeacherSubmissionNotificationService
{
public function __construct(
protected AdministratorSharedService $shared,
protected TeacherSubmissionSupportService $support
) {
}
public function send(Request $request, int $adminId): array
{
$notify = $request->input('notify');
if (!is_array($notify)) {
return [
'success' => false,
'message' => 'Select at least one teacher to notify.',
'status' => 422,
];
}
$missingItemsPayload = $request->input('missing_items', []);
$targets = $this->extractTargets($notify);
if (empty($targets)) {
return [
'success' => false,
'message' => 'Select at least one teacher to notify.',
'status' => 422,
];
}
$teacherIds = array_values(array_unique(array_column($targets, 'teacher_id')));
$classSectionIds = array_values(array_unique(array_column($targets, 'class_section_id')));
$classSections = ClassSection::query()
->select('class_section_id', 'class_section_name')
->whereIn('class_section_id', $classSectionIds)
->get()
->keyBy('class_section_id');
$teachers = User::query()
->select('id', 'firstname', 'lastname', 'email')
->whereIn('id', $teacherIds)
->get()
->keyBy('id');
$adminUser = User::find($adminId);
$adminName = trim(($adminUser->firstname ?? '') . ' ' . ($adminUser->lastname ?? '')) ?: 'Administrator';
$scoreUrl = url('/');
$sentCount = 0;
$failCount = 0;
foreach ($targets as $target) {
$classSectionId = (int) $target['class_section_id'];
$teacherId = (int) $target['teacher_id'];
$teacher = $teachers->get($teacherId);
$sectionName = $classSections->get($classSectionId)?->class_section_name ?? "Section {$classSectionId}";
$teacherName = trim(($teacher->firstname ?? '') . ' ' . ($teacher->lastname ?? '')) ?: 'Teacher';
$missingPayload = $missingItemsPayload[$classSectionId][$teacherId] ?? '';
$missingItems = $this->support->parseMissingItemsPayload((string) $missingPayload);
$missingNote = !empty($missingItems)
? '<p>Outstanding items: ' . e($this->support->formatMissingItemsText($missingItems)) . '.</p>'
: '<p>Our records show no outstanding submissions for this section, but please verify if anything still needs attention.</p>';
$subject = "Reminder: Complete submissions for {$sectionName}";
$body = "<p>Dear {$teacherName},</p>"
. "<p>Administration is gently reminding you to wrap up any remaining score submissions and/or comments for {$sectionName}.</p>"
. $missingNote
. "<p>Visit <a href=\"{$scoreUrl}\">Teacher Score Submission</a> to address any remaining items.</p>"
. "<p>Thank you,<br>{$adminName}</p>";
$email = $teacher->email ?? '';
$status = 'failed';
if (!empty($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) {
try {
Mail::html($body, function ($message) use ($email, $subject) {
$message->to($email)->subject($subject);
});
$status = 'sent';
} catch (\Throwable $e) {
Log::error('Teacher submission notification failed: ' . $e->getMessage());
}
}
if ($status === 'sent') {
$sentCount++;
} else {
$failCount++;
}
TeacherSubmissionNotificationHistory::create([
'teacher_id' => $teacherId,
'class_section_id' => $classSectionId,
'admin_id' => $adminId,
'notification_category' => 'teacher_submissions',
'message' => $this->support->truncateNotificationMessage($body),
'status' => $status,
'school_year' => $this->shared->getSchoolYear(),
'semester' => $this->shared->getSemester(),
'sent_at' => now(),
]);
}
$parts = [];
if ($sentCount > 0) {
$parts[] = $sentCount . ' reminder' . ($sentCount === 1 ? '' : 's') . ' sent';
}
if ($failCount > 0) {
$parts[] = $failCount . ' reminder' . ($failCount === 1 ? '' : 's') . ' failed';
}
return [
'success' => $failCount === 0,
'message' => !empty($parts) ? implode(' and ', $parts) : 'No notifications were sent.',
'sent' => $sentCount,
'failed' => $failCount,
'status' => $failCount === 0 ? 200 : 207,
];
}
protected function extractTargets(array $notify): array
{
$targets = [];
foreach ($notify as $sectionIdRaw => $teachers) {
$sectionId = (int) $sectionIdRaw;
if ($sectionId <= 0 || !is_array($teachers)) {
continue;
}
foreach ($teachers as $teacherIdRaw => $value) {
$teacherId = (int) $teacherIdRaw;
if ($teacherId <= 0 || $value === null || $value === '') {
continue;
}
$targets["{$sectionId}_{$teacherId}"] = [
'class_section_id' => $sectionId,
'teacher_id' => $teacherId,
];
}
}
return array_values($targets);
}
}
@@ -0,0 +1,285 @@
<?php
namespace App\Services\Administrator;
use App\Models\AttendanceDay;
use App\Models\ScoreComment;
use App\Models\SemesterScore;
use App\Models\TeacherSubmissionNotificationHistory;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class TeacherSubmissionReportService
{
public function __construct(
protected AdministratorSharedService $shared,
protected TeacherSubmissionSupportService $support
) {
}
public function report(): array
{
$semester = $this->shared->getSemester();
$schoolYear = $this->shared->getSchoolYear();
$assignmentRows = DB::table('teacher_class as tc')
->select([
'tc.class_section_id',
'cs.class_section_name',
'tc.teacher_id',
'u.firstname',
'u.lastname',
'tc.position',
])
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'tc.class_section_id')
->leftJoin('users as u', 'u.id', '=', 'tc.teacher_id')
->where('tc.school_year', $schoolYear)
->where('tc.semester', $semester)
->orderBy('cs.class_section_name')
->get()
->map(fn ($r) => (array) $r)
->all();
$teachersBySection = [];
foreach ($assignmentRows as $assignment) {
$sectionId = (int) ($assignment['class_section_id'] ?? 0);
if ($sectionId <= 0) {
continue;
}
$positionKey = strtolower(trim((string) ($assignment['position'] ?? '')));
$roleKey = $positionKey !== '' ? $positionKey : 'teacher';
$positionLabel = match ($roleKey) {
'ta' => 'TA',
'main' => 'Main',
default => $roleKey !== '' ? ucfirst($roleKey) : 'Teacher',
};
$teacherFullName = trim(($assignment['firstname'] ?? '') . ' ' . ($assignment['lastname'] ?? ''));
$teacherId = (int) ($assignment['teacher_id'] ?? 0);
if ($teacherFullName === '' || $teacherId <= 0) {
continue;
}
if (!isset($teachersBySection[$sectionId])) {
$teachersBySection[$sectionId] = [
'class_section' => $assignment['class_section_name'] ?? "Section {$sectionId}",
'teachers' => [],
];
}
$teachersBySection[$sectionId]['teachers'][] = [
'id' => $teacherId,
'label' => "{$positionLabel}: {$teacherFullName}",
'role_key' => $roleKey,
];
}
$today = now()->format('Y-m-d');
$rows = [];
$totalStatuses = 0;
$missingItemCount = 0;
$allTeacherIds = [];
$allClassSectionIds = [];
foreach ($teachersBySection as $classSectionId => $section) {
$classSectionId = (int) $classSectionId;
if ($classSectionId <= 0) {
continue;
}
$studentIds = DB::table('student_class')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->pluck('student_id')
->map(fn ($id) => (int) $id)
->filter()
->values()
->all();
$expected = count($studentIds);
$midtermStudents = [];
$participationStudents = [];
$scoreRecords = SemesterScore::query()
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get();
foreach ($scoreRecords as $score) {
$sid = (int) ($score->student_id ?? 0);
if ($sid <= 0 || ($expected > 0 && !in_array($sid, $studentIds, true))) {
continue;
}
if (trim((string) ($score->midterm_exam_score ?? '')) !== '') {
$midtermStudents[$sid] = true;
}
if (trim((string) ($score->participation_score ?? '')) !== '') {
$participationStudents[$sid] = true;
}
}
$midtermCommentStudents = [];
$ptapCommentStudents = [];
if (!empty($studentIds)) {
$comments = ScoreComment::query()
->select('student_id', 'score_type', 'comment')
->whereIn('student_id', $studentIds)
->where('semester', $semester)
->where('school_year', $schoolYear)
->whereIn('score_type', ['midterm', 'ptap'])
->get();
foreach ($comments as $comment) {
$sid = (int) ($comment->student_id ?? 0);
if ($sid <= 0) {
continue;
}
$text = trim((string) ($comment->comment ?? ''));
if ($text === '') {
continue;
}
$type = strtolower(trim((string) ($comment->score_type ?? '')));
if ($type === 'midterm') {
$midtermCommentStudents[$sid] = true;
}
if ($type === 'ptap') {
$ptapCommentStudents[$sid] = true;
}
}
}
$attendanceRow = AttendanceDay::query()
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->where('date', $today)
->first();
$attendanceSubmitted = $attendanceRow
&& in_array(strtolower((string) ($attendanceRow->status ?? '')), ['submitted', 'published', 'finalized'], true);
$teacherList = $section['teachers'] ?? [];
usort(
$teacherList,
fn ($a, $b) => $this->support->teacherRolePriority($a['role_key'] ?? 'teacher')
<=> $this->support->teacherRolePriority($b['role_key'] ?? 'teacher')
);
foreach ($teacherList as $teacherEntry) {
if (!empty($teacherEntry['id'])) {
$allTeacherIds[] = $teacherEntry['id'];
}
}
$allClassSectionIds[] = $classSectionId;
$midtermScoreStatus = $this->support->submissionStatus(count($midtermStudents), $expected);
$midtermCommentStatus = $this->support->submissionStatus(count($midtermCommentStudents), $expected);
$participationStatus = $this->support->submissionStatus(count($participationStudents), $expected);
$ptapCommentStatus = $this->support->submissionStatus(count($ptapCommentStudents), $expected);
$attendanceStatus = $this->support->attendanceStatus($attendanceSubmitted);
$statusDetails = [
'midterm_score_status' => $midtermScoreStatus,
'midterm_comment_status' => $midtermCommentStatus,
'participation_status' => $participationStatus,
'ptap_comment_status' => $ptapCommentStatus,
'attendance_status' => $attendanceStatus,
];
$missingItemsForSection = $this->support->buildMissingItems($statusDetails);
$missingItemCount += count($missingItemsForSection);
$totalStatuses += count($statusDetails);
$rows[] = [
'class_section' => $section['class_section'] ?? "Section {$classSectionId}",
'class_section_id' => $classSectionId,
'teachers' => array_values($teacherList),
'midterm_score_status' => $midtermScoreStatus,
'midterm_comment_status' => $midtermCommentStatus,
'participation_status' => $participationStatus,
'ptap_comment_status' => $ptapCommentStatus,
'attendance_status' => $attendanceStatus,
'missing_items' => $missingItemsForSection,
'student_count' => $expected,
];
}
$historyMap = $this->notificationHistoryMap($allTeacherIds, $allClassSectionIds);
$summary = [
'total_items' => $totalStatuses,
'missing_items' => $missingItemCount,
'submitted_items' => max(0, $totalStatuses - $missingItemCount),
'submission_percentage' => $totalStatuses > 0
? (int) round((($totalStatuses - $missingItemCount) / $totalStatuses) * 100)
: 100,
];
return [
'rows' => $rows,
'semester' => $semester,
'schoolYear' => $schoolYear,
'notificationHistory' => $historyMap,
'summary' => $summary,
];
}
protected function notificationHistoryMap(array $allTeacherIds, array $allClassSectionIds): array
{
$historyMap = [];
$teacherIds = array_values(array_unique($allTeacherIds));
$classSectionIds = array_values(array_unique($allClassSectionIds));
if (empty($teacherIds) || empty($classSectionIds)) {
return $historyMap;
}
$historyRecords = TeacherSubmissionNotificationHistory::query()
->select('teacher_submission_notification_history.*', 'u.firstname', 'u.lastname')
->leftJoin('users as u', 'u.id', '=', 'teacher_submission_notification_history.admin_id')
->where('notification_category', 'teacher_submissions')
->whereIn('teacher_submission_notification_history.teacher_id', $teacherIds)
->whereIn('teacher_submission_notification_history.class_section_id', $classSectionIds)
->orderByDesc('sent_at')
->get();
foreach ($historyRecords as $record) {
$sectionId = (int) ($record->class_section_id ?? 0);
$teacherId = (int) ($record->teacher_id ?? 0);
if ($sectionId <= 0 || $teacherId <= 0) {
continue;
}
$adminName = trim(($record->firstname ?? '') . ' ' . ($record->lastname ?? ''));
if ($adminName === '') {
$adminName = 'Administrator';
}
$historyMap[$sectionId][$teacherId][] = [
'sent_at_text' => $record->sent_at ? Carbon::parse($record->sent_at)->format('M j, Y g:i A') : '',
'admin_name' => $adminName,
'status' => strtolower((string) ($record->status ?? 'sent')),
];
}
foreach ($historyMap as &$teachersHistory) {
foreach ($teachersHistory as &$entries) {
$entries = array_slice($entries, 0, 3);
}
}
return $historyMap;
}
}
@@ -0,0 +1,118 @@
<?php
namespace App\Services\Administrator;
class TeacherSubmissionSupportService
{
public function submissionStatus(int $filled, int $expected): array
{
if ($expected <= 0) {
return [
'label' => 'No students',
'badge' => 'bg-secondary',
'detail' => '',
'completed' => true,
];
}
$completed = $filled >= $expected;
return [
'label' => $completed ? 'Submitted' : 'Missing',
'badge' => $completed ? 'bg-success' : 'bg-danger',
'detail' => "{$filled}/{$expected}",
'completed' => $completed,
];
}
public function attendanceStatus(bool $submitted): array
{
return [
'label' => $submitted ? 'Submitted' : 'Missing',
'badge' => $submitted ? 'bg-success' : 'bg-danger',
'completed' => $submitted,
];
}
public function buildMissingItems(array $statusMap): array
{
$labels = [
'midterm_score_status' => 'midterm scores',
'midterm_comment_status' => 'midterm comments',
'participation_status' => 'participation',
'ptap_comment_status' => 'PTAP comments',
'attendance_status' => 'attendance',
];
$items = [];
foreach ($statusMap as $key => $status) {
if (!(bool) ($status['completed'] ?? true) && isset($labels[$key])) {
$items[] = $labels[$key];
}
}
return array_values($items);
}
public function teacherRolePriority(string $roleKey): int
{
return match (strtolower($roleKey)) {
'main' => 1,
'ta' => 2,
default => 3,
};
}
public function truncateNotificationMessage(string $html, int $limit = 1000): string
{
$text = trim(strip_tags($html));
if ($text === '') {
return '';
}
return mb_strlen($text) <= $limit
? $text
: mb_substr($text, 0, $limit) . '…';
}
public function parseMissingItemsPayload(string $payload): array
{
if ($payload === '') {
return [];
}
$decoded = json_decode(base64_decode($payload, true) ?: '', true);
if (!is_array($decoded)) {
return [];
}
$items = [];
foreach ($decoded as $item) {
$item = trim((string) $item);
if ($item !== '') {
$items[] = $item;
}
}
return array_values(array_unique($items));
}
public function formatMissingItemsText(array $items): string
{
$items = array_values(array_filter(array_map('trim', $items), fn ($v) => $v !== ''));
$count = count($items);
if ($count === 0) {
return '';
}
if ($count === 1) {
return $items[0];
}
if ($count === 2) {
return $items[0] . ' and ' . $items[1];
}
$last = array_pop($items);
return implode(', ', $items) . ' and ' . $last;
}
}
@@ -0,0 +1,353 @@
<?php
namespace App\Services\Assignment;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\StudentClass;
use App\Models\TeacherClass;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class AssignmentService
{
public function __construct(
protected Configuration $configurationModel,
protected TeacherClass $teacherClassModel,
protected StudentClass $studentClassModel,
protected ClassSection $classSectionModel,
) {}
public function getCurrentSemester(): string
{
return (string) ($this->getConfigValue('semester') ?? '');
}
public function getCurrentSchoolYear(): string
{
return (string) ($this->getConfigValue('school_year') ?? '');
}
protected function getConfigValue(string $key): mixed
{
return $this->configurationModel
->newQuery()
->where('config_key', $key)
->value('config_value');
}
public function getAssignmentsOverview(?string $schoolYear = null, ?string $semester = null): array
{
$currentSemester = $this->getCurrentSemester();
$currentSchoolYear = $this->getCurrentSchoolYear();
$selectedSemester = (string) ($semester ?? $currentSemester);
$selectedYear = (string) ($schoolYear ?? $currentSchoolYear);
$teacherClasses = $this->loadTeacherClasses($selectedYear);
$studentClasses = $this->loadStudentClasses($selectedYear);
$teacherBySection = $teacherClasses->groupBy('class_section_id');
$studentBySection = $studentClasses->groupBy('class_section_id');
$allSectionIds = $teacherBySection->keys()
->merge($studentBySection->keys())
->unique()
->filter()
->values();
$sectionNames = $this->getSectionNamesMap($allSectionIds->all());
$classSections = $allSectionIds->map(function ($sectionId) use (
$teacherBySection,
$studentBySection,
$sectionNames,
$currentSemester,
$currentSchoolYear
) {
$teacherRows = $teacherBySection->get($sectionId, collect());
$studentRows = $studentBySection->get($sectionId, collect());
if ($teacherRows->isEmpty() && $studentRows->isEmpty()) {
return null;
}
$mainTeachers = $teacherRows
->where('position', 'main')
->pluck('teacher_full_name')
->filter()
->unique()
->values();
$teacherAssistants = $teacherRows
->where('position', 'ta')
->pluck('teacher_full_name')
->filter()
->unique()
->values();
$semesterMeta = $this->firstNonEmpty([
$teacherRows->pluck('semester')->first(fn ($v) => filled($v)),
$studentRows->pluck('semester')->first(fn ($v) => filled($v)),
$currentSemester,
]);
$schoolYearMeta = $this->firstNonEmpty([
$teacherRows->pluck('school_year')->first(fn ($v) => filled($v)),
$studentRows->pluck('school_year')->first(fn ($v) => filled($v)),
$currentSchoolYear,
]);
$descriptionMeta = $this->firstNonEmpty([
$teacherRows->pluck('description')->first(fn ($v) => filled($v)),
$studentRows->pluck('description')->first(fn ($v) => filled($v)),
'',
]);
$students = $studentRows
->filter(fn ($row) => !empty($row->student))
->map(function ($row) {
$student = $row->student;
return [
'id' => (int) $student->id,
'firstname' => (string) ($student->firstname ?? ''),
'lastname' => (string) ($student->lastname ?? ''),
'age' => $student->age,
'gender' => (string) ($student->gender ?? ''),
'registration_grade' => (string) ($student->registration_grade ?? ''),
'photo_consent' => (bool) ($student->photo_consent ?? false),
'tuition_paid' => (bool) ($student->tuition_paid ?? false),
'school_id' => (string) ($student->school_id ?? ''),
];
})
->values();
return [
'class_section_id' => (int) $sectionId,
'class_section_name' => (string) ($sectionNames[$sectionId] ?? ''),
'main_teachers' => $mainTeachers->all(),
'teacher_assistants' => $teacherAssistants->all(),
'students' => $students->all(),
'semester' => (string) $semesterMeta,
'school_year' => (string) $schoolYearMeta,
'description' => (string) $descriptionMeta,
];
})
->filter()
->sortBy('class_section_name', SORT_NATURAL | SORT_FLAG_CASE)
->values()
->all();
return [
'classSections' => $classSections,
'schoolYears' => $this->getSchoolYears($currentSchoolYear),
'schoolYear' => $selectedYear,
'selectedYear' => $selectedYear,
'selectedSemester' => $selectedSemester,
'semester' => $currentSemester,
'current_school_year' => $currentSchoolYear,
];
}
public function storeAssignment(array $data, int|string|null $updatedBy = null): StudentClass
{
return $this->studentClassModel->newQuery()->updateOrCreate(
[
'student_id' => $data['student_id'],
'class_section_id' => $data['class_section_id'],
'semester' => $data['semester'],
'school_year' => $data['school_year'],
],
[
'description' => $data['description'] ?? null,
'updated_by' => $updatedBy,
]
);
}
public function getClassAssignmentData(): array
{
$currentSemester = $this->getCurrentSemester();
$currentSchoolYear = $this->getCurrentSchoolYear();
$teacherClasses = $this->loadTeacherClasses();
$studentClasses = $this->loadStudentClasses();
$teacherBySection = $teacherClasses->groupBy('class_section_id');
$studentBySection = $studentClasses->groupBy('class_section_id');
$allSectionIds = $teacherBySection->keys()
->merge($studentBySection->keys())
->unique()
->filter()
->values();
$sectionNames = $this->getSectionNamesMap($allSectionIds->all());
$classSections = $allSectionIds->map(function ($sectionId) use (
$teacherBySection,
$studentBySection,
$sectionNames,
$currentSemester,
$currentSchoolYear
) {
$teacherRows = $teacherBySection->get($sectionId, collect());
$studentRows = $studentBySection->get($sectionId, collect());
if ($teacherRows->isEmpty() && $studentRows->isEmpty()) {
return null;
}
$mainTeachers = $teacherRows
->where('position', 'main')
->pluck('teacher_full_name')
->filter()
->unique()
->values();
$teacherAssistants = $teacherRows
->where('position', 'ta')
->pluck('teacher_full_name')
->filter()
->unique()
->values();
$semesterMeta = $this->firstNonEmpty([
$teacherRows->pluck('semester')->first(fn ($v) => filled($v)),
$studentRows->pluck('semester')->first(fn ($v) => filled($v)),
$currentSemester,
]);
$schoolYearMeta = $this->firstNonEmpty([
$teacherRows->pluck('school_year')->first(fn ($v) => filled($v)),
$studentRows->pluck('school_year')->first(fn ($v) => filled($v)),
$currentSchoolYear,
]);
$descriptionMeta = $this->firstNonEmpty([
$teacherRows->pluck('description')->first(fn ($v) => filled($v)),
$studentRows->pluck('description')->first(fn ($v) => filled($v)),
'',
]);
$students = $studentRows
->filter(fn ($row) => !empty($row->student))
->map(function ($row) {
$student = $row->student;
return [
'id' => (int) $student->id,
'firstname' => (string) ($student->firstname ?? ''),
'lastname' => (string) ($student->lastname ?? ''),
'age' => $student->age,
'gender' => (string) ($student->gender ?? ''),
'registration_grade' => (string) ($student->registration_grade ?? ''),
'photo_consent' => (bool) ($student->photo_consent ?? false),
'tuition_paid' => (bool) ($student->tuition_paid ?? false),
'school_id' => (string) ($student->school_id ?? ''),
];
})
->values();
return [
'class_section_id' => (int) $sectionId,
'class_section_name' => (string) ($sectionNames[$sectionId] ?? ''),
'main_teachers' => $mainTeachers->all(),
'teacher_assistants' => $teacherAssistants->all(),
'students' => $students->all(),
'semester' => (string) $semesterMeta,
'school_year' => (string) $schoolYearMeta,
'description' => (string) $descriptionMeta,
];
})
->filter()
->sortBy('class_section_name', SORT_NATURAL | SORT_FLAG_CASE)
->values()
->all();
return [
'classSections' => $classSections,
'semester' => $currentSemester,
'school_year' => $currentSchoolYear,
];
}
protected function loadTeacherClasses(?string $schoolYear = null): Collection
{
return $this->teacherClassModel
->newQuery()
->with([
'teacher:id,firstname,lastname',
])
->when(filled($schoolYear), fn ($q) => $q->where('school_year', $schoolYear))
->get()
->map(function ($row) {
$row->teacher_full_name = trim(
((string) optional($row->teacher)->firstname) . ' ' .
((string) optional($row->teacher)->lastname)
);
return $row;
});
}
protected function loadStudentClasses(?string $schoolYear = null): Collection
{
return $this->studentClassModel
->newQuery()
->with([
'student:id,firstname,lastname,age,gender,registration_grade,photo_consent,tuition_paid,school_id,is_active',
])
->whereHas('student', fn ($q) => $q->where('is_active', 1))
->when(filled($schoolYear), fn ($q) => $q->where('school_year', $schoolYear))
->get();
}
protected function getSectionNamesMap(array $sectionIds): array
{
if (empty($sectionIds)) {
return [];
}
return $this->classSectionModel
->newQuery()
->whereIn('id', $sectionIds)
->get()
->mapWithKeys(function ($section) {
$name = $section->section_name ?? $section->name ?? '';
return [(int) $section->id => (string) $name];
})
->all();
}
protected function getSchoolYears(string $fallbackYear = ''): array
{
$years = DB::table('teacher_class')
->selectRaw('DISTINCT school_year')
->whereNotNull('school_year')
->orderByDesc('school_year')
->pluck('school_year')
->map(fn ($year) => (string) $year)
->filter()
->values()
->all();
if (empty($years) && $fallbackYear !== '') {
$years[] = $fallbackYear;
}
return $years;
}
protected function firstNonEmpty(array $values): mixed
{
foreach ($values as $value) {
if (filled($value)) {
return $value;
}
}
return null;
}
}
-283
View File
@@ -1,283 +0,0 @@
<?php
namespace App\Services;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\Student;
use App\Models\StudentClass;
use App\Models\TeacherClass;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class AssignmentService
{
public function __construct(
protected User $userModel,
protected Configuration $configModel,
protected Student $studentModel,
protected ClassSection $classSectionModel,
protected TeacherClass $teacherClassModel,
protected StudentClass $studentClassModel
) {}
public function buildClassAssignments(array $filters = []): array
{
$currentSemester = (string) ($this->getConfigValue('semester') ?? '');
$currentSchoolYear = (string) ($this->getConfigValue('school_year') ?? '');
$selectedSemester = (string) ($filters['semester'] ?? $currentSemester);
$selectedYear = (string) ($filters['school_year'] ?? $currentSchoolYear);
$forApi = (bool) ($filters['for_api'] ?? false);
// Teacher classes (year-filtered; no semester filtering like your original code)
$teacherQ = $this->teacherClassModel->newQuery();
if ($selectedYear !== '') {
$teacherQ->where('school_year', $selectedYear);
}
$teacherClassesAll = $teacherQ->get()->toArray();
// Student classes (active + year-filtered)
$studentQ = method_exists($this->studentClassModel, 'active')
? $this->studentClassModel->active()
: $this->studentClassModel->newQuery()->where('is_active', 1);
if ($selectedYear !== '') {
// If your table is aliased in scopeActive(), you can keep student_class.school_year
$studentQ->where('school_year', $selectedYear);
}
$studentClassesAll = $studentQ->get()->toArray();
// Group teacher and student classes by section
$teacherBySection = [];
foreach ($teacherClassesAll as $tc) {
$secId = (int) ($tc['class_section_id'] ?? 0);
if ($secId > 0) {
$teacherBySection[$secId][] = $tc;
}
}
$studentsBySection = [];
foreach ($studentClassesAll as $sc) {
$secId = (int) ($sc['class_section_id'] ?? 0);
if ($secId > 0) {
$studentsBySection[$secId][] = $sc;
}
}
$allSectionIds = array_values(array_unique(array_merge(
array_keys($teacherBySection),
array_keys($studentsBySection)
)));
$classSections = [];
foreach ($allSectionIds as $classSectionId) {
$teacherClasses = $teacherBySection[$classSectionId] ?? [];
$studentClasses = $studentsBySection[$classSectionId] ?? [];
if (empty($teacherClasses) && empty($studentClasses)) {
continue;
}
$classSectionName = $this->resolveClassSectionName($classSectionId);
$mainTeachers = [];
$teacherAssistants = [];
$sectionSemester = '';
$sectionSchoolYear = '';
$description = '';
// Teacher metadata + names
foreach ($teacherClasses as $teacherClass) {
$teacherId = (int) ($teacherClass['teacher_id'] ?? 0);
if ($teacherId > 0) {
$teacher = $this->userModel->newQuery()->find($teacherId);
if ($teacher) {
$teacherName = trim(
(string) ($teacher->firstname ?? '') . ' ' . (string) ($teacher->lastname ?? '')
);
if ($teacherName !== '') {
if (($teacherClass['position'] ?? '') === 'main') {
$mainTeachers[] = $teacherName;
} elseif (($teacherClass['position'] ?? '') === 'ta') {
$teacherAssistants[] = $teacherName;
}
}
}
}
if ($sectionSemester === '' && !empty($teacherClass['semester'])) {
$sectionSemester = (string) $teacherClass['semester'];
}
if ($sectionSchoolYear === '' && !empty($teacherClass['school_year'])) {
$sectionSchoolYear = (string) $teacherClass['school_year'];
}
if ($description === '' && !empty($teacherClass['description'])) {
$description = (string) $teacherClass['description'];
}
}
// Students
$students = [];
foreach ($studentClasses as $studentClass) {
if ($sectionSemester === '' && !empty($studentClass['semester'])) {
$sectionSemester = (string) $studentClass['semester'];
}
if ($sectionSchoolYear === '' && !empty($studentClass['school_year'])) {
$sectionSchoolYear = (string) $studentClass['school_year'];
}
if ($description === '' && !empty($studentClass['description'])) {
$description = (string) $studentClass['description'];
}
$studentId = (int) ($studentClass['student_id'] ?? 0);
if ($studentId <= 0) {
continue;
}
$student = $this->studentModel->newQuery()
->where('id', $studentId)
->where('is_active', 1)
->first();
if (!$student) {
continue;
}
$students[] = $forApi
? [
'id' => (int) $student->id,
'firstname' => (string) ($student->firstname ?? ''),
'lastname' => (string) ($student->lastname ?? ''),
'age' => $student->age ?? null,
'gender' => (string) ($student->gender ?? ''),
'registration_grade' => (string) ($student->registration_grade ?? ''),
'photo_consent' => (bool) ($student->photo_consent ?? false),
'tuition_paid' => (bool) ($student->tuition_paid ?? false),
'school_id' => (string) ($student->school_id ?? ''),
]
: [
'id' => (int) $student->id,
'firstname' => e((string) ($student->firstname ?? '')),
'lastname' => e((string) ($student->lastname ?? '')),
'age' => e((string) ($student->age ?? '')),
'gender' => e((string) ($student->gender ?? '')),
'registration_grade' => e((string) ($student->registration_grade ?? '')),
'photo_consent' => e(((bool) ($student->photo_consent ?? false)) ? 'Yes' : 'No'),
'tuition_paid' => e(((bool) ($student->tuition_paid ?? false)) ? 'Yes' : 'No'),
'school_id' => e((string) ($student->school_id ?? '')),
];
}
$classSections[] = [
'class_section_id' => (int) $classSectionId,
'class_section_name' => (string) $classSectionName,
'main_teachers' => array_values(array_unique($mainTeachers)),
'teacher_assistants' => array_values(array_unique($teacherAssistants)),
'students' => $students,
'semester' => $sectionSemester !== '' ? $sectionSemester : $currentSemester,
'school_year' => $sectionSchoolYear !== '' ? $sectionSchoolYear : $currentSchoolYear,
'description' => $description,
];
}
// Sort sections by name
usort($classSections, fn ($a, $b) => strcmp(
(string) ($a['class_section_name'] ?? ''),
(string) ($b['class_section_name'] ?? '')
));
$schoolYearsList = $this->getSchoolYearsList($currentSchoolYear);
$payload = [
'classSections' => $classSections,
'schoolYears' => $schoolYearsList,
'selectedYear' => $selectedYear,
'selectedSemester' => $selectedSemester,
'semester' => $currentSemester,
'school_year' => $currentSchoolYear,
];
// If you're using Laravel Sanctum/API only, CSRF may not be needed.
// Keep it if your frontend still expects it:
if (function_exists('csrf_token')) {
$payload['csrfToken'] = csrf_token();
}
return $payload;
}
public function saveStudentAssignment(array $data, ?int $updatedBy = null): mixed
{
$payload = [
'student_id' => $data['student_id'],
'class_section_id' => $data['class_section_id'],
'semester' => $data['semester'] ?? $this->getConfigValue('semester'),
'school_year' => $data['school_year'] ?? $this->getConfigValue('school_year'),
'description' => $data['description'] ?? null,
'updated_by' => $updatedBy,
];
// If your StudentClass model supports updateOrCreate:
// adjust unique keys as needed (e.g. student_id + school_year + semester)
return $this->studentClassModel->newQuery()->updateOrCreate(
[
'student_id' => $payload['student_id'],
'school_year' => $payload['school_year'],
'semester' => $payload['semester'],
],
$payload
);
}
protected function getConfigValue(string $key): mixed
{
// Match your existing model API if present
if (method_exists($this->configModel, 'getConfig')) {
return $this->configModel->getConfig($key);
}
// Generic fallback
return $this->configModel->newQuery()
->where('key', $key)
->value('value');
}
protected function resolveClassSectionName(int $classSectionId): string
{
if (method_exists($this->classSectionModel, 'getClassSectionNameBySectionId')) {
return (string) ($this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? '');
}
$section = $this->classSectionModel->newQuery()->find($classSectionId);
// Adjust to your actual field(s)
return (string) ($section->name ?? $section->title ?? '');
}
protected function getSchoolYearsList(string $fallbackYear = ''): array
{
try {
$years = DB::table('teacher_class')
->select('school_year')
->whereNotNull('school_year')
->distinct()
->orderByDesc('school_year')
->pluck('school_year')
->map(fn ($y) => (string) $y)
->filter(fn ($y) => $y !== '')
->values()
->all();
if (!empty($years)) {
return $years;
}
} catch (\Throwable $e) {
// fallback below
}
return $fallbackYear !== '' ? [$fallbackYear] : [];
}
}
@@ -0,0 +1,75 @@
<?php
namespace App\Services\Attendance;
use Illuminate\Contracts\Auth\Authenticatable;
class AttendancePolicyService
{
public function canEditDay(array $dayRow, array $currentUser): bool
{
$status = strtolower((string)($dayRow['status'] ?? 'draft'));
$roles = array_map([$this, 'normalizeRole'], (array)($currentUser['roles'] ?? []));
$permissions = array_map('strtolower', (array)($currentUser['permissions'] ?? []));
$isAdmin = $this->isAdminLike($roles, $permissions);
$isTeacher = in_array('teacher', $roles, true) || in_array('ta', $roles, true) || in_array('teacher_assistant', $roles, true);
if ($status === 'finalized' || $status === 'published') {
return $isAdmin;
}
if ($status === 'submitted') {
return $isAdmin;
}
if ($status === 'draft') {
return $isAdmin || $isTeacher;
}
return $isAdmin;
}
public function isTeacher(array $roles): bool
{
$roles = array_map([$this, 'normalizeRole'], $roles);
return in_array('teacher', $roles, true)
|| in_array('ta', $roles, true)
|| in_array('teacher_assistant', $roles, true)
|| in_array('assistant_teacher', $roles, true);
}
public function isAdminLike(array $roles, array $permissions = []): bool
{
$roles = array_map([$this, 'normalizeRole'], $roles);
$excluded = [
'parent',
'guest',
'teacher',
'teacher_assistant',
'assistant_teacher',
'ta',
];
foreach ($roles as $role) {
if ($role !== '' && !in_array($role, $excluded, true)) {
return true;
}
}
foreach ($permissions as $permission) {
$permission = strtolower(trim((string)$permission));
if (str_contains($permission, 'attendance.manage') || str_contains($permission, 'attendance.admin')) {
return true;
}
}
return false;
}
public function normalizeRole(?string $role): string
{
return str_replace([' ', '-'], '_', strtolower(trim((string)$role)));
}
}
@@ -0,0 +1,604 @@
<?php
namespace App\Services\Attendance;
use App\Models\AttendanceData;
use App\Models\AttendanceDay;
use App\Models\AttendanceRecord;
use App\Models\Calendar;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\Student;
use App\Models\StudentClass;
use App\Models\TeacherClass;
use App\Models\User;
use App\Models\UserRole;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class AttendanceQueryService
{
public function __construct(
protected Configuration $configuration,
protected AttendanceData $attendanceData,
protected AttendanceDay $attendanceDay,
protected AttendanceRecord $attendanceRecord,
protected Student $student,
protected StudentClass $studentClass,
protected ClassSection $classSection,
protected TeacherClass $teacherClass,
protected Calendar $calendar,
protected User $user,
protected UserRole $userRole,
protected AttendanceService $attendanceService,
protected SemesterRangeService $semesterRangeService,
) {}
public function teacherGrid(string $semester, string $schoolYear, string $date, int $sectionCode): array
{
$bySection = $this->teacherClass->assignedBySectionForTerm($semester, $schoolYear);
$sectionCodes = array_keys($bySection);
$labels = [];
if (!empty($sectionCodes)) {
$rows = $this->classSection
->query()
->select(['class_section_id', 'class_section_name'])
->whereIn('class_section_id', $sectionCodes)
->orderBy('class_section_id')
->get()
->toArray();
foreach ($rows as $row) {
$code = (int)$row['class_section_id'];
$name = trim((string)($row['class_section_name'] ?? ''));
$labels[$code] = $name !== '' ? $name : 'Section #' . $code;
}
foreach ($sectionCodes as $code) {
$labels[$code] ??= 'Section #' . $code;
}
}
$grid = [];
$locked = false;
if ($sectionCode > 0) {
$assigned = $this->teacherClass->assignedForSectionTerm($sectionCode, $semester, $schoolYear);
$teacherIds = array_map(fn($r) => (int)$r['teacher_id'], $assigned);
$statusMap = [];
if (!empty($teacherIds)) {
$rows = DB::table('staff_attendance as sa')
->select('sa.user_id', 'sa.status', 'sa.reason')
->where('sa.semester', $semester)
->where('sa.school_year', $schoolYear)
->whereDate('sa.date', $date)
->whereIn('sa.user_id', $teacherIds)
->get();
foreach ($rows as $row) {
$statusMap[(int)$row->user_id] = [
'status' => $row->status,
'reason' => $row->reason,
];
}
}
foreach ($assigned as $teacher) {
$teacherId = (int)$teacher['teacher_id'];
$position = strtolower((string)($teacher['position'] ?? 'main'));
$position = in_array($position, ['main', 'ta'], true) ? $position : 'main';
$name = trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? ''));
$cell = $statusMap[$teacherId] ?? ['status' => null, 'reason' => null];
$grid[] = [
'teacher_id' => $teacherId,
'name' => $name !== '' ? $name : 'User#' . $teacherId,
'position' => $position,
'status' => $cell['status'],
'reason' => $cell['reason'],
];
}
try {
$locked = method_exists($this->attendanceDay, 'isFinalized')
? $this->attendanceDay->isFinalized($sectionCode, $date, $semester, $schoolYear)
: false;
} catch (\Throwable) {
$locked = false;
}
}
return [
'semester' => $semester,
'school_year' => $schoolYear,
'date' => $date,
'class_section_id' => $sectionCode,
'sections' => $labels,
'grid' => $grid,
'locked' => $locked,
];
}
public function teacherAttendanceFormData(?int $userId, int $requestedSectionId = 0): array
{
if (!$userId) {
throw new RuntimeException('User not logged in.');
}
$teacher = $this->user->find($userId);
$teacherName = $teacher
? trim(($teacher->firstname ?? '') . ' ' . ($teacher->lastname ?? ''))
: 'Unknown Teacher';
$semester = $this->attendanceService->currentSemester();
$schoolYear = $this->attendanceService->currentSchoolYear();
$assignments = $this->teacherClass->getClassAssignmentsByUserId($userId, $schoolYear, $semester);
if (empty($assignments)) {
throw new RuntimeException('You do not have an assigned class yet.');
}
$activeSection = null;
foreach ($assignments as $assignment) {
$cid = (int)($assignment['class_section_id'] ?? 0);
$pk = (int)($assignment['class_section_pk'] ?? 0);
if ($requestedSectionId > 0 && ($requestedSectionId === $cid || $requestedSectionId === $pk)) {
$activeSection = $assignment;
break;
}
}
$activeSection ??= $assignments[0];
$classSectionCode = (int)($activeSection['class_section_id'] ?? 0);
$classSectionPk = (int)($activeSection['class_section_pk'] ?? 0);
$classSectionId = $classSectionCode > 0 ? $classSectionCode : $classSectionPk;
if ($classSectionId <= 0) {
throw new RuntimeException('Unable to determine your class section.');
}
$today = Carbon::today();
$anchorSunday = $today->dayOfWeek === Carbon::SUNDAY ? $today->copy() : $today->copy()->next(Carbon::SUNDAY);
$sundayDates = [
$anchorSunday->copy()->subWeeks(2)->toDateString(),
$anchorSunday->copy()->subWeek()->toDateString(),
$anchorSunday->toDateString(),
];
$currentSunday = $sundayDates[2];
$students = $this->student->getByClassAndYear($classSectionId, $schoolYear, $semester);
if (empty($students) && $classSectionPk > 0 && $classSectionPk !== $classSectionId) {
$students = $this->student->getByClassAndYear($classSectionPk, $schoolYear, $semester);
}
$attendanceRows = [];
$lockedByStudent = [];
foreach ($students as $student) {
$studentId = (int)$student['id'];
$rows = AttendanceData::query()
->select(['date', 'status', 'is_reported', 'reason', 'modified_by'])
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->where(function ($q) use ($classSectionId) {
$q->where('class_section_id', $classSectionId)
->orWhere('class_section_id', (int)$classSectionId);
})
->whereIn('date', $sundayDates)
->get()
->map(fn($row) => (array)$row)
->all();
$rows = $this->attendanceService->normalizeAttendanceEntries($rows);
$recentRows = AttendanceData::query()
->select(['date', 'status'])
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->where(function ($q) use ($classSectionId) {
$q->where('class_section_id', $classSectionId)
->orWhere('class_section_id', (int)$classSectionId);
})
->orderByDesc('date')
->limit(3)
->get()
->toArray();
$recentHistory = [];
foreach ($recentRows as $row) {
$recentHistory[] = [
'date' => substr((string)($row['date'] ?? ''), 0, 10),
'status' => strtolower((string)($row['status'] ?? '')),
];
}
$snapshot = [];
$rowsByDate = [];
foreach ($rows as $entry) {
$d = (string)($entry['date'] ?? '');
$snapshot[$d] = $entry['status'] ?? null;
$rowsByDate[$d] = $entry;
}
$statusHistory = [];
foreach ($sundayDates as $date) {
$statusHistory[$date] = array_key_exists($date, $snapshot)
? $snapshot[$date]
: ($date === $currentSunday ? null : 'N/A');
}
$todayRow = $rowsByDate[$currentSunday] ?? null;
$lockInfo = $this->attendanceService->detectExternalSubmission($todayRow);
$todayReason = $todayRow['reason'] ?? null;
$attendanceRows[] = [
'student' => $student,
'sunday_statuses' => $statusHistory,
'today' => $statusHistory[$currentSunday] ?? null,
'today_reason' => $todayReason,
'locked' => $lockInfo['locked'],
'locked_source' => $lockInfo['source'],
'recent_three' => $recentHistory,
];
$lockedByStudent[$studentId] = [
'locked' => $lockInfo['locked'],
'source' => $lockInfo['source'],
'reason' => $todayReason,
'status' => $statusHistory[$currentSunday] ?? null,
];
}
$assigned = $this->teacherClass->assignedForSectionTerm($classSectionId, $semester, $schoolYear);
$teacherRows = [];
if (!empty($assigned)) {
$teacherIds = array_map(static fn($r) => (int)$r['teacher_id'], $assigned);
$statusMap = [];
if (!empty($teacherIds)) {
$rows = DB::table('staff_attendance as sa')
->select('sa.user_id', 'sa.date', 'sa.status', 'sa.reason')
->where('sa.semester', $semester)
->where('sa.school_year', $schoolYear)
->whereIn('sa.user_id', $teacherIds)
->whereIn('sa.date', $sundayDates)
->get();
foreach ($rows as $row) {
$statusMap[(int)$row->user_id][(string)$row->date] = [
'status' => $row->status,
'reason' => $row->reason,
];
}
}
foreach ($assigned as $teacher) {
$teacherId = (int)$teacher['teacher_id'];
$position = strtolower((string)($teacher['position'] ?? 'main'));
$position = in_array($position, ['main', 'ta'], true) ? $position : 'main';
$name = trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? ''));
$history = [];
foreach ($sundayDates as $date) {
$history[$date] = isset($statusMap[$teacherId][$date]['status'])
? $statusMap[$teacherId][$date]['status']
: ($date === $currentSunday ? null : 'N/A');
}
$teacherRows[] = [
'teacher_id' => $teacherId,
'position' => $position,
'name' => $name !== '' ? $name : 'User#' . $teacherId,
'sunday_statuses' => $history,
'today' => $history[$currentSunday] ?? null,
];
}
}
$noSchoolDays = [];
$calendarRows = $this->calendar->query()
->where('no_school', 1)
->whereIn('date', $sundayDates)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get()
->toArray();
foreach ($calendarRows as $row) {
$d = (string)($row['date'] ?? '');
$noSchoolDays[$d] = [
'title' => $row['title'] ?? 'No School',
'description' => $row['description'] ?? '',
];
}
return [
'teacher_name' => $teacherName,
'students' => $attendanceRows,
'teachers' => $teacherRows,
'class_section_id' => $classSectionId,
'sunday_dates' => $sundayDates,
'current_sunday' => $currentSunday,
'enable_attendance' => (int)$this->configuration->getConfig('enable_attendance'),
'can_edit' => ((int)$this->configuration->getConfig('enable_attendance') === 1),
'class_id' => (int)$this->classSection->getClassId($classSectionId),
'no_school_days' => $noSchoolDays,
'today' => $currentSunday,
'locked_by_student' => $lockedByStudent,
];
}
public function buildDailyAttendanceData(string $termSemester, string $termYear): array
{
$classSections = $this->classSection
->query()
->select('classSection.id', 'classSection.class_id', 'classSection.class_section_id', 'classSection.class_section_name')
->join('student_class as sc', 'sc.class_section_id', '=', 'classSection.class_section_id')
->where('sc.school_year', $termYear)
->groupBy('classSection.id', 'classSection.class_id', 'classSection.class_section_id', 'classSection.class_section_name')
->get()
->map(fn($row) => (array)$row)
->all();
$attendanceData = [];
$attendanceRecord = [];
$studentsBySection = [];
$grades = [];
$datesBySection = [];
$studentSchoolMap = [];
$sectionCounts = $this->studentClass->getStudentCountsBySection($termYear);
foreach ($classSections as $classSection) {
$secPk = (int)($classSection['id'] ?? 0);
$secCodeRaw = (string)($classSection['class_section_id'] ?? '');
$secCode = $secCodeRaw !== '' ? $secCodeRaw : (string)$secPk;
$classId = (int)($classSection['class_id'] ?? 0);
$studentsBySection[$secCode] = [];
$datesBySection[$secCode] = [];
$attendanceData[$secCode] = [];
$attendanceRecord[$secCode] = [];
$countForCode = (int)($sectionCounts[$secCode] ?? 0);
$countForPk = (int)($sectionCounts[$secPk] ?? 0);
$sectionHasStudents = ($countForCode + $countForPk) > 0;
$students = $this->studentClass->getClassStudents($secCode, $termYear);
if (!$students && $secPk && (string)$secPk !== (string)$secCode) {
$students = $this->studentClass->getClassStudents($secPk, $termYear);
}
if (!$sectionHasStudents || !$students) {
continue;
}
$hasRoster = false;
foreach ($students as $sc) {
$studentId = (int)$sc['student_id'];
$student = $this->student
->query()
->select(['id', 'firstname', 'lastname', 'school_id'])
->where('id', $studentId)
->where('is_active', 1)
->first();
if (!$student) {
continue;
}
$student = $student->toArray();
$studentsBySection[$secCode][] = $student;
$studentSchoolMap[$studentId] = (string)($student['school_id'] ?? '');
$hasRoster = true;
$entries = AttendanceData::query()
->where('student_id', $studentId)
->where(function ($q) use ($secCode, $secPk) {
$q->where('class_section_id', $secCode);
if ($secPk && (string)$secPk !== (string)$secCode) {
$q->orWhere('class_section_id', $secPk);
}
})
->when($termSemester !== '', fn($q) => $q->where('semester', $termSemester))
->when($termYear !== '', fn($q) => $q->where('school_year', $termYear))
->orderBy('date')
->get()
->map(fn($row) => $row->toArray())
->all();
$entries = $this->attendanceService->normalizeAttendanceEntries($entries);
$attendanceData[$secCode][$studentId] = $entries;
foreach ($entries as $entry) {
$d = (string)($entry['date'] ?? '');
if ($d !== '') {
$datesBySection[$secCode][$d] = true;
}
}
$summary = AttendanceRecord::query()
->where('student_id', $studentId)
->where(function ($q) use ($secCode, $secPk) {
$q->where('class_section_id', $secCode);
if ($secPk && (string)$secPk !== (string)$secCode) {
$q->orWhere('class_section_id', $secPk);
}
})
->when($termSemester !== '', fn($q) => $q->where('semester', $termSemester))
->when($termYear !== '', fn($q) => $q->where('school_year', $termYear))
->first();
$attendanceRecord[$secCode][$studentId] = $summary?->toArray() ?? [
'total_presence' => 0,
'total_late' => 0,
'total_absence' => 0,
'total_attendance' => 0,
];
}
if (!$hasRoster) {
unset($studentsBySection[$secCode], $datesBySection[$secCode], $attendanceData[$secCode], $attendanceRecord[$secCode]);
continue;
}
$grades[$classId][] = $classSection;
}
foreach ($attendanceData as $secId => &$studentEntries) {
foreach ($studentEntries as &$entries) {
usort($entries, static fn($a, $b) => strcmp((string)($a['date'] ?? ''), (string)($b['date'] ?? '')));
}
}
unset($studentEntries, $entries);
foreach ($datesBySection as $sec => $set) {
$arr = array_keys($set);
sort($arr, SORT_STRING);
$datesBySection[$sec] = $arr;
}
ksort($grades, SORT_NUMERIC);
[$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($termYear);
$semesterNorm = $this->semesterRangeService->normalizeSemester($termSemester);
if ($semesterNorm !== '') {
$semRange = $this->semesterRangeService->getSemesterRange($termYear, $semesterNorm);
if ($semRange) {
[$rangeStart, $rangeEnd] = $semRange;
}
}
$dateList = $this->semesterRangeService->buildSundayList($rangeStart, $rangeEnd);
$noSchoolDays = [];
$events = $this->calendar->query()
->where('no_school', 1)
->where('date', '>=', $rangeStart)
->where('date', '<=', $rangeEnd)
->get()
->toArray();
foreach ($events as $event) {
$d = substr((string)($event['date'] ?? ''), 0, 10);
if ($d !== '') {
$noSchoolDays[$d] = true;
}
}
$anchorSunday = now()->dayOfWeek === Carbon::SUNDAY
? now()->toDateString()
: now()->next(Carbon::SUNDAY)->toDateString();
$passedDatesSet = [];
foreach ($dateList as $d) {
if ($d <= $anchorSunday && empty($noSchoolDays[$d])) {
$passedDatesSet[$d] = true;
}
}
$totalPassedDays = count($passedDatesSet);
if ($totalPassedDays > 0 && $termYear !== '' && $termSemester !== '') {
foreach ($attendanceData as $secCode => $studentEntries) {
foreach ($studentEntries as $studentId => $entries) {
$statusByDate = [];
foreach ($entries as $entry) {
$d = substr((string)($entry['date'] ?? ''), 0, 10);
if ($d === '' || empty($passedDatesSet[$d])) {
continue;
}
$st = strtolower(trim((string)($entry['status'] ?? '')));
if (!in_array($st, ['present', 'absent', 'late'], true)) {
continue;
}
$statusByDate[$d] = $st;
}
$p = 0;
$l = 0;
$a = 0;
foreach ($statusByDate as $st) {
if ($st === 'present') {
$p++;
} elseif ($st === 'late') {
$l++;
} elseif ($st === 'absent') {
$a++;
}
}
$sum = $p + $l + $a;
$record = $attendanceRecord[$secCode][$studentId] ?? [];
$storedSum = (int)($record['total_presence'] ?? 0)
+ (int)($record['total_late'] ?? 0)
+ (int)($record['total_absence'] ?? 0);
if ($storedSum !== $totalPassedDays && $storedSum !== $sum) {
DB::table('attendance_record')
->updateOrInsert(
[
'student_id' => $studentId,
'semester' => $termSemester,
'school_year' => $termYear,
],
[
'class_section_id' => $secCode,
'school_id' => $studentSchoolMap[$studentId] ?? '',
'total_presence' => $p,
'total_late' => $l,
'total_absence' => $a,
'total_attendance' => $sum,
'modified_by' => auth()->id(),
'updated_at' => now(),
'created_at' => now(),
]
);
$attendanceRecord[$secCode][$studentId]['total_presence'] = $p;
$attendanceRecord[$secCode][$studentId]['total_late'] = $l;
$attendanceRecord[$secCode][$studentId]['total_absence'] = $a;
$attendanceRecord[$secCode][$studentId]['total_attendance'] = $sum;
}
}
}
}
$adminName = '';
if (auth()->id()) {
$u = $this->user->query()->select('firstname', 'lastname')->find(auth()->id());
if ($u) {
$adminName = trim(($u->firstname ?? '') . ' ' . ($u->lastname ?? ''));
}
}
return [
'attendanceData' => $attendanceData,
'attendanceRecord' => $attendanceRecord,
'studentsBySection' => $studentsBySection,
'grades' => $grades,
'datesBySection' => $datesBySection,
'dateList' => $dateList,
'noSchoolDays' => $noSchoolDays,
'totalPassedDays' => $totalPassedDays,
'semester' => $termSemester,
'schoolYear' => $termYear,
'currentAdminName' => $adminName,
];
}
}
@@ -0,0 +1,153 @@
<?php
namespace App\Services\Attendance;
use App\Models\AttendanceRecord;
class AttendanceRecordSyncService
{
public function __construct(
protected AttendanceRecord $attendanceRecord
) {}
public function updateAttendanceRecord(
int $studentId,
string $schoolId,
string $newStatus,
string $semester,
string $schoolYear,
?string $oldStatus,
?int $modifiedBy = null
): void {
$record = AttendanceRecord::query()
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
if (!$record) {
return;
}
$newStatus = strtolower($newStatus);
$oldStatus = strtolower((string)$oldStatus);
$presence = (int)($record->total_presence ?? 0);
$absence = (int)($record->total_absence ?? 0);
$late = (int)($record->total_late ?? 0);
if ($oldStatus === 'present') {
$presence--;
} elseif ($oldStatus === 'absent') {
$absence--;
} elseif ($oldStatus === 'late') {
$late--;
}
if ($newStatus === 'present') {
$presence++;
} elseif ($newStatus === 'absent') {
$absence++;
} elseif ($newStatus === 'late') {
$late++;
}
$record->update([
'total_presence' => max(0, $presence),
'total_absence' => max(0, $absence),
'total_late' => max(0, $late),
'updated_at' => now(),
'modified_by' => $modifiedBy,
]);
}
public function addNewAttendanceRecord(
int $classSectionId,
int $studentId,
string $schoolId,
string $status,
string $semester,
string $schoolYear,
?int $modifiedBy = null
): void {
$status = strtolower($status);
$record = AttendanceRecord::query()
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
if ($record) {
$record->update([
'total_presence' => (int)($record->total_presence ?? 0) + ($status === 'present' ? 1 : 0),
'total_absence' => (int)($record->total_absence ?? 0) + ($status === 'absent' ? 1 : 0),
'total_late' => (int)($record->total_late ?? 0) + ($status === 'late' ? 1 : 0),
'total_attendance' => (int)($record->total_attendance ?? 0) + 1,
'updated_at' => now(),
'modified_by' => $modifiedBy,
]);
return;
}
AttendanceRecord::query()->create([
'class_section_id' => $classSectionId,
'student_id' => $studentId,
'school_id' => $schoolId,
'semester' => $semester,
'school_year' => $schoolYear,
'created_at' => now(),
'updated_at' => now(),
'modified_by' => $modifiedBy,
'total_presence' => $status === 'present' ? 1 : 0,
'total_absence' => $status === 'absent' ? 1 : 0,
'total_late' => $status === 'late' ? 1 : 0,
'total_attendance' => 1,
]);
}
public function upsertAttendanceRecordTotals(
int $studentId,
string $classSectionId,
string $schoolId,
string $semester,
string $schoolYear,
int $totalPresence,
int $totalLate,
int $totalAbsence,
int $totalAttendance,
?int $modifiedBy = null
): bool {
if ($semester === '' || $schoolYear === '') {
return false;
}
$record = AttendanceRecord::query()
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$payload = [
'total_presence' => max(0, $totalPresence),
'total_late' => max(0, $totalLate),
'total_absence' => max(0, $totalAbsence),
'total_attendance' => max(0, $totalAttendance),
'updated_at' => now(),
'modified_by' => $modifiedBy,
];
if ($record) {
return $record->update($payload);
}
$payload['class_section_id'] = (int)$classSectionId;
$payload['student_id'] = $studentId;
$payload['school_id'] = $schoolId;
$payload['semester'] = $semester;
$payload['school_year'] = $schoolYear;
$payload['created_at'] = now();
return (bool)AttendanceRecord::query()->create($payload);
}
}
@@ -0,0 +1,275 @@
<?php
namespace App\Services\Attendance;
use App\Models\AttendanceData;
use App\Models\AttendanceDay;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\UserRole;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class AttendanceService
{
public function __construct(
protected Configuration $configuration,
protected AttendanceData $attendanceData,
protected AttendanceDay $attendanceDay,
protected ClassSection $classSection,
protected UserRole $userRole,
protected AttendancePolicyService $attendancePolicyService,
protected StudentAttendanceWriterService $studentAttendanceWriterService,
protected TeacherAttendanceSubmissionService $teacherAttendanceSubmissionService,
protected AttendanceRecordSyncService $attendanceRecordSyncService,
) {}
public function currentSemester(): string
{
return (string)($this->configuration->getConfig('semester') ?? 'Fall');
}
public function currentSchoolYear(): string
{
return (string)($this->configuration->getConfig('school_year') ?? now()->year . '-' . (now()->year + 1));
}
public function updateAttendanceManagement(Authenticatable $user, array $data): array
{
$classSectionId = (int)$data['class_section_id'];
$studentId = (int)$data['student_id'];
$status = strtolower((string)$data['status']);
$date = (string)$data['date'];
$reason = $data['reason'] ?? null;
$classId = $data['class_id'] ?? null;
$semester = (string)($data['semester'] ?? $this->currentSemester());
$schoolYear = (string)($data['school_year'] ?? $this->currentSchoolYear());
$dayRow = AttendanceDay::query()
->where('class_section_id', $classSectionId)
->whereDate('date', $date)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$dayPolicyRow = $dayRow?->toArray() ?? [
'status' => 'draft',
'class_section_id' => $classSectionId,
'date' => $date,
'semester' => $semester,
'school_year' => $schoolYear,
];
$currentUser = [
'id' => $user->id,
'roles' => method_exists($user, 'roles') ? $user->roles->pluck('name')->all() : [],
'permissions' => method_exists($user, 'permissions') ? $user->permissions->pluck('name')->all() : [],
];
if (!$this->attendancePolicyService->canEditDay($dayPolicyRow, $currentUser)) {
throw new RuntimeException('Attendance is locked for your role.');
}
$reportedRaw = strtolower((string)($data['is_reported'] ?? ''));
$isReported = $status === 'present'
? 'no'
: (in_array($reportedRaw, ['1', 'yes', 'y', 'true', 'on'], true) ? 'yes' : 'no');
DB::transaction(function () use (
$user,
$classSectionId,
$studentId,
$status,
$date,
$reason,
$classId,
$semester,
$schoolYear,
$isReported
) {
$this->studentAttendanceWriterService->saveOrUpdateStudentAttendance([
'class_section_id' => $classSectionId,
'student_id' => $studentId,
'school_id' => null,
'status' => $status,
'reason' => $reason,
'is_reported' => $isReported,
'semester' => $semester,
'school_year' => $schoolYear,
'date' => $date,
'class_id' => (int)$classId,
'user_id' => $user->id,
]);
AttendanceDay::query()->firstOrCreate(
[
'class_section_id' => $classSectionId,
'date' => $date,
'semester' => $semester,
'school_year' => $schoolYear,
],
[
'status' => 'draft',
'created_at' => now(),
'updated_at' => now(),
]
);
});
return [
'ok' => true,
'message' => 'Attendance updated successfully.',
];
}
public function submitTeacherAttendance(Authenticatable $user, array $data): array
{
return $this->teacherAttendanceSubmissionService->submit(
$user,
$data,
fn (?array $row) => $this->detectExternalSubmission($row),
fn () => $this->currentSemester(),
fn () => $this->currentSchoolYear(),
);
}
public function adminAddEntry(Authenticatable $user, array $data): array
{
$classSectionId = (int)$data['class_section_id'];
$studentId = (int)$data['student_id'];
$status = strtolower((string)$data['status']);
$date = (string)($data['date'] ?? now()->toDateString());
$reason = $data['reason'] ?? null;
$isReported = in_array(strtolower((string)($data['is_reported'] ?? 'no')), ['yes', 'no'], true)
? strtolower((string)($data['is_reported'] ?? 'no'))
: 'no';
$section = $this->classSection
->query()
->select('class_id', 'id', 'class_section_id')
->where('id', $classSectionId)
->orWhere('class_section_id', $classSectionId)
->first();
$resolvedClassId = (int)($section->class_id ?? 0);
if ($resolvedClassId <= 0) {
throw new RuntimeException('Cannot resolve class_id for this section.');
}
DB::transaction(function () use (
$user,
$classSectionId,
$studentId,
$status,
$date,
$reason,
$resolvedClassId,
$isReported
) {
AttendanceDay::query()->firstOrCreate(
[
'class_section_id' => $classSectionId,
'date' => $date,
'semester' => $this->currentSemester(),
'school_year' => $this->currentSchoolYear(),
],
[
'status' => 'draft',
'created_at' => now(),
'updated_at' => now(),
]
);
$this->studentAttendanceWriterService->saveOrUpdateStudentAttendance([
'class_section_id' => $classSectionId,
'student_id' => $studentId,
'school_id' => $data['school_id'] ?? null,
'status' => $status,
'reason' => $reason,
'is_reported' => $isReported,
'semester' => $this->currentSemester(),
'school_year' => $this->currentSchoolYear(),
'date' => $date,
'class_id' => $resolvedClassId,
'user_id' => $user->id,
]);
});
return [
'ok' => true,
'message' => 'Entry saved successfully.',
];
}
public function normalizeAttendanceEntries(array $entries): array
{
foreach ($entries as &$row) {
$row['status'] = $this->normalizeStatus($row['status'] ?? '');
$reportedRaw = $row['is_reported'] ?? ($row['reported'] ?? '');
$row['is_reported'] = $this->normalizeReportedFlag($reportedRaw);
}
return $entries;
}
public function normalizeStatus(?string $status): string
{
$status = strtolower(trim((string)$status));
return in_array($status, ['present', 'absent', 'late'], true) ? $status : $status;
}
public function normalizeReportedFlag(mixed $flag): string
{
$v = strtolower(trim((string)$flag));
return in_array($v, ['yes', '1', 'true', 'y', 'on'], true) ? 'yes' : 'no';
}
public function detectExternalSubmission(?array $row): array
{
if (empty($row) || !is_array($row)) {
return ['locked' => false, 'source' => null];
}
$reportedRaw = strtolower((string)($row['is_reported'] ?? ''));
$reason = strtolower((string)($row['reason'] ?? ''));
$isParent = in_array($reportedRaw, ['yes', '1', 'true', 'y'], true)
|| str_contains($reason, 'parent-reported')
|| str_contains($reason, 'parent reported');
if ($isParent) {
return ['locked' => true, 'source' => 'parent'];
}
$modifierId = (int)($row['modified_by'] ?? 0);
if ($modifierId > 0 && $this->isAdminLikeUserId($modifierId)) {
return ['locked' => true, 'source' => 'admin'];
}
return ['locked' => false, 'source' => null];
}
public function isAdminLikeUserId(int $userId): bool
{
if ($userId <= 0) {
return false;
}
$roles = $this->userRole->getRolesByUserId($userId);
if (empty($roles)) {
return false;
}
$excluded = ['parent', 'guest', 'teacher', 'teacher_assistant', 'assistant_teacher', 'ta'];
foreach ($roles as $role) {
$name = strtolower(str_replace([' ', '-'], '_', (string)($role['role_name'] ?? '')));
if ($name !== '' && !in_array($name, $excluded, true)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,77 @@
<?php
namespace App\Services\Attendance;
use Carbon\Carbon;
class SemesterRangeService
{
public function normalizeSemester(?string $semester): string
{
$value = strtolower(trim((string)$semester));
return match ($value) {
'fall', 'autumn' => 'Fall',
'spring' => 'Spring',
default => '',
};
}
public function getSchoolYearRange(string $schoolYear): array
{
[$startYear, $endYear] = $this->parseSchoolYear($schoolYear);
return [
Carbon::create($startYear, 9, 1)->toDateString(),
Carbon::create($endYear, 5, 31)->toDateString(),
];
}
public function getSemesterRange(string $schoolYear, string $semester): ?array
{
[$startYear, $endYear] = $this->parseSchoolYear($schoolYear);
$semester = $this->normalizeSemester($semester);
return match ($semester) {
'Fall' => [
Carbon::create($startYear, 9, 21)->toDateString(),
Carbon::create($endYear, 1, 18)->toDateString(),
],
'Spring' => [
Carbon::create($endYear, 1, 25)->toDateString(),
Carbon::create($endYear, 5, 31)->toDateString(),
],
default => null,
};
}
public function buildSundayList(string $startDate, string $endDate): array
{
$start = Carbon::parse($startDate)->startOfDay();
$end = Carbon::parse($endDate)->startOfDay();
if ($start->dayOfWeek !== Carbon::SUNDAY) {
$start = $start->next(Carbon::SUNDAY);
}
$dates = [];
$cursor = $start->copy();
while ($cursor->lte($end)) {
$dates[] = $cursor->toDateString();
$cursor->addWeek();
}
return $dates;
}
protected function parseSchoolYear(string $schoolYear): array
{
if (preg_match('/^\s*(\d{4})\s*-\s*(\d{4})\s*$/', $schoolYear, $m)) {
return [(int)$m[1], (int)$m[2]];
}
$year = (int) date('Y');
return [$year, $year + 1];
}
}
@@ -0,0 +1,415 @@
<?php
namespace App\Services\Attendance;
use App\Models\Configuration;
use App\Models\StaffAttendance;
use App\Models\TeacherClass;
use App\Models\ClassSection;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\StreamedResponse;
class StaffAttendanceService
{
public function __construct(
protected Configuration $configuration,
protected StaffAttendance $staffAttendance,
protected TeacherClass $teacherClass,
protected ClassSection $classSection,
protected SemesterRangeService $semesterRangeService,
) {}
public function monthData(?string $semester, ?string $schoolYear): array
{
$semester = $semester ?: (string)$this->configuration->getConfig('semester');
$schoolYear = $schoolYear ?: (string)$this->configuration->getConfig('school_year');
[$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYear);
$semesterNorm = $this->semesterRangeService->normalizeSemester($semester);
if ($semesterNorm !== '') {
$semesterRange = $this->semesterRangeService->getSemesterRange($schoolYear, $semesterNorm);
if ($semesterRange) {
[$rangeStart, $rangeEnd] = $semesterRange;
}
}
$days = $this->semesterRangeService->buildSundayList($rangeStart, $rangeEnd);
$assignedBySection = $semesterNorm !== ''
? $this->teacherClass->assignedBySectionForTerm($semesterNorm, $schoolYear)
: $this->teacherClass->assignedBySectionForTerm($semester, $schoolYear);
$sectionCodes = array_keys($assignedBySection);
$labels = [];
if (!empty($sectionCodes)) {
$rows = $this->classSection->query()
->select(['class_section_id', 'class_section_name'])
->whereIn('class_section_id', $sectionCodes)
->get()
->toArray();
foreach ($rows as $row) {
$labels[(int)$row['class_section_id']] = trim((string)$row['class_section_name']) ?: 'Section #' . (int)$row['class_section_id'];
}
}
$teacherIds = [];
foreach ($assignedBySection as $rows) {
foreach ($rows as $row) {
$teacherIds[(int)$row['teacher_id']] = true;
}
}
$teacherIds = array_keys($teacherIds);
$statusByTeacher = [];
if (!empty($teacherIds) && !empty($days)) {
$query = DB::table('staff_attendance')
->select('user_id', 'date', 'status', 'reason')
->where('school_year', $schoolYear)
->whereIn('user_id', $teacherIds)
->whereIn('date', $days);
if ($semesterNorm !== '') {
$query->where('semester', $semesterNorm);
}
$rows = $query->get();
foreach ($rows as $row) {
$statusByTeacher[(int)$row->user_id][(string)$row->date] = [
'status' => strtolower((string)$row->status),
'reason' => $row->reason,
];
}
}
$noSchoolRows = DB::table('calendar_events')
->select('date')
->where('school_year', $schoolYear)
->where('no_school', 1)
->where('date', '>=', $rangeStart)
->where('date', '<=', $rangeEnd)
->get();
$noSchoolDays = [];
foreach ($noSchoolRows as $row) {
$noSchoolDays[] = (string)$row->date;
}
$sections = [];
foreach ($sectionCodes as $code) {
$teachersPayload = [];
foreach (($assignedBySection[$code] ?? []) as $row) {
$teacherId = (int)$row['teacher_id'];
$name = trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? '')) ?: ('User#' . $teacherId);
$position = strtolower((string)($row['position'] ?? 'main'));
$position = in_array($position, ['main', 'ta'], true) ? $position : 'main';
$cells = [];
$totals = ['p' => 0, 'a' => 0, 'l' => 0];
foreach ($days as $date) {
$cell = $statusByTeacher[$teacherId][$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']++;
}
}
$teachersPayload[] = [
'teacher_id' => $teacherId,
'name' => $name,
'position' => $position,
'cells' => $cells,
'totals' => $totals,
];
}
$sections[] = [
'section_code' => (int)$code,
'label' => $labels[$code] ?? ('Section #' . (int)$code),
'teachers' => $teachersPayload,
];
}
return [
'filters' => [
'semester' => $semester,
'school_year' => $schoolYear,
'range_start' => $rangeStart,
'range_end' => $rangeEnd,
],
'sundays' => $days,
'noSchoolDays' => $noSchoolDays,
'sections' => $sections,
'admins' => [],
];
}
public function adminsAttendanceData(?string $date, ?string $semester, ?string $schoolYear): array
{
$date = $date ?: now()->toDateString();
$semester = $semester ?: (string)$this->configuration->getConfig('semester');
$schoolYear = $schoolYear ?: (string)$this->configuration->getConfig('school_year');
$allStaff = $this->staffAttendance->staffWithStatusForDay($date, $semester, $schoolYear, []);
$userIds = [];
foreach ($allStaff as $row) {
$uid = (int)($row['user_id'] ?? 0);
if ($uid > 0) {
$userIds[$uid] = true;
}
}
$userIds = array_keys($userIds);
$rolesRows = DB::table('user_roles as ur')
->select('ur.user_id', 'r.id as role_id', 'r.name as role_name', 'r.slug as role_slug', 'r.priority')
->join('roles as r', 'r.id', '=', 'ur.role_id')
->where('r.is_active', 1)
->whereIn('ur.user_id', $userIds)
->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)));
}
$excludeSlugs = ['parent', 'guest', 'teacher', 'teacher_assistant', 'ta', 'assistant_teacher'];
$staffByUser = [];
foreach ($allStaff as $row) {
$uid = (int)($row['user_id'] ?? 0);
if ($uid > 0 && !isset($staffByUser[$uid])) {
$staffByUser[$uid] = $row;
}
}
$adminsGrid = [];
foreach ($userIds as $uid) {
$roles = $rolesByUser[$uid] ?? [];
$picked = null;
foreach ($roles as $role) {
$slug = strtolower((string)($role['role_slug'] ?? $role['role_name'] ?? ''));
if (!in_array($slug, $excludeSlugs, true)) {
$picked = $role;
break;
}
}
if (!$picked) {
continue;
}
$base = $staffByUser[$uid] ?? ['user_id' => $uid];
$base['role_name'] = $picked['role_name'] ?? 'Admin';
$base['role_slug'] = $picked['role_slug'] ?? null;
$adminsGrid[] = $base;
}
usort($adminsGrid, function ($a, $b) {
$an = trim(($a['firstname'] ?? '') . ' ' . ($a['lastname'] ?? ''));
$bn = trim(($b['firstname'] ?? '') . ' ' . ($b['lastname'] ?? ''));
return strcasecmp($an, $bn);
});
return [
'semester' => $semester,
'school_year' => $schoolYear,
'date' => $date,
'admins_grid' => $adminsGrid,
];
}
public function saveAdmins(Authenticatable $user, array $data): array
{
$date = (string)$data['date'];
$semester = (string)$data['semester'];
$schoolYear = (string)$data['school_year'];
$admins = (array)($data['admins'] ?? $data['staff'] ?? []);
$allowedStatuses = ['present', 'absent', 'late'];
$excludedRoleSlugs = ['parent', 'guest', 'teacher', 'teacher_assistant'];
DB::transaction(function () use ($admins, $date, $semester, $schoolYear, $allowedStatuses, $excludedRoleSlugs) {
foreach ($admins as $uid => $row) {
$uid = (int)$uid;
$status = strtolower(trim((string)($row['status'] ?? '')));
$reason = trim((string)($row['reason'] ?? ''));
$roleName = (string)($row['role_name'] ?? '');
$roleSlug = strtolower(str_replace(['-', ' '], '_', (string)($row['role_slug'] ?? $roleName)));
if (in_array($roleSlug, $excludedRoleSlugs, true)) {
continue;
}
if ($status === '') {
DB::table('staff_attendance')
->where('user_id', $uid)
->whereDate('date', $date)
->where('semester', $semester)
->where('school_year', $schoolYear)
->delete();
continue;
}
if (!in_array($status, $allowedStatuses, true)) {
continue;
}
DB::table('staff_attendance')->updateOrInsert(
[
'user_id' => $uid,
'date' => $date,
'semester' => $semester,
'school_year' => $schoolYear,
],
[
'role_name' => $roleName !== '' ? $roleName : 'Admin',
'status' => $status,
'reason' => $reason !== '' ? $reason : null,
'updated_at' => now(),
'created_at' => now(),
]
);
}
});
return [
'ok' => true,
'message' => 'Admins attendance saved successfully.',
];
}
public function saveCell(Authenticatable $user, array $data): array
{
$date = (string)$data['date'];
$status = (string)($data['status'] ?? '');
$targetId = (int)$data['user_id'];
$roleName = trim((string)($data['role_name'] ?? ''));
$semester = (string)$data['semester'];
$schoolYear = (string)$data['school_year'];
$reason = trim((string)($data['reason'] ?? ''));
if ($status === '') {
DB::table('staff_attendance')
->where('user_id', $targetId)
->whereDate('date', $date)
->where('semester', $semester)
->where('school_year', $schoolYear)
->delete();
return ['ok' => true];
}
DB::table('staff_attendance')->updateOrInsert(
[
'user_id' => $targetId,
'date' => $date,
'semester' => $semester,
'school_year' => $schoolYear,
],
[
'role_name' => $roleName !== '' ? $roleName : 'Admin',
'status' => $status,
'reason' => $reason !== '' ? $reason : null,
'updated_at' => now(),
'created_at' => now(),
]
);
return ['ok' => true];
}
public function monthCsv(?string $semester, ?string $schoolYear, ?string $scope): StreamedResponse
{
$semester = $semester ?: (string)$this->configuration->getConfig('semester');
$schoolYear = $schoolYear ?: (string)$this->configuration->getConfig('school_year');
$scope = strtolower((string)$scope);
[$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYear);
$semesterNorm = $this->semesterRangeService->normalizeSemester($semester);
if ($semesterNorm !== '') {
$semesterRange = $this->semesterRangeService->getSemesterRange($schoolYear, $semesterNorm);
if ($semesterRange) {
[$rangeStart, $rangeEnd] = $semesterRange;
}
}
$daysList = $this->semesterRangeService->buildSundayList($rangeStart, $rangeEnd);
$filename = ($scope === 'admins' ? 'admin_attendance_' : 'teacher_attendance_')
. $semester . '_' . str_replace('/', '-', $schoolYear) . '.csv';
return response()->streamDownload(function () use ($semester, $schoolYear, $scope, $daysList, $semesterNorm, $rangeStart, $rangeEnd) {
$out = fopen('php://output', 'w');
if ($scope === 'admins') {
fputcsv($out, ['User ID', 'Date', 'Status', 'Reason']);
$query = DB::table('staff_attendance')
->select('user_id', 'date', 'status', 'reason')
->where('school_year', $schoolYear)
->where('date', '>=', $rangeStart)
->where('date', '<=', $rangeEnd);
if ($semesterNorm !== '') {
$query->where('semester', $semesterNorm);
}
foreach ($query->orderBy('date')->get() as $row) {
fputcsv($out, [
$row->user_id,
$row->date,
strtoupper(substr((string)$row->status, 0, 1)),
$row->reason,
]);
}
fclose($out);
return;
}
fputcsv($out, ['Teacher ID', 'Date', 'Status', 'Reason']);
$query = DB::table('staff_attendance')
->select('user_id', 'date', 'status', 'reason')
->where('school_year', $schoolYear)
->where('date', '>=', $rangeStart)
->where('date', '<=', $rangeEnd);
if ($semesterNorm !== '') {
$query->where('semester', $semesterNorm);
}
foreach ($query->orderBy('date')->get() as $row) {
fputcsv($out, [
$row->user_id,
$row->date,
strtoupper(substr((string)$row->status, 0, 1)),
$row->reason,
]);
}
fclose($out);
}, $filename);
}
}
@@ -0,0 +1,181 @@
<?php
namespace App\Services\Attendance;
use App\Models\AttendanceData;
use App\Models\Student;
use RuntimeException;
class StudentAttendanceWriterService
{
public function __construct(
protected AttendanceData $attendanceData,
protected Student $student,
protected AttendanceRecordSyncService $attendanceRecordSyncService,
) {}
public function updateAttendanceData(
int $attendanceId,
string $newStatus,
?string $newReason = null,
?string $reported = null,
?int $userId = null
): void {
$payload = [
'status' => strtolower($newStatus),
'reason' => $newReason ?: null,
'modified_by' => $userId,
'updated_at' => now(),
];
if ($reported !== null) {
$payload['is_reported'] = in_array(strtolower($reported), ['yes', 'no'], true)
? strtolower($reported)
: 'no';
}
AttendanceData::query()->whereKey($attendanceId)->update($payload);
}
public function insertAttendanceData(
int $classSectionId,
int $studentId,
string $studentSchoolId,
string $status,
?string $reason,
string $reported,
string $semester,
string $schoolYear,
string $date,
int $classId,
?int $userId = null
): void {
AttendanceData::query()->create([
'class_id' => $classId,
'class_section_id' => $classSectionId,
'student_id' => $studentId,
'school_id' => $studentSchoolId,
'status' => strtolower($status),
'reason' => $reason ?: null,
'is_reported' => $reported,
'is_notified' => 'no',
'semester' => $semester,
'school_year' => $schoolYear,
'date' => $date,
'modified_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
]);
}
public function resolveStudentSchoolId(int $studentId, ?string $studentSchoolId = null): string
{
$studentSchoolId = trim((string)$studentSchoolId);
if ($studentSchoolId !== '') {
return $studentSchoolId;
}
$studentRow = $this->student->query()->select('school_id')->find($studentId);
if (!$studentRow || empty($studentRow->school_id)) {
throw new RuntimeException('Student school ID not found.');
}
return (string)$studentRow->school_id;
}
public function saveOrUpdateStudentAttendance(array $payload): array
{
$classSectionId = (int)$payload['class_section_id'];
$studentId = (int)$payload['student_id'];
$studentSchoolId = $this->resolveStudentSchoolId($studentId, $payload['school_id'] ?? null);
$status = strtolower((string)$payload['status']);
$reason = $payload['reason'] ?? null;
$reported = $payload['is_reported'] ?? null;
$semester = (string)$payload['semester'];
$schoolYear = (string)$payload['school_year'];
$date = (string)$payload['date'];
$classId = (int)$payload['class_id'];
$userId = $payload['user_id'] ?? null;
$existing = AttendanceData::query()
->where('student_id', $studentId)
->whereDate('date', $date)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
if ($existing) {
$oldStatus = $existing->status;
$this->updateAttendanceData(
(int)$existing->id,
$status,
$reason,
$reported,
$userId
);
if ($status !== $oldStatus) {
$this->attendanceRecordSyncService->updateAttendanceRecord(
$studentId,
$studentSchoolId,
$status,
$semester,
$schoolYear,
$oldStatus,
$userId
);
}
return [
'mode' => 'updated',
'attendance_id' => (int)$existing->id,
'old_status' => $oldStatus,
'new_status' => $status,
'school_id' => $studentSchoolId,
];
}
$this->insertAttendanceData(
$classSectionId,
$studentId,
$studentSchoolId,
$status,
$reason,
$reported ?? 'no',
$semester,
$schoolYear,
$date,
$classId,
$userId
);
$this->attendanceRecordSyncService->addNewAttendanceRecord(
$classSectionId,
$studentId,
$studentSchoolId,
$status,
$semester,
$schoolYear,
$userId
);
$created = AttendanceData::query()
->where('student_id', $studentId)
->whereDate('date', $date)
->where('semester', $semester)
->where('school_year', $schoolYear)
->latest('id')
->first();
return [
'mode' => 'created',
'attendance_id' => (int)($created->id ?? 0),
'old_status' => null,
'new_status' => $status,
'school_id' => $studentSchoolId,
];
}
}
@@ -0,0 +1,220 @@
<?php
namespace App\Services\Attendance;
use App\Libraries\AttendanceAutoPublish;
use App\Models\AttendanceData;
use App\Models\AttendanceDay;
use App\Models\Student;
use App\Models\TeacherClass;
use Carbon\Carbon;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class TeacherAttendanceSubmissionService
{
public function __construct(
protected AttendanceDay $attendanceDay,
protected AttendanceData $attendanceData,
protected TeacherClass $teacherClass,
protected Student $student,
protected AttendancePolicyService $attendancePolicyService,
protected StudentAttendanceWriterService $studentAttendanceWriterService,
) {}
public function submit(
Authenticatable $user,
array $data,
callable $detectExternalSubmission,
callable $currentSemester,
callable $currentSchoolYear
): array {
$rawSection = trim((string)$data['class_section_id']);
if ($rawSection === '') {
throw new RuntimeException('Missing or invalid class section.');
}
$sectionRow = DB::table('classSection')
->select('id', 'class_id', 'class_section_id')
->where('class_section_id', $rawSection)
->first();
if (!$sectionRow && ctype_digit($rawSection)) {
$sectionRow = DB::table('classSection')
->select('id', 'class_id', 'class_section_id')
->where('id', (int)$rawSection)
->first();
}
if (!$sectionRow) {
throw new RuntimeException('Invalid class section.');
}
$classSectionId = (int)$sectionRow->class_section_id;
$classId = (int)$sectionRow->class_id;
$attendanceData = $data['attendance'] ?? [];
$teachersData = $data['teachers'] ?? [];
if (empty($attendanceData) || !is_array($attendanceData)) {
throw new RuntimeException('No student attendance data provided.');
}
if (empty($teachersData) || !is_array($teachersData)) {
throw new RuntimeException('No teacher attendance data provided.');
}
$rawDate = $data['date'] ?? now()->toDateString();
$attendDate = Carbon::parse($rawDate)->toDateString();
$semester = (string)$currentSemester();
$schoolYear = (string)$currentSchoolYear();
$dayRow = AttendanceDay::query()
->where('class_section_id', $classSectionId)
->whereDate('date', $attendDate)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$currentUser = [
'id' => $user->id,
'roles' => method_exists($user, 'roles') ? $user->roles->pluck('name')->all() : [],
'permissions' => method_exists($user, 'permissions') ? $user->permissions->pluck('name')->all() : [],
];
$dayPolicyRow = $dayRow?->toArray() ?? [
'status' => 'draft',
'class_section_id' => $classSectionId,
'date' => $attendDate,
'semester' => $semester,
'school_year' => $schoolYear,
];
if (!$this->attendancePolicyService->canEditDay($dayPolicyRow, $currentUser)) {
throw new RuntimeException('Attendance is locked for your role.');
}
$existingRows = AttendanceData::query()
->where('class_section_id', $classSectionId)
->whereDate('date', $attendDate)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get();
$existingByStudent = [];
foreach ($existingRows as $row) {
$existingByStudent[(int)$row->student_id] = $row->toArray();
}
$allowedStatuses = ['present', 'absent', 'late'];
$assignedTeachers = $this->teacherClass->assignedForSectionTerm($classSectionId, $semester, $schoolYear);
foreach ($assignedTeachers as $teacher) {
$teacherId = (int)$teacher['teacher_id'];
$status = strtolower((string)($teachersData[$teacherId]['status'] ?? ''));
if (!in_array($status, $allowedStatuses, true)) {
throw new RuntimeException('Please fill teacher attendance for all assigned teachers.');
}
}
$isTeacher = $this->attendancePolicyService->isTeacher($currentUser['roles']);
DB::transaction(function () use (
$user,
$assignedTeachers,
$teachersData,
$attendanceData,
$existingByStudent,
$classSectionId,
$classId,
$attendDate,
$semester,
$schoolYear,
$isTeacher,
$detectExternalSubmission
) {
$dayRow = AttendanceDay::query()->firstOrCreate(
[
'class_section_id' => $classSectionId,
'date' => $attendDate,
'semester' => $semester,
'school_year' => $schoolYear,
],
[
'status' => 'draft',
'created_at' => now(),
'updated_at' => now(),
]
);
foreach ($assignedTeachers as $teacher) {
$teacherId = (int)$teacher['teacher_id'];
$position = strtolower((string)($teacher['position'] ?? 'main'));
$position = $position === 'ta' ? 'ta' : 'main';
DB::table('staff_attendance')->updateOrInsert(
[
'user_id' => $teacherId,
'date' => $attendDate,
'semester' => $semester,
'school_year' => $schoolYear,
],
[
'role_name' => 'Teacher',
'class_section_id' => $classSectionId,
'position' => $position,
'status' => strtolower((string)($teachersData[$teacherId]['status'] ?? 'present')),
'reason' => ($teachersData[$teacherId]['reason'] ?? null) ?: null,
'updated_at' => now(),
'created_at' => now(),
]
);
}
foreach ($attendanceData as $row) {
$studentId = (int)($row['student_id'] ?? 0);
$status = strtolower(trim((string)($row['status'] ?? '')));
$reason = trim((string)($row['reason'] ?? ''));
$existing = $existingByStudent[$studentId] ?? null;
if ($isTeacher && $existing) {
$lockInfo = $detectExternalSubmission($existing);
if (($lockInfo['locked'] ?? false) === true) {
continue;
}
}
$this->studentAttendanceWriterService->saveOrUpdateStudentAttendance([
'class_section_id' => $classSectionId,
'student_id' => $studentId,
'school_id' => $row['school_id'] ?? null,
'status' => $status,
'reason' => $reason,
'is_reported' => 'no',
'semester' => $semester,
'school_year' => $schoolYear,
'date' => $attendDate,
'class_id' => $classId,
'user_id' => $user->id,
]);
}
$autoPublishAt = AttendanceAutoPublish::secondSundayAfterEndOfDay($attendDate);
$dayRow->update([
'status' => 'submitted',
'submitted_by' => $user->id,
'submitted_at' => now(),
'auto_publish_at' => $autoPublishAt,
'updated_at' => now(),
]);
});
return [
'ok' => true,
'message' => 'Attendance submitted successfully.',
];
}
}
+18 -109
View File
@@ -2,140 +2,49 @@
namespace App\Services;
use App\Models\AttendanceCommentTemplate; // <-- change if your model class name differs
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\DB;
use App\Models\AttendanceCommentTemplate;
class AttendanceCommentTemplateService
{
public function list(bool $activeOnly = false): array
{
$query = AttendanceCommentTemplate::query()->orderBy('min_score', 'asc');
$query = AttendanceCommentTemplate::query();
if ($activeOnly) {
$query->where('is_active', 1);
}
return $query->get()
->map(fn ($row) => $this->transform($row))
->values()
->all();
return $query
->orderBy('min_score')
->get()
->toArray();
}
public function findOrFail(int $id): array
{
$template = AttendanceCommentTemplate::query()->find($id);
if (!$template) {
throw new ModelNotFoundException("Attendance comment template not found.");
}
return $this->transform($template);
return AttendanceCommentTemplate::query()
->findOrFail($id)
->toArray();
}
public function create(array $data): array
{
return DB::transaction(function () use ($data) {
$this->ensureNoOverlap(
(int) $data['min_score'],
(int) $data['max_score'],
null,
!empty($data['is_active'])
);
$template = AttendanceCommentTemplate::query()->create([
'min_score' => (int) $data['min_score'],
'max_score' => (int) $data['max_score'],
'template_text' => (string) $data['template_text'],
'is_active' => (bool) ($data['is_active'] ?? true),
]);
return $this->transform($template->fresh());
});
return AttendanceCommentTemplate::query()
->create($data)
->toArray();
}
public function update(int $id, array $data): array
{
return DB::transaction(function () use ($id, $data) {
$template = AttendanceCommentTemplate::query()->find($id);
$template = AttendanceCommentTemplate::query()->findOrFail($id);
$template->fill($data);
$template->save();
if (!$template) {
throw new ModelNotFoundException("Attendance comment template not found.");
}
$newMin = array_key_exists('min_score', $data) ? (int) $data['min_score'] : (int) $template->min_score;
$newMax = array_key_exists('max_score', $data) ? (int) $data['max_score'] : (int) $template->max_score;
$newIsActive = array_key_exists('is_active', $data) ? (bool) $data['is_active'] : (bool) $template->is_active;
$this->ensureNoOverlap($newMin, $newMax, $id, $newIsActive);
$template->fill([
'min_score' => $newMin,
'max_score' => $newMax,
'template_text' => array_key_exists('template_text', $data)
? (string) $data['template_text']
: (string) $template->template_text,
'is_active' => $newIsActive,
]);
$template->save();
return $this->transform($template->fresh());
});
return $template->toArray();
}
public function delete(int $id): void
{
$template = AttendanceCommentTemplate::query()->find($id);
if (!$template) {
throw new ModelNotFoundException("Attendance comment template not found.");
}
$template->delete();
AttendanceCommentTemplate::query()->whereKey($id)->delete();
}
/**
* Prevent overlapping active score ranges.
*/
private function ensureNoOverlap(int $min, int $max, ?int $ignoreId = null, bool $isActive = true): void
{
if (!$isActive) {
return; // allow inactive templates to overlap
}
$query = AttendanceCommentTemplate::query()
->where('is_active', 1)
->where(function ($q) use ($min, $max) {
$q->whereBetween('min_score', [$min, $max])
->orWhereBetween('max_score', [$min, $max])
->orWhere(function ($q2) use ($min, $max) {
$q2->where('min_score', '<=', $min)
->where('max_score', '>=', $max);
});
});
if ($ignoreId !== null) {
$query->where('id', '!=', $ignoreId);
}
if ($query->exists()) {
abort(response()->json([
'status' => 'error',
'message' => 'Score range overlaps with an existing active template.',
], 422));
}
}
private function transform($row): array
{
return [
'id' => (int) ($row->id ?? 0),
'min_score' => (int) ($row->min_score ?? 0),
'max_score' => (int) ($row->max_score ?? 0),
'template_text' => (string) ($row->template_text ?? ''),
'is_active' => (bool) ($row->is_active ?? false),
];
}
}
}
@@ -0,0 +1,339 @@
<?php
namespace App\Services\AttendanceTracking;
use App\Models\AttendanceData;
use App\Models\AttendanceTracking;
use App\Models\Student;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Throwable;
class AttendanceCaseQueryService
{
public function __construct(
protected Student $studentModel,
protected AttendanceData $attendanceDataModel,
protected AttendanceTracking $attendanceTrackingModel,
protected ViolationRuleEngineService $violationRuleEngine,
protected AttendanceParentLookupService $parentLookupService
) {
}
public function getStudentCase(
int $studentId,
?string $codeParam = null,
?string $incidentDate = null,
bool $includeNotified = false,
bool $pendingOnly = false,
?string $schoolYearFilter = null,
?string $semesterFilter = null,
?string $defaultSchoolYear = null,
?string $defaultSemester = null
): array {
$student = $this->studentModel->query()->find($studentId);
if (!$student) {
return [
'success' => false,
'message' => 'Student not found',
'status' => 404,
];
}
$student = $student->toArray();
$student['name'] = trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? ''));
$codeParam = strtoupper((string) ($codeParam ?? ''));
$incidentDate = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $incidentDate) ? (string) $incidentDate : '';
$statusFilter = ['absent', 'late'];
if (str_starts_with($codeParam, 'ABS')) {
$statusFilter = ['absent'];
} elseif (str_starts_with($codeParam, 'LATE')) {
$statusFilter = ['late'];
} elseif (str_starts_with($codeParam, 'MIX')) {
$statusFilter = ['absent', 'late'];
}
$schoolYear = $schoolYearFilter
?? ($incidentDate !== ''
? $this->violationRuleEngine->schoolYearForDate($incidentDate, $defaultSchoolYear)
: (string) $defaultSchoolYear);
$semester = $semesterFilter;
if ($semester === null && $codeParam === '' && $incidentDate === '') {
$semester = (string) $defaultSemester;
}
[$syStart, $syEnd] = $this->violationRuleEngine->deriveSchoolYearBounds($schoolYear ?: date('Y'));
$today = Carbon::today();
$start = Carbon::parse($syStart)->startOfDay();
$rangeEnd = Carbon::parse(min($syEnd, $today->format('Y-m-d')))->startOfDay();
$windowWeeks = $this->violationRuleEngine->windowWeeksForViolationCode($codeParam);
$endYmd = $incidentDate !== '' ? $incidentDate : date('Y-m-d');
$startYmd = $this->violationRuleEngine->activeWeekWindowStartYmd($endYmd, $windowWeeks, $schoolYear, $semester);
$studentIdValues = [(int) $studentId];
$studentCodeRaw = trim((string) ($student['student_code'] ?? $student['school_id'] ?? ''));
$studentCodeValues = [];
if ($studentCodeRaw !== '') {
if (is_numeric($studentCodeRaw)) {
$studentCodeNum = (int) $studentCodeRaw;
if ($studentCodeNum > 0 && $studentCodeNum !== (int) $studentId) {
$studentIdValues[] = $studentCodeNum;
}
} else {
$studentCodeValues[] = $studentCodeRaw;
}
}
$studentIdValues = array_values(array_unique(array_filter($studentIdValues, fn ($v) => $v > 0)));
$studentCodeValues = array_values(array_unique(array_filter($studentCodeValues, fn ($v) => $v !== '')));
$qb = $this->attendanceDataModel->query()
->select([
'id',
'student_id',
'class_id',
'class_section_id',
'date',
'status',
'reason',
'school_year',
'semester',
'is_reported',
'is_notified',
])
->where(function ($q) use ($studentIdValues, $studentCodeValues) {
$q->whereIn('student_id', $studentIdValues);
if (!empty($studentCodeValues)) {
$q->orWhereIn('student_id', $studentCodeValues);
}
})
->where('date', '>=', max($start->format('Y-m-d'), $startYmd))
->where('date', '<', $this->violationRuleEngine->dayBounds($endYmd)[1]);
if ($statusFilter === ['absent']) {
$qb->whereRaw("(LOWER(TRIM(status)) LIKE 'abs%' OR LOWER(TRIM(status)) = 'a')");
} elseif ($statusFilter === ['late']) {
$qb->whereRaw("(LOWER(TRIM(status)) LIKE 'late%' OR LOWER(TRIM(status)) = 'l')");
} else {
$qb->whereRaw("(LOWER(TRIM(status)) LIKE 'abs%' OR LOWER(TRIM(status)) LIKE 'late%' OR LOWER(TRIM(status)) IN ('a','l'))");
}
if ($pendingOnly) {
$this->violationRuleEngine->applyUnreportedAttendanceFilter($qb, $this->attendanceDataModel->getTable());
if (!$includeNotified) {
$qb->whereRaw("(LOWER(TRIM(COALESCE(is_notified, ''))) NOT IN ('yes','1'))");
}
}
if ($schoolYearFilter !== null) {
$qb->where('school_year', $schoolYearFilter);
}
if ($semesterFilter !== null) {
$qb->where('semester', $semesterFilter);
}
$attendanceRows = $qb->orderByDesc('date')->get()->toArray();
$data = [
'student' => $student,
'attendance' => $attendanceRows,
'violation_code' => $codeParam !== '' ? $codeParam : null,
'violation_date' => $incidentDate,
'violation_notified' => null,
'show_violation_summary' => true,
'school_year' => $schoolYear,
'semester' => $semester,
];
if ($data['violation_date'] === '' && !empty($data['attendance'][0]['date'])) {
$data['violation_date'] = substr((string) $data['attendance'][0]['date'], 0, 10);
}
if (!empty($data['attendance'][0]['is_notified'])) {
$data['violation_notified'] = $data['attendance'][0]['is_notified'];
}
if ($data['violation_date'] !== '') {
foreach ($attendanceRows as $row) {
$ymd = substr((string) ($row['date'] ?? ''), 0, 10);
if ($ymd === $data['violation_date']) {
$data['show_violation_summary'] = false;
break;
}
}
}
return [
'success' => true,
'data' => $data,
];
}
public function getNotifiedViolations(
?string $schoolYearParam = null,
?string $semesterParam = null,
?string $defaultSchoolYear = null,
?string $defaultSemester = null
): array {
$schoolYear = filled($schoolYearParam) ? (string) $schoolYearParam : (string) $defaultSchoolYear;
$semester = filled($semesterParam) ? (string) $semesterParam : (string) $defaultSemester;
$trackingData = $this->attendanceTrackingModel->query()
->where('school_year', $schoolYear)
->where('semester', $semester)
->where(function ($q) {
$q->where('is_notified', 1)
->orWhere('is_notified', '1')
->orWhereRaw("LOWER(TRIM(COALESCE(is_notified, ''))) = 'yes'");
})
->orderByDesc('date')
->get()
->toArray();
if (empty($trackingData)) {
$latest = $this->attendanceTrackingModel->query()
->select(['school_year', 'semester'])
->where(function ($q) {
$q->where('is_notified', 1)
->orWhere('is_notified', '1')
->orWhereRaw("LOWER(TRIM(COALESCE(is_notified, ''))) = 'yes'");
})
->orderByDesc('date')
->orderByDesc('id')
->first();
if ($latest) {
$schoolYear = (string) ($latest->school_year ?? $schoolYear);
$semester = (string) ($latest->semester ?? $semester);
$trackingData = $this->attendanceTrackingModel->query()
->where('school_year', $schoolYear)
->where('semester', $semester)
->where(function ($q) {
$q->where('is_notified', 1)
->orWhere('is_notified', '1')
->orWhereRaw("LOWER(TRIM(COALESCE(is_notified, ''))) = 'yes'");
})
->orderByDesc('date')
->get()
->toArray();
}
}
if (empty($trackingData)) {
return [
'students' => [],
'school_year' => $schoolYear,
'semester' => $semester,
'show_actions' => false,
];
}
$studentIds = array_unique(array_map(fn ($r) => (int) ($r['student_id'] ?? 0), $trackingData));
$students = $this->studentModel->query()
->whereIn('id', $studentIds)
->where('is_active', 1)
->get()
->toArray();
$activeIdSet = array_flip(array_map(
static fn ($row) => (int) ($row['id'] ?? 0),
$students
));
$trackingData = array_values(array_filter(
$trackingData,
static fn ($row) => isset($activeIdSet[(int) ($row['student_id'] ?? 0)])
));
$studentIds = array_keys($activeIdSet);
if (empty($trackingData)) {
return [
'students' => [],
'school_year' => $schoolYear,
'semester' => $semester,
'show_actions' => false,
];
}
$studentMap = [];
foreach ($students as $s) {
$studentMap[$s['id']] = $s;
}
$classByStudent = [];
try {
$rows = DB::table('student_class as sc')
->select([
'sc.student_id',
'sc.class_section_id',
'cs.class_section_name',
'cs.class_id',
'c.class_name',
])
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->leftJoin('classes as c', 'c.id', '=', 'cs.class_id')
->whereIn('sc.student_id', $studentIds)
->where('sc.school_year', $schoolYear)
->orderByDesc('sc.id')
->get();
foreach ($rows as $row) {
$sid = (int) ($row->student_id ?? 0);
if ($sid > 0 && !isset($classByStudent[$sid])) {
$classByStudent[$sid] = [
'class_section_id' => $row->class_section_id ?? null,
'class_section_name' => (string) ($row->class_section_name ?? ''),
'class_id' => $row->class_id ?? null,
'class_name' => (string) ($row->class_name ?? ''),
];
}
}
} catch (Throwable) {
}
$mergedData = [];
foreach ($trackingData as $row) {
$sid = (int) ($row['student_id'] ?? 0);
if (!isset($studentMap[$sid])) {
continue;
}
$merged = array_merge($studentMap[$sid], $row);
if (isset($classByStudent[$sid])) {
$merged['class_section_name'] = (string) ($classByStudent[$sid]['class_section_name'] ?? '');
$merged['class_name'] = (string) ($classByStudent[$sid]['class_name'] ?? '');
} elseif (!empty($merged['registration_grade'])) {
$merged['grade'] = (string) $merged['registration_grade'];
}
try {
$p = $this->parentLookupService->getPrimaryParentForStudent($sid);
if ($p) {
$merged['parent_name'] = $p['parent_name'] ?? '';
$merged['parent_email'] = $p['email'] ?? '';
$merged['parent_phone'] = $p['phone'] ?? '';
}
} catch (Throwable) {
}
$mergedData[] = $merged;
}
return [
'students' => $mergedData,
'school_year' => $schoolYear,
'semester' => $semester,
];
}
}
@@ -0,0 +1,103 @@
<?php
namespace App\Services\AttendanceTracking;
use App\Models\AttendanceData;
use App\Models\Configuration;
use App\Models\Student;
use Illuminate\Support\Facades\DB;
class AttendanceCommunicationSupportService
{
protected string $semester;
protected string $schoolYear;
public function __construct(
protected Student $studentModel,
protected AttendanceData $attendanceDataModel,
protected Configuration $configModel,
protected AttendanceParentLookupService $parentLookupService,
protected AttendanceEmailComposerService $emailComposerService
) {
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
}
public function compose(
int $studentId,
string $code = 'ABS_1',
string $variant = 'default',
?string $incidentDate = null
): array {
if ($studentId <= 0) {
return [
'success' => false,
'message' => 'Missing student_id.',
'status' => 422,
];
}
$student = $this->studentModel->query()->find($studentId)?->toArray() ?? [];
$parent = $this->parentLookupService->getPrimaryParentForStudent($studentId);
$secondary = $this->parentLookupService->getSecondaryParentForStudent($studentId, $this->schoolYear);
$result = $this->emailComposerService->composePreview(
$studentId,
$student,
$parent,
$secondary,
$code,
$variant,
$incidentDate
);
if (($result['success'] ?? false) && isset($result['data'])) {
$result['data']['semester'] = $this->semester;
$result['data']['school_year'] = $this->schoolYear;
}
return $result;
}
public function parentsInfo(int $studentId): array
{
return $this->parentLookupService->parentsInfo($studentId, $this->schoolYear);
}
public function getViolationDatesForStudent(int $studentId, string $code, ?string $incidentDate = null): array
{
$code = strtoupper(trim($code));
$statuses = ['absent', 'late'];
if (str_starts_with($code, 'ABS')) {
$statuses = ['absent'];
} elseif (str_starts_with($code, 'LATE')) {
$statuses = ['late'];
} elseif (str_starts_with($code, 'MIX')) {
$statuses = ['absent', 'late'];
}
if (method_exists($this->attendanceDataModel, 'getRecentUnreportedDates')) {
return $this->attendanceDataModel->getRecentUnreportedDates(
$studentId,
$statuses,
$incidentDate,
35,
$this->semester,
$this->schoolYear
);
}
return $this->attendanceDataModel->query()
->where('student_id', $studentId)
->whereIn(DB::raw('LOWER(TRIM(status))'), $statuses)
->when($incidentDate, fn ($q) => $q->where('date', '<=', $incidentDate))
->orderByDesc('date')
->limit(35)
->pluck('date')
->map(fn ($d) => substr((string) $d, 0, 10))
->unique()
->values()
->all();
}
}
@@ -0,0 +1,218 @@
<?php
namespace App\Services\AttendanceTracking;
use App\Models\AttendanceEmailTemplate;
use Illuminate\Support\Str;
class AttendanceEmailComposerService
{
public function __construct(
protected AttendanceEmailTemplate $attendanceEmailTemplateModel
) {
}
public function buildTemplateContext(array $violation, ?array $parent = null): array
{
$studentName = (string) ($violation['name'] ?? '');
$incident = (string) ($violation['last_date'] ?? date('Y-m-d'));
return [
'{{parent_name}}' => (string) ($parent['parent_name'] ?? 'Parent/Guardian'),
'{{student_name}}' => $studentName,
'{{incident_date}}' => $incident,
'{{school_phone}}' => '978-364-0219',
'{{voicemail_phone}}' => (string) ($parent['phone'] ?? '—'),
'{{class_time}}' => '10:00 AM',
];
}
public function renderTemplate(string $code, string $variant, array $context): ?array
{
$row = null;
if (method_exists($this->attendanceEmailTemplateModel, 'getTemplate')) {
$row = $this->attendanceEmailTemplateModel->getTemplate($code, $variant)
?: $this->attendanceEmailTemplateModel->getTemplate($code, 'default');
} else {
$row = $this->attendanceEmailTemplateModel->query()
->where('code', $code)
->where('variant', $variant)
->where('is_active', 1)
->first();
if (!$row) {
$row = $this->attendanceEmailTemplateModel->query()
->where('code', $code)
->where('variant', 'default')
->where('is_active', 1)
->first();
}
$row = $row?->toArray();
}
if (!$row) {
return null;
}
$subject = strtr($row['subject'], $context);
$body = strtr($row['body_html'], $context);
return [$subject, $body];
}
public function renderWithEmailLayout(string $subject, string $bodyHtml): string
{
return view('emails._wrap_layout', [
'title' => $subject,
'body_html' => $bodyHtml,
])->render();
}
public function pickVariant(string $code, ?string $globalVariantOverride = null): string
{
if (!empty($globalVariantOverride)) {
return $globalVariantOverride;
}
return match ($code) {
'ABS_2', 'ABS_2_IN3W' => 'no_answer',
default => 'default',
};
}
public function buildFallbackAutoEmail(string $code, array $violation, string $parentName = ''): array
{
$subject = match ($code) {
'ABS_1' => 'Attendance Notice: Unreported Absence',
'LATE_2' => 'Attendance Notice: Repeated Lateness',
default => 'Attendance Notice',
};
$studentName = (string) ($violation['name'] ?? '');
$date = (string) ($violation['last_date'] ?? date('Y-m-d'));
$body = "<p>Dear " . ($parentName ?: 'Parent/Guardian') . "</p>"
. "<p>We'd like to inform you about your student's recent attendance:</p>"
. "<ul>"
. "<li><strong>Student:</strong> {$studentName}</li>"
. "<li><strong>Issue:</strong> " . (string) ($violation['violation'] ?? '') . "</li>"
. "<li><strong>As of:</strong> {$date}</li>"
. "</ul>"
. "<p>If you have any questions, please contact the school office.</p>";
return [$subject, $body];
}
public function normalizeBodyToHtml(string $bodyInput): string
{
if ($bodyInput !== '' && preg_match('/<[^>]+>/', $bodyInput)) {
return $bodyInput;
}
if ($bodyInput !== '' && stripos($bodyInput, '&lt;') !== false) {
$decoded = html_entity_decode($bodyInput, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
if (preg_match('/<[^>]+>/', $decoded)) {
return $decoded;
}
}
return nl2br(e($bodyInput), false);
}
public function plainTextToHtml(string $text): string
{
$text = preg_replace("/\r\n?/", "\n", trim($text));
if ($text === '') {
return '<p></p>';
}
$escaped = htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$escaped = preg_replace_callback(
'#\bhttps?://[^\s<]+#i',
static function ($m) {
$url = $m[0];
$urlAttr = htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
return '<a href="' . $urlAttr . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>';
},
$escaped
);
$paragraphs = preg_split("/\n{2,}/", $escaped);
$paragraphs = array_map(static function ($p) {
$p = preg_replace("/\n/", "<br>", $p);
return '<p>' . $p . '</p>';
}, $paragraphs);
return implode("\n", $paragraphs);
}
public function renderAutoEmailFor(array $violation, ?array $parent = null): ?array
{
$code = (string) ($violation['violation_code'] ?? $violation['code'] ?? '');
$ctx = $this->buildTemplateContext($violation, $parent);
$tpl = $this->renderTemplate($code, 'default', $ctx);
if (!$tpl) {
return $this->buildFallbackAutoEmail($code, $violation, (string) ($parent['parent_name'] ?? ''));
}
return $tpl;
}
public function composePreview(
int $studentId,
array $student,
?array $primaryParent,
?array $secondaryParent,
string $code,
string $variant,
?string $incidentDate = null
): array {
$lastDate = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $incidentDate)
? $incidentDate
: date('Y-m-d');
$violation = [
'id' => $studentId,
'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
'last_date' => $lastDate,
'violation' => $code,
'violation_code' => $code,
];
$ctx = $this->buildTemplateContext($violation, $primaryParent);
$rendered = $this->renderTemplate($code, $variant, $ctx);
if (!$rendered) {
return [
'success' => false,
'message' => "Template not found for {$code} ({$variant}).",
'status' => 404,
];
}
[$subject, $body] = $rendered;
return [
'success' => true,
'data' => [
'student_id' => $studentId,
'student_name' => $violation['name'],
'parent_email' => (string) ($primaryParent['email'] ?? ''),
'parent_name' => (string) ($primaryParent['parent_name'] ?? ''),
'secondary_email' => (string) ($secondaryParent['email'] ?? ''),
'secondary_name' => (string) ($secondaryParent['parent_name'] ?? ''),
'code' => $code,
'variant' => $variant,
'subject' => $subject,
'body_html' => $body,
'incident_date' => $lastDate,
],
];
}
}
@@ -0,0 +1,10 @@
<?php
namespace App\Services\AttendanceTracking;
interface AttendanceMailerService
{
public function send(string $to, string $subject, string $html): bool;
public function queueAttendanceEvent(array $payload): void;
}
@@ -0,0 +1,101 @@
<?php
namespace App\Services\AttendanceTracking;
use App\Models\ParentNotification;
use Illuminate\Support\Facades\Log;
use Throwable;
class AttendanceNotificationLogService
{
public function __construct(
protected ParentNotification $notificationModel
) {
}
public function notificationAlreadySent(
int $studentId,
string $code,
string $ymd,
string $channel = 'email',
?string $to = null
): bool {
try {
if (method_exists($this->notificationModel, 'hasSent')) {
return (bool) $this->notificationModel->hasSent($studentId, $code, $ymd, $channel, $to);
}
$query = $this->notificationModel->query()
->where('student_id', $studentId)
->where('code', $code)
->where('incident_date', $ymd)
->where('channel', $channel);
if (!empty($to)) {
$query->where('to_address', $to);
}
return $query->exists();
} catch (Throwable $e) {
Log::debug('notificationAlreadySent(): ' . $e->getMessage());
return false;
}
}
public function logNotification(
int $studentId,
string $code,
string $ymd,
string $channel,
?string $to,
string $subject,
string $status,
string $response = '',
?string $semester = null,
?string $schoolYear = null
): void {
try {
$where = [
'student_id' => $studentId,
'code' => $code,
'incident_date' => $ymd,
'channel' => $channel,
];
if (!empty($to)) {
$where['to_address'] = $to;
}
$existing = $this->notificationModel->query()
->where($where)
->orderByDesc('id')
->first();
if ($existing && !empty($existing->id)) {
$existing->update([
'subject' => $subject,
'status' => $status,
'response' => $response,
'semester' => $semester,
'school_year' => $schoolYear,
'updated_at' => now(),
]);
} else {
$this->notificationModel->query()->create([
'student_id' => $studentId,
'code' => $code,
'incident_date' => $ymd,
'channel' => $channel,
'to_address' => $to,
'subject' => $subject,
'status' => $status,
'response' => $response,
'semester' => $semester,
'school_year' => $schoolYear,
]);
}
} catch (Throwable $e) {
Log::debug('logNotification(): ' . $e->getMessage());
}
}
}
@@ -0,0 +1,510 @@
<?php
namespace App\Services\AttendanceTracking;
use App\Models\AttendanceData;
use App\Models\AttendanceTracking;
use App\Models\Configuration;
use App\Models\Student;
use App\Models\StudentClass;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
class AttendanceNotificationWorkflowService
{
protected string $semester;
protected string $schoolYear;
public function __construct(
protected Student $studentModel,
protected StudentClass $studentClassModel,
protected AttendanceData $attendanceDataModel,
protected AttendanceTracking $attendanceTrackingModel,
protected Configuration $configModel,
protected AttendanceMailerService $attendanceMailerService,
protected ViolationRuleEngineService $violationRuleEngine,
protected AttendanceParentLookupService $parentLookupService,
protected AttendanceEmailComposerService $emailComposerService,
protected AttendanceNotificationLogService $notificationLogService
) {
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
}
public function record(array $data): array
{
$sid = (int) $data['student_id'];
$ymd = substr((string) $data['date'], 0, 10);
$semester = $data['semester'] ?? $this->semester;
$schoolYear = $data['school_year'] ?? $this->schoolYear;
$subject = $data['subject_type'] ?? 'Absent';
$student = $this->studentModel->query()->find($sid)?->toArray() ?? [];
$parentEmail = $data['parent_email'] ?? '';
$parentName = $data['parent_name'] ?? '';
if (!$parentEmail) {
$parent = $this->parentLookupService->getPrimaryParentForStudent($sid) ?? [];
$parentEmail = $parent['email'] ?? '';
$parentName = $parent['parent_name'] ?? '';
}
if (!$parentEmail) {
return [
'success' => false,
'message' => 'No parent email found for this student.',
'status' => 422,
];
}
[$start, $end] = $this->violationRuleEngine->dayBounds($ymd);
$row = $this->attendanceTrackingModel->query()
->where('student_id', $sid)
->where('semester', $semester)
->where('school_year', $schoolYear)
->where('date', '>=', $start)
->where('date', '<', $end)
->orderByDesc('date')
->first();
$row = $row?->toArray();
$consequence = null;
$reasonTxt = (string) ($row['reason'] ?? '');
if (preg_match('/\bABS_4\b|dismissal/i', $reasonTxt)) {
$consequence = 'dismissal';
} elseif (preg_match('/\bABS_3\b|final_warning/i', $reasonTxt)) {
$consequence = 'final_warning';
} elseif (preg_match('/\bABS_2\b|follow_up/i', $reasonTxt)) {
$consequence = 'follow_up';
}
$class = method_exists($this->studentClassModel, 'getClassSectionsByStudentId')
? $this->studentClassModel->getClassSectionsByStudentId($sid, $this->schoolYear)
: [];
$payload = [
'student_id' => $sid,
'student' => $student,
'parent' => ['email' => $parentEmail, 'name' => $parentName],
'semester' => $semester,
'school_year' => $schoolYear,
'class' => $class,
'now' => now()->toDateTimeString(),
'date' => $ymd,
'subject' => $subject,
'consequence' => $consequence ?: 'follow_up',
];
try {
$this->attendanceMailerService->queueAttendanceEvent($payload);
if ($row && !empty($row['id']) && method_exists($this->attendanceTrackingModel, 'markAsNotified')) {
$this->attendanceTrackingModel->markAsNotified((int) $row['id']);
}
return [
'success' => true,
'message' => 'Notification queued.',
'data' => $payload,
];
} catch (Throwable $e) {
Log::error('Attendance record queue failed: ' . $e->getMessage());
return [
'success' => false,
'message' => $e->getMessage(),
'status' => 500,
];
}
}
public function sendAutoEmails(array $violations, ?string $variantOverride = null): array
{
$toSend = array_values(array_filter($violations, fn ($v) => ($v['action'] ?? '') === 'auto_email'));
$sent = 0;
$skipped = 0;
$errors = 0;
$details = [];
foreach ($toSend as $v) {
$sid = (int) ($v['id'] ?? 0);
$code = (string) ($v['violation_code'] ?? $v['code'] ?? '');
$date = (string) ($v['last_date'] ?? '');
$to = trim((string) ($v['parent_email'] ?? ''));
$pname = (string) ($v['parent_name'] ?? '');
if ($sid <= 0 || !$code || !$date || !$to) {
$errors++;
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'missing data'];
continue;
}
if ($this->notificationLogService->notificationAlreadySent($sid, $code, $date, 'email', $to)) {
$skipped++;
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'duplicate'];
continue;
}
$parent = $this->parentLookupService->getPrimaryParentForStudent($sid);
$context = $this->emailComposerService->buildTemplateContext($v, $parent);
$variant = $this->emailComposerService->pickVariant($code, $variantOverride);
$tpl = $this->emailComposerService->renderTemplate($code, $variant, $context);
if (!$tpl) {
[$subject, $body] = $this->emailComposerService->buildFallbackAutoEmail($code, $v, $pname);
} else {
[$subject, $body] = $tpl;
}
$wrapped = $this->emailComposerService->renderWithEmailLayout($subject, $body);
$anyOk = false;
try {
$ok = $this->attendanceMailerService->send($to, $subject, $wrapped);
if ($ok) {
$anyOk = true;
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'sent'];
$this->notificationLogService->logNotification(
$sid,
$code,
$date,
'email',
$to,
$subject,
'sent',
'OK',
$this->semester,
$this->schoolYear
);
} else {
$errors++;
$this->notificationLogService->logNotification(
$sid,
$code,
$date,
'email',
$to,
$subject,
'failed',
'send returned false',
$this->semester,
$this->schoolYear
);
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'failed'];
}
} catch (Throwable $e) {
$errors++;
$this->notificationLogService->logNotification(
$sid,
$code,
$date,
'email',
$to,
$subject,
'failed',
$e->getMessage(),
$this->semester,
$this->schoolYear
);
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'error: ' . $e->getMessage()];
}
try {
$sec = $this->parentLookupService->getSecondaryParentForStudent($sid, $this->schoolYear);
$to2 = trim((string) ($sec['email'] ?? ''));
if ($to2 !== '' && strcasecmp($to2, $to) !== 0) {
if ($this->notificationLogService->notificationAlreadySent($sid, $code, $date, 'email', $to2)) {
$skipped++;
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'duplicate_cc'];
} else {
$ok2 = $this->attendanceMailerService->send($to2, $subject, $wrapped);
if ($ok2) {
$anyOk = true;
$this->notificationLogService->logNotification(
$sid,
$code,
$date,
'email',
$to2,
$subject,
'sent',
'OK',
$this->semester,
$this->schoolYear
);
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'cc_sent'];
} else {
$errors++;
$this->notificationLogService->logNotification(
$sid,
$code,
$date,
'email',
$to2,
$subject,
'failed',
'send returned false',
$this->semester,
$this->schoolYear
);
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'cc_failed'];
}
}
}
} catch (Throwable $e) {
$errors++;
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'cc_error: ' . $e->getMessage()];
}
if ($anyOk) {
$sent++;
if (method_exists($this->attendanceTrackingModel, 'markRuleAsNotified')) {
$this->attendanceTrackingModel->markRuleAsNotified(
$sid,
$code,
$date,
$this->semester,
$this->schoolYear
);
}
$allDates = [];
$absDates = array_map(fn ($d) => substr((string) $d, 0, 10), (array) ($v['absences'] ?? []));
$lateDates = array_map(fn ($d) => substr((string) $d, 0, 10), (array) ($v['lates'] ?? []));
if (!empty($absDates)) {
$allDates = array_merge($allDates, $absDates);
}
if (!empty($lateDates)) {
$allDates = array_merge($allDates, $lateDates);
}
if (empty($allDates)) {
$allDates[] = substr($date, 0, 10);
}
if (method_exists($this->attendanceDataModel, 'markReportedAndNotified')) {
$this->attendanceDataModel->markReportedAndNotified(
$sid,
$allDates,
$this->semester,
$this->schoolYear
);
}
}
}
return [
'success' => true,
'sent' => $sent,
'skipped' => $skipped,
'errors' => $errors,
'details' => $details,
];
}
public function sendEmailManual(array $data, array $violationDates = []): array
{
$sid = (int) $data['student_id'];
$to = trim((string) $data['to']);
$subject = (string) $data['subject'];
$bodyInput = (string) $data['body_html'];
$code = (string) $data['code'];
$variant = (string) ($data['variant'] ?? 'default');
$ymdRaw = (string) ($data['incident_date'] ?? '');
$ymd = $ymdRaw !== '' ? substr($ymdRaw, 0, 10) : '';
$safeHtmlBody = $this->emailComposerService->normalizeBodyToHtml($bodyInput);
$wrapped = $this->emailComposerService->renderWithEmailLayout($subject, $safeHtmlBody);
$semester = $this->semester ?: (string) ($this->configModel->getConfig('semester') ?? '');
$schoolYear = $this->schoolYear ?: (string) ($this->configModel->getConfig('school_year') ?? '');
try {
$anyOk = false;
$ok = $this->attendanceMailerService->send($to, $subject, $wrapped);
$this->notificationLogService->logNotification(
$sid,
$code,
$ymd,
'email',
$to,
$subject,
$ok ? 'sent' : 'failed',
$ok ? 'OK' : 'send returned false',
$semester,
$schoolYear
);
if ($ok) {
$anyOk = true;
}
$sec = $this->parentLookupService->getSecondaryParentForStudent($sid, $schoolYear);
$to2 = trim((string) ($sec['email'] ?? ''));
if ($to2 !== '' && strcasecmp($to2, $to) !== 0) {
if (!$this->notificationLogService->notificationAlreadySent($sid, $code, $ymd, 'email', $to2)) {
$ok2 = $this->attendanceMailerService->send($to2, $subject, $wrapped);
$this->notificationLogService->logNotification(
$sid,
$code,
$ymd,
'email',
$to2,
$subject,
$ok2 ? 'sent' : 'failed',
$ok2 ? 'OK' : 'send returned false',
$semester,
$schoolYear
);
if ($ok2) {
$anyOk = true;
}
}
}
if (!$anyOk) {
return [
'success' => false,
'message' => 'Failed to send email.',
'status' => 500,
];
}
if (method_exists($this->attendanceTrackingModel, 'markRuleAsNotified')) {
$this->attendanceTrackingModel->markRuleAsNotified(
$sid,
$code,
$ymd !== '' ? $ymd : date('Y-m-d'),
$semester,
$schoolYear
);
}
if (!empty($violationDates) && method_exists($this->attendanceDataModel, 'markReportedAndNotified')) {
$this->attendanceDataModel->markReportedAndNotified(
$sid,
$violationDates,
$semester,
$schoolYear
);
}
return [
'success' => true,
'message' => 'Email sent successfully.',
'data' => [
'student_id' => $sid,
'to' => $to,
'secondary_to' => $to2 ?? null,
'subject' => $subject,
'variant' => $variant,
'incident_date' => $ymd,
],
];
} catch (Throwable $e) {
$this->notificationLogService->logNotification(
$sid,
$code,
$ymd,
'email',
$to,
$subject,
'failed',
$e->getMessage(),
$semester,
$schoolYear
);
return [
'success' => false,
'message' => 'Error: ' . $e->getMessage(),
'status' => 500,
];
}
}
public function saveNotificationNote(array $data): array
{
$sid = (int) $data['student_id'];
$code = (string) $data['code'];
$note = trim((string) $data['note']);
$ymdRaw = (string) ($data['incident_date'] ?? '');
$ymd = $ymdRaw !== '' ? substr($ymdRaw, 0, 10) : '';
try {
$day = $ymd !== '' ? $ymd : date('Y-m-d');
[$start, $end] = $this->violationRuleEngine->dayBounds($day);
$row = $this->attendanceTrackingModel->query()
->where('student_id', $sid)
->where('semester', $this->semester)
->where('school_year', $this->schoolYear)
->where('date', '>=', $start)
->where('date', '<', $end)
->where('reason', 'like', '%' . $code . '%')
->orderByDesc('date')
->first();
if (!$row) {
$row = $this->attendanceTrackingModel->query()
->where('student_id', $sid)
->where('semester', $this->semester)
->where('school_year', $this->schoolYear)
->where('date', '>=', $start)
->where('date', '<', $end)
->orderByDesc('date')
->first();
}
if ($row && !empty($row->id)) {
DB::table($this->attendanceTrackingModel->getTable())
->where('id', (int) $row->id)
->update([
'notif_counter' => DB::raw('COALESCE(notif_counter,0)+1'),
'note' => $note,
'is_notified' => 1,
'updated_at' => now(),
]);
} else {
$this->attendanceTrackingModel->query()->create([
'student_id' => $sid,
'date' => $start,
'is_reported' => 0,
'reason' => $code,
'is_notified' => 1,
'notif_counter' => 1,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'note' => $note,
'created_at' => now(),
'updated_at' => now(),
]);
}
return [
'success' => true,
'message' => 'Note saved.',
];
} catch (Throwable $e) {
return [
'success' => false,
'message' => 'Failed to save note: ' . $e->getMessage(),
'status' => 500,
];
}
}
}
@@ -0,0 +1,161 @@
<?php
namespace App\Services\AttendanceTracking;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
class AttendanceParentLookupService
{
public function getPrimaryParentForStudent(int $studentId): ?array
{
$row = DB::table('students as s')
->selectRaw('u.id AS user_id, u.email, u.cellphone, CONCAT(u.firstname, " ", u.lastname) AS parent_name')
->leftJoin('users as u', 'u.id', '=', 's.parent_id')
->where('s.id', $studentId)
->first();
if (!$row || empty($row->user_id)) {
return null;
}
return [
'user_id' => (int) $row->user_id,
'email' => (string) ($row->email ?? ''),
'parent_name' => (string) ($row->parent_name ?? ''),
'phone' => $row->cellphone ?? null,
];
}
public function getSecondaryParentForStudent(int $studentId, ?string $fallbackSchoolYear = null): ?array
{
try {
$stu = DB::table('students')
->select('id', 'parent_id', 'school_year', 'semester')
->where('id', $studentId)
->first();
if (!$stu) {
return null;
}
$primaryId = (int) ($stu->parent_id ?? 0);
$schoolYear = (string) ($stu->school_year ?? $fallbackSchoolYear ?? '');
$pb = DB::table('parents')->where('firstparent_id', $primaryId);
if ($schoolYear !== '') {
$pb->where('school_year', $schoolYear);
}
$pRow = $pb->orderByDesc('updated_at')->first();
if (!$pRow) {
return null;
}
$secondId = (int) ($pRow->secondparent_id ?? 0);
if ($secondId > 0) {
$u2 = DB::table('users')
->select('id', 'firstname', 'lastname', 'email', 'cellphone')
->where('id', $secondId)
->first();
if ($u2 && !empty($u2->email)) {
return [
'user_id' => (int) $u2->id,
'email' => (string) $u2->email,
'parent_name' => trim(($u2->firstname ?? '') . ' ' . ($u2->lastname ?? '')),
'phone' => (string) ($u2->cellphone ?? ''),
];
}
}
$fallbackEmail = (string) ($pRow->secondparent_email ?? '');
$fallbackPhone = (string) ($pRow->secondparent_phone ?? '');
$fallbackFirst = (string) ($pRow->secondparent_firstname ?? '');
$fallbackLast = (string) ($pRow->secondparent_lastname ?? '');
if ($fallbackEmail || $fallbackPhone || $fallbackFirst || $fallbackLast) {
return [
'user_id' => $secondId ?: null,
'email' => $fallbackEmail,
'parent_name' => trim($fallbackFirst . ' ' . $fallbackLast) ?: null,
'phone' => $fallbackPhone,
];
}
} catch (Throwable $e) {
Log::error('getSecondaryParentForStudent() failed: ' . $e->getMessage());
}
return null;
}
public function parentsInfo(int $studentId, ?string $currentSchoolYear = null): array
{
if ($studentId <= 0) {
return [
'success' => false,
'message' => 'Missing student_id',
'status' => 422,
];
}
try {
$stu = DB::table('students')
->select('id', 'parent_id', 'school_year', 'semester')
->where('id', $studentId)
->first();
if (!$stu) {
return [
'success' => false,
'message' => 'Student not found',
'status' => 404,
];
}
$primaryId = (int) $stu->parent_id;
$schoolYear = $stu->school_year ?: $currentSchoolYear;
$u1 = DB::table('users')
->select('id', 'firstname', 'lastname', 'email', 'cellphone')
->where('id', $primaryId)
->first();
$primary = $u1 ? [
'id' => (int) $u1->id,
'firstname' => (string) $u1->firstname,
'lastname' => (string) $u1->lastname,
'email' => (string) $u1->email,
'cellphone' => (string) $u1->cellphone,
] : null;
$secondaryRaw = $this->getSecondaryParentForStudent($studentId, $schoolYear);
$secondary = $secondaryRaw ? [
'id' => $secondaryRaw['user_id'] ?? null,
'firstname' => null,
'lastname' => null,
'email' => $secondaryRaw['email'] ?? '',
'cellphone' => $secondaryRaw['phone'] ?? '',
'name' => $secondaryRaw['parent_name'] ?? '',
] : null;
return [
'success' => true,
'data' => [
'primary' => $primary,
'secondary' => $secondary,
],
];
} catch (Throwable $e) {
Log::error('parentsInfo() failed: ' . $e->getMessage());
return [
'success' => false,
'message' => 'Server error',
'status' => 500,
];
}
}
}
@@ -0,0 +1,257 @@
<?php
namespace App\Services\AttendanceTracking;
use App\Models\AttendanceData;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Throwable;
class AttendancePendingViolationService
{
public function __construct(
protected AttendanceData $attendanceDataModel,
protected ViolationRuleEngineService $violationRuleEngine,
protected AttendanceViolationStudentResolverService $studentResolverService
) {
}
public function getPendingViolations(
string $defaultSchoolYear,
?string $schoolYearParam = null,
?string $semesterParam = null
): array {
$schoolYear = filled($schoolYearParam) ? (string) $schoolYearParam : (string) $defaultSchoolYear;
$semester = filled($semesterParam) ? (string) $semesterParam : null;
$debugInfo = [
'school_year_param' => $schoolYear,
'semester_param' => $semester,
'class_students' => 0,
'student_ids' => 0,
'attendance_rows' => 0,
'filtered_rows' => 0,
'violations' => 0,
'active_weeks' => 0,
'abs_last5' => 0,
'late_last5' => 0,
];
$resolved = $this->studentResolverService->resolveForSchoolYear($schoolYear, $semester);
$schoolYear = (string) ($resolved['school_year'] ?? $schoolYear);
$semester = $resolved['semester'] ?? $semester;
$studentIds = $resolved['student_ids'] ?? [];
$studentCodes = $resolved['student_codes'] ?? [];
$students = $resolved['students'] ?? [];
$studentCodeToId = $resolved['student_code_to_id'] ?? [];
$existingIds = $resolved['existing_ids'] ?? [];
$resolverDebug = $resolved['debug'] ?? [];
$debugInfo['class_students'] = $resolverDebug['class_students'] ?? 0;
$debugInfo['student_ids'] = $resolverDebug['student_ids'] ?? 0;
if (empty($studentIds) && empty($studentCodes)) {
return [
'students' => [],
'school_year' => $schoolYear,
'semester' => $semester,
'error' => 'No student identifiers found for the current school year.',
'debug' => $debugInfo,
];
}
if (empty($students)) {
return [
'students' => [],
'school_year' => $schoolYear,
'semester' => $semester,
'error' => 'No students found matching attendance records.',
'debug' => $debugInfo,
];
}
$today = Carbon::today();
[$syStart, $syEnd] = $this->violationRuleEngine->deriveSchoolYearBounds($schoolYear);
$start = Carbon::parse($syStart)->startOfDay();
$rangeEnd = Carbon::parse(min($syEnd, $today->format('Y-m-d')))->startOfDay();
$endEx = $rangeEnd->copy()->addDay();
$qb = $this->attendanceDataModel->query()
->select([
'id',
'student_id',
'class_id',
'class_section_id',
'date',
'status',
'reason',
'school_year',
'semester',
'is_reported',
'is_notified',
])
->when(!empty($schoolYear), fn ($q) => $q->where('school_year', $schoolYear))
->when(!empty($semester), fn ($q) => $q->where('semester', $semester))
->where(function ($q) use ($studentIds, $studentCodes) {
if (!empty($studentIds)) {
$q->whereIn('student_id', $studentIds);
}
if (!empty($studentCodes)) {
if (!empty($studentIds)) {
$q->orWhereIn('student_id', $studentCodes);
} else {
$q->whereIn('student_id', $studentCodes);
}
}
});
$this->violationRuleEngine->applyUnreportedAttendanceFilter($qb, $this->attendanceDataModel->getTable());
$qb->whereRaw("(LOWER(TRIM(COALESCE(is_notified, ''))) NOT IN ('yes','1'))")
->whereRaw("LOWER(TRIM(status)) IN ('absent','late')")
->where('date', '>=', $start->format('Y-m-d'))
->where('date', '<', $endEx->format('Y-m-d'));
$termRows = $qb->orderByDesc('date')->get()->toArray();
if (empty($termRows)) {
$latestAttendanceTerm = DB::table($this->attendanceDataModel->getTable())
->select('school_year', 'semester', 'date')
->when(!empty($studentIds), fn ($q) => $q->whereIn('student_id', $studentIds))
->orderByDesc('date')
->first();
if ($latestAttendanceTerm) {
$schoolYear = !empty($latestAttendanceTerm->school_year)
? (string) $latestAttendanceTerm->school_year
: $schoolYear;
$semester = !empty($latestAttendanceTerm->semester)
? (string) $latestAttendanceTerm->semester
: $semester;
[$syStart, $syEnd] = $this->violationRuleEngine->deriveSchoolYearBounds($schoolYear ?: $defaultSchoolYear);
$start = Carbon::parse($syStart)->startOfDay();
$rangeEnd = Carbon::parse(min($syEnd, $today->format('Y-m-d')))->startOfDay();
$endEx = $rangeEnd->copy()->addDay();
$qb = $this->attendanceDataModel->query()
->select([
'id',
'student_id',
'class_id',
'class_section_id',
'date',
'status',
'reason',
'school_year',
'semester',
'is_reported',
'is_notified',
])
->when(!empty($schoolYear), fn ($q) => $q->where('school_year', $schoolYear))
->when(!empty($semester), fn ($q) => $q->where('semester', $semester))
->where(function ($q) use ($studentIds, $studentCodes) {
if (!empty($studentIds)) {
$q->whereIn('student_id', $studentIds);
}
if (!empty($studentCodes)) {
if (!empty($studentIds)) {
$q->orWhereIn('student_id', $studentCodes);
} else {
$q->whereIn('student_id', $studentCodes);
}
}
});
$this->violationRuleEngine->applyUnreportedAttendanceFilter($qb, $this->attendanceDataModel->getTable());
$qb->whereRaw("(LOWER(TRIM(COALESCE(is_notified, ''))) NOT IN ('yes','1'))")
->whereRaw("LOWER(TRIM(status)) IN ('absent','late')")
->where('date', '>=', $start->format('Y-m-d'))
->where('date', '<', $endEx->format('Y-m-d'));
$termRows = $qb->orderByDesc('date')->get()->toArray();
}
if (empty($termRows)) {
return [
'students' => [],
'school_year' => $schoolYear,
'semester' => $semester,
'debug' => $debugInfo,
];
}
}
$attendanceRows = [];
foreach ($termRows as $row) {
$dStr = $row['date'] ?? null;
if (!$dStr) {
continue;
}
try {
$d = Carbon::parse($dStr);
} catch (Throwable) {
continue;
}
if ($d->lt($start) || !$d->lt($endEx)) {
continue;
}
$sid = $this->studentResolverService->ensureAttendanceStudentExists(
$students,
$studentCodeToId,
$existingIds,
$row['student_id'] ?? null
);
if ($sid === null || $sid <= 0) {
continue;
}
$row['student_id'] = $sid;
$row['status'] = strtolower(trim((string) ($row['status'] ?? '')));
$row['date'] = $d->format('Y-m-d');
$attendanceRows[] = $row;
}
if (empty($attendanceRows)) {
return [
'students' => [],
'school_year' => $schoolYear,
'semester' => $semester,
'debug' => $debugInfo,
];
}
$debugInfo['attendance_rows'] = count($termRows);
$debugInfo['filtered_rows'] = count($attendanceRows);
$violations = $this->violationRuleEngine->computeViolations(
$students,
$attendanceRows,
$schoolYear,
null,
$attendanceRows
);
$ruleDebug = $this->violationRuleEngine->getDebugCompute();
$debugInfo['violations'] = count($violations);
$debugInfo['active_weeks'] = $ruleDebug['active_weeks'] ?? 0;
$debugInfo['by_student'] = $ruleDebug['by_student'] ?? 0;
$debugInfo['abs_last5'] = $ruleDebug['abs_last5'] ?? 0;
$debugInfo['late_last5'] = $ruleDebug['late_last5'] ?? 0;
return [
'students' => $violations,
'school_year' => $schoolYear,
'semester' => $semester,
'debug' => $debugInfo,
];
}
}
@@ -0,0 +1,180 @@
<?php
namespace App\Services\AttendanceTracking;
use App\Models\AttendanceData;
use App\Models\AttendanceTracking;
use App\Models\Configuration;
use App\Models\Student;
use App\Models\StudentClass;
use Carbon\Carbon;
class AttendanceTrackingService
{
protected string $semester;
protected string $schoolYear;
public function __construct(
protected Student $studentModel,
protected StudentClass $studentClassModel,
protected AttendanceData $attendanceDataModel,
protected AttendanceTracking $attendanceTrackingModel,
protected Configuration $configModel,
protected AttendanceMailerService $attendanceMailerService,
protected ViolationRuleEngineService $violationRuleEngine,
protected AttendanceParentLookupService $parentLookupService,
protected AttendanceEmailComposerService $emailComposerService,
protected AttendanceNotificationLogService $notificationLogService,
protected AttendanceViolationStudentResolverService $studentResolverService,
protected AttendanceCaseQueryService $attendanceCaseQueryService,
protected AttendanceNotificationWorkflowService $attendanceNotificationWorkflowService,
protected AttendanceCommunicationSupportService $attendanceCommunicationSupportService,
protected AttendancePendingViolationService $attendancePendingViolationService
) {
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
}
public function getPendingViolations(?string $schoolYearParam = null, ?string $semesterParam = null): array
{
return $this->attendancePendingViolationService->getPendingViolations(
$this->schoolYear,
$schoolYearParam,
$semesterParam
);
}
public function getStudentCase(
int $studentId,
?string $codeParam = null,
?string $incidentDate = null,
bool $includeNotified = false,
bool $pendingOnly = false,
?string $schoolYearFilter = null,
?string $semesterFilter = null
): array {
return $this->attendanceCaseQueryService->getStudentCase(
$studentId,
$codeParam,
$incidentDate,
$includeNotified,
$pendingOnly,
$schoolYearFilter,
$semesterFilter,
$this->schoolYear,
$this->semester
);
}
public function getNotifiedViolations(?string $schoolYearParam = null, ?string $semesterParam = null): array
{
return $this->attendanceCaseQueryService->getNotifiedViolations(
$schoolYearParam,
$semesterParam,
$this->schoolYear,
$this->semester
);
}
public function compose(
int $studentId,
string $code = 'ABS_1',
string $variant = 'default',
?string $incidentDate = null
): array {
return $this->attendanceCommunicationSupportService->compose(
$studentId,
$code,
$variant,
$incidentDate
);
}
public function parentsInfo(int $studentId): array
{
return $this->attendanceCommunicationSupportService->parentsInfo($studentId);
}
public function record(array $data): array
{
return $this->attendanceNotificationWorkflowService->record($data);
}
public function sendAutoEmails(?string $variantOverride = null): array
{
$classStudentsQuery = $this->studentClassModel->query();
if (method_exists($this->studentClassModel, 'scopeActive')) {
$classStudentsQuery->active();
}
$classStudents = $classStudentsQuery
->where('school_year', $this->schoolYear)
->get()
->toArray();
if (empty($classStudents)) {
return [
'success' => true,
'sent' => 0,
'skipped' => 0,
'errors' => 0,
'details' => [],
];
}
$studentIds = array_column($classStudents, 'student_id');
$students = $this->studentModel->query()
->whereIn('id', $studentIds)
->where('is_active', 1)
->get()
->toArray();
$attendanceQuery = $this->attendanceDataModel->query()
->whereIn('student_id', $studentIds)
->where('school_year', $this->schoolYear)
->where('date', '>=', Carbon::today()->subWeeks(5)->format('Y-m-d'));
$this->violationRuleEngine->applyUnreportedAttendanceFilter(
$attendanceQuery,
$this->attendanceDataModel->getTable()
);
$attendanceData = $attendanceQuery
->whereRaw("LOWER(TRIM(status)) IN ('absent','late')")
->orderByDesc('date')
->get()
->toArray();
$violations = $this->violationRuleEngine->computeViolations(
$students,
$attendanceData,
$this->schoolYear,
null,
$attendanceData
);
return $this->attendanceNotificationWorkflowService->sendAutoEmails($violations, $variantOverride);
}
public function sendEmailManual(array $data): array
{
$sid = (int) $data['student_id'];
$code = (string) $data['code'];
$ymdRaw = (string) ($data['incident_date'] ?? '');
$ymd = $ymdRaw !== '' ? substr($ymdRaw, 0, 10) : '';
$violationDates = $this->attendanceCommunicationSupportService->getViolationDatesForStudent(
$sid,
$code,
$ymd
);
return $this->attendanceNotificationWorkflowService->sendEmailManual($data, $violationDates);
}
public function saveNotificationNote(array $data): array
{
return $this->attendanceNotificationWorkflowService->saveNotificationNote($data);
}
}
@@ -0,0 +1,371 @@
<?php
namespace App\Services\AttendanceTracking;
use App\Models\AttendanceData;
use App\Models\Student;
use App\Models\StudentClass;
use Illuminate\Support\Facades\DB;
class AttendanceViolationStudentResolverService
{
public function __construct(
protected Student $studentModel,
protected StudentClass $studentClassModel,
protected AttendanceData $attendanceDataModel
) {
}
public function resolveForSchoolYear(string $schoolYear, ?string $semester = null): array
{
$debug = [
'class_students' => 0,
'student_ids' => 0,
];
$classStudentsQuery = $this->studentClassModel->query();
if (method_exists($this->studentClassModel, 'scopeActive')) {
$classStudentsQuery->active();
}
$classStudents = $classStudentsQuery
->where('school_year', $schoolYear)
->get()
->toArray();
$debug['class_students'] = count($classStudents);
if (empty($classStudents)) {
$latestTerm = DB::table('student_class')
->select('school_year', 'semester')
->orderByDesc('id')
->first();
if ($latestTerm && !empty($latestTerm->school_year)) {
$schoolYear = (string) $latestTerm->school_year;
if (!empty($latestTerm->semester)) {
$semester = (string) $latestTerm->semester;
}
$classStudentsQuery = $this->studentClassModel->query();
if (method_exists($this->studentClassModel, 'scopeActive')) {
$classStudentsQuery->active();
}
$classStudents = $classStudentsQuery
->where('school_year', $schoolYear)
->get()
->toArray();
$debug['class_students'] = count($classStudents);
}
}
[$studentIds, $studentCodes] = $this->extractStudentIdentifiers($classStudents);
if (empty($studentIds) && empty($studentCodes)) {
[$studentIds, $studentCodes] = $this->deriveIdentifiersFromAttendanceData($schoolYear);
}
[$studentIds, $studentCodes] = $this->filterInactiveIdentifiers($studentIds, $studentCodes);
$debug['student_ids'] = count($studentIds) + count($studentCodes);
if (empty($studentIds) && empty($studentCodes)) {
return [
'school_year' => $schoolYear,
'semester' => $semester,
'student_ids' => [],
'student_codes' => [],
'students' => [],
'student_code_to_id'=> [],
'existing_ids' => [],
'debug' => $debug,
];
}
$students = $this->loadActiveStudents($studentIds, $studentCodes);
$students = $this->addPlaceholderStudentsForMissingCodes($students, $studentCodes);
$students = $this->ensureEntriesForAllIdentifiers($students, $studentIds, $studentCodes);
[$studentCodeToId, $existingIds] = $this->buildLookupMaps($students);
return [
'school_year' => $schoolYear,
'semester' => $semester,
'student_ids' => $studentIds,
'student_codes' => $studentCodes,
'students' => $students,
'student_code_to_id' => $studentCodeToId,
'existing_ids' => $existingIds,
'debug' => $debug,
];
}
public function ensureAttendanceStudentExists(
array &$students,
array &$studentCodeToId,
array &$existingIds,
mixed $rawStudentId
): ?int {
if (is_numeric($rawStudentId)) {
$sid = (int) $rawStudentId;
if ($sid <= 0) {
return null;
}
if (!isset($existingIds[$sid])) {
$students[] = [
'id' => $sid,
'school_id' => (string) $sid,
'firstname' => (string) $sid,
'lastname' => '',
'parent_id' => 0,
];
$existingIds[$sid] = true;
}
return $sid;
}
if (is_string($rawStudentId)) {
$code = trim($rawStudentId);
if ($code === '') {
return null;
}
$sid = $studentCodeToId[$code] ?? null;
if ($sid === null) {
$sid = abs(crc32($code)) ?: random_int(3000000, 3999999);
$studentCodeToId[$code] = $sid;
if (!isset($existingIds[$sid])) {
$students[] = [
'id' => $sid,
'school_id' => $code,
'firstname' => $code,
'lastname' => '',
'parent_id' => 0,
];
$existingIds[$sid] = true;
}
}
return $sid;
}
return null;
}
protected function extractStudentIdentifiers(array $classStudents): array
{
$studentIds = [];
$studentCodes = [];
foreach ($classStudents as $row) {
$raw = $row['student_id'] ?? null;
if (is_numeric($raw)) {
$studentIds[] = (int) $raw;
} elseif (is_string($raw) && $raw !== '') {
$studentCodes[] = trim($raw);
}
}
$studentIds = array_values(array_unique(array_filter($studentIds, fn ($v) => $v > 0)));
$studentCodes = array_values(array_unique(array_filter($studentCodes, fn ($v) => $v !== '')));
return [$studentIds, $studentCodes];
}
protected function deriveIdentifiersFromAttendanceData(string $schoolYear): array
{
$studentIds = [];
$studentCodes = [];
$attendanceStudents = DB::table($this->attendanceDataModel->getTable())
->selectRaw('DISTINCT student_id')
->when(!empty($schoolYear), fn ($q) => $q->where('school_year', $schoolYear))
->orderBy('student_id')
->get();
foreach ($attendanceStudents as $r) {
$raw = $r->student_id ?? null;
if (is_numeric($raw)) {
$studentIds[] = (int) $raw;
} elseif (is_string($raw) && $raw !== '') {
$studentCodes[] = trim($raw);
}
}
$studentIds = array_values(array_unique(array_filter($studentIds, fn ($v) => $v > 0)));
$studentCodes = array_values(array_unique(array_filter($studentCodes, fn ($v) => $v !== '')));
return [$studentIds, $studentCodes];
}
protected function filterInactiveIdentifiers(array $studentIds, array $studentCodes): array
{
if (!empty($studentIds)) {
$inactiveRows = $this->studentModel->query()
->select('id')
->whereIn('id', $studentIds)
->where('is_active', 0)
->get()
->toArray();
$inactiveIdSet = array_flip(array_map(
static fn ($r) => (int) ($r['id'] ?? 0),
$inactiveRows
));
$studentIds = array_values(array_filter(
$studentIds,
static fn ($id) => !isset($inactiveIdSet[$id])
));
}
if (!empty($studentCodes)) {
$inactiveCodeRows = $this->studentModel->query()
->select('school_id')
->whereIn('school_id', $studentCodes)
->where('is_active', 0)
->get()
->toArray();
$inactiveCodeSet = array_flip(array_map(
static fn ($r) => (string) ($r['school_id'] ?? ''),
$inactiveCodeRows
));
$studentCodes = array_values(array_filter(
$studentCodes,
static fn ($code) => $code !== '' && !isset($inactiveCodeSet[$code])
));
}
return [$studentIds, $studentCodes];
}
protected function loadActiveStudents(array $studentIds, array $studentCodes): array
{
$students = [];
if (!empty($studentIds)) {
$students = $this->studentModel->query()
->whereIn('id', $studentIds)
->where('is_active', 1)
->get()
->toArray();
}
if (!empty($studentCodes)) {
$byCode = $this->studentModel->query()
->whereIn('school_id', $studentCodes)
->where('is_active', 1)
->get()
->toArray();
$seen = array_flip(array_map(fn ($s) => (int) ($s['id'] ?? 0), $students));
foreach ($byCode as $s) {
$sid = (int) ($s['id'] ?? 0);
if ($sid > 0 && !isset($seen[$sid])) {
$students[] = $s;
$seen[$sid] = true;
}
}
}
return $students;
}
protected function addPlaceholderStudentsForMissingCodes(array $students, array $studentCodes): array
{
$knownCodes = [];
foreach ($students as $s) {
$code = trim((string) ($s['school_id'] ?? ''));
if ($code !== '') {
$knownCodes[$code] = true;
}
}
foreach ($studentCodes as $code) {
if (!isset($knownCodes[$code])) {
$virtualId = abs(crc32($code)) ?: random_int(1000000, 1999999);
$students[] = [
'id' => $virtualId,
'school_id' => $code,
'firstname' => $code,
'lastname' => '',
'parent_id' => 0,
'class_name' => '',
];
}
}
return $students;
}
protected function ensureEntriesForAllIdentifiers(array $students, array $studentIds, array $studentCodes): array
{
[$studentCodeToId, $existingIds] = $this->buildLookupMaps($students);
foreach (array_merge($studentIds, $studentCodes) as $sidOrCode) {
if (is_numeric($sidOrCode)) {
$sid = (int) $sidOrCode;
if ($sid > 0 && !isset($existingIds[$sid])) {
$students[] = [
'id' => $sid,
'school_id' => (string) $sid,
'firstname' => (string) $sid,
'lastname' => '',
'parent_id' => 0,
'class_name' => '',
];
$existingIds[$sid] = true;
}
} else {
$code = (string) $sidOrCode;
if ($code !== '' && !isset($studentCodeToId[$code])) {
$virtualId = abs(crc32($code)) ?: random_int(2000000, 2999999);
$students[] = [
'id' => $virtualId,
'school_id' => $code,
'firstname' => $code,
'lastname' => '',
'parent_id' => 0,
'class_name' => '',
];
$studentCodeToId[$code] = $virtualId;
$existingIds[$virtualId] = true;
}
}
}
return $students;
}
protected function buildLookupMaps(array $students): array
{
$studentCodeToId = [];
foreach ($students as $stu) {
$code = trim((string) ($stu['student_code'] ?? $stu['school_id'] ?? ''));
if ($code !== '') {
$studentCodeToId[$code] = (int) ($stu['id'] ?? 0);
}
}
$existingIds = array_flip(array_map(
fn ($s) => (int) ($s['id'] ?? 0),
$students
));
return [$studentCodeToId, $existingIds];
}
}
@@ -0,0 +1,690 @@
<?php
namespace App\Services\AttendanceTracking;
use App\Models\AttendanceTracking;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Throwable;
class ViolationRuleEngineService
{
protected array $debugCompute = [];
protected ?array $attendanceReportedColumns = null;
public function __construct(
protected AttendanceTracking $attendanceTrackingModel,
protected AttendanceParentLookupService $parentLookupService
) {
}
public function getDebugCompute(): array
{
return $this->debugCompute;
}
public function computeViolations(
array $students,
array $attendanceData,
string $schoolYear,
?string $semester = null,
?array $weekRowsFallback = null
): array {
$this->debugCompute = ['active_weeks' => 0, 'by_student' => 0];
$studentCodeToId = [];
foreach ($students as $stu) {
$code = trim((string) ($stu['student_code'] ?? $stu['school_id'] ?? ''));
if ($code !== '') {
$studentCodeToId[$code] = (int) ($stu['id'] ?? 0);
}
}
$byStudent = [];
foreach ($attendanceData as $r) {
$rawSid = $r['student_id'] ?? null;
$sid = is_numeric($rawSid)
? (int) $rawSid
: ($studentCodeToId[trim((string) $rawSid)] ?? 0);
if ($sid <= 0) {
continue;
}
$ymd = substr((string) ($r['date'] ?? ''), 0, 10);
$stat = strtolower(trim((string) ($r['status'] ?? '')));
if (!in_array($stat, ['absent', 'late'], true)) {
continue;
}
$byStudent[$sid][$stat][] = $ymd;
}
$this->debugCompute['by_student'] = count($byStudent);
[$syStart, $syEnd] = $this->deriveSchoolYearBounds($schoolYear);
$weekRowsSource = null;
if (!empty($weekRowsFallback)) {
foreach ($weekRowsFallback as $r) {
$st = strtolower(trim((string) ($r['status'] ?? '')));
if ($st !== '' && !in_array($st, ['absent', 'late'], true)) {
$weekRowsSource = $weekRowsFallback;
break;
}
}
}
$activeWeekKeys = $this->getActiveWeeksFromAttendanceData($syStart, $syEnd, $schoolYear, $semester, $weekRowsSource);
$this->debugCompute['active_weeks'] = count($activeWeekKeys);
if (empty($activeWeekKeys)) {
return [];
}
$weekIndex = array_flip($activeWeekKeys);
$currentWeekIdx = count($activeWeekKeys) - 1;
$windowStartIdx = max(0, $currentWeekIdx - 4);
$requiredWeeksCount = $currentWeekIdx - $windowStartIdx + 1;
$keepLastWeeks = function (array $dates) use ($weekIndex, $windowStartIdx, $currentWeekIdx): array {
$out = [];
foreach ($dates as $d) {
$key = date('o-\WW', strtotime($d));
if (isset($weekIndex[$key])) {
$idx = $weekIndex[$key];
$ymd = substr((string) $d, 0, 10);
if ($idx >= $windowStartIdx && $idx <= $currentWeekIdx) {
$out[] = $ymd;
}
}
}
$out = array_values(array_unique($out));
rsort($out);
return $out;
};
$classByStudent = [];
try {
$studentIdsForClass = array_values(array_unique(array_map(
static fn($s) => (int) ($s['id'] ?? 0),
$students
)));
if (!empty($studentIdsForClass)) {
$rows = DB::table('student_class as sc')
->select([
'sc.student_id',
'sc.class_section_id',
'cs.class_section_name',
'cs.class_id',
'c.class_name',
])
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->leftJoin('classes as c', 'c.id', '=', 'cs.class_id')
->whereIn('sc.student_id', $studentIdsForClass)
->where('sc.school_year', $schoolYear)
->orderByDesc('sc.id')
->get();
foreach ($rows as $row) {
$sid = (int) ($row->student_id ?? 0);
if ($sid <= 0) {
continue;
}
if (!isset($classByStudent[$sid])) {
$classByStudent[$sid] = [
'class_section_id' => $row->class_section_id ?? null,
'class_section_name' => (string) ($row->class_section_name ?? ''),
'class_id' => $row->class_id ?? null,
'class_name' => (string) ($row->class_name ?? ''),
];
}
}
}
} catch (Throwable $e) {
Log::debug('computeViolations(): class prefetch failed: ' . $e->getMessage());
}
$out = [];
$absCountLast5 = 0;
$lateCountLast5 = 0;
foreach ($students as $stu) {
$sid = (int) ($stu['id'] ?? 0);
$name = trim(($stu['firstname'] ?? '') . ' ' . ($stu['lastname'] ?? ''));
$absDatesAll = array_values(array_unique($byStudent[$sid]['absent'] ?? []));
$lateDatesAll = array_values(array_unique($byStudent[$sid]['late'] ?? []));
$absDates = $keepLastWeeks($absDatesAll);
$lateDates = $keepLastWeeks($lateDatesAll);
if (empty($absDates) && empty($lateDates)) {
continue;
}
$absWeekIdx = $this->datesToWeekIndices($absDates, $weekIndex);
$lateWeekIdx = $this->datesToWeekIndices($lateDates, $weekIndex);
$absCountLast5 += count($absDates);
$lateCountLast5 += count($lateDates);
$absenceViolation = null;
if (!empty($absDates)) {
$lastAbsDate = $absDates[0];
$A2row = $this->hasNConsecutiveItems($absDates, 2, 7);
$A3row = $this->hasNConsecutiveItems($absDates, 3, 7);
$A4row = $this->hasNConsecutiveItems($absDates, 4, 7);
$A_in3w2 = $this->hasNInWActiveWeeks($absWeekIdx, 2, 3);
$A_in4w3 = $this->hasNInWActiveWeeks($absWeekIdx, 3, 4);
$A_in5w4 = $this->hasNInWActiveWeeks($absWeekIdx, 4, 5);
if ($A4row || $A_in5w4) {
$absenceViolation = [
'type' => 'absence',
'code' => 'ABS_4',
'severity' => 4,
'action' => 'expel_notify',
'color' => '#ff4d4f',
'title' => '4 Absences (in a row or within last 5 active weeks)',
'description' => 'Notify team to inform parent about expulsion and send email.',
'last_date' => $lastAbsDate,
];
} elseif ($A3row || $A_in4w3) {
$absenceViolation = [
'type' => 'absence',
'code' => 'ABS_3',
'severity' => 3,
'action' => 'team_notify',
'color' => '#fa8c16',
'title' => '3 Absences (in a row or within last 4 active weeks)',
'description' => 'Team calls parent and sends email.',
'last_date' => $lastAbsDate,
];
} elseif ($A2row || $A_in3w2) {
$absenceViolation = [
'type' => 'absence',
'code' => 'ABS_2',
'severity' => 2,
'action' => 'team_notify',
'color' => '#fadb14',
'title' => '2 Absences (in a row or within last 3 active weeks)',
'description' => 'Team calls parent and sends email.',
'last_date' => $lastAbsDate,
];
}
}
$lateViolation = null;
if (!empty($lateDates)) {
$lastLateDate = $lateDates[0];
$L2row = $this->hasNConsecutiveItems($lateDates, 2, 7);
$L3row = $this->hasNConsecutiveItems($lateDates, 3, 7);
$L4row = $this->hasNConsecutiveItems($lateDates, 4, 7);
$L_in3w2 = $this->hasNInWActiveWeeks($lateWeekIdx, 2, 3);
$L_in4w3 = $this->hasNInWActiveWeeks($lateWeekIdx, 3, 4);
$lateCoversAllWindowWeeks = false;
if ($requiredWeeksCount >= 4) {
$lateWeeksSet = array_flip($lateWeekIdx);
$lateCoversAllWindowWeeks = true;
for ($i = $windowStartIdx; $i <= $currentWeekIdx; $i++) {
if (!isset($lateWeeksSet[$i])) {
$lateCoversAllWindowWeeks = false;
break;
}
}
}
if ($L4row || $lateCoversAllWindowWeeks) {
$lateViolation = [
'type' => 'late',
'code' => 'LATE_4',
'severity' => 4,
'action' => 'team_notify',
'color' => '#ff4d4f',
'title' => '4 Lates in a row OR in each of the last 4 active weeks',
'description' => 'Team calls parent and sends email.',
'last_date' => $lastLateDate,
];
} else {
$twoLatesOneAbsWithin4 = $this->twoLatesOneAbsInWWeeks($lateWeekIdx, $absWeekIdx, 4);
if ($L3row || $L_in4w3 || $twoLatesOneAbsWithin4) {
$lateViolation = [
'type' => $twoLatesOneAbsWithin4 ? 'mix' : 'late',
'code' => $twoLatesOneAbsWithin4 ? 'MIX_L2A1' : 'LATE_3',
'severity' => 3,
'action' => 'team_notify',
'color' => '#002766',
'title' => $twoLatesOneAbsWithin4
? '2 Lates + 1 Absence (within last 4 active weeks)'
: '3 Lates (in a row or within last 4 active weeks)',
'description' => 'Team calls parent and sends email.',
'last_date' => $lastLateDate,
];
} elseif ($L2row || $L_in3w2) {
$lateViolation = [
'type' => 'late',
'code' => 'LATE_2',
'severity' => 1,
'action' => 'auto_email',
'color' => '#bae7ff',
'title' => '2 Lates (in a row or within last 3 active weeks)',
'description' => 'Send automated email to parent.',
'last_date' => $lastLateDate,
];
}
}
}
$chosen = $this->chooseHigherSeverity($absenceViolation, $lateViolation);
if (!$chosen && count($absDates) === 1) {
$chosen = [
'type' => 'absence',
'code' => 'ABS_1',
'severity' => 1,
'action' => 'auto_email',
'color' => '#e6f7ff',
'title' => '1 Unreported Absence',
'description' => 'Send automated email to parent.',
'last_date' => $absDates[0],
];
}
if (!$chosen) {
continue;
}
$parent = $this->parentLookupService->getPrimaryParentForStudent($sid);
[$start, $end] = $this->dayBounds($chosen['last_date']);
$trackingQ = $this->attendanceTrackingModel->query()
->where('student_id', $sid)
->where('school_year', $schoolYear)
->where('date', '>=', $start)
->where('date', '<', $end)
->where('reason', 'like', '%' . $chosen['code'] . '%');
if ($semester !== null) {
$trackingQ->where('semester', $semester);
}
$tracking = $trackingQ->orderByDesc('date')->first();
$tracking = $tracking?->toArray() ?? [];
$isNotified = (bool) ($tracking['is_notified'] ?? 0);
$notifCnt = (int) ($tracking['notif_counter'] ?? 0);
$cls = $classByStudent[$sid] ?? [];
$classLabel = (string) ($cls['class_section_name'] ?? '');
if ($classLabel === '' && !empty($cls['class_name'])) {
$classLabel = (string) $cls['class_name'];
}
$out[] = [
'id' => $sid,
'name' => $name,
'class_name' => $classLabel,
'class_section_name' => (string) ($cls['class_section_name'] ?? ''),
'className' => (string) ($cls['class_name'] ?? ''),
'class_id' => $cls['class_id'] ?? null,
'class_section_id' => $cls['class_section_id'] ?? null,
'parent_email' => $parent['email'] ?? '',
'parent_name' => $parent['parent_name'] ?? '',
'parent_phone' => $parent['phone'] ?? null,
'violation' => $chosen['title'],
'violation_code' => $chosen['code'],
'type' => $chosen['type'],
'severity' => $chosen['severity'],
'action' => $chosen['action'],
'color' => $chosen['color'],
'last_absence' => $absDates[0] ?? null,
'last_late' => $lateDates[0] ?? null,
'last_date' => $chosen['last_date'],
'is_notified' => $isNotified,
'notif_counter' => $notifCnt,
'absences' => $absDates,
'lates' => $lateDates,
];
}
$this->debugCompute['abs_last5'] = $absCountLast5;
$this->debugCompute['late_last5'] = $lateCountLast5;
return $out;
}
public function chooseHigherSeverity(?array $a, ?array $b): ?array
{
if ($a && $b) {
if ($a['severity'] === $b['severity']) {
$prio = ['expel_notify' => 3, 'team_notify' => 2, 'auto_email' => 1];
return ($prio[$a['action']] ?? 0) >= ($prio[$b['action']] ?? 0) ? $a : $b;
}
return $a['severity'] > $b['severity'] ? $a : $b;
}
return $a ?: $b;
}
public function attendanceReportedColumns(string $table = 'attendance_data'): array
{
if ($this->attendanceReportedColumns !== null) {
return $this->attendanceReportedColumns;
}
$this->attendanceReportedColumns = [
'is_reported' => Schema::hasColumn($table, 'is_reported'),
'reported' => Schema::hasColumn($table, 'reported'),
];
return $this->attendanceReportedColumns;
}
public function applyUnreportedAttendanceFilter($qb, string $table = 'attendance_data'): void
{
$cols = $this->attendanceReportedColumns($table);
if (!empty($cols['is_reported'])) {
$qb->whereRaw("(LOWER(TRIM(COALESCE(is_reported, ''))) NOT IN ('yes','1','true','y','on'))");
}
if (!empty($cols['reported'])) {
$qb->whereRaw("(LOWER(TRIM(COALESCE(reported, ''))) NOT IN ('yes','1','true','y','on'))");
}
$qb->whereRaw("(LOWER(COALESCE(reason, '')) NOT LIKE '%parent-reported%')")
->whereRaw("(LOWER(COALESCE(reason, '')) NOT LIKE '%parent reported%')");
if (Schema::hasTable('parent_attendance_reports')) {
$qb->whereRaw("NOT EXISTS (
SELECT 1 FROM parent_attendance_reports par
WHERE par.student_id = {$table}.student_id
AND DATE(par.report_date) = DATE({$table}.date)
AND par.type IN ('absent','late')
)");
}
}
public function deriveSchoolYearBounds(string $schoolYear): array
{
if (!preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $m)) {
$y = (int) date('Y');
$startY = (int) (date('n') >= 8 ? $y : $y - 1);
return [sprintf('%d-08-01', $startY), sprintf('%d-07-31', $startY + 1)];
}
$startY = (int) $m[1];
$endY = (int) $m[2];
return [sprintf('%d-08-01', $startY), sprintf('%d-07-31', $endY)];
}
public function schoolYearForDate(string $ymd, ?string $fallbackSchoolYear = null): string
{
$ts = strtotime($ymd);
if ($ts === false) {
return (string) $fallbackSchoolYear;
}
$y = (int) date('Y', $ts);
$m = (int) date('n', $ts);
$startY = ($m >= 8) ? $y : $y - 1;
return sprintf('%d-%d', $startY, $startY + 1);
}
public function dayBounds(string $ymd): array
{
$d = substr($ymd, 0, 10);
return [
$d . ' 00:00:00',
date('Y-m-d', strtotime($d . ' +1 day')) . ' 00:00:00',
];
}
public function getActiveWeeksFromAttendanceData(
string $startYmd,
string $endYmd,
?string $schoolYear = null,
?string $semester = null,
?array $weekRowsFallback = null
): array {
$weeks = [];
if (!empty($weekRowsFallback)) {
foreach ($weekRowsFallback as $r) {
$d = $r['date'] ?? null;
if (!$d) {
continue;
}
$ts = strtotime($d);
if ($ts === false) {
continue;
}
$ymd = date('Y-m-d', $ts);
if ($ymd < $startYmd || $ymd > $endYmd) {
continue;
}
$wk = date('o-\WW', $ts);
$weeks[$wk] = true;
}
}
if (empty($weeks)) {
$rows = DB::table('attendance_data')
->selectRaw('DATE(date) AS ymd')
->where('school_year', $schoolYear)
->whereRaw('DATE(date) >= ?', [$startYmd])
->whereRaw('DATE(date) <= ?', [$endYmd])
->where(function ($q) {
$q->whereNull('reason')
->orWhere(function ($q2) {
$q2->where('reason', 'not like', '%break%')
->where('reason', 'not like', '%cancel%');
});
})
->when($semester !== null && $semester !== '', fn($q) => $q->where('semester', $semester))
->groupBy(DB::raw('DATE(date)'))
->orderBy('ymd')
->get();
foreach ($rows as $r) {
$wk = date('o-\WW', strtotime($r->ymd));
$weeks[$wk] = true;
}
}
$keys = array_keys($weeks);
sort($keys, SORT_NATURAL);
return $keys;
}
public function datesToWeekIndices(array $dates, array $weekIndex): array
{
$set = [];
foreach ($dates as $ymd) {
$wk = date('o-\WW', strtotime($ymd));
if (isset($weekIndex[$wk])) {
$set[$weekIndex[$wk]] = true;
}
}
$idx = array_keys($set);
sort($idx);
return $idx;
}
public function windowWeeksForViolationCode(string $code): int
{
$code = strtoupper(trim($code));
if ($code === '') {
return 5;
}
if (Str::startsWith($code, 'ABS_2') || Str::startsWith($code, 'LATE_2')) {
return 3;
}
if (Str::startsWith($code, 'ABS_3') || Str::startsWith($code, 'LATE_3') || Str::startsWith($code, 'MIX')) {
return 4;
}
if (Str::startsWith($code, 'LATE_4')) {
return 4;
}
if (Str::startsWith($code, 'ABS_4')) {
return 5;
}
return 5;
}
public function isoWeekStartYmd(string $weekKey): string
{
if (preg_match('/^(\d{4})-W(\d{2})$/', $weekKey, $m)) {
return date('Y-m-d', strtotime($m[1] . '-W' . $m[2] . '-1'));
}
return date('Y-m-d');
}
public function activeWeekWindowStartYmd(
string $endYmd,
int $windowWeeks,
string $schoolYear,
?string $semester
): string {
$windowWeeks = max(1, $windowWeeks);
[$syStart, $syEnd] = $this->deriveSchoolYearBounds($schoolYear);
$endBound = $endYmd;
if ($syEnd !== '' && $endBound > $syEnd) {
$endBound = $syEnd;
}
$activeWeekKeys = $this->getActiveWeeksFromAttendanceData($syStart, $endBound, $schoolYear, $semester, null);
if (empty($activeWeekKeys)) {
$days = ($windowWeeks * 7) - 1;
return date('Y-m-d', strtotime($endBound . ' -' . $days . ' days'));
}
$activeWeekKeys = array_values($activeWeekKeys);
$weekIndex = array_flip($activeWeekKeys);
$endWeekKey = date('o-\WW', strtotime($endBound));
if (!isset($weekIndex[$endWeekKey])) {
$endWeekKey = null;
for ($i = count($activeWeekKeys) - 1; $i >= 0; $i--) {
$wkStart = $this->isoWeekStartYmd($activeWeekKeys[$i]);
if ($wkStart <= $endBound) {
$endWeekKey = $activeWeekKeys[$i];
break;
}
}
if ($endWeekKey === null) {
$endWeekKey = end($activeWeekKeys);
}
}
$currentIdx = $weekIndex[$endWeekKey] ?? (count($activeWeekKeys) - 1);
$windowStartIdx = max(0, $currentIdx - ($windowWeeks - 1));
$startWeekKey = $activeWeekKeys[$windowStartIdx] ?? $activeWeekKeys[0];
return $this->isoWeekStartYmd($startWeekKey);
}
public function hasNConsecutiveItems(array $datesDesc, int $n, int $daysApart): bool
{
$N = count($datesDesc);
if ($N < $n) {
return false;
}
$dates = $datesDesc;
sort($dates);
$run = 1;
for ($i = 1; $i < $N; $i++) {
$prev = Carbon::parse($dates[$i - 1]);
$curr = Carbon::parse($dates[$i]);
$diff = $prev->diffInDays($curr);
if ($diff === $daysApart) {
$run++;
if ($run >= $n) {
return true;
}
} else {
$run = 1;
}
}
return false;
}
public function hasNInWActiveWeeks(array $weekIdx, int $n, int $w): bool
{
$N = count($weekIdx);
if ($N < $n) {
return false;
}
$l = 0;
for ($r = 0; $r < $N; $r++) {
while ($weekIdx[$r] - $weekIdx[$l] > ($w - 1)) {
$l++;
}
if (($r - $l + 1) >= $n) {
return true;
}
}
return false;
}
public function twoLatesOneAbsInWWeeks(array $lateIdx, array $absIdx, int $w): bool
{
if (count($lateIdx) < 2 || count($absIdx) < 1) {
return false;
}
$L = $lateIdx;
$A = $absIdx;
$iL1 = 0;
$iL2 = 1;
$iA = 0;
while ($iL2 < count($L)) {
$windowStart = $L[$iL1];
$windowEnd = $windowStart + ($w - 1);
if ($L[$iL2] > $windowEnd) {
$iL1++;
$iL2 = $iL1 + 1;
continue;
}
while ($iA < count($A) && $A[$iA] < $windowStart) {
$iA++;
}
if ($iA < count($A) && $A[$iA] <= $windowEnd) {
return true;
}
$iL2++;
if ($iL2 >= count($L)) {
$iL1++;
$iL2 = $iL1 + 1;
}
}
return false;
}
}
@@ -0,0 +1,9 @@
<?php
namespace App\Services;
use App\Services\AttendanceTracking\AttendanceTrackingService as CoreService;
class AttendanceTrackingService extends CoreService
{
}
@@ -0,0 +1,81 @@
<?php
namespace App\Services\Badges;
class BadgeFormDataService
{
public function __construct(
protected BadgeUserLookupService $lookupService,
protected BadgeTextFormatter $formatter
) {
}
public function build(?string $schoolYear, array $selectedUserIds = [], ?string $activeRole = null): array
{
$selectedUserIds = $this->formatter->normalizeIds($selectedUserIds);
$users = $this->lookupService->fetchStaffList($schoolYear, $selectedUserIds);
foreach ($users as &$u) {
if (!isset($u['user_id'])) {
if (isset($u['id'])) {
$u['user_id'] = (int) $u['id'];
}
}
$rolesStr = $u['roles'] ?? '';
if ($rolesStr === '' && isset($u['role_name'])) {
$rolesStr = (string) $u['role_name'];
}
$u['roles_raw'] = $rolesStr;
$u['roles'] = $this->formatter->formatRolesCsv($rolesStr);
if (empty($u['role_name'])) {
$parts = array_filter(array_map('trim', explode(',', $rolesStr)));
$u['role_name'] = $parts[0] ?? '';
}
$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;
}
unset($u);
foreach ($users as &$u) {
$rolesDetect = strtolower($u['roles_raw'] ?? ($u['role_name_raw'] ?? ''));
$isTeacherish = str_contains($rolesDetect, 'teacher') || preg_match('/\bta\b/', $rolesDetect);
if ($isTeacherish && !empty($u['user_id'])) {
$assign = $this->lookupService->getLatestClassForUser((int) $u['user_id'], $schoolYear);
if ($assign) {
$u['class_section_id'] = $assign['class_section_id'] ?? null;
$u['class_section_name'] = $assign['class_section_name'] ?? null;
}
}
}
unset($u);
$rolesTabs = [
'teacher' => 'Teachers',
'ta' => 'Teacher Assistants',
'admin' => 'Admins',
'staff' => 'Staff',
];
$activeRole = array_key_exists((string) $activeRole, $rolesTabs)
? (string) $activeRole
: 'teacher';
return [
'users' => $users,
'schoolYears' => $this->lookupService->getAvailableSchoolYears(),
'selectedYear' => $schoolYear,
'selectedUserIds' => $selectedUserIds,
'rolesTabs' => $rolesTabs,
'active_role' => $activeRole,
];
}
}
+196
View File
@@ -0,0 +1,196 @@
<?php
namespace App\Services\Badges;
use FPDF;
use Illuminate\Http\Response;
class BadgePdfService
{
public function __construct(
protected BadgeUserLookupService $lookupService,
protected BadgePrintLogService $printLogService,
protected BadgeTextFormatter $formatter
) {
}
public function generate(
array $userIds,
?string $schoolYear,
array $rolesMap = [],
array $classesMap = [],
?int $actorId = null
): Response {
$userIds = $this->formatter->normalizeIds($userIds);
$pdf = new FPDF('P', 'mm', 'A4');
$pdf->SetAutoPageBreak(false);
$badgeW = 95;
$badgeH = 67;
$marginL = 14;
$marginT = 6;
$gutterX = 0;
$gutterY = 0;
$cols = 2;
$rows = 4;
$perPage = $cols * $rows;
$pagesAdded = 0;
$i = 0;
$seen = [];
foreach ($userIds as $uid) {
$info = $this->lookupService->getUserInfoById($uid, $schoolYear);
if (!$info || !is_array($info)) {
continue;
}
$userId = (int) ($info['user_id'] ?? $uid);
$name = $this->formatter->norm($info['name'] ?? '');
$postedRole = $rolesMap[$userId] ?? null;
$roleResolved = $postedRole !== null
? $postedRole
: $this->formatter->resolveRole($info);
$roleResolved = $this->formatter->formatRole((string) $roleResolved);
$info['role_resolved'] = $roleResolved;
if (!empty($classesMap[$userId])) {
$info['class_section_name'] = (string) $classesMap[$userId];
}
$class = $this->formatter->norm($info['class_section_name'] ?? '');
$badgeKey = strtolower(trim($userId . '|' . $name . '|' . $roleResolved . '|' . $class));
if (isset($seen[$badgeKey])) {
continue;
}
$seen[$badgeKey] = true;
if (($i % $perPage) === 0) {
$pdf->AddPage();
$pagesAdded++;
}
$indexOnPage = $i % $perPage;
$row = intdiv($indexOnPage, $cols);
$col = $indexOnPage % $cols;
$x = $marginL + $col * ($badgeW + $gutterX);
$y = $marginT + $row * ($badgeH + $gutterY);
$this->drawBadgeInCell($pdf, $info, $x, $y, $badgeW, $badgeH, $schoolYear);
$i++;
}
if ($pagesAdded === 0) {
$pdf->AddPage();
$pdf->SetFont('Arial', '', 10);
$pdf->SetXY(10, 20);
$pdf->MultiCell(0, 6, 'No valid staff selected or data not found.', 0, 'L');
}
$pdfString = $pdf->Output('S');
$this->printLogService->logSafely(
userIds: $userIds,
actorId: $actorId,
schoolYear: $schoolYear,
rolesMap: $rolesMap,
classesMap: $classesMap,
copies: 1
);
return response($pdfString, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="Staff_Badges.pdf"',
]);
}
protected function drawBadgeInCell(FPDF $pdf, array $data, float $x, float $y, float $w, float $h, ?string $schoolYear): void
{
$pdf->SetFillColor(255, 255, 255);
$pdf->Rect($x, $y, $w, $h, 'F');
$pdf->SetDrawColor(200, 200, 200);
$pdf->Rect($x, $y, $w, $h);
$logoSize = 6;
if (!empty($data['school_logo']) && file_exists($data['school_logo'])) {
$pdf->Image($data['school_logo'], $x + $w - 6 - $logoSize, $y + 4, $logoSize + 3, $logoSize - 1);
}
if (!empty($data['isgl_logo']) && file_exists($data['isgl_logo'])) {
$pdf->Image($data['isgl_logo'], $x + 4, $y + 4, $logoSize + 3, $logoSize - 1);
}
$padX = 4;
$cursorY = $y + 4 + $logoSize + 2;
$schoolName = strtoupper($this->formatter->norm($data['school_name'] ?? 'AL RAHMA SUNDAY SCHOOL'));
$pdf->SetFont('Arial', '', 16);
$pdf->SetXY($x + $padX, $cursorY);
$pdf->MultiCell($w - 2 * $padX, 5, $this->formatter->toPdf($schoolName), 0, 'C');
$pdf->Ln(9);
$pdf->SetFont('Arial', 'B', 14);
$pdf->SetX($x + $padX);
$name = strtoupper($this->formatter->norm($data['name'] ?? 'STAFF'));
$pdf->Cell($w - 2 * $padX, 6, $this->formatter->toPdf($name), 0, 1, 'C');
$roleResolved = $this->formatter->norm((string) ($data['role_resolved'] ?? ''));
if ($roleResolved === '') {
$roleResolved = $this->formatter->resolveRole($data);
}
if ($roleResolved !== '') {
$roleResolved = $this->formatter->formatRole($roleResolved);
}
$classRaw = $this->formatter->norm($data['class_section_name'] ?? '');
$class = $this->formatter->formatClass($classRaw);
$detectSrc = strtolower($this->formatter->norm(
($data['roles_raw'] ?? '') !== '' ? $data['roles_raw'] : (($data['role_name_raw'] ?? '') !== '' ? $data['role_name_raw'] : $roleResolved)
));
$isTeacherish = str_contains($detectSrc, 'teacher') || preg_match('/\bta\b/', $detectSrc);
if ($isTeacherish && $class !== '') {
if (strtolower($class) === 'youth') {
$display = 'Youth ' . $roleResolved;
} elseif ($class === 'KG') {
$display = 'KG ' . $roleResolved;
} else {
$display = 'Grade ' . $class . ' ' . $roleResolved;
}
} elseif ($roleResolved !== '') {
$display = $roleResolved;
} elseif ($class !== '') {
$display = $class;
} else {
$display = 'STAFF';
}
$pdf->Ln(6);
$pdf->SetFont('Arial', '', 14);
$displayOut = $this->formatter->toPdf($display);
$maxTextWidth = $w - 2 * $padX;
$best = $this->formatter->fitText($pdf, $displayOut, 11, 7, $maxTextWidth);
$pdf->SetFont('Arial', '', $best + 4);
if ($pdf->GetStringWidth($displayOut) <= $maxTextWidth) {
$pdf->SetX($x + $padX);
$pdf->Cell($maxTextWidth, 5, $displayOut, 0, 1, 'C');
} else {
$pdf->SetX($x + $padX);
$pdf->MultiCell($maxTextWidth, 5, $displayOut, 0, 'C');
}
$footerYear = $this->formatter->norm((string) ($schoolYear ?? ($data['school_year'] ?? '')));
if ($footerYear !== '') {
$pdf->SetFont('Arial', 'I', 14);
$pdf->SetXY($x + $padX, $y + $h - 9);
$pdf->Cell($w - 2 * $padX, 4, $this->formatter->toPdf($footerYear), 0, 0, 'C');
}
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Services\Badges;
use App\Models\BadgePrintLog;
use Illuminate\Support\Facades\Log;
class BadgePrintLogService
{
public function __construct(
protected BadgePrintLog $badgePrintLogModel,
protected BadgeTextFormatter $formatter
) {
}
public function getStatus(array $userIds, ?string $schoolYear): array
{
$userIds = $this->formatter->normalizeIds($userIds);
return $this->badgePrintLogModel->getStatus($userIds, $schoolYear);
}
public function log(
array $userIds,
?int $actorId,
?string $schoolYear,
array $rolesMap = [],
array $classesMap = [],
int $copies = 1
): int {
$userIds = $this->formatter->normalizeIds($userIds);
return $this->badgePrintLogModel->logPrints(
$userIds,
$actorId,
$schoolYear,
is_array($rolesMap) ? $rolesMap : [],
is_array($classesMap) ? $classesMap : [],
$copies
);
}
public function logSafely(
array $userIds,
?int $actorId,
?string $schoolYear,
array $rolesMap = [],
array $classesMap = [],
int $copies = 1
): void {
try {
$this->log($userIds, $actorId, $schoolYear, $rolesMap, $classesMap, $copies);
} catch (\Throwable $e) {
Log::error('Failed to log badge prints: ' . $e->getMessage());
}
}
}
+700
View File
@@ -0,0 +1,700 @@
<?php
namespace App\Services\Badges;
use App\Models\BadgePrintLog;
use App\Models\ClassSection;
use App\Models\Role;
use App\Models\TeacherClass;
use App\Models\User;
use App\Models\UserRole;
use FPDF;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class BadgeService
{
public function __construct(
protected User $userModel,
protected UserRole $userRoleModel,
protected Role $roleModel,
protected TeacherClass $teacherClassModel,
protected ClassSection $classSectionModel,
protected BadgePrintLog $badgePrintLogModel
) {
}
public function generateBadgePdf(
array $userIds,
?string $schoolYear,
array $rolesMap = [],
array $classesMap = [],
?int $actorId = null
): Response {
$userIds = $this->normalizeIds($userIds);
$pdf = new FPDF('P', 'mm', 'A4');
$pdf->SetAutoPageBreak(false);
$badgeW = 95;
$badgeH = 67;
$marginL = 14;
$marginT = 6;
$gutterX = 0;
$gutterY = 0;
$cols = 2;
$rows = 4;
$perPage = $cols * $rows;
$pagesAdded = 0;
$i = 0;
$seen = [];
foreach ($userIds as $uid) {
$info = $this->getUserInfoById($uid, $schoolYear);
if (!$info || !is_array($info)) {
continue;
}
$userId = (int) ($info['user_id'] ?? $uid);
$name = $this->norm($info['name'] ?? '');
$postedRole = $rolesMap[$userId] ?? null;
$roleResolved = $postedRole !== null
? $postedRole
: $this->resolveRole($info);
$roleResolved = $this->formatRole($roleResolved);
$info['role_resolved'] = $roleResolved;
if (!empty($classesMap[$userId])) {
$info['class_section_name'] = (string) $classesMap[$userId];
}
$class = $this->norm($info['class_section_name'] ?? '');
$badgeKey = strtolower(trim($userId . '|' . $name . '|' . $roleResolved . '|' . $class));
if (isset($seen[$badgeKey])) {
continue;
}
$seen[$badgeKey] = true;
if (($i % $perPage) === 0) {
$pdf->AddPage();
$pagesAdded++;
}
$indexOnPage = $i % $perPage;
$row = intdiv($indexOnPage, $cols);
$col = $indexOnPage % $cols;
$x = $marginL + $col * ($badgeW + $gutterX);
$y = $marginT + $row * ($badgeH + $gutterY);
$this->drawBadgeInCell($pdf, $info, $x, $y, $badgeW, $badgeH, $schoolYear);
$i++;
}
if ($pagesAdded === 0) {
$pdf->AddPage();
$pdf->SetFont('Arial', '', 10);
$pdf->SetXY(10, 20);
$pdf->MultiCell(0, 6, 'No valid staff selected or data not found.', 0, 'L');
}
$pdfString = $pdf->Output('S');
try {
$this->badgePrintLogModel->logPrints(
$userIds,
$actorId,
$schoolYear,
is_array($rolesMap) ? $rolesMap : [],
is_array($classesMap) ? $classesMap : [],
1
);
} catch (\Throwable $e) {
Log::error('Failed to log badge prints: ' . $e->getMessage());
}
return response($pdfString, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="Staff_Badges.pdf"',
]);
}
public function getBadgePrintStatus(array $ids, ?string $schoolYear): array
{
$ids = $this->normalizeIds($ids);
return $this->badgePrintLogModel->getStatus($ids, $schoolYear);
}
public function logBadgePrint(
array $ids,
?string $schoolYear,
array $roles = [],
array $classes = [],
?int $actorId = null
): int {
$ids = $this->normalizeIds($ids);
return $this->badgePrintLogModel->logPrints(
$ids,
$actorId,
$schoolYear,
is_array($roles) ? $roles : [],
is_array($classes) ? $classes : [],
1
);
}
public function getBadgeFormData(?string $schoolYear, array $selectedUserIds = [], ?string $activeRole = null): array
{
$selectedUserIds = $this->normalizeIds($selectedUserIds);
$users = $this->fetchStaffList($schoolYear, $selectedUserIds);
foreach ($users as &$u) {
if (!isset($u['user_id'])) {
if (isset($u['id'])) {
$u['user_id'] = (int) $u['id'];
}
}
$rolesStr = $u['roles'] ?? '';
if ($rolesStr === '' && isset($u['role_name'])) {
$rolesStr = (string) $u['role_name'];
}
$u['roles_raw'] = $rolesStr;
$u['roles'] = $this->formatRolesCsv($rolesStr);
if (empty($u['role_name'])) {
$parts = array_filter(array_map('trim', explode(',', $rolesStr)));
$u['role_name'] = $parts[0] ?? '';
}
$u['role_name_raw'] = $u['role_name'];
$u['role_name'] = $this->formatRole((string) $u['role_name']);
$u['class_section_id'] = $u['class_section_id'] ?? null;
$u['class_section_name'] = $u['class_section_name'] ?? null;
}
unset($u);
foreach ($users as &$u) {
$rolesDetect = strtolower($u['roles_raw'] ?? ($u['role_name_raw'] ?? ''));
$isTeacherish = str_contains($rolesDetect, 'teacher') || preg_match('/\bta\b/', $rolesDetect);
if ($isTeacherish && !empty($u['user_id'])) {
$assign = $this->getLatestClassForUser((int) $u['user_id'], $schoolYear);
if ($assign) {
$u['class_section_id'] = $assign['class_section_id'] ?? null;
$u['class_section_name'] = $assign['class_section_name'] ?? null;
}
}
}
unset($u);
$schoolYears = $this->getAvailableSchoolYears();
$rolesTabs = [
'teacher' => 'Teachers',
'ta' => 'Teacher Assistants',
'admin' => 'Admins',
'staff' => 'Staff',
];
$activeRole = array_key_exists((string) $activeRole, $rolesTabs) ? (string) $activeRole : 'teacher';
return [
'users' => $users,
'schoolYears' => $schoolYears,
'selectedYear' => $schoolYear,
'selectedUserIds' => $selectedUserIds,
'rolesTabs' => $rolesTabs,
'active_role' => $activeRole,
];
}
protected function fetchStaffList(?string $schoolYear, array $selectedUserIds = []): array
{
$query = DB::table('users')
->leftJoin('user_roles', 'user_roles.user_id', '=', 'users.id')
->leftJoin('roles', 'roles.id', '=', 'user_roles.role_id')
->select([
'users.id',
'users.firstname',
'users.lastname',
DB::raw("GROUP_CONCAT(DISTINCT CASE WHEN LOWER(roles.name) != 'parent' THEN roles.name END ORDER BY roles.name SEPARATOR ', ') as roles"),
])
->groupBy('users.id', 'users.firstname', 'users.lastname');
if (!empty($selectedUserIds)) {
$query->whereIn('users.id', $selectedUserIds);
}
if (!empty($schoolYear) && Schema::hasColumn('user_roles', 'school_year')) {
$query->where('user_roles.school_year', $schoolYear);
}
return array_map(static fn ($row) => (array) $row, $query->get()->all());
}
protected function getLatestClassForUser(int $userId, ?string $schoolYear = null): ?array
{
$query = DB::table('teacher_class as tc')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'tc.class_section_id')
->select([
'tc.class_section_id',
'cs.class_section_name',
])
->where('tc.teacher_id', $userId);
if (!empty($schoolYear)) {
$query->where('tc.school_year', $schoolYear);
}
$row = $query
->orderByRaw("FIELD(tc.position, 'main', 'ta')")
->orderByRaw('IFNULL(tc.updated_at,"0000-00-00 00:00:00") DESC')
->orderByDesc('tc.id')
->first();
if (!$row || empty($row->class_section_id)) {
return null;
}
return [
'class_section_id' => (int) $row->class_section_id,
'class_section_name' => $row->class_section_name ?? null,
];
}
public function getUserInfoById(int $id, ?string $schoolYear = null): ?array
{
$u = DB::table('users')
->select(['id as user_id', 'firstname', 'lastname'])
->where('id', $id)
->first();
if (!$u) {
return null;
}
$userId = (int) $u->user_id;
$fullname = trim(($u->firstname ?? '') . ' ' . ($u->lastname ?? ''));
$rolesRows = DB::table('user_roles as ur')
->join('roles as r', 'r.id', '=', 'ur.role_id')
->select(['r.id as role_id', 'r.name as role_name'])
->where('ur.user_id', $userId)
->get();
$allRoles = [];
foreach ($rolesRows as $rr) {
$name = trim((string) ($rr->role_name ?? ''));
if ($name !== '' && strtolower($name) !== 'parent') {
$allRoles[] = ucwords(strtolower($name));
}
}
$allRoles = array_values(array_unique($allRoles));
$rolesCsv = $allRoles ? implode(', ', $allRoles) : '';
$tcQuery = DB::table('teacher_class')
->select(['class_section_id', 'updated_at', 'id', 'position', 'school_year'])
->where('teacher_id', $userId);
if (!empty($schoolYear)) {
$tcQuery->where('school_year', $schoolYear);
}
$assignment = $tcQuery
->orderByRaw('IFNULL(updated_at, "1970-01-01 00:00:00") DESC, id DESC')
->first();
$classId = $assignment->class_section_id ?? null;
$position = strtolower(trim((string) ($assignment->position ?? '')));
$className = null;
if (!empty($classId)) {
$className = $this->resolveClassName((int) $classId);
}
if (in_array($position, ['ta', 'teacher_assistant', 'assistant'], true)) {
$roleLabel = 'Teacher Assistant';
} elseif ($position === 'teacher' || $position === 'main') {
$roleLabel = 'Teacher';
} elseif (!empty($allRoles)) {
$roleLabel = $allRoles[0];
} else {
$roleLabel = 'Staff';
}
$schoolLogo = $this->firstExisting([
public_path('assets/images/school_logo.png'),
public_path('assets/images/logo.png'),
]);
$isglLogo = $this->firstExisting([
public_path('assets/images/isgl_logo.png'),
public_path('assets/images/isgl.png'),
]);
$jobTitle = '';
if (!empty($roleLabel) && !empty($className)) {
$jobTitle = "{$roleLabel} - {$className}";
} elseif (!empty($roleLabel)) {
$jobTitle = $roleLabel;
} elseif (!empty($className)) {
$jobTitle = $className;
}
return [
'user_id' => $userId,
'name' => $fullname !== '' ? $fullname : 'STAFF',
'role' => $roleLabel,
'roles' => $rolesCsv,
'class_section_id' => $classId ? (int) $classId : null,
'class_section_name' => $className,
'job_title' => $jobTitle,
'school_name' => 'Al Rahma Sunday School',
'school_id' => $userId,
'school_logo' => $schoolLogo,
'isgl_logo' => $isglLogo,
'school_year' => $assignment->school_year ?? $schoolYear,
];
}
protected function drawBadgeInCell(FPDF $pdf, array $data, float $x, float $y, float $w, float $h, ?string $schoolYear): void
{
$pdf->SetFillColor(255, 255, 255);
$pdf->Rect($x, $y, $w, $h, 'F');
$pdf->SetDrawColor(200, 200, 200);
$pdf->Rect($x, $y, $w, $h);
$logoSize = 6;
if (!empty($data['school_logo']) && file_exists($data['school_logo'])) {
$pdf->Image($data['school_logo'], $x + $w - 6 - $logoSize, $y + 4, $logoSize + 3, $logoSize - 1);
}
if (!empty($data['isgl_logo']) && file_exists($data['isgl_logo'])) {
$pdf->Image($data['isgl_logo'], $x + 4, $y + 4, $logoSize + 3, $logoSize - 1);
}
$padX = 4;
$cursorY = $y + 4 + $logoSize + 2;
$schoolName = strtoupper($this->norm($data['school_name'] ?? 'AL RAHMA SUNDAY SCHOOL'));
$pdf->SetFont('Arial', '', 16);
$pdf->SetXY($x + $padX, $cursorY);
$pdf->MultiCell($w - 2 * $padX, 5, $this->toPdf($schoolName), 0, 'C');
$pdf->Ln(9);
$pdf->SetFont('Arial', 'B', 14);
$pdf->SetX($x + $padX);
$name = strtoupper($this->norm($data['name'] ?? 'STAFF'));
$pdf->Cell($w - 2 * $padX, 6, $this->toPdf($name), 0, 1, 'C');
$roleResolved = $this->norm((string) ($data['role_resolved'] ?? ''));
if ($roleResolved === '') {
$roleResolved = $this->resolveRole($data);
}
if ($roleResolved !== '') {
$roleResolved = $this->formatRole($roleResolved);
}
$classRaw = $this->norm($data['class_section_name'] ?? '');
$class = $this->formatClass($classRaw);
$detectSrc = strtolower($this->norm(
($data['roles_raw'] ?? '') !== '' ? $data['roles_raw'] : (($data['role_name_raw'] ?? '') !== '' ? $data['role_name_raw'] : $roleResolved)
));
$isTeacherish = str_contains($detectSrc, 'teacher') || preg_match('/\bta\b/', $detectSrc);
if ($isTeacherish && $class !== '') {
if (strtolower($class) === 'youth') {
$display = 'Youth ' . $roleResolved;
} elseif ($class === 'KG') {
$display = 'KG ' . $roleResolved;
} else {
$display = 'Grade ' . $class . ' ' . $roleResolved;
}
} elseif ($roleResolved !== '') {
$display = $roleResolved;
} elseif ($class !== '') {
$display = $class;
} else {
$display = 'STAFF';
}
$pdf->Ln(6);
$pdf->SetFont('Arial', '', 14);
$displayOut = $this->toPdf($display);
$maxTextWidth = $w - 2 * $padX;
$best = $this->fitText($pdf, $displayOut, 11, 7, $maxTextWidth);
$pdf->SetFont('Arial', '', $best + 4);
if ($pdf->GetStringWidth($displayOut) <= $maxTextWidth) {
$pdf->SetX($x + $padX);
$pdf->Cell($maxTextWidth, 5, $displayOut, 0, 1, 'C');
} else {
$pdf->SetX($x + $padX);
$pdf->MultiCell($maxTextWidth, 5, $displayOut, 0, 'C');
}
$footerYear = $this->norm((string) ($schoolYear ?? ($data['school_year'] ?? '')));
if ($footerYear !== '') {
$pdf->SetFont('Arial', 'I', 14);
$pdf->SetXY($x + $padX, $y + $h - 9);
$pdf->Cell($w - 2 * $padX, 4, $this->toPdf($footerYear), 0, 0, 'C');
}
}
protected function resolveRole(array $info): string
{
$candidates = [
'role',
'active_role',
'role_name',
fn ($r) => !empty($r['roles'])
? (array_filter(array_map('trim', explode(',', (string) $r['roles'])))[0] ?? '')
: '',
'job_title',
'title',
'position',
'staff_role',
'department_role',
'dept_role',
];
foreach ($candidates as $key) {
if (is_callable($key)) {
$v = $key($info);
if ($this->norm($v) !== '') {
return $this->norm($v);
}
} else {
if (!empty($info[$key])) {
$v = $this->norm((string) $info[$key]);
if ($v !== '') {
return $v;
}
}
}
}
return 'STAFF';
}
protected function normalizeIds(array $ids): array
{
return array_values(array_unique(array_filter(array_map(static function ($v) {
if (is_string($v)) {
$v = trim($v);
}
if ($v === '' || $v === null) {
return null;
}
return (int) $v;
}, $ids), static fn ($v) => $v !== null && $v > 0)));
}
protected function norm(?string $s): string
{
$s = (string) $s;
$s = str_replace(["\xC2\xA0", "\xA0"], ' ', $s);
$s = preg_replace('/\s+/u', ' ', $s) ?? $s;
$s = preg_replace('/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/u', '', $s) ?? $s;
return trim($s);
}
protected function toPdf(string $s): string
{
if (function_exists('iconv')) {
$o = @iconv('UTF-8', 'ISO-8859-1//TRANSLIT', $s);
if ($o !== false && $o !== '') {
return $o;
}
}
if (function_exists('mb_convert_encoding')) {
$o = @mb_convert_encoding($s, 'ISO-8859-1', 'UTF-8');
if ($o !== '') {
return $o;
}
}
$o = @utf8_decode($s);
return $o !== '' ? $o : 'STAFF';
}
protected function fitText(FPDF $pdf, string $text, int $startSize, int $minSize, float $maxWidth): int
{
$size = $startSize;
while ($size >= $minSize) {
$pdf->SetFontSize($size);
if ($pdf->GetStringWidth($text) <= $maxWidth) {
return $size;
}
$size--;
}
return $minSize;
}
protected function formatRole(?string $role): string
{
$role = (string) $role;
$role = str_replace(['-', '_'], ' ', $role);
$role = preg_replace('/\s+/u', ' ', trim($role));
if ($role === '') {
return '';
}
$special = [
'of' => 'of',
'head' => 'Head',
];
$tokens = explode(' ', $role);
$out = [];
foreach ($tokens as $tok) {
if ($tok === '') {
continue;
}
$core = preg_replace('/^\P{L}+|\P{L}+$/u', '', $tok);
$start = strpos($tok, $core);
if ($start === false) {
$out[] = ucfirst(mb_strtolower($tok, 'UTF-8'));
continue;
}
$pre = substr($tok, 0, $start);
$suf = substr($tok, $start + strlen($core));
$lw = mb_strtolower($core, 'UTF-8');
if (isset($special[$lw])) {
$coreFmt = $special[$lw];
} elseif (preg_match('/^\p{L}{1,4}$/u', $core)) {
$coreFmt = mb_strtoupper($core, 'UTF-8');
} else {
$coreFmt = preg_replace_callback(
'/\p{L}+/u',
static fn ($m) => mb_strtoupper(mb_substr($m[0], 0, 1, 'UTF-8'), 'UTF-8')
. mb_strtolower(mb_substr($m[0], 1, null, 'UTF-8'), 'UTF-8'),
mb_strtolower($core, 'UTF-8')
);
}
$out[] = $pre . $coreFmt . $suf;
}
return implode(' ', $out);
}
protected function formatRolesCsv(?string $csv): string
{
if ($csv === null) {
return '';
}
$parts = array_filter(array_map('trim', explode(',', (string) $csv)), 'strlen');
if (empty($parts)) {
return '';
}
return implode(', ', array_map(fn ($r) => $this->formatRole($r), $parts));
}
protected function formatClass(string $class): string
{
$c = trim($class);
if ($c === '') {
return '';
}
$lc = strtolower($c);
if ($lc === 'kg' || $lc === 'kindergarten') {
return 'KG';
}
if ($lc === 'youth') {
return 'Youth';
}
return $c;
}
protected function firstExisting(array $paths): ?string
{
foreach ($paths as $path) {
if (!empty($path) && file_exists($path)) {
return $path;
}
}
return null;
}
protected function resolveClassName(int $classSectionId): ?string
{
$row = DB::table('classSection')
->select('class_section_name')
->where('class_section_id', $classSectionId)
->first();
return $row->class_section_name ?? null;
}
protected function getAvailableSchoolYears(): array
{
$schoolYears = [];
try {
if (DB::getSchemaBuilder()->hasTable('teacher_class')) {
$schoolYears = DB::table('teacher_class')
->selectRaw('DISTINCT school_year')
->whereNotNull('school_year')
->orderByDesc('school_year')
->pluck('school_year')
->filter()
->values()
->all();
}
} catch (\Throwable) {
}
if (empty($schoolYears)) {
try {
if (
DB::getSchemaBuilder()->hasTable('user_roles') &&
DB::getSchemaBuilder()->hasColumn('user_roles', 'school_year')
) {
$schoolYears = DB::table('user_roles')
->selectRaw('DISTINCT school_year')
->whereNotNull('school_year')
->orderByDesc('school_year')
->pluck('school_year')
->filter()
->values()
->all();
}
} catch (\Throwable) {
}
}
return $schoolYears;
}
}
+206
View File
@@ -0,0 +1,206 @@
<?php
namespace App\Services\Badges;
use FPDF;
class BadgeTextFormatter
{
public function normalizeIds(array $ids): array
{
return array_values(array_unique(array_filter(array_map(static function ($v) {
if (is_string($v)) {
$v = trim($v);
}
if ($v === '' || $v === null) {
return null;
}
return (int) $v;
}, $ids), static fn ($v) => $v !== null && $v > 0)));
}
public function norm(?string $s): string
{
$s = (string) $s;
$s = str_replace(["\xC2\xA0", "\xA0"], ' ', $s);
$s = preg_replace('/\s+/u', ' ', $s) ?? $s;
$s = preg_replace('/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/u', '', $s) ?? $s;
return trim($s);
}
public function toPdf(string $s): string
{
if (function_exists('iconv')) {
$o = @iconv('UTF-8', 'ISO-8859-1//TRANSLIT', $s);
if ($o !== false && $o !== '') {
return $o;
}
}
if (function_exists('mb_convert_encoding')) {
$o = @mb_convert_encoding($s, 'ISO-8859-1', 'UTF-8');
if ($o !== '') {
return $o;
}
}
$o = @utf8_decode($s);
return $o !== '' ? $o : 'STAFF';
}
public function fitText(FPDF $pdf, string $text, int $startSize, int $minSize, float $maxWidth): int
{
$size = $startSize;
while ($size >= $minSize) {
$pdf->SetFontSize($size);
if ($pdf->GetStringWidth($text) <= $maxWidth) {
return $size;
}
$size--;
}
return $minSize;
}
public function formatRole(?string $role): string
{
$role = (string) $role;
$role = str_replace(['-', '_'], ' ', $role);
$role = preg_replace('/\s+/u', ' ', trim($role));
if ($role === '') {
return '';
}
$special = [
'of' => 'of',
'head' => 'Head',
];
$tokens = explode(' ', $role);
$out = [];
foreach ($tokens as $tok) {
if ($tok === '') {
continue;
}
$core = preg_replace('/^\P{L}+|\P{L}+$/u', '', $tok);
$start = strpos($tok, $core);
if ($start === false) {
$out[] = ucfirst(mb_strtolower($tok, 'UTF-8'));
continue;
}
$pre = substr($tok, 0, $start);
$suf = substr($tok, $start + strlen($core));
$lw = mb_strtolower($core, 'UTF-8');
if (isset($special[$lw])) {
$coreFmt = $special[$lw];
} elseif (preg_match('/^\p{L}{1,4}$/u', $core)) {
$coreFmt = mb_strtoupper($core, 'UTF-8');
} else {
$coreFmt = preg_replace_callback(
'/\p{L}+/u',
static fn ($m) => mb_strtoupper(mb_substr($m[0], 0, 1, 'UTF-8'), 'UTF-8')
. mb_strtolower(mb_substr($m[0], 1, null, 'UTF-8'), 'UTF-8'),
mb_strtolower($core, 'UTF-8')
);
}
$out[] = $pre . $coreFmt . $suf;
}
return implode(' ', $out);
}
public function formatRolesCsv(?string $csv): string
{
if ($csv === null) {
return '';
}
$parts = array_filter(array_map('trim', explode(',', (string) $csv)), 'strlen');
if (empty($parts)) {
return '';
}
return implode(', ', array_map(fn ($r) => $this->formatRole($r), $parts));
}
public function formatClass(string $class): string
{
$c = trim($class);
if ($c === '') {
return '';
}
$lc = strtolower($c);
if ($lc === 'kg' || $lc === 'kindergarten') {
return 'KG';
}
if ($lc === 'youth') {
return 'Youth';
}
return $c;
}
public function resolveRole(array $info): string
{
$candidates = [
'role',
'active_role',
'role_name',
fn ($r) => !empty($r['roles'])
? (array_values(array_filter(array_map('trim', explode(',', (string) $r['roles']))))[0] ?? '')
: '',
'job_title',
'title',
'position',
'staff_role',
'department_role',
'dept_role',
];
foreach ($candidates as $key) {
if (is_callable($key)) {
$v = $key($info);
if ($this->norm($v) !== '') {
return $this->norm($v);
}
} else {
if (!empty($info[$key])) {
$v = $this->norm((string) $info[$key]);
if ($v !== '') {
return $v;
}
}
}
}
return 'STAFF';
}
public function firstExisting(array $paths): ?string
{
foreach ($paths as $path) {
if (!empty($path) && file_exists($path)) {
return $path;
}
}
return null;
}
}
@@ -0,0 +1,211 @@
<?php
namespace App\Services\Badges;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class BadgeUserLookupService
{
public function __construct(
protected BadgeTextFormatter $formatter
) {
}
public function fetchStaffList(?string $schoolYear, array $selectedUserIds = []): array
{
$query = DB::table('users')
->leftJoin('user_roles', 'user_roles.user_id', '=', 'users.id')
->leftJoin('roles', 'roles.id', '=', 'user_roles.role_id')
->select([
'users.id',
'users.firstname',
'users.lastname',
DB::raw("GROUP_CONCAT(DISTINCT CASE WHEN LOWER(roles.name) != 'parent' THEN roles.name END ORDER BY roles.name SEPARATOR ', ') as roles"),
])
->groupBy('users.id', 'users.firstname', 'users.lastname');
if (!empty($selectedUserIds)) {
$query->whereIn('users.id', $selectedUserIds);
}
if (!empty($schoolYear) && Schema::hasColumn('user_roles', 'school_year')) {
$query->where('user_roles.school_year', $schoolYear);
}
return array_map(static fn ($row) => (array) $row, $query->get()->all());
}
public function getLatestClassForUser(int $userId, ?string $schoolYear = null): ?array
{
$query = DB::table('teacher_class as tc')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'tc.class_section_id')
->select([
'tc.class_section_id',
'cs.class_section_name',
])
->where('tc.teacher_id', $userId);
if (!empty($schoolYear)) {
$query->where('tc.school_year', $schoolYear);
}
$row = $query
->orderByRaw("FIELD(tc.position, 'main', 'ta')")
->orderByRaw('IFNULL(tc.updated_at,"0000-00-00 00:00:00") DESC')
->orderByDesc('tc.id')
->first();
if (!$row || empty($row->class_section_id)) {
return null;
}
return [
'class_section_id' => (int) $row->class_section_id,
'class_section_name' => $row->class_section_name ?? null,
];
}
public function getUserInfoById(int $id, ?string $schoolYear = null): ?array
{
$u = DB::table('users')
->select(['id as user_id', 'firstname', 'lastname'])
->where('id', $id)
->first();
if (!$u) {
return null;
}
$userId = (int) $u->user_id;
$fullname = trim(($u->firstname ?? '') . ' ' . ($u->lastname ?? ''));
$rolesRows = DB::table('user_roles as ur')
->join('roles as r', 'r.id', '=', 'ur.role_id')
->select(['r.id as role_id', 'r.name as role_name'])
->where('ur.user_id', $userId)
->get();
$allRoles = [];
foreach ($rolesRows as $rr) {
$name = trim((string) ($rr->role_name ?? ''));
if ($name !== '' && strtolower($name) !== 'parent') {
$allRoles[] = ucwords(strtolower($name));
}
}
$allRoles = array_values(array_unique($allRoles));
$rolesCsv = $allRoles ? implode(', ', $allRoles) : '';
$tcQuery = DB::table('teacher_class')
->select(['class_section_id', 'updated_at', 'id', 'position', 'school_year'])
->where('teacher_id', $userId);
if (!empty($schoolYear)) {
$tcQuery->where('school_year', $schoolYear);
}
$assignment = $tcQuery
->orderByRaw('IFNULL(updated_at, "1970-01-01 00:00:00") DESC, id DESC')
->first();
$classId = $assignment->class_section_id ?? null;
$position = strtolower(trim((string) ($assignment->position ?? '')));
$className = null;
if (!empty($classId)) {
$className = $this->resolveClassName((int) $classId);
}
if (in_array($position, ['ta', 'teacher_assistant', 'assistant'], true)) {
$roleLabel = 'Teacher Assistant';
} elseif ($position === 'teacher' || $position === 'main') {
$roleLabel = 'Teacher';
} elseif (!empty($allRoles)) {
$roleLabel = $allRoles[0];
} else {
$roleLabel = 'Staff';
}
$schoolLogo = $this->formatter->firstExisting([
public_path('assets/images/school_logo.png'),
public_path('assets/images/logo.png'),
]);
$isglLogo = $this->formatter->firstExisting([
public_path('assets/images/isgl_logo.png'),
public_path('assets/images/isgl.png'),
]);
$jobTitle = '';
if (!empty($roleLabel) && !empty($className)) {
$jobTitle = "{$roleLabel} - {$className}";
} elseif (!empty($roleLabel)) {
$jobTitle = $roleLabel;
} elseif (!empty($className)) {
$jobTitle = $className;
}
return [
'user_id' => $userId,
'name' => $fullname !== '' ? $fullname : 'STAFF',
'role' => $roleLabel,
'roles' => $rolesCsv,
'class_section_id' => $classId ? (int) $classId : null,
'class_section_name' => $className,
'job_title' => $jobTitle,
'school_name' => 'Al Rahma Sunday School',
'school_id' => $userId,
'school_logo' => $schoolLogo,
'isgl_logo' => $isglLogo,
'school_year' => $assignment->school_year ?? $schoolYear,
];
}
public function getAvailableSchoolYears(): array
{
$schoolYears = [];
try {
if (Schema::hasTable('teacher_class')) {
$schoolYears = DB::table('teacher_class')
->selectRaw('DISTINCT school_year')
->whereNotNull('school_year')
->orderByDesc('school_year')
->pluck('school_year')
->filter()
->values()
->all();
}
} catch (\Throwable) {
}
if (empty($schoolYears)) {
try {
if (Schema::hasTable('user_roles') && Schema::hasColumn('user_roles', 'school_year')) {
$schoolYears = DB::table('user_roles')
->selectRaw('DISTINCT school_year')
->whereNotNull('school_year')
->orderByDesc('school_year')
->pluck('school_year')
->filter()
->values()
->all();
}
} catch (\Throwable) {
}
}
return $schoolYears;
}
protected function resolveClassName(int $classSectionId): ?string
{
$row = DB::table('classSection')
->select('class_section_name')
->where('class_section_id', $classSectionId)
->first();
return $row->class_section_name ?? null;
}
}
-69
View File
@@ -1,69 +0,0 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;
class EmailService
{
protected array $from = [];
protected array $to = [];
protected string $subject = '';
protected string $message = '';
public function setFrom(string $email, string $name = ''): self
{
$this->from = ['email' => $email, 'name' => $name];
return $this;
}
public function setTo(string|array $email): self
{
$this->to = is_array($email) ? $email : [$email];
return $this;
}
public function setSubject(string $subject): self
{
$this->subject = $subject;
return $this;
}
public function setMessage(string $message): self
{
$this->message = $message;
return $this;
}
public function send(string $to = '', string $subject = '', string $message = '', ?string $template = null): bool
{
if (!empty($to)) {
$this->setTo($to);
}
if ($subject !== '') {
$this->setSubject($subject);
}
if ($message !== '') {
$this->setMessage($message);
}
if (empty($this->to)) {
return false;
}
try {
Mail::html($this->message, function ($mail) use ($template) {
$mail->to($this->to);
if (!empty($this->from)) {
$mail->from($this->from['email'], $this->from['name']);
}
$mail->subject($this->subject);
});
return true;
} catch (\Throwable $e) {
Log::error('EmailService send failed: ' . $e->getMessage());
return false;
}
}
}
-12
View File
@@ -1,12 +0,0 @@
<?php
namespace App\Services;
class FeeCalculationService
{
public function calculateRefund(array $students, int $parentId): float
{
// TODO: Replace with actual refund calculation logic.
return 0.0;
}
}
-74
View File
@@ -1,74 +0,0 @@
<?php
namespace App\Services;
class NavbarService
{
/**
* Build a hierarchical tree of nav items grouped by parent_id.
*
* @param array $items
* @return array
*/
public function buildTree(array $items): array
{
$normalized = array_map([$this, 'normalizeItem'], $items);
$grouped = [];
foreach ($normalized as $item) {
$parentId = $this->castParentId($item['parent_id'] ?? null);
$grouped[$parentId][] = $item;
}
$buildLevel = function ($parentId) use (&$grouped, &$buildLevel) {
if (!isset($grouped[$parentId])) {
return [];
}
$children = $grouped[$parentId];
usort($children, fn ($a, $b) => ($a['order'] ?? 0) <=> ($b['order'] ?? 0));
return array_map(function ($child) use ($buildLevel) {
$child['children'] = $buildLevel($child['id']);
return $child;
}, $children);
};
return $buildLevel(null);
}
private function normalizeItem($item): array
{
if (is_object($item)) {
$item = (array) $item;
}
return [
'id' => (int) ($item['id'] ?? 0),
'label' => (string) ($item['label'] ?? ''),
'url' => $item['url'] ?? '',
'icon' => $item['icon'] ?? $item['icon_class'] ?? null,
'parent_id' => $item['parent_id'] ?? $item['menu_parent_id'] ?? null,
'order' => (int) ($item['order'] ?? $item['sort_order'] ?? 0),
'is_enabled'=> (bool) ($item['is_enabled'] ?? true),
];
}
private function castParentId($value): ?int
{
if ($value === null || $value === '') {
return null;
}
return (int) $value;
}
/**
* Clear navigation cache (placeholder for future caching implementation)
*/
public function clearCache(): void
{
// TODO: Implement cache clearing if caching is added
// For now, this is a no-op to maintain compatibility
}
}
-31
View File
@@ -1,31 +0,0 @@
<?php
namespace App\Services;
class PhoneFormatterService
{
/**
* Normalize a phone number by stripping non-digits and formatting US numbers.
*/
public function formatPhoneNumber(string $phone): string
{
$digits = preg_replace('/\D+/', '', $phone);
if ($digits === null || $digits === '') {
return '';
}
if (strlen($digits) === 10) {
return sprintf('(%s) %s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
}
return $digits;
}
/**
* Determine if a phone string contains any digits.
*/
public function hasDigits(string $phone): bool
{
return preg_match('/\d/', $phone) === 1;
}
}
-13
View File
@@ -1,13 +0,0 @@
<?php
namespace App\Services;
use Illuminate\Support\Str;
class SchoolIdService
{
public function generate(): string
{
return strtoupper('SID-' . Str::random(8));
}
}
-45
View File
@@ -1,45 +0,0 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
class SemesterScoreService
{
public function updateStudentScores(array $data, string $semester, string $schoolYear): void
{
Log::info('SemesterScoreService placeholder update', [
'student_id' => $data['student_id'] ?? null,
'class_section_id' => $data['class_section_id'] ?? null,
'semester' => $semester,
'school_year' => $schoolYear,
]);
// TODO: Implement actual score recalculation logic.
}
/**
* Update scores for multiple students
* @param array $studentInfo Array of student info arrays, each containing at least student_id and class_section_id
*/
public function updateScoresForStudents(array $studentInfo): void
{
if (empty($studentInfo)) {
return;
}
foreach ($studentInfo as $student) {
if (!isset($student['student_id']) || !isset($student['class_section_id'])) {
continue;
}
try {
$this->updateStudentScores($student, $student['semester'] ?? '', $student['school_year'] ?? '');
} catch (\Throwable $e) {
Log::error('Failed to update scores for student', [
'student_id' => $student['student_id'] ?? null,
'error' => $e->getMessage(),
]);
}
}
}
}