606 lines
22 KiB
PHP
606 lines
22 KiB
PHP
<?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;
|
|
}
|
|
}
|
|
} |