fix parent, teacher and admin pages
This commit is contained in:
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Settings\SchoolCalendar\SchoolCalendarContextService;
|
||||
use App\Services\Settings\SchoolCalendar\SchoolCalendarFormatterService;
|
||||
use App\Services\Settings\SchoolCalendar\SchoolCalendarMeetingService;
|
||||
use App\Services\Settings\SchoolCalendar\SchoolCalendarQueryService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
class TeacherCalendarPageController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private SchoolCalendarContextService $contextService,
|
||||
private SchoolCalendarQueryService $queryService,
|
||||
private SchoolCalendarMeetingService $meetingService,
|
||||
private SchoolCalendarFormatterService $formatterService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function show(): View
|
||||
{
|
||||
$schoolYear = $this->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<int>
|
||||
*/
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
* ============================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<int>
|
||||
*/
|
||||
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<int, array{class_section_id:int, teacher_id:int, position:string, firstname:?string, lastname:?string}>
|
||||
*/
|
||||
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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'] ?? ''),
|
||||
|
||||
@@ -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<array{id: int, student_id: int, school_id: string, firstname: string, lastname: string, name: string, is_active: int}>
|
||||
*/
|
||||
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<array{id: int, student_id: int, school_id: string, firstname: string, lastname: string, name: string, is_active: int}> $rows
|
||||
* @return list<array{id: int, student_id: int, school_id: string, firstname: string, lastname: string, name: string, is_active: int}>
|
||||
*/
|
||||
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()
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,663 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Teacher Calendar</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4efe6;
|
||||
--panel: #fffaf2;
|
||||
--panel-strong: #f0e3ce;
|
||||
--ink: #1d2a39;
|
||||
--muted: #6b7280;
|
||||
--accent: #0f766e;
|
||||
--accent-soft: #d4f4ef;
|
||||
--line: #d8ccb8;
|
||||
--shadow: 0 18px 45px rgba(38, 32, 24, 0.12);
|
||||
--today: #f59e0b;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15, 118, 110, 0.15), transparent 35%),
|
||||
radial-gradient(circle at top right, rgba(245, 158, 11, 0.18), transparent 28%),
|
||||
linear-gradient(180deg, #f7f1e7 0%, #efe7da 100%);
|
||||
}
|
||||
|
||||
.shell {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 48px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.9fr);
|
||||
align-items: end;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.summary-card,
|
||||
.calendar-card,
|
||||
.agenda-card {
|
||||
background: rgba(255, 250, 242, 0.92);
|
||||
border: 1px solid rgba(216, 204, 184, 0.9);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
border-radius: 28px;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: clamp(2rem, 4vw, 3.6rem);
|
||||
line-height: 0.94;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero p,
|
||||
.summary-meta,
|
||||
.agenda-empty,
|
||||
.legend span,
|
||||
.calendar-note,
|
||||
.month-caption {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
border-radius: 24px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
background: var(--panel-strong);
|
||||
border-radius: 18px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
display: block;
|
||||
font-size: 1.8rem;
|
||||
line-height: 1;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
grid-template-columns: minmax(0, 1.65fr) minmax(300px, 0.95fr);
|
||||
}
|
||||
|
||||
.calendar-card,
|
||||
.agenda-card {
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-header,
|
||||
.agenda-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 22px 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(240, 227, 206, 0.45);
|
||||
}
|
||||
|
||||
.calendar-header h2,
|
||||
.agenda-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.calendar-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.nav-button,
|
||||
.today-button {
|
||||
padding: 10px 14px;
|
||||
background: #e8ddcb;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.today-button {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.calendar-body {
|
||||
padding: 18px 18px 22px;
|
||||
}
|
||||
|
||||
.weekdays,
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.weekdays {
|
||||
padding: 0 6px 12px;
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.day {
|
||||
min-height: 132px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(216, 204, 184, 0.85);
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
transition: transform 140ms ease, box-shadow 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
|
||||
.day:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 14px 28px rgba(54, 46, 33, 0.08);
|
||||
}
|
||||
|
||||
.day.muted {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.day.today {
|
||||
border-color: rgba(245, 158, 11, 0.9);
|
||||
box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.day.selected {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px rgba(15, 118, 110, 0.22);
|
||||
background: #fffdfa;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.today .day-number {
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
color: #9a5600;
|
||||
}
|
||||
|
||||
.event-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.event-pill {
|
||||
display: block;
|
||||
padding: 7px 9px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.2;
|
||||
color: #12202f;
|
||||
background: #dbe7f5;
|
||||
border-left: 4px solid #8aa6cf;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.event-pill.more {
|
||||
background: var(--panel-strong);
|
||||
border-left-color: #9c8360;
|
||||
color: #5c4730;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 16px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.legend-swatch {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.agenda-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.agenda-date {
|
||||
margin: 0 0 14px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.agenda-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.agenda-item {
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(216, 204, 184, 0.85);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.agenda-item h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.agenda-item p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.agenda-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 10px;
|
||||
color: #5e5c52;
|
||||
}
|
||||
|
||||
.agenda-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: #8aa6cf;
|
||||
}
|
||||
|
||||
.calendar-note {
|
||||
margin-top: 10px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.hero,
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.shell {
|
||||
padding: 16px 12px 28px;
|
||||
}
|
||||
|
||||
.weekdays,
|
||||
.grid {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.day {
|
||||
min-height: 108px;
|
||||
padding: 10px 8px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.event-pill {
|
||||
padding: 6px 7px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.calendar-header,
|
||||
.agenda-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<section class="hero">
|
||||
<div class="hero-card hero">
|
||||
<div>
|
||||
<div class="eyebrow">Teacher Portal</div>
|
||||
<h1>School Calendar</h1>
|
||||
<p>Monthly view for teacher-visible events, no-school days, and staff-only items.</p>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="summary-card">
|
||||
<div class="eyebrow">Current Scope</div>
|
||||
<div class="summary-meta">School Year {{ $schoolYear }} · Default Semester {{ $defaultSemester }}</div>
|
||||
<div class="summary-grid">
|
||||
<div class="stat">
|
||||
<strong id="stat-total">0</strong>
|
||||
<span>Total events</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<strong id="stat-no-school">0</strong>
|
||||
<span>No-school days</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<strong id="stat-staff">0</strong>
|
||||
<span>Staff-only events</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="layout">
|
||||
<article class="calendar-card">
|
||||
<div class="calendar-header">
|
||||
<div>
|
||||
<h2 id="month-label">Month</h2>
|
||||
<div class="month-caption" id="month-caption">Teacher schedule overview</div>
|
||||
</div>
|
||||
<div class="calendar-toolbar">
|
||||
<button class="nav-button" id="prev-month" type="button">Prev</button>
|
||||
<button class="today-button" id="jump-today" type="button">Today</button>
|
||||
<button class="nav-button" id="next-month" type="button">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="calendar-body">
|
||||
<div class="weekdays">
|
||||
<div>Sun</div>
|
||||
<div>Mon</div>
|
||||
<div>Tue</div>
|
||||
<div>Wed</div>
|
||||
<div>Thu</div>
|
||||
<div>Fri</div>
|
||||
<div>Sat</div>
|
||||
</div>
|
||||
<div class="grid" id="calendar-grid"></div>
|
||||
<div class="legend">
|
||||
<div class="legend-item"><span class="legend-swatch" style="background:#dbe7f5"></span><span>General event</span></div>
|
||||
<div class="legend-item"><span class="legend-swatch" style="background:#ffc107"></span><span>No school</span></div>
|
||||
<div class="legend-item"><span class="legend-swatch" style="background:#28a745"></span><span>Staff only</span></div>
|
||||
<div class="legend-item"><span class="legend-swatch" style="background:#6f42c1"></span><span>Meeting</span></div>
|
||||
</div>
|
||||
<div class="calendar-note">Select any day to inspect the event list for that date.</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside class="agenda-card">
|
||||
<div class="agenda-header">
|
||||
<h2>Day Agenda</h2>
|
||||
<div class="month-caption" id="agenda-count">0 events</div>
|
||||
</div>
|
||||
<div class="agenda-body">
|
||||
<h3 class="agenda-date" id="agenda-date"></h3>
|
||||
<div class="agenda-list" id="agenda-list"></div>
|
||||
<p class="agenda-empty" id="agenda-empty">No events scheduled for this day.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const rawEvents = @json($events);
|
||||
|
||||
const events = rawEvents.map((event) => {
|
||||
const start = String(event.start || '').slice(0, 10);
|
||||
return {
|
||||
...event,
|
||||
start,
|
||||
backgroundColor: event.backgroundColor || '#8aa6cf',
|
||||
};
|
||||
});
|
||||
|
||||
const eventsByDate = events.reduce((map, event) => {
|
||||
if (!event.start) {
|
||||
return map;
|
||||
}
|
||||
if (!map[event.start]) {
|
||||
map[event.start] = [];
|
||||
}
|
||||
map[event.start].push(event);
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
Object.values(eventsByDate).forEach((list) => {
|
||||
list.sort((left, right) => String(left.title || '').localeCompare(String(right.title || '')));
|
||||
});
|
||||
|
||||
const monthLabel = document.getElementById('month-label');
|
||||
const monthCaption = document.getElementById('month-caption');
|
||||
const calendarGrid = document.getElementById('calendar-grid');
|
||||
const agendaDate = document.getElementById('agenda-date');
|
||||
const agendaList = document.getElementById('agenda-list');
|
||||
const agendaEmpty = document.getElementById('agenda-empty');
|
||||
const agendaCount = document.getElementById('agenda-count');
|
||||
const today = new Date();
|
||||
const todayKey = toDateKey(today);
|
||||
|
||||
const availableDates = Object.keys(eventsByDate).sort();
|
||||
const initialDate = availableDates.includes(todayKey) ? todayKey : (availableDates[0] || todayKey);
|
||||
|
||||
let selectedDate = initialDate;
|
||||
let visibleMonth = new Date(initialDate + 'T12:00:00');
|
||||
visibleMonth.setDate(1);
|
||||
|
||||
document.getElementById('prev-month').addEventListener('click', () => {
|
||||
visibleMonth = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() - 1, 1);
|
||||
renderCalendar();
|
||||
});
|
||||
|
||||
document.getElementById('next-month').addEventListener('click', () => {
|
||||
visibleMonth = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() + 1, 1);
|
||||
renderCalendar();
|
||||
});
|
||||
|
||||
document.getElementById('jump-today').addEventListener('click', () => {
|
||||
selectedDate = todayKey;
|
||||
visibleMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
renderCalendar();
|
||||
renderAgenda();
|
||||
});
|
||||
|
||||
function renderCalendar() {
|
||||
calendarGrid.innerHTML = '';
|
||||
|
||||
const year = visibleMonth.getFullYear();
|
||||
const month = visibleMonth.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const leading = firstDay.getDay();
|
||||
const totalCells = Math.ceil((leading + lastDay.getDate()) / 7) * 7;
|
||||
const startDate = new Date(year, month, 1 - leading);
|
||||
|
||||
monthLabel.textContent = new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' }).format(firstDay);
|
||||
monthCaption.textContent = `${countMonthEvents(year, month)} event${countMonthEvents(year, month) === 1 ? '' : 's'} in view`;
|
||||
|
||||
for (let i = 0; i < totalCells; i += 1) {
|
||||
const cellDate = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate() + i);
|
||||
const dateKey = toDateKey(cellDate);
|
||||
const dayEvents = eventsByDate[dateKey] || [];
|
||||
const isCurrentMonth = cellDate.getMonth() === month;
|
||||
const isToday = dateKey === todayKey;
|
||||
const isSelected = dateKey === selectedDate;
|
||||
|
||||
const cell = document.createElement('button');
|
||||
cell.type = 'button';
|
||||
cell.className = `day${isCurrentMonth ? '' : ' muted'}${isToday ? ' today' : ''}${isSelected ? ' selected' : ''}`;
|
||||
cell.addEventListener('click', () => {
|
||||
selectedDate = dateKey;
|
||||
if (!isCurrentMonth) {
|
||||
visibleMonth = new Date(cellDate.getFullYear(), cellDate.getMonth(), 1);
|
||||
}
|
||||
renderCalendar();
|
||||
renderAgenda();
|
||||
});
|
||||
|
||||
const number = document.createElement('span');
|
||||
number.className = 'day-number';
|
||||
number.textContent = String(cellDate.getDate());
|
||||
cell.appendChild(number);
|
||||
|
||||
const stack = document.createElement('div');
|
||||
stack.className = 'event-stack';
|
||||
|
||||
dayEvents.slice(0, 3).forEach((event) => {
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'event-pill';
|
||||
pill.textContent = event.title || 'Untitled';
|
||||
pill.style.borderLeftColor = event.backgroundColor || '#8aa6cf';
|
||||
pill.style.background = withAlpha(event.backgroundColor || '#dbe7f5', 0.18);
|
||||
stack.appendChild(pill);
|
||||
});
|
||||
|
||||
if (dayEvents.length > 3) {
|
||||
const more = document.createElement('span');
|
||||
more.className = 'event-pill more';
|
||||
more.textContent = `+${dayEvents.length - 3} more`;
|
||||
stack.appendChild(more);
|
||||
}
|
||||
|
||||
cell.appendChild(stack);
|
||||
calendarGrid.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAgenda() {
|
||||
const dayEvents = eventsByDate[selectedDate] || [];
|
||||
agendaDate.textContent = new Intl.DateTimeFormat('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).format(new Date(selectedDate + 'T12:00:00'));
|
||||
agendaList.innerHTML = '';
|
||||
agendaCount.textContent = `${dayEvents.length} event${dayEvents.length === 1 ? '' : 's'}`;
|
||||
agendaEmpty.style.display = dayEvents.length === 0 ? 'block' : 'none';
|
||||
|
||||
dayEvents.forEach((event) => {
|
||||
const item = document.createElement('article');
|
||||
item.className = 'agenda-item';
|
||||
|
||||
const tag = document.createElement('div');
|
||||
tag.className = 'agenda-tag';
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'agenda-dot';
|
||||
dot.style.background = event.backgroundColor || '#8aa6cf';
|
||||
tag.appendChild(dot);
|
||||
tag.append(document.createTextNode(buildTagLabel(event)));
|
||||
|
||||
const title = document.createElement('h3');
|
||||
title.textContent = event.title || 'Untitled';
|
||||
|
||||
const description = document.createElement('p');
|
||||
description.textContent = event.description || 'No extra details provided.';
|
||||
|
||||
item.appendChild(tag);
|
||||
item.appendChild(title);
|
||||
item.appendChild(description);
|
||||
agendaList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function buildTagLabel(event) {
|
||||
const props = event.extendedProps || {};
|
||||
if (String(event.id || '').startsWith('pm-')) {
|
||||
return 'Meeting';
|
||||
}
|
||||
if (props.no_school) {
|
||||
return 'No School';
|
||||
}
|
||||
if (props.notify_teacher && props.notify_admin && !props.notify_parent) {
|
||||
return 'Staff Only';
|
||||
}
|
||||
return 'School Event';
|
||||
}
|
||||
|
||||
function countMonthEvents(year, month) {
|
||||
return events.filter((event) => {
|
||||
const date = new Date(event.start + 'T12:00:00');
|
||||
return date.getFullYear() === year && date.getMonth() === month;
|
||||
}).length;
|
||||
}
|
||||
|
||||
function toDateKey(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function withAlpha(hex, alpha) {
|
||||
const normalized = hex.replace('#', '');
|
||||
if (normalized.length !== 6) {
|
||||
return 'rgba(219, 231, 245, 0.18)';
|
||||
}
|
||||
const r = parseInt(normalized.slice(0, 2), 16);
|
||||
const g = parseInt(normalized.slice(2, 4), 16);
|
||||
const b = parseInt(normalized.slice(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
document.getElementById('stat-total').textContent = String(events.length);
|
||||
document.getElementById('stat-no-school').textContent = String(events.filter((event) => event.extendedProps && event.extendedProps.no_school).length);
|
||||
document.getElementById('stat-staff').textContent = String(events.filter((event) => {
|
||||
const props = event.extendedProps || {};
|
||||
return props.notify_teacher && props.notify_admin && !props.notify_parent;
|
||||
}).length);
|
||||
|
||||
renderCalendar();
|
||||
renderAgenda();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user