add projet

This commit is contained in:
root
2026-03-05 12:29:37 -05:00
parent 8d1eef8ba8
commit 23b7db1107
9109 changed files with 1106501 additions and 73 deletions
@@ -0,0 +1,330 @@
<?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;
}
}
}
@@ -0,0 +1,83 @@
<?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;
}
}
@@ -0,0 +1,606 @@
<?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;
}
}
}
+283
View File
@@ -0,0 +1,283 @@
<?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,141 @@
<?php
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;
class AttendanceCommentTemplateService
{
public function list(bool $activeOnly = false): array
{
$query = AttendanceCommentTemplate::query()->orderBy('min_score', 'asc');
if ($activeOnly) {
$query->where('is_active', 1);
}
return $query->get()
->map(fn ($row) => $this->transform($row))
->values()
->all();
}
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);
}
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());
});
}
public function update(int $id, array $data): array
{
return DB::transaction(function () use ($id, $data) {
$template = AttendanceCommentTemplate::query()->find($id);
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());
});
}
public function delete(int $id): void
{
$template = AttendanceCommentTemplate::query()->find($id);
if (!$template) {
throw new ModelNotFoundException("Attendance comment template not found.");
}
$template->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),
];
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;
class EmailService
{
protected array $from = [];
protected array $to = [];
protected string $subject = '';
protected string $message = '';
public function setFrom(string $email, string $name = ''): self
{
$this->from = ['email' => $email, 'name' => $name];
return $this;
}
public function setTo(string|array $email): self
{
$this->to = is_array($email) ? $email : [$email];
return $this;
}
public function setSubject(string $subject): self
{
$this->subject = $subject;
return $this;
}
public function setMessage(string $message): self
{
$this->message = $message;
return $this;
}
public function send(string $to = '', string $subject = '', string $message = '', ?string $template = null): bool
{
if (!empty($to)) {
$this->setTo($to);
}
if ($subject !== '') {
$this->setSubject($subject);
}
if ($message !== '') {
$this->setMessage($message);
}
if (empty($this->to)) {
return false;
}
try {
Mail::html($this->message, function ($mail) use ($template) {
$mail->to($this->to);
if (!empty($this->from)) {
$mail->from($this->from['email'], $this->from['name']);
}
$mail->subject($this->subject);
});
return true;
} catch (\Throwable $e) {
Log::error('EmailService send failed: ' . $e->getMessage());
return false;
}
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Services;
class FeeCalculationService
{
public function calculateRefund(array $students, int $parentId): float
{
// TODO: Replace with actual refund calculation logic.
return 0.0;
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace App\Services;
class NavbarService
{
/**
* Build a hierarchical tree of nav items grouped by parent_id.
*
* @param array $items
* @return array
*/
public function buildTree(array $items): array
{
$normalized = array_map([$this, 'normalizeItem'], $items);
$grouped = [];
foreach ($normalized as $item) {
$parentId = $this->castParentId($item['parent_id'] ?? null);
$grouped[$parentId][] = $item;
}
$buildLevel = function ($parentId) use (&$grouped, &$buildLevel) {
if (!isset($grouped[$parentId])) {
return [];
}
$children = $grouped[$parentId];
usort($children, fn ($a, $b) => ($a['order'] ?? 0) <=> ($b['order'] ?? 0));
return array_map(function ($child) use ($buildLevel) {
$child['children'] = $buildLevel($child['id']);
return $child;
}, $children);
};
return $buildLevel(null);
}
private function normalizeItem($item): array
{
if (is_object($item)) {
$item = (array) $item;
}
return [
'id' => (int) ($item['id'] ?? 0),
'label' => (string) ($item['label'] ?? ''),
'url' => $item['url'] ?? '',
'icon' => $item['icon'] ?? $item['icon_class'] ?? null,
'parent_id' => $item['parent_id'] ?? $item['menu_parent_id'] ?? null,
'order' => (int) ($item['order'] ?? $item['sort_order'] ?? 0),
'is_enabled'=> (bool) ($item['is_enabled'] ?? true),
];
}
private function castParentId($value): ?int
{
if ($value === null || $value === '') {
return null;
}
return (int) $value;
}
/**
* Clear navigation cache (placeholder for future caching implementation)
*/
public function clearCache(): void
{
// TODO: Implement cache clearing if caching is added
// For now, this is a no-op to maintain compatibility
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Services;
class PhoneFormatterService
{
/**
* Normalize a phone number by stripping non-digits and formatting US numbers.
*/
public function formatPhoneNumber(string $phone): string
{
$digits = preg_replace('/\D+/', '', $phone);
if ($digits === null || $digits === '') {
return '';
}
if (strlen($digits) === 10) {
return sprintf('(%s) %s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
}
return $digits;
}
/**
* Determine if a phone string contains any digits.
*/
public function hasDigits(string $phone): bool
{
return preg_match('/\d/', $phone) === 1;
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Services;
use Illuminate\Support\Str;
class SchoolIdService
{
public function generate(): string
{
return strtoupper('SID-' . Str::random(8));
}
}
+45
View File
@@ -0,0 +1,45 @@
<?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(),
]);
}
}
}
}