reconstruction of the project

This commit is contained in:
root
2026-03-08 16:33:24 -04:00
parent 23b7db1107
commit c8de5f7edc
9157 changed files with 77877 additions and 1073823 deletions
@@ -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;
}
}