reconstruction of the project
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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, '<') !== 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SchoolIdService
|
||||
{
|
||||
public function generate(): string
|
||||
{
|
||||
return strtoupper('SID-' . Str::random(8));
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user