diff --git a/app/Http/Controllers/Api/Auth/AuthController.php b/app/Http/Controllers/Api/Auth/AuthController.php index f7447f8e..44bf7935 100644 --- a/app/Http/Controllers/Api/Auth/AuthController.php +++ b/app/Http/Controllers/Api/Auth/AuthController.php @@ -103,11 +103,15 @@ class AuthController extends BaseApiController return $this->respondError('Unauthorized.', 401); } + $teacherContext = $user->teacherSessionContext(); + return $this->respondSuccess([ 'id' => (int) $user->id, 'firstname' => $user->firstname, 'lastname' => $user->lastname, 'email' => $user->email, + 'class_section_id' => $teacherContext['class_section_id'], + 'class_section_name' => $teacherContext['class_section_name'], ], 'OK'); } diff --git a/app/Http/Controllers/Api/Staff/TeacherController.php b/app/Http/Controllers/Api/Staff/TeacherController.php index 9a10ec07..83c2ea10 100644 --- a/app/Http/Controllers/Api/Staff/TeacherController.php +++ b/app/Http/Controllers/Api/Staff/TeacherController.php @@ -54,6 +54,7 @@ class TeacherController extends BaseApiController 'students_by_section' => $studentsBySection, 'assigned_names' => $data['assignedNames'] ?? [], 'active_class_section_id' => $data['active_class_section_id'] ?? null, + 'class_section_name' => $data['class_section_name'] ?? null, ]); } diff --git a/app/Http/Controllers/Api/Students/StudentController.php b/app/Http/Controllers/Api/Students/StudentController.php index cad842e1..7f3d0e11 100644 --- a/app/Http/Controllers/Api/Students/StudentController.php +++ b/app/Http/Controllers/Api/Students/StudentController.php @@ -106,6 +106,8 @@ class StudentController extends BaseApiController $validator = Validator::make($data, [ 'q' => ['nullable', 'string', 'max:255'], 'limit' => ['nullable', 'integer', 'min:1', 'max:1000'], + 'class_section_id' => ['nullable', 'integer', 'min:1'], + 'school_year' => ['nullable', 'string', 'max:32'], ]); if ($validator->fails()) { @@ -118,35 +120,24 @@ class StudentController extends BaseApiController $payload = $validator->validated(); $query = trim((string) ($payload['q'] ?? '')); $limit = (int) ($payload['limit'] ?? ($query !== '' ? 50 : 1000)); + $classSectionId = isset($payload['class_section_id']) ? (int) $payload['class_section_id'] : null; + $schoolYear = isset($payload['school_year']) ? trim((string) $payload['school_year']) : null; - $studentsQuery = Student::query() - ->select('id', 'school_id', 'firstname', 'lastname', 'is_active') - ->where('is_active', 1); - - if ($query !== '') { - $studentsQuery->where(function ($builder) use ($query) { - $builder->where('firstname', 'like', '%' . $query . '%') - ->orWhere('lastname', 'like', '%' . $query . '%') - ->orWhere('school_id', 'like', '%' . $query . '%'); - }); + $user = Auth::user(); + if (! $user instanceof User) { + return response()->json([ + 'ok' => false, + 'message' => 'Unauthorized.', + ], 401); } - $students = $studentsQuery - ->orderBy('lastname') - ->orderBy('firstname') - ->limit($limit) - ->get() - ->map(fn (Student $student) => [ - 'id' => (int) $student->id, - 'student_id' => (int) $student->id, - 'school_id' => (string) ($student->school_id ?? ''), - 'firstname' => (string) ($student->firstname ?? ''), - 'lastname' => (string) ($student->lastname ?? ''), - 'name' => trim(((string) ($student->firstname ?? '')) . ' ' . ((string) ($student->lastname ?? ''))), - 'is_active' => (int) ($student->is_active ?? 0), - ]) - ->values() - ->all(); + $students = $this->scoreCardService->scoreCardSelectableStudents( + $user, + $query, + $limit, + $classSectionId > 0 ? $classSectionId : null, + $schoolYear !== '' ? $schoolYear : null, + ); return response()->json([ 'ok' => true, diff --git a/app/Http/Controllers/Web/TeacherCalendarPageController.php b/app/Http/Controllers/Web/TeacherCalendarPageController.php new file mode 100644 index 00000000..d9a9b807 --- /dev/null +++ b/app/Http/Controllers/Web/TeacherCalendarPageController.php @@ -0,0 +1,50 @@ +contextService->defaultSchoolYear(); + $events = $this->queryService + ->filterEventsForAudience( + $this->queryService->listEvents([ + 'school_year' => $schoolYear, + ]), + 'teacher', + ) + ->map(fn ($event) => $this->formatterService->formatEvent($event, 'teacher')) + ->values() + ->all(); + + foreach ($this->meetingService->listMeetings($schoolYear, 'teacher', null) as $meeting) { + $events[] = $this->formatterService->formatMeetingEvent($meeting, 'teacher'); + } + + usort($events, function (array $left, array $right): int { + return strcmp((string) ($left['start'] ?? ''), (string) ($right['start'] ?? '')); + }); + + return view('teacher.calendar', [ + 'events' => $events, + 'schoolYear' => $schoolYear, + 'defaultSemester' => $this->contextService->defaultSemester(), + ]); + } +} diff --git a/app/Http/Requests/Users/UserUpdateRequest.php b/app/Http/Requests/Users/UserUpdateRequest.php index 0fb48c0f..9c688b6a 100644 --- a/app/Http/Requests/Users/UserUpdateRequest.php +++ b/app/Http/Requests/Users/UserUpdateRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests\Users; use App\Http\Requests\ApiFormRequest; +use App\Models\User; use Illuminate\Validation\Rule; class UserUpdateRequest extends ApiFormRequest @@ -14,14 +15,30 @@ class UserUpdateRequest extends ApiFormRequest public function rules(): array { - $userId = (int) ($this->route('userId') ?? 0); + $userId = (int) ( + $this->route('userId') + ?? $this->route('id') + ?? $this->input('id') + ?? 0 + ); + $currentEmail = ''; + + if ($userId > 0) { + $currentEmail = strtolower(trim((string) (User::query()->whereKey($userId)->value('email') ?? ''))); + } + + $emailRules = ['sometimes', 'email', 'max:255']; + $incomingEmail = strtolower(trim((string) $this->input('email', ''))); + if ($incomingEmail !== '' && $incomingEmail !== $currentEmail) { + $emailRules[] = Rule::unique('users', 'email')->ignore($userId); + } return [ 'firstname' => ['sometimes', 'string', 'max:255'], 'lastname' => ['sometimes', 'string', 'max:255'], 'gender' => ['sometimes', 'nullable', 'string', Rule::in(['Male', 'Female', 'male', 'female', 'MALE', 'FEMALE'])], 'cellphone' => ['sometimes', 'string', 'max:25'], - 'email' => ['sometimes', 'email', 'max:255', Rule::unique('users', 'email')->ignore($userId)], + 'email' => $emailRules, 'address_street' => ['sometimes', 'string', 'max:255'], 'apt' => ['sometimes', 'nullable', 'string', 'max:25'], 'city' => ['sometimes', 'string', 'max:255'], diff --git a/app/Http/Resources/Attendance/TeacherAttendanceFormResource.php b/app/Http/Resources/Attendance/TeacherAttendanceFormResource.php index f69a268f..0e72ddfc 100644 --- a/app/Http/Resources/Attendance/TeacherAttendanceFormResource.php +++ b/app/Http/Resources/Attendance/TeacherAttendanceFormResource.php @@ -9,6 +9,10 @@ class TeacherAttendanceFormResource extends JsonResource { public function toArray(Request $request): array { + $enable = (int)($this['enable_attendance'] ?? 0); + $res = $this->resource; + $hasCanEditKey = is_array($res) && array_key_exists('can_edit', $res); + return [ 'teacher_name' => $this['teacher_name'] ?? null, 'students' => $this['students'] ?? [], @@ -17,7 +21,8 @@ class TeacherAttendanceFormResource extends JsonResource 'sunday_dates' => $this['sunday_dates'] ?? [], 'current_sunday' => $this['current_sunday'] ?? null, 'enable_attendance' => $this['enable_attendance'] ?? null, - 'can_edit' => (bool)($this['can_edit'] ?? false), + // Do not default missing `can_edit` to false — that disables the whole form while attendance is enabled. + 'can_edit' => $hasCanEditKey ? (bool) $this['can_edit'] : ($enable === 1), 'class_id' => $this['class_id'] ?? null, 'no_school_days' => $this['no_school_days'] ?? [], 'today' => $this['today'] ?? null, diff --git a/app/Http/Resources/Frontend/FrontendUserResource.php b/app/Http/Resources/Frontend/FrontendUserResource.php index b02f262e..7d6ab4d1 100644 --- a/app/Http/Resources/Frontend/FrontendUserResource.php +++ b/app/Http/Resources/Frontend/FrontendUserResource.php @@ -9,9 +9,15 @@ class FrontendUserResource extends JsonResource { public function toArray(Request $request): array { + $teacherContext = method_exists($this->resource, 'teacherSessionContext') + ? $this->resource->teacherSessionContext() + : ['class_section_id' => null, 'class_section_name' => null]; + return [ 'firstname' => $this->firstname ?? null, 'lastname' => $this->lastname ?? null, + 'class_section_id' => $teacherContext['class_section_id'], + 'class_section_name' => $teacherContext['class_section_name'], ]; } } diff --git a/app/Http/Resources/Settings/SchoolCalendarEventResource.php b/app/Http/Resources/Settings/SchoolCalendarEventResource.php index 26f98fdc..c9ed375d 100644 --- a/app/Http/Resources/Settings/SchoolCalendarEventResource.php +++ b/app/Http/Resources/Settings/SchoolCalendarEventResource.php @@ -13,6 +13,7 @@ class SchoolCalendarEventResource extends JsonResource 'title' => (string) ($this['title'] ?? ''), 'start' => (string) ($this['start'] ?? ''), 'description' => (string) ($this['description'] ?? ''), + 'backgroundColor' => $this['backgroundColor'] ?? null, 'extendedProps' => (array) ($this['extendedProps'] ?? []), ]; } diff --git a/app/Models/Staff.php b/app/Models/Staff.php index 5aa9c819..62fa5769 100644 --- a/app/Models/Staff.php +++ b/app/Models/Staff.php @@ -132,6 +132,18 @@ class Staff extends BaseModel ->first(); } + // The table enforces unique email addresses, so a user cannot safely + // have multiple staff rows across different school years with the same + // generated email. If an exact school-year row is missing but the user + // already has any staff row, update that existing row instead of trying + // to insert a duplicate-email record. + if (!$existing) { + $existing = static::query() + ->where('user_id', $userId) + ->orderByDesc('id') + ->first(); + } + $now = now(); // use UTC if app.timezone=UTC (recommended) // Only allow fillable keys @@ -195,4 +207,4 @@ class Staff extends BaseModel 'updated_at' => ['nullable', 'date'], ]; } -} \ No newline at end of file +} diff --git a/app/Models/TeacherClass.php b/app/Models/TeacherClass.php index 17148f75..c8c48e43 100644 --- a/app/Models/TeacherClass.php +++ b/app/Models/TeacherClass.php @@ -150,6 +150,27 @@ class TeacherClass extends BaseModel })->all(); } + /** + * Distinct `class_section_id` values this teacher has ever been assigned to (`teacher_class`), + * ignoring school year / semester (covers legacy rows vs `class_progress_reports`). + * + * @return list + */ + public static function distinctSectionIdsForTeacher(int $userId): array + { + return static::query() + ->where('teacher_id', $userId) + ->whereNotNull('class_section_id') + ->where('class_section_id', '>', 0) + ->distinct() + ->orderBy('class_section_id') + ->pluck('class_section_id') + ->map(static fn ($id) => (int) $id) + ->unique() + ->values() + ->all(); + } + /** CI: getClassSectionsByTeacherId() */ public static function getClassSectionsByTeacherId(int $teacherId): array { @@ -285,15 +306,41 @@ class TeacherClass extends BaseModel /** * CI: assignedForSectionTerm() * Returns rows: class_section_id, teacher_id, position, firstname, lastname + * + * @param int|null $alsoSectionCode When teacher_class rows use the section PK while $sectionCode + * is the business code (or the reverse), pass the other id. + * @param bool $requireSemester When false, ignore semester (legacy DBs with blank/wrong tc.semester). */ - public static function assignedForSectionTerm(int $sectionCode, string $semester, string $schoolYear): array - { - return DB::table('teacher_class as tc') + public static function assignedForSectionTerm( + int $sectionCode, + string $semester, + string $schoolYear, + ?int $alsoSectionCode = null, + bool $requireSemester = true, + ): array { + $sectionIds = array_values(array_unique(array_filter( + [$sectionCode, $alsoSectionCode ?? 0], + static fn (int $v): bool => $v > 0, + ))); + + if ($sectionIds === []) { + return []; + } + + $q = DB::table('teacher_class as tc') ->selectRaw('tc.class_section_id, tc.teacher_id, tc.position, u.firstname, u.lastname') ->leftJoin('users as u', 'u.id', '=', 'tc.teacher_id') - ->where('tc.class_section_id', $sectionCode) - ->where('tc.school_year', $schoolYear) - ->orderByRaw("CASE tc.position WHEN 'main' THEN 0 WHEN 'ta' THEN 1 ELSE 2 END") + ->whereIn('tc.class_section_id', $sectionIds) + ->where('tc.school_year', $schoolYear); + + if ($requireSemester) { + $sem = trim($semester); + if ($sem !== '') { + $q->whereRaw('TRIM(tc.semester) = ?', [$sem]); + } + } + + return $q->orderByRaw("CASE tc.position WHEN 'main' THEN 0 WHEN 'ta' THEN 1 ELSE 2 END") ->orderBy('u.firstname', 'asc') ->get() ->map(fn ($r) => (array) $r) @@ -301,10 +348,15 @@ class TeacherClass extends BaseModel } /** CI: assignedMapForSectionTerm() */ - public static function assignedMapForSectionTerm(int $sectionCode, string $semester, string $schoolYear): array - { + public static function assignedMapForSectionTerm( + int $sectionCode, + string $semester, + string $schoolYear, + ?int $alsoSectionCode = null, + bool $requireSemester = true, + ): array { $out = []; - foreach (static::assignedForSectionTerm($sectionCode, $semester, $schoolYear) as $r) { + foreach (static::assignedForSectionTerm($sectionCode, $semester, $schoolYear, $alsoSectionCode, $requireSemester) as $r) { $out[(int) $r['teacher_id']] = $r; } return $out; diff --git a/app/Models/User.php b/app/Models/User.php index a56872ba..c7e11a45 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -97,6 +97,62 @@ class User extends Authenticatable implements JWTSubject ->all(); } + /** + * Resolve the primary teacher class section to persist in auth/session payloads. + * + * @return array{class_section_id: ?int, class_section_name: ?string} + */ + public function teacherSessionContext(): array + { + $empty = [ + 'class_section_id' => null, + 'class_section_name' => null, + ]; + + $roles = array_map( + static fn ($role) => strtolower(trim((string) $role)), + $this->roleNamesLikeCodeIgniter(), + ); + + $isTeacherScoped = in_array('teacher', $roles, true) + || in_array('teacher_assistant', $roles, true) + || in_array('ta', $roles, true); + + if (! $isTeacherScoped) { + return $empty; + } + + $schoolYear = trim((string) (Configuration::getConfig('school_year') ?? '')); + $semester = trim((string) (Configuration::getConfig('semester') ?? '')); + + $assignments = TeacherClass::getClassAssignmentsByUserId( + (int) $this->id, + $schoolYear !== '' ? $schoolYear : null, + $semester !== '' ? $semester : null, + ); + + if ($assignments === []) { + $assignments = TeacherClass::getClassAssignmentsByUserId( + (int) $this->id, + null, + $semester !== '' ? $semester : null, + ); + } + + $primary = $assignments[0] ?? null; + if (! is_array($primary)) { + return $empty; + } + + $classSectionId = (int) ($primary['class_section_id'] ?? 0); + $classSectionName = trim((string) ($primary['class_section_name'] ?? '')); + + return [ + 'class_section_id' => $classSectionId > 0 ? $classSectionId : null, + 'class_section_name' => $classSectionName !== '' ? $classSectionName : null, + ]; + } + /* ============================================================ * Relationships * ============================================================ diff --git a/app/Policies/ClassProgressReportPolicy.php b/app/Policies/ClassProgressReportPolicy.php index 8189bfd3..c762c8e9 100644 --- a/app/Policies/ClassProgressReportPolicy.php +++ b/app/Policies/ClassProgressReportPolicy.php @@ -3,7 +3,6 @@ namespace App\Policies; use App\Models\ClassProgressReport; -use App\Models\Configuration; use App\Models\TeacherClass; use App\Models\User; @@ -16,7 +15,11 @@ class ClassProgressReportPolicy public function view(User $user, ClassProgressReport $report): bool { - return $this->owns($user, $report) || $this->isAdmin($user); + if ($this->isAdmin($user)) { + return true; + } + + return $this->isAuthor($user, $report) || $this->teachesSection($user, $report); } public function create(User $user): bool @@ -26,33 +29,27 @@ class ClassProgressReportPolicy public function update(User $user, ClassProgressReport $report): bool { - return $this->owns($user, $report) || $this->isAdmin($user); + return $this->isAdmin($user) || $this->isAuthor($user, $report); } public function delete(User $user, ClassProgressReport $report): bool { - return $this->owns($user, $report) || $this->isAdmin($user); + return $this->isAdmin($user) || $this->isAuthor($user, $report); } - private function owns(User $user, ClassProgressReport $report): bool + private function isAuthor(User $user, ClassProgressReport $report): bool { - if ((int) $report->teacher_id === (int) $user->id) { - return true; - } + return (int) $report->teacher_id === (int) $user->id; + } - $schoolYear = (string) (Configuration::getConfig('school_year') ?? ''); - if ($schoolYear === '') { - return false; - } - - $semester = (string) (Configuration::getConfig('semester') ?? ''); - foreach (TeacherClass::assignedForSectionTerm((int) $report->class_section_id, $semester, $schoolYear) as $row) { - if ((int) ($row['teacher_id'] ?? 0) === (int) $user->id) { - return true; - } - } - - return false; + /** Same relaxed section membership used by `ClassProgressQueryService::listReports`. */ + private function teachesSection(User $user, ClassProgressReport $report): bool + { + return in_array( + (int) $report->class_section_id, + TeacherClass::distinctSectionIdsForTeacher((int) $user->id), + true + ); } private function isAdmin(User $user): bool diff --git a/app/Policies/IpAttemptPolicy.php b/app/Policies/IpAttemptPolicy.php index 17bf6c23..aa9bf8f4 100644 --- a/app/Policies/IpAttemptPolicy.php +++ b/app/Policies/IpAttemptPolicy.php @@ -22,12 +22,12 @@ class IpAttemptPolicy return $this->isAdmin($user); } - public function update(User $user, IpAttempt $ipAttempt): bool + public function update(User $user, ?IpAttempt $ipAttempt = null): bool { return $this->isAdmin($user); } - public function delete(User $user, IpAttempt $ipAttempt): bool + public function delete(User $user, ?IpAttempt $ipAttempt = null): bool { return $this->isAdmin($user); } diff --git a/app/Services/Attendance/AttendanceQueryService.php b/app/Services/Attendance/AttendanceQueryService.php index e69cf4d1..3312bb35 100644 --- a/app/Services/Attendance/AttendanceQueryService.php +++ b/app/Services/Attendance/AttendanceQueryService.php @@ -15,6 +15,7 @@ use App\Models\User; use App\Models\UserRole; use Carbon\Carbon; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use RuntimeException; class AttendanceQueryService @@ -181,6 +182,10 @@ class AttendanceQueryService $attendanceRows = []; $lockedByStudent = []; + $attendanceTable = $this->attendanceData->getTable(); + $dateInSundaysSql = 'DATE(`' . $attendanceTable . '`.`date`) IN (' . + implode(',', array_fill(0, count($sundayDates), '?')) . ')'; + foreach ($students as $student) { $studentId = (int)$student['id']; @@ -189,11 +194,13 @@ class AttendanceQueryService ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->where('semester', $semester) - ->where(function ($q) use ($classSectionId) { - $q->where('class_section_id', $classSectionId) - ->orWhere('class_section_id', (int)$classSectionId); + ->where(function ($q) use ($classSectionId, $classSectionPk) { + $q->where('class_section_id', $classSectionId); + if ($classSectionPk > 0 && $classSectionPk !== $classSectionId) { + $q->orWhere('class_section_id', $classSectionPk); + } }) - ->whereIn('date', $sundayDates) + ->whereRaw($dateInSundaysSql, $sundayDates) ->get() ->map(fn($row) => (array)$row) ->all(); @@ -205,9 +212,11 @@ class AttendanceQueryService ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->where('semester', $semester) - ->where(function ($q) use ($classSectionId) { - $q->where('class_section_id', $classSectionId) - ->orWhere('class_section_id', (int)$classSectionId); + ->where(function ($q) use ($classSectionId, $classSectionPk) { + $q->where('class_section_id', $classSectionId); + if ($classSectionPk > 0 && $classSectionPk !== $classSectionId) { + $q->orWhere('class_section_id', $classSectionPk); + } }) ->orderByDesc('date') ->limit(3) @@ -225,7 +234,10 @@ class AttendanceQueryService $snapshot = []; $rowsByDate = []; foreach ($rows as $entry) { - $d = (string)($entry['date'] ?? ''); + $d = $this->calendarDateKey($entry['date'] ?? null); + if ($d === '') { + continue; + } $snapshot[$d] = $entry['status'] ?? null; $rowsByDate[$d] = $entry; } @@ -237,6 +249,23 @@ class AttendanceQueryService : ($date === $currentSunday ? null : 'N/A'); } + // Recent list uses the same section filters but no Sunday window; if the windowed query + // missed rows (SQL/driver quirks), still show statuses that match the visible columns. + foreach ($recentRows as $row) { + $d = $this->calendarDateKey($row['date'] ?? null); + if ($d === '' || !in_array($d, $sundayDates, true)) { + continue; + } + $rowStatus = $row['status'] ?? null; + if ($rowStatus === null || $rowStatus === '') { + continue; + } + $current = $statusHistory[$d] ?? null; + if ($current === 'N/A' || $current === null) { + $statusHistory[$d] = $rowStatus; + } + } + $todayRow = $rowsByDate[$currentSunday] ?? null; $lockInfo = $this->attendanceService->detectExternalSubmission($todayRow); $todayReason = $todayRow['reason'] ?? null; @@ -259,7 +288,56 @@ class AttendanceQueryService ]; } - $assigned = $this->teacherClass->assignedForSectionTerm($classSectionId, $semester, $schoolYear); + $candidates = array_values(array_unique(array_filter( + [$classSectionCode, $classSectionPk], + static fn (int $v): bool => $v > 0, + ))); + $alsoSectionId = null; + foreach ($candidates as $cid) { + if ($cid !== $classSectionId) { + $alsoSectionId = $cid; + break; + } + } + + $assigned = $this->teacherClass->assignedForSectionTerm( + $classSectionId, + $semester, + $schoolYear, + $alsoSectionId, + true, + ); + if ($assigned === []) { + $assigned = $this->teacherClass->assignedForSectionTerm( + $classSectionId, + $semester, + $schoolYear, + $alsoSectionId, + false, + ); + } + if ($assigned === []) { + $assigned = $this->teacherRosterFromStaffAttendance( + $sundayDates, + $semester, + $schoolYear, + $classSectionId, + $classSectionPk, + ); + } + if ($assigned === [] && $userId > 0) { + $self = $this->user->query()->select(['id', 'firstname', 'lastname'])->find($userId); + if ($self) { + $assigned = [[ + 'class_section_id' => $classSectionId, + 'teacher_id' => (int) $self->id, + 'position' => 'main', + 'firstname' => $self->firstname, + 'lastname' => $self->lastname, + ]]; + } + } + $teacherRows = []; if (!empty($assigned)) { @@ -267,16 +345,34 @@ class AttendanceQueryService $statusMap = []; if (!empty($teacherIds)) { - $rows = DB::table('staff_attendance as sa') + $staffDateInSql = 'DATE(`sa`.`date`) IN (' . + implode(',', array_fill(0, count($sundayDates), '?')) . ')'; + $staffQ = DB::table('staff_attendance as sa') ->select('sa.user_id', 'sa.date', 'sa.status', 'sa.reason') ->where('sa.semester', $semester) ->where('sa.school_year', $schoolYear) ->whereIn('sa.user_id', $teacherIds) - ->whereIn('sa.date', $sundayDates) - ->get(); + ->whereRaw($staffDateInSql, $sundayDates); + + if (Schema::hasColumn('staff_attendance', 'class_section_id')) { + $staffQ->where(function ($w) use ($classSectionId, $classSectionPk) { + $w->whereNull('sa.class_section_id') + ->orWhere('sa.class_section_id', 0) + ->orWhere('sa.class_section_id', $classSectionId); + if ($classSectionPk > 0 && $classSectionPk !== $classSectionId) { + $w->orWhere('sa.class_section_id', $classSectionPk); + } + }); + } + + $rows = $staffQ->get(); foreach ($rows as $row) { - $statusMap[(int)$row->user_id][(string)$row->date] = [ + $dk = $this->calendarDateKey($row->date ?? null); + if ($dk === '') { + continue; + } + $statusMap[(int)$row->user_id][$dk] = [ 'status' => $row->status, 'reason' => $row->reason, ]; @@ -296,27 +392,39 @@ class AttendanceQueryService : ($date === $currentSunday ? null : 'N/A'); } + $todayReason = null; + if (isset($statusMap[$teacherId][$currentSunday]) && is_array($statusMap[$teacherId][$currentSunday])) { + $todayReason = $statusMap[$teacherId][$currentSunday]['reason'] ?? null; + } + $teacherRows[] = [ 'teacher_id' => $teacherId, 'position' => $position, 'name' => $name !== '' ? $name : 'User#' . $teacherId, 'sunday_statuses' => $history, 'today' => $history[$currentSunday] ?? null, + 'today_reason' => $todayReason, ]; } } $noSchoolDays = []; + $calendarTable = $this->calendar->getTable(); + $calDateInSql = 'DATE(`' . $calendarTable . '`.`date`) IN (' . + implode(',', array_fill(0, count($sundayDates), '?')) . ')'; $calendarRows = $this->calendar->query() ->where('no_school', 1) - ->whereIn('date', $sundayDates) + ->whereRaw($calDateInSql, $sundayDates) ->where('semester', $semester) ->where('school_year', $schoolYear) ->get() ->toArray(); foreach ($calendarRows as $row) { - $d = (string)($row['date'] ?? ''); + $d = $this->calendarDateKey($row['date'] ?? null); + if ($d === '') { + continue; + } $noSchoolDays[$d] = [ 'title' => $row['title'] ?? 'No School', 'description' => $row['description'] ?? '', @@ -601,4 +709,171 @@ class AttendanceQueryService 'currentAdminName' => $adminName, ]; } + + /** + * Distinct user_ids with staff rows on the given Sundays, scoped by teacher_class section assignment + * (works when staff_attendance has no class_section_id column). + * + * @return list + */ + private function distinctStaffAttendeeIdsViaTeacherClassJoin( + array $sundayDates, + string $semester, + string $schoolYear, + int $classSectionId, + int $classSectionPk, + ): array { + $sectionIds = array_values(array_unique(array_filter( + [$classSectionId, $classSectionPk], + static fn (int $v): bool => $v > 0, + ))); + if ($sundayDates === [] || $sectionIds === []) { + return []; + } + + $staffDateInSql = 'DATE(`sa`.`date`) IN (' . + implode(',', array_fill(0, count($sundayDates), '?')) . ')'; + + $run = static function ( + bool $requireTcSemester, + bool $requireSaSemester, + ) use ($sundayDates, $schoolYear, $semester, $staffDateInSql, $sectionIds): array { + $q = DB::table('staff_attendance as sa') + ->join('teacher_class as tc', function ($j) use ($sectionIds, $schoolYear, $requireTcSemester, $semester) { + $j->on('tc.teacher_id', '=', 'sa.user_id') + ->whereIn('tc.class_section_id', $sectionIds) + ->where('tc.school_year', '=', $schoolYear); + if ($requireTcSemester && trim($semester) !== '') { + $j->whereRaw('TRIM(tc.semester) = ?', [trim($semester)]); + } + }) + ->where('sa.school_year', $schoolYear) + ->whereRaw($staffDateInSql, $sundayDates); + if ($requireSaSemester && trim($semester) !== '') { + $q->whereRaw('TRIM(sa.semester) = ?', [trim($semester)]); + } + + return $q->distinct() + ->pluck('sa.user_id') + ->map(static fn ($v): int => (int) $v) + ->filter(static fn (int $v): bool => $v > 0) + ->values() + ->all(); + }; + + $ids = $run(true, true); + if ($ids === []) { + $ids = $run(false, true); + } + if ($ids === []) { + $ids = $run(false, false); + } + + return $ids; + } + + /** + * When teacher_class has no rows, build a roster from staff_attendance for this section/window. + * + * @return array + */ + private function teacherRosterFromStaffAttendance( + array $sundayDates, + string $semester, + string $schoolYear, + int $classSectionId, + int $classSectionPk, + ): array { + if ($sundayDates === []) { + return []; + } + + $staffDateInSql = 'DATE(`sa`.`date`) IN (' . + implode(',', array_fill(0, count($sundayDates), '?')) . ')'; + + $ids = []; + + if (Schema::hasColumn('staff_attendance', 'class_section_id')) { + $applySectionScope = function ($query) use ($classSectionId, $classSectionPk): void { + $query->where(function ($w) use ($classSectionId, $classSectionPk) { + $w->whereNull('sa.class_section_id') + ->orWhere('sa.class_section_id', 0) + ->orWhere('sa.class_section_id', $classSectionId); + if ($classSectionPk > 0 && $classSectionPk !== $classSectionId) { + $w->orWhere('sa.class_section_id', $classSectionPk); + } + }); + }; + + $base = function (bool $useSemester) use ($sundayDates, $schoolYear, $semester, $staffDateInSql, $applySectionScope) { + $q = DB::table('staff_attendance as sa') + ->where('sa.school_year', $schoolYear) + ->whereRaw($staffDateInSql, $sundayDates); + if ($useSemester && trim($semester) !== '') { + $q->whereRaw('TRIM(sa.semester) = ?', [trim($semester)]); + } + $applySectionScope($q); + + return $q->distinct()->pluck('sa.user_id')->map(static fn ($v) => (int) $v)->filter(static fn (int $v) => $v > 0)->values()->all(); + }; + + $ids = $base(true); + if ($ids === []) { + $ids = $base(false); + } + } + + if ($ids === []) { + $ids = $this->distinctStaffAttendeeIdsViaTeacherClassJoin( + $sundayDates, + $semester, + $schoolYear, + $classSectionId, + $classSectionPk, + ); + } + + if ($ids === []) { + return []; + } + + $users = $this->user->query() + ->select(['id', 'firstname', 'lastname']) + ->whereIn('id', $ids) + ->orderBy('firstname') + ->orderBy('lastname') + ->get(); + + $out = []; + foreach ($users as $u) { + $out[] = [ + 'class_section_id' => $classSectionId, + 'teacher_id' => (int) $u->id, + 'position' => 'main', + 'firstname' => $u->firstname, + 'lastname' => $u->lastname, + ]; + } + + return $out; + } + + /** + * Normalize DB datetimes / date strings to Y-m-d so they match {@see $sundayDates} keys. + */ + private function calendarDateKey(mixed $value): string + { + $s = trim((string)($value ?? '')); + if ($s === '') { + return ''; + } + if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $s, $m)) { + return $m[1]; + } + try { + return Carbon::parse($s)->toDateString(); + } catch (\Throwable) { + return ''; + } + } } \ No newline at end of file diff --git a/app/Services/Attendance/TeacherAttendanceSubmissionService.php b/app/Services/Attendance/TeacherAttendanceSubmissionService.php index 636b28ca..19617a6a 100644 --- a/app/Services/Attendance/TeacherAttendanceSubmissionService.php +++ b/app/Services/Attendance/TeacherAttendanceSubmissionService.php @@ -53,6 +53,8 @@ class TeacherAttendanceSubmissionService } $classSectionId = (int)$sectionRow->class_section_id; + $classSectionPk = (int)($sectionRow->id ?? 0); + $alsoSectionId = ($classSectionPk > 0 && $classSectionPk !== $classSectionId) ? $classSectionPk : null; $classId = (int)$sectionRow->class_id; $attendanceData = $data['attendance'] ?? []; $teachersData = $data['teachers'] ?? []; @@ -108,7 +110,22 @@ class TeacherAttendanceSubmissionService } $allowedStatuses = ['present', 'absent', 'late']; - $assignedTeachers = $this->teacherClass->assignedForSectionTerm($classSectionId, $semester, $schoolYear); + $assignedTeachers = $this->teacherClass->assignedForSectionTerm( + $classSectionId, + $semester, + $schoolYear, + $alsoSectionId, + true, + ); + if ($assignedTeachers === []) { + $assignedTeachers = $this->teacherClass->assignedForSectionTerm( + $classSectionId, + $semester, + $schoolYear, + $alsoSectionId, + false, + ); + } foreach ($assignedTeachers as $teacher) { $teacherId = (int)$teacher['teacher_id']; diff --git a/app/Services/Auth/ApiLoginSecurityService.php b/app/Services/Auth/ApiLoginSecurityService.php index 3626a907..23067cd7 100644 --- a/app/Services/Auth/ApiLoginSecurityService.php +++ b/app/Services/Auth/ApiLoginSecurityService.php @@ -116,12 +116,13 @@ class ApiLoginSecurityService /** * Top-level JSON body aligned with the CodeIgniter 4 `AuthController::apiLogin` success payload. * - * @return array{status: true, token: string, user: array{id: int, name: string, roles: object}} + * @return array{status: true, token: string, user: array{id: int, name: string, roles: object, class_section_id: ?int, class_section_name: ?string}} */ public function buildLoginResponse(User $user, string $jwtToken): array { $roleNames = $user->roleNamesLikeCodeIgniter(); $fullName = trim(($user->firstname ?? '').' '.($user->lastname ?? '')); + $teacherContext = $user->teacherSessionContext(); return [ 'status' => true, @@ -130,6 +131,8 @@ class ApiLoginSecurityService 'id' => (int) $user->id, 'name' => $fullName, 'roles' => $this->rolesToObjectMap($roleNames), + 'class_section_id' => $teacherContext['class_section_id'], + 'class_section_name' => $teacherContext['class_section_name'], ], ]; } diff --git a/app/Services/Auth/AuthSessionService.php b/app/Services/Auth/AuthSessionService.php index 23b38dc1..980afeb4 100644 --- a/app/Services/Auth/AuthSessionService.php +++ b/app/Services/Auth/AuthSessionService.php @@ -124,6 +124,7 @@ class AuthSessionService $schoolYear = Configuration::getConfig('school_year'); $semester = Configuration::getConfig('semester'); + $teacherContext = $user->teacherSessionContext(); session([ 'user_id' => $user->id, @@ -136,6 +137,8 @@ class AuthSessionService 'roles' => $roleNames, 'semester' => $semester, 'school_year' => $schoolYear, + 'class_section_id' => $teacherContext['class_section_id'], + 'class_section_name' => $teacherContext['class_section_name'], ]); $this->applyStylePreferencesToSession((int) $user->id); diff --git a/app/Services/ClassProgress/ClassProgressQueryService.php b/app/Services/ClassProgress/ClassProgressQueryService.php index 119f9f9d..1d6875c8 100644 --- a/app/Services/ClassProgress/ClassProgressQueryService.php +++ b/app/Services/ClassProgress/ClassProgressQueryService.php @@ -14,15 +14,26 @@ class ClassProgressQueryService public function listReports(User $user, array $filters): LengthAwarePaginator { $query = ClassProgressReport::query() - ->with('classSection') + ->with(['classSection', 'teacher']) ->orderBy($filters['sort_by'] ?? 'week_start', $filters['sort_dir'] ?? 'desc'); if (!$this->isAdmin($user)) { + $relaxedSectionIds = TeacherClass::distinctSectionIdsForTeacher((int) $user->id); + if (! empty($filters['class_section_id'])) { - $ids = $this->resolveAssignedTeacherIds((int) $filters['class_section_id']); - $query->whereIn('teacher_id', $ids !== [] ? $ids : [(int) $user->id]); + $sid = (int) $filters['class_section_id']; + // Section filter: if this user has ever taught the section, show all rows for that section + // (legacy DB often has `class_progress_reports` without matching current-year `teacher_class`). + if (! in_array($sid, $relaxedSectionIds, true)) { + $query->where('teacher_id', (int) $user->id); + } } else { - $query->where('teacher_id', $user->id); + $query->where(function ($q) use ($user, $relaxedSectionIds) { + $q->where('teacher_id', (int) $user->id); + if ($relaxedSectionIds !== []) { + $q->orWhereIn('class_section_id', $relaxedSectionIds); + } + }); } } elseif (! empty($filters['teacher_id'])) { $query->where('teacher_id', (int) $filters['teacher_id']); @@ -76,10 +87,7 @@ class ClassProgressQueryService ->whereDate('week_start', $report->week_start) ->orderBy('subject', 'asc'); - if (! $this->isAdmin($user)) { - $ids = $this->resolveAssignedTeacherIds((int) $report->class_section_id); - $query->whereIn('teacher_id', $ids !== [] ? $ids : [(int) $user->id]); - } + // Non-admins reach this only after policy `view` passed on the anchor report (author or teaches section). $rows = $query->get(); $attachments = $this->attachmentMap($rows->pluck('id')->all()); @@ -160,6 +168,9 @@ class ClassProgressQueryService $semester = (string) (Configuration::getConfig('semester') ?? ''); $rows = TeacherClass::assignedForSectionTerm($classSectionId, $semester, $schoolYear); + if ($rows === []) { + $rows = TeacherClass::assignedForSectionTerm($classSectionId, $semester, $schoolYear, null, false); + } $ids = []; foreach ($rows as $row) { $id = (int) ($row['teacher_id'] ?? 0); @@ -177,8 +188,10 @@ class ClassProgressQueryService return true; } - $ids = $this->resolveAssignedTeacherIds((int) $report->class_section_id); - - return $ids !== [] && in_array((int) $user->id, $ids, true); + return in_array( + (int) $report->class_section_id, + TeacherClass::distinctSectionIdsForTeacher((int) $user->id), + true + ); } } diff --git a/app/Services/Roles/RoleQueryService.php b/app/Services/Roles/RoleQueryService.php index 0e8276a5..f0be5453 100644 --- a/app/Services/Roles/RoleQueryService.php +++ b/app/Services/Roles/RoleQueryService.php @@ -13,7 +13,10 @@ class RoleQueryService { $query = Role::query() ->select('roles.*') - ->leftJoin('user_roles', 'user_roles.role_id', '=', 'roles.id') + ->leftJoin('user_roles', function ($join) { + $join->on('user_roles.role_id', '=', 'roles.id') + ->whereNull('user_roles.deleted_at'); + }) ->selectRaw('COUNT(DISTINCT user_roles.user_id) AS user_count') ->groupBy('roles.id'); @@ -42,7 +45,10 @@ class RoleQueryService public function listUsersWithRoles(array $filters): LengthAwarePaginator { $query = DB::table('users') - ->leftJoin('user_roles', 'user_roles.user_id', '=', 'users.id') + ->leftJoin('user_roles', function ($join) { + $join->on('user_roles.user_id', '=', 'users.id') + ->whereNull('user_roles.deleted_at'); + }) ->leftJoin('roles', 'roles.id', '=', 'user_roles.role_id') ->select( 'users.id', diff --git a/app/Services/Roles/RoleStaffService.php b/app/Services/Roles/RoleStaffService.php index 2e4009b5..17473e0b 100644 --- a/app/Services/Roles/RoleStaffService.php +++ b/app/Services/Roles/RoleStaffService.php @@ -11,17 +11,14 @@ class RoleStaffService public function syncStaffRecord(User $user, array $newRoleNames): void { $excludedRoles = ['parent', 'student', 'guest']; - $loweredNewRoles = array_map('strtolower', $newRoleNames); - - $existingStaff = Staff::query()->where('user_id', $user->id)->first(); - $existingRoles = []; - if ($existingStaff && !empty($existingStaff->role_name)) { - $existingRoles = array_map('strtolower', array_map('trim', explode(',', $existingStaff->role_name))); - } - - $allRoles = array_unique(array_merge($existingRoles, $loweredNewRoles)); - $staffRoles = array_filter($newRoleNames, fn ($role) => !in_array(strtolower($role), $excludedRoles, true)); - $activeRole = !empty($staffRoles) ? $staffRoles[0] : 'inactive'; + $staffRoles = array_values(array_unique(array_filter( + array_map( + static fn ($role) => trim((string) $role), + $newRoleNames + ), + static fn ($role) => $role !== '' && !in_array(strtolower($role), $excludedRoles, true) + ))); + $activeRole = !empty($staffRoles) ? strtolower((string) $staffRoles[0]) : 'inactive'; $email = $this->generateUniqueStaffEmail($user->firstname ?? '', $user->lastname ?? '', (int) $user->id); @@ -31,7 +28,7 @@ class RoleStaffService 'lastname' => $user->lastname ?? '', 'email' => $email, 'phone' => $user->cellphone ?? '', - 'role_name' => implode(', ', $allRoles), + 'role_name' => $this->compactRoleList($staffRoles), 'active_role' => $activeRole, 'school_year' => $user->school_year ?? '', ]; @@ -42,6 +39,27 @@ class RoleStaffService } } + /** + * Fit the current staff role list into the legacy `staff.role_name` field. + */ + private function compactRoleList(array $roles, int $maxLength = 100): string + { + if ($roles === []) { + return 'inactive'; + } + + $out = ''; + foreach ($roles as $role) { + $next = $out === '' ? $role : $out . ', ' . $role; + if (strlen($next) > $maxLength) { + break; + } + $out = $next; + } + + return $out !== '' ? $out : substr((string) $roles[0], 0, $maxLength); + } + private function generateUniqueStaffEmail(string $firstname, string $lastname, int $userId): string { $domain = '@alrahmaisgl.org'; diff --git a/app/Services/Settings/SchoolCalendar/SchoolCalendarFormatterService.php b/app/Services/Settings/SchoolCalendar/SchoolCalendarFormatterService.php index 6b5d5585..c75ab131 100644 --- a/app/Services/Settings/SchoolCalendar/SchoolCalendarFormatterService.php +++ b/app/Services/Settings/SchoolCalendar/SchoolCalendarFormatterService.php @@ -3,11 +3,16 @@ namespace App\Services\Settings\SchoolCalendar; use App\Models\CalendarEvent; +use Carbon\Carbon; +use Carbon\CarbonInterface; class SchoolCalendarFormatterService { public function formatEvent(CalendarEvent|array $event, ?string $audience = null): array { + $eventDate = $event instanceof CalendarEvent + ? $this->extractDateValue($event) + : $this->normalizeDateValue($event['date'] ?? null); $event = $event instanceof CalendarEvent ? $event->toArray() : $event; $audience = $audience !== null ? strtolower(trim($audience)) : null; @@ -36,8 +41,9 @@ class SchoolCalendarFormatterService return [ 'id' => (int) ($event['id'] ?? 0), 'title' => $title, - 'start' => (string) ($event['date'] ?? ''), + 'start' => $eventDate, 'description' => (string) ($event['description'] ?? ''), + 'backgroundColor' => $backgroundColor, 'extendedProps' => [ 'semester' => (string) ($event['semester'] ?? ''), 'school_year' => (string) ($event['school_year'] ?? ''), @@ -51,6 +57,43 @@ class SchoolCalendarFormatterService ]; } + private function extractDateValue(CalendarEvent $event): string + { + $raw = $event->getRawOriginal('date'); + if (is_string($raw) && trim($raw) !== '') { + return $this->normalizeDateValue($raw); + } + + $value = $event->getAttribute('date'); + if ($value instanceof CarbonInterface) { + return $value->toDateString(); + } + + return $this->normalizeDateValue($value); + } + + private function normalizeDateValue(mixed $value): string + { + if ($value instanceof CarbonInterface) { + return $value->toDateString(); + } + + $value = trim((string) $value); + if ($value === '') { + return ''; + } + + if (preg_match('/^\d{4}-\d{2}-\d{2}/', $value, $matches) === 1) { + return $matches[0]; + } + + try { + return Carbon::parse($value)->toDateString(); + } catch (\Throwable) { + return $value; + } + } + public function formatMeetingEvent(array $row, ?string $audience = null): array { $audience = $audience !== null ? strtolower(trim($audience)) : null; @@ -83,6 +126,7 @@ class SchoolCalendarFormatterService 'title' => $title, 'start' => $start, 'description' => implode("\n", $descParts), + 'backgroundColor' => '#6f42c1', 'extendedProps' => [ 'semester' => (string) ($row['semester'] ?? ''), 'school_year' => (string) ($row['school_year'] ?? ''), diff --git a/app/Services/Students/StudentScoreCardService.php b/app/Services/Students/StudentScoreCardService.php index 0f706ae6..0506c54f 100644 --- a/app/Services/Students/StudentScoreCardService.php +++ b/app/Services/Students/StudentScoreCardService.php @@ -2,11 +2,143 @@ namespace App\Services\Students; +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 StudentScoreCardService { + /** + * Students shown on the score-card picker (CI `StudentController::scoreCardList` parity). + * Teachers: only students in `class_section_id` for `school_year` after `teacher_class` check. + * Parents: their children only. Other roles: active students (optional search/limit). + * + * @return list + */ + public function scoreCardSelectableStudents( + User $user, + string $searchQuery, + int $limit, + ?int $classSectionId, + ?string $schoolYear, + ): array { + $rolesLower = array_map('strtolower', $user->roleNamesLikeCodeIgniter()); + $isTeacher = in_array('teacher', $rolesLower, true) || in_array('teacher_assistant', $rolesLower, true); + $isParent = in_array('parent', $rolesLower, true); + + $cid = (int) ($classSectionId ?? 0); + $year = $schoolYear !== null && trim($schoolYear) !== '' ? trim($schoolYear) : (string) (Configuration::getConfig('school_year') ?? ''); + + if ($isTeacher && $cid > 0) { + $assigned = TeacherClass::query() + ->where('teacher_id', $user->id) + ->where('class_section_id', $cid) + ->when($year !== '', fn ($q) => $q->where('school_year', $year)) + ->exists(); + + if (! $assigned) { + return []; + } + + $students = $year !== '' + ? Student::getByClassAndYear($cid, $year) + : Student::query() + ->whereIn('id', StudentClass::query()->select('student_id')->where('class_section_id', $cid)) + ->where('is_active', 1) + ->orderBy('lastname') + ->orderBy('firstname') + ->get() + ->all(); + + $rows = array_map(fn (Student $s) => $this->selectableStudentPayload($s), $students); + + return $this->applySearchAndLimit($rows, $searchQuery, $limit); + } + + if ($isTeacher && ! $isParent) { + return []; + } + + if ($isParent) { + $q = Student::query() + ->select('id', 'school_id', 'firstname', 'lastname', 'is_active') + ->where('parent_id', $user->id) + ->where('is_active', 1); + + if ($searchQuery !== '') { + $sq = $searchQuery; + $q->where(function ($b) use ($sq) { + $b->where('firstname', 'like', '%'.$sq.'%') + ->orWhere('lastname', 'like', '%'.$sq.'%') + ->orWhere('school_id', 'like', '%'.$sq.'%'); + }); + } + + $students = $q->orderBy('lastname')->orderBy('firstname')->limit($limit)->get(); + + return $students->map(fn (Student $s) => $this->selectableStudentPayload($s))->values()->all(); + } + + $studentsQuery = Student::query() + ->select('id', 'school_id', 'firstname', 'lastname', 'is_active') + ->where('is_active', 1); + + if ($searchQuery !== '') { + $studentsQuery->where(function ($builder) use ($searchQuery) { + $builder->where('firstname', 'like', '%'.$searchQuery.'%') + ->orWhere('lastname', 'like', '%'.$searchQuery.'%') + ->orWhere('school_id', 'like', '%'.$searchQuery.'%'); + }); + } + + return $studentsQuery + ->orderBy('lastname') + ->orderBy('firstname') + ->limit($limit) + ->get() + ->map(fn (Student $student) => $this->selectableStudentPayload($student)) + ->values() + ->all(); + } + + /** + * @param list $rows + * @return list + */ + private function applySearchAndLimit(array $rows, string $searchQuery, int $limit): array + { + if ($searchQuery === '') { + return array_slice($rows, 0, $limit); + } + + $needle = strtolower($searchQuery); + $filtered = array_values(array_filter($rows, static function (array $r) use ($needle): bool { + $hay = strtolower( + ($r['firstname'] ?? '').' '.($r['lastname'] ?? '').' '.($r['school_id'] ?? '') + ); + + return str_contains($hay, $needle); + })); + + return array_slice($filtered, 0, $limit); + } + + private function selectableStudentPayload(Student $student): array + { + return [ + 'id' => (int) $student->id, + 'student_id' => (int) $student->id, + 'school_id' => (string) ($student->school_id ?? ''), + 'firstname' => (string) ($student->firstname ?? ''), + 'lastname' => (string) ($student->lastname ?? ''), + 'name' => trim(((string) ($student->firstname ?? '')).' '.((string) ($student->lastname ?? ''))), + 'is_active' => (int) ($student->is_active ?? 0), + ]; + } + public function scoreCard(int $studentId): array { $student = Student::query() diff --git a/app/Services/Teachers/TeacherDashboardService.php b/app/Services/Teachers/TeacherDashboardService.php index 36350055..3723e007 100644 --- a/app/Services/Teachers/TeacherDashboardService.php +++ b/app/Services/Teachers/TeacherDashboardService.php @@ -41,6 +41,7 @@ class TeacherDashboardService 'students' => [], 'studentsBySection' => [], 'active_class_section_id' => null, + 'class_section_name' => null, 'assignedNames' => [], ]; } @@ -118,12 +119,24 @@ class TeacherDashboardService $teacher = User::query()->find($userId)?->toArray(); + $classSectionName = null; + if ($activeClassSectionId !== null && (int) $activeClassSectionId > 0) { + foreach ($classes as $row) { + if ((int) ($row['class_section_id'] ?? 0) === (int) $activeClassSectionId) { + $n = trim((string) ($row['class_section_name'] ?? '')); + $classSectionName = $n !== '' ? $n : null; + break; + } + } + } + return [ 'teacher' => $teacher, 'classes' => $classes, 'students' => $activeClassSectionId ? ($studentsBySection[$activeClassSectionId] ?? []) : [], 'studentsBySection' => $studentsBySection, 'active_class_section_id' => $activeClassSectionId, + 'class_section_name' => $classSectionName, 'assignedNames' => $assignedNames, ]; } diff --git a/resources/views/teacher/calendar.blade.php b/resources/views/teacher/calendar.blade.php new file mode 100644 index 00000000..c185615a --- /dev/null +++ b/resources/views/teacher/calendar.blade.php @@ -0,0 +1,663 @@ + + + + + + Teacher Calendar + + + +
+
+
+
+
Teacher Portal
+

School Calendar

+

Monthly view for teacher-visible events, no-school days, and staff-only items.

+
+
+ +
+ +
+
+
+
+

Month

+
Teacher schedule overview
+
+
+ + + +
+
+
+
+
Sun
+
Mon
+
Tue
+
Wed
+
Thu
+
Fri
+
Sat
+
+
+
+
General event
+
No school
+
Staff only
+
Meeting
+
+
Select any day to inspect the event list for that date.
+
+
+ + +
+
+ + + + diff --git a/routes/web.php b/routes/web.php index 6ac320d3..2779ae12 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Api\Auth\AuthSessionController; use App\Http\Controllers\Api\Auth\SessionTimeoutController; use App\Http\Controllers\Api\System\DashboardRedirectController; +use App\Http\Controllers\Web\TeacherCalendarPageController; use Illuminate\Support\Facades\Route; /* @@ -75,4 +76,8 @@ Route::middleware('web')->group(function () { Route::middleware('auth')->get('dashboard', [DashboardRedirectController::class, 'dashboard']) ->name('dashboard.redirect'); + Route::middleware('auth')->get('teacher/calendar', [TeacherCalendarPageController::class, 'show']) + ->name('teacher.calendar'); + Route::middleware('auth')->get('app/teacher/calendar', [TeacherCalendarPageController::class, 'show']) + ->name('app.teacher.calendar'); }); diff --git a/tests/Feature/Api/V1/Settings/SchoolCalendarControllerTest.php b/tests/Feature/Api/V1/Settings/SchoolCalendarControllerTest.php index dcefcf66..be959119 100644 --- a/tests/Feature/Api/V1/Settings/SchoolCalendarControllerTest.php +++ b/tests/Feature/Api/V1/Settings/SchoolCalendarControllerTest.php @@ -39,6 +39,7 @@ class SchoolCalendarControllerTest extends TestCase $response->assertOk(); $response->assertJsonPath('status', true); $this->assertNotEmpty($response->json('data.events')); + $this->assertArrayHasKey('backgroundColor', $response->json('data.events.0')); $this->assertArrayHasKey('extendedProps', $response->json('data.events.0')); } diff --git a/tests/Unit/ApiLoginSecurityServiceTest.php b/tests/Unit/ApiLoginSecurityServiceTest.php index bdbb89d8..cbf3c6c5 100644 --- a/tests/Unit/ApiLoginSecurityServiceTest.php +++ b/tests/Unit/ApiLoginSecurityServiceTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit; +use App\Models\User; use App\Services\Auth\ApiLoginSecurityService; use PHPUnit\Framework\TestCase; @@ -20,4 +21,58 @@ class ApiLoginSecurityServiceTest extends TestCase $this->assertStringContainsString('"Teacher":true', $payload); $this->assertStringContainsString('"Admin":true', $payload); } + + public function test_build_login_response_includes_teacher_class_context(): void + { + $service = new ApiLoginSecurityService(); + + $user = $this->getMockBuilder(User::class) + ->onlyMethods(['roleNamesLikeCodeIgniter', 'teacherSessionContext']) + ->getMock(); + + $user->id = 99; + $user->firstname = 'Amina'; + $user->lastname = 'Teacher'; + + $user->method('roleNamesLikeCodeIgniter')->willReturn(['Teacher']); + $user->method('teacherSessionContext')->willReturn([ + 'class_section_id' => 42, + 'class_section_name' => 'Grade 5 - A', + ]); + + $payload = $service->buildLoginResponse($user, 'jwt-token'); + + $this->assertTrue($payload['status']); + $this->assertSame('jwt-token', $payload['token']); + $this->assertSame(99, $payload['user']['id']); + $this->assertSame('Amina Teacher', $payload['user']['name']); + $this->assertSame(42, $payload['user']['class_section_id']); + $this->assertSame('Grade 5 - A', $payload['user']['class_section_name']); + $this->assertTrue($payload['user']['roles']->Teacher); + } + + public function test_build_login_response_includes_teacher_assistant_class_context(): void + { + $service = new ApiLoginSecurityService(); + + $user = $this->getMockBuilder(User::class) + ->onlyMethods(['roleNamesLikeCodeIgniter', 'teacherSessionContext']) + ->getMock(); + + $user->id = 100; + $user->firstname = 'Samir'; + $user->lastname = 'TA'; + + $user->method('roleNamesLikeCodeIgniter')->willReturn(['Teacher_Assistant']); + $user->method('teacherSessionContext')->willReturn([ + 'class_section_id' => 17, + 'class_section_name' => 'Grade 3 - B', + ]); + + $payload = $service->buildLoginResponse($user, 'jwt-token'); + + $this->assertSame(17, $payload['user']['class_section_id']); + $this->assertSame('Grade 3 - B', $payload['user']['class_section_name']); + $this->assertTrue($payload['user']['roles']->Teacher_Assistant); + } } diff --git a/tests/Unit/Resources/Settings/SchoolCalendarEventResourceTest.php b/tests/Unit/Resources/Settings/SchoolCalendarEventResourceTest.php index 5ff27e23..0a14559b 100644 --- a/tests/Unit/Resources/Settings/SchoolCalendarEventResourceTest.php +++ b/tests/Unit/Resources/Settings/SchoolCalendarEventResourceTest.php @@ -14,6 +14,7 @@ class SchoolCalendarEventResourceTest extends TestCase 'title' => 'Event', 'start' => '2026-01-01', 'description' => 'Desc', + 'backgroundColor' => '#28a745', 'extendedProps' => ['no_school' => 0], ]); @@ -23,6 +24,8 @@ class SchoolCalendarEventResourceTest extends TestCase $this->assertArrayHasKey('title', $payload); $this->assertArrayHasKey('start', $payload); $this->assertArrayHasKey('description', $payload); + $this->assertArrayHasKey('backgroundColor', $payload); $this->assertArrayHasKey('extendedProps', $payload); + $this->assertSame('#28a745', $payload['backgroundColor']); } } diff --git a/tests/Unit/Services/Settings/SchoolCalendar/SchoolCalendarFormatterServiceTest.php b/tests/Unit/Services/Settings/SchoolCalendar/SchoolCalendarFormatterServiceTest.php index ac5ecfa4..9a07b15a 100644 --- a/tests/Unit/Services/Settings/SchoolCalendar/SchoolCalendarFormatterServiceTest.php +++ b/tests/Unit/Services/Settings/SchoolCalendar/SchoolCalendarFormatterServiceTest.php @@ -20,7 +20,24 @@ class SchoolCalendarFormatterServiceTest extends TestCase 'notify_admin' => 0, ], 'parent'); + $this->assertSame('#ffc107', $formatted['backgroundColor']); $this->assertSame('#ffc107', $formatted['extendedProps']['backgroundColor']); $this->assertSame('No School Day', $formatted['title']); } + + public function test_format_event_normalizes_iso_dates_to_plain_calendar_date(): void + { + $service = new SchoolCalendarFormatterService(); + $formatted = $service->formatEvent([ + 'id' => 2, + 'title' => 'First Day of School', + 'date' => '2025-09-21T00:00:00.000000Z', + 'no_school' => 0, + 'notify_parent' => 0, + 'notify_teacher' => 0, + 'notify_admin' => 0, + ], 'teacher'); + + $this->assertSame('2025-09-21', $formatted['start']); + } } diff --git a/tests/Unit/Services/Teachers/TeacherDashboardServiceTest.php b/tests/Unit/Services/Teachers/TeacherDashboardServiceTest.php index 635510e0..f8880cb2 100644 --- a/tests/Unit/Services/Teachers/TeacherDashboardServiceTest.php +++ b/tests/Unit/Services/Teachers/TeacherDashboardServiceTest.php @@ -24,6 +24,7 @@ class TeacherDashboardServiceTest extends TestCase $data = $service->classView($teacherId, $classSectionId); $this->assertSame($classSectionId, $data['active_class_section_id']); + $this->assertSame('1A', $data['class_section_name']); $this->assertNotEmpty($data['classes']); $this->assertNotEmpty($data['students']); }