From 7331acc91397cd9d4e621a5fdd02366a97c0eb02 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Tue, 23 Jun 2026 11:51:46 +0200 Subject: [PATCH] fixed attendance issues, removed students attendance, kept staff attendance. Added ability for teachers create and edit their calss students profiles and appropriate guardiance --- backend/docs/campus-attendance.md | 8 +- backend/docs/data-model-guide.md | 6 +- backend/docs/staff-attendance.md | 9 +- backend/docs/users.md | 11 + backend/src/db/api/users.ts | 30 +- ...0-grant-teacher-student-user-management.ts | 117 +++ ...ill-all-teacher-student-user-management.ts | 1 + ...ckfill-teacher-guardian-link-permission.ts | 1 + backend/src/db/models/users.ts | 10 + .../db/seeders/20200430130760-user-roles.ts | 4 + backend/src/db/seeders/user-roles.test.ts | 32 + backend/src/services/file.ts | 4 + .../src/services/guardian_students.test.ts | 118 +++ backend/src/services/guardian_students.ts | 68 +- backend/src/services/iam_capabilities.test.ts | 10 +- .../src/services/shared/role-policy.test.ts | 13 +- backend/src/services/shared/role-policy.ts | 4 +- backend/src/services/staff_attendance.test.ts | 41 + backend/src/services/staff_attendance.ts | 31 +- backend/src/services/users.test.ts | 402 ++++++++++ backend/src/services/users.ts | 162 +++- .../docs/campus-attendance-integration.md | 77 +- .../docs/director-dashboard-integration.md | 6 +- frontend/docs/frontend-architecture.md | 3 +- frontend/docs/my-class-integration.md | 49 ++ frontend/docs/top-bar-integration.md | 5 +- .../src/business/app-shell/selectors.test.ts | 10 + frontend/src/business/app-shell/selectors.ts | 1 + .../src/business/campus-attendance/hooks.ts | 395 +--------- .../campus-attendance/mappers.test.ts | 66 +- .../src/business/campus-attendance/mappers.ts | 33 - .../campus-attendance/printReport.test.ts | 14 +- .../business/campus-attendance/printReport.ts | 64 +- .../campus-attendance/selectors.test.ts | 161 +--- .../business/campus-attendance/selectors.ts | 217 +----- .../src/business/campus-attendance/types.ts | 53 +- .../src/business/director-dashboard/hooks.ts | 8 +- .../director-dashboard/selectors.test.ts | 2 +- .../business/director-dashboard/selectors.ts | 4 +- frontend/src/business/my-class/api.ts | 14 +- .../src/business/my-class/selectors.test.ts | 90 +++ frontend/src/business/my-class/selectors.ts | 75 ++ .../staff-attendance/selectors.test.ts | 4 +- .../business/staff-attendance/selectors.ts | 3 +- frontend/src/business/top-bar/hooks.ts | 44 +- .../src/business/top-bar/selectors.test.ts | 69 +- frontend/src/business/top-bar/selectors.ts | 23 +- frontend/src/business/user-admin/api.ts | 1 + .../CampusAttendanceEntryForm.tsx | 230 +----- .../IndividualCampusAttendanceView.tsx | 46 +- .../SuperintendentAttendanceView.tsx | 57 +- .../src/components/common/ImageUpload.tsx | 13 +- frontend/src/components/common/UserAvatar.tsx | 7 +- .../esa-funding/EsaFundingImpactRoles.tsx | 75 +- frontend/src/pages/ProfilePage.tsx | 108 +-- .../modules/CampusAttendanceDetailsPage.tsx | 52 +- frontend/src/pages/modules/MyClassPage.tsx | 704 ++++++++++++------ frontend/src/pages/modules/UserAdminPage.tsx | 226 ++++-- frontend/src/shared/api/guardianStudents.ts | 10 +- frontend/src/shared/api/users.ts | 8 +- frontend/src/test-seeds/campusAttendance.ts | 33 - 61 files changed, 2391 insertions(+), 1751 deletions(-) create mode 100644 backend/src/db/migrations/20260623090000-grant-teacher-student-user-management.ts create mode 100644 backend/src/db/migrations/20260623091000-backfill-all-teacher-student-user-management.ts create mode 100644 backend/src/db/migrations/20260623092000-backfill-teacher-guardian-link-permission.ts create mode 100644 backend/src/services/guardian_students.test.ts create mode 100644 backend/src/services/users.test.ts create mode 100644 frontend/docs/my-class-integration.md create mode 100644 frontend/src/business/my-class/selectors.test.ts create mode 100644 frontend/src/business/my-class/selectors.ts diff --git a/backend/docs/campus-attendance.md b/backend/docs/campus-attendance.md index aabcac2..2f8d8f1 100644 --- a/backend/docs/campus-attendance.md +++ b/backend/docs/campus-attendance.md @@ -1,7 +1,7 @@ # Campus Attendance Backend ## Purpose -The campus attendance slice owns campus attendance system links (`campus_attendance_config`) and manually entered daily campus attendance summaries (`campus_attendance_summaries`), both scoped per organization. The backend is the source of truth for these records. The UI works with daily aggregate totals, not student-level attendance sessions; student-level data remains in the separate generated `attendance_sessions` / `attendance_records` models and is not handled here. +The campus attendance slice owns campus attendance system links (`campus_attendance_config`) and legacy manually entered daily campus attendance summaries (`campus_attendance_summaries`), both scoped per organization. The active frontend attendance workflow tracks staff attendance through `staff_attendance_records`; student/classroom aggregate entry has been removed from the UI. Student-level data remains in the separate generated `attendance_sessions` / `attendance_records` models and is not handled here. ## Slice Files (by layer) - Route: `src/routes/campus_attendance.ts` (thin wiring; `GET /configs`, `PUT /configs/:campusKey`, `GET /summaries`, `PUT /summaries/:campusKey/:date`). Mounted at `/api/campus_attendance` behind the `authenticated` middleware in `src/index.ts`. @@ -28,7 +28,7 @@ Summary DTO fields: `id`, `campus_key`, `date` (from `attendance_date`), `total_ - Mutations (`PUT` config / summary) additionally require `FILL_ATTENDANCE` (`assertCanManageCampusAttendance`). Global-access users still pass through the shared global-access permission bypass. - Campus-key access (`assertCanAccessCampusKey`): organization/global active scope may access any campus key in the active organization; school scope may access campus keys under the active school; campus/class scope may access only the current campus key. A mismatch or missing campus key throws `ForbiddenError`. - The frontend does not send organization, campus UUID, creator, updater, or label fields. The backend derives them from the authenticated user (`requireOrganizationId`, `getCampusId`, `getDisplayName`, `currentUser.id`). -- Organization and school attendance entry still writes a campus-level summary. The aggregate screens choose a scoped campus and call the same `PUT /summaries/:campusKey/:date` endpoint; organization/school totals are read-time aggregates, not separate rows. +- The legacy summary mutation still writes a campus-level summary. Current frontend attendance entry no longer calls this endpoint. ## Tenant Scope - Every read and write filters by `organizationId: requireOrganizationId(currentUser)`. @@ -50,9 +50,9 @@ Summary DTO fields: `id`, `campus_key`, `date` (from `attendance_date`), `total_ ## Source-of-truth contract (Workstream 12) -Per the customer decision (2026-06-11), the **source of truth for campus attendance is manual entry by the `office_manager`** (and the higher campus/tenant roles), via the `PUT` config/summary endpoints guarded by the `FILL_ATTENDANCE` permission. There is no automatic derivation from student-level records. +Per the later staff-only attendance decision, the active attendance source of truth is `staff_attendance_records`. The campus summary endpoints remain for legacy data/API compatibility and are not used by the current frontend attendance entry form. -**Import from external office applications is deferred:** the customer expects to import these aggregates from other office apps in future, but the specific applications are not yet known, so no import endpoint is built now. When an application is chosen, add a dedicated import endpoint (server-side validated, same tenant/campus scoping) alongside the manual path; until then manual entry is the only writer. No frontend-only attendance source exists — every UI value traces to a `campus_attendance_summaries` row. +**Import from external office applications is deferred:** the customer expects to import these aggregates from other office apps in future, but the specific applications are not yet known, so no import endpoint is built now. When an application is chosen, add a dedicated import endpoint (server-side validated, same tenant/campus scoping) alongside the manual path. ## Tests - `src/api/controllers/campus_attendance.controller.test.ts` covers controller delegation. diff --git a/backend/docs/data-model-guide.md b/backend/docs/data-model-guide.md index 3b806e6..e460516 100644 --- a/backend/docs/data-model-guide.md +++ b/backend/docs/data-model-guide.md @@ -23,8 +23,8 @@ Also: every tenant-owned table carries `organizationId` (+ optional `campusId`) |---|---|---| | Staff acknowledge a policy/safety doc | **`policy_documents` + `policy_acknowledgments`** | per `userId × documentId × version`, idempotent. **Do not** repurpose `assessments` for acknowledgment. | | Classroom-timer sounds (file/url/recipe) | **`audio_files`** | `kind` discriminator; recipe = synth params. | -| Campus daily attendance (working `/attendance`) | **`campus_attendance_config` + `campus_attendance_summaries`** | manual **aggregate** entry by `office_manager` (`FILL_ATTENDANCE`). NOT per-student rows. | -| Staff attendance | **`staff_attendance_records`** | the staff-attendance slice. | +| Campus attendance config / legacy aggregates | **`campus_attendance_config` + `campus_attendance_summaries`** | config plus legacy aggregate rows. Current `/attendance` entry is staff-only and does not write student/classroom aggregate rows. | +| Staff attendance (working `/attendance`) | **`staff_attendance_records`** | active staff-attendance slice for campus, school, and organization attendance rollups. | | Weekly F.R.A.M.E. entry | **`frame_entries`** | `week_of` is the canonical Sunday-start ISO (`shared/constants/week.ts`). | | Per-user progress / daily self-state | **`user_progress`** | `progress_type` + `item_id` + `value`. Backs sign-learned, Classroom Support favorites, and the daily zone check-in (`item_id` = campus-local date). | | Backend-owned editable content | **`content_catalog`** | scoped/editable JSONB payloads by `content_type`; truly global static catalogs stay in frontend constants. | @@ -56,7 +56,7 @@ a coherent academic/SIS graph: - **`attendance_sessions`** — one roll-call **event** for a class (`session_date`, `session_type`, `taken_by`→`users`, `class`/`class_subject`). - **`attendance_records`** — a **student's status** in a session (`status` present/absent/late, `minutes_late`) per `studentId`. -- Two tables = one session → many student rows. **This is the per-student model and is currently unwired.** The working `/attendance` page uses the **aggregate** `campus_attendance_*` instead, and staff use `staff_attendance_records`. If per-student attendance is needed, wire these; do not fold student + staff + aggregate into one model without a deliberate decision. +- Two tables = one session → many student rows. **This is the per-student model and is currently unwired.** The working `/attendance` page uses `staff_attendance_records`; legacy `campus_attendance_*` aggregate rows remain separate. If per-student attendance is needed, wire these; do not fold student + staff + aggregate into one model without a deliberate decision. ### Scheduling (header/detail pair) diff --git a/backend/docs/staff-attendance.md b/backend/docs/staff-attendance.md index 0fdaed8..75e04ca 100644 --- a/backend/docs/staff-attendance.md +++ b/backend/docs/staff-attendance.md @@ -47,14 +47,19 @@ Invalid `startDate`/`endDate` (non-ISO) or a non-positive / non-integer `limit` ## Access Rules -Enforced by `visibilityScope` in the service: +Enforced by the service: - A user who does NOT have `READ_STAFF_ATTENDANCE_REPORTS` sees only their own - records, scoped by `userId` (`requireUserId`). + records from `GET /records`, scoped by `userId` (`requireUserId`). - A user with `READ_STAFF_ATTENDANCE_REPORTS` sees scope-filtered records: organization-wide for owner/superintendent, school campuses plus users directly assigned to that school for principal/registrar, and a single campus for director/campus scope. +- `GET /summary` uses scope-filtered record counts for users with either + `READ_STAFF_ATTENDANCE_REPORTS` or `FILL_ATTENDANCE`; users without either + permission receive only their own summary counts. This lets attendance fillers + and report readers share the same completion signal without exposing the + report row list. - A user with `FILL_ATTENDANCE` can upsert staff attendance only for staff users inside their effective scope: organization office users at organization scope, school office users at school scope, or campus users at campus/class scope. diff --git a/backend/docs/users.md b/backend/docs/users.md index dddd8bc..f7775f3 100644 --- a/backend/docs/users.md +++ b/backend/docs/users.md @@ -62,6 +62,15 @@ record when `req.params.id`/`req.body.id` equals their own id. otherwise `ValidationError('errors.forbidden.message')`. - `create` rejects a duplicate email (`iam.errors.userAlreadyExists`) and a missing email (`iam.errors.emailRequired`). +- Teacher users can be seeded with `CREATE_USERS` / `UPDATE_USERS` so they can manage student and + guardian accounts from `/my-class`. Service-level role policy limits that access to the `student` + and `guardian` target roles. Class-scope guards require student targets and requested `classId` + values to match the teacher's own `classId`; guardian updates require an existing + `guardian_students` link to a student in the teacher's class. Class-scoped user management cannot + add custom permissions or permission exclusions. +- Guardian-student links are handled by `guardian_students`. Teachers also receive + `CREATE_GUARDIAN_STUDENTS`; the link service verifies class-scoped actors only link `guardian` + users to `student` users in their own class. ## Tenant Scope @@ -70,6 +79,8 @@ record when `req.params.id`/`req.body.id` equals their own id. (`currentUser.app_role.globalAccess`) have the org constraint removed and read across organizations. - On `create`, organization membership is set from `data.organizations` via `setOrganizations`. +- On `create` with `classId`, the service resolves the class and stamps the user's `campusId` and + organization from the class's campus. - On `update`, role/org/custom-permission associations are only changed when their respective fields are present in the input. diff --git a/backend/src/db/api/users.ts b/backend/src/db/api/users.ts index c61d910..9e486c9 100644 --- a/backend/src/db/api/users.ts +++ b/backend/src/db/api/users.ts @@ -78,9 +78,9 @@ type UserListSortField = | 'name' | 'email' | 'phoneNumber' - | 'organization' | 'school' | 'campus' + | 'class' | 'role'; const NO_USER: CurrentUser = { id: null }; @@ -253,9 +253,9 @@ function parseUserSortField(value: unknown): UserListSortField | null { case 'name': case 'email': case 'phoneNumber': - case 'organization': case 'school': case 'campus': + case 'class': case 'role': return value; default: @@ -282,18 +282,18 @@ function userListOrder(field: unknown, sort: unknown): OrderItem[] { [col('users.lastName'), 'ASC'], [col('users.firstName'), 'ASC'], ]; - case 'organization': - return [ - [col('organizations.name'), direction], - [col('users.lastName'), 'ASC'], - [col('users.firstName'), 'ASC'], - ]; case 'school': return [ [col('school.name'), direction], [col('users.lastName'), 'ASC'], [col('users.firstName'), 'ASC'], ]; + case 'class': + return [ + [col('class.name'), direction], + [col('users.lastName'), 'ASC'], + [col('users.firstName'), 'ASC'], + ]; case 'campus': return [ [col('campus.name'), direction], @@ -738,6 +738,20 @@ class UsersDBApi { { model: db.schools, as: 'school', required: false }, { model: db.campuses, as: 'campus', required: false }, { model: db.classes, as: 'class', required: false }, + { + model: db.class_enrollments, + as: 'class_enrollments_student', + required: false, + attributes: ['id', 'classId', 'studentId'], + include: [ + { + model: db.classes, + as: 'class', + attributes: ['id', 'name', 'logo'], + required: false, + }, + ], + }, { model: db.permissions, as: 'custom_permissions', required: false }, { model: db.permissions, as: 'custom_permissions_filter', required: false }, { model: db.file, as: 'avatar' }, diff --git a/backend/src/db/migrations/20260623090000-grant-teacher-student-user-management.ts b/backend/src/db/migrations/20260623090000-grant-teacher-student-user-management.ts new file mode 100644 index 0000000..55e6828 --- /dev/null +++ b/backend/src/db/migrations/20260623090000-grant-teacher-student-user-management.ts @@ -0,0 +1,117 @@ +import { v4 as uuid } from 'uuid'; +import type { QueryInterface } from 'sequelize'; +import { ROLE_NAMES } from '@/shared/constants/roles'; + +const TEACHER_STUDENT_MANAGEMENT_PERMISSIONS = [ + 'CREATE_USERS', + 'UPDATE_USERS', + 'CREATE_GUARDIAN_STUDENTS', +] as const; + +function isIdRow(value: unknown): value is { id: string } { + return ( + value !== null + && typeof value === 'object' + && 'id' in value + && typeof value.id === 'string' + ); +} + +function isNamedIdRow(value: unknown): value is { id: string; name: string } { + return ( + isIdRow(value) + && 'name' in value + && typeof value.name === 'string' + ); +} + +function rows(value: unknown): readonly unknown[] { + return Array.isArray(value) ? value : []; +} + +export default { + up: async (queryInterface: QueryInterface) => { + const now = new Date(); + const [teacherRows] = await queryInterface.sequelize.query( + 'SELECT "id" FROM "roles" WHERE "name" = :name', + { replacements: { name: ROLE_NAMES.TEACHER } }, + ); + const teacherIds = rows(teacherRows).filter(isIdRow).map((row) => row.id); + + if (teacherIds.length === 0) { + return; + } + + const [permissionRows] = await queryInterface.sequelize.query( + 'SELECT "id", "name" FROM "permissions" WHERE "name" IN (:names)', + { replacements: { names: TEACHER_STUDENT_MANAGEMENT_PERMISSIONS } }, + ); + const permissionIds = new Map( + rows(permissionRows) + .filter(isNamedIdRow) + .map((permission) => [permission.name, permission.id]), + ); + + const missingPermissions = TEACHER_STUDENT_MANAGEMENT_PERMISSIONS + .filter((permissionName) => !permissionIds.has(permissionName)); + + if (missingPermissions.length > 0) { + await queryInterface.bulkInsert('permissions', missingPermissions.map((permissionName) => { + const id = uuid(); + permissionIds.set(permissionName, id); + return { + id, + name: permissionName, + createdAt: now, + updatedAt: now, + }; + })); + } + + const permissionIdValues = TEACHER_STUDENT_MANAGEMENT_PERMISSIONS + .map((permissionName) => permissionIds.get(permissionName)) + .filter((permissionId): permissionId is string => Boolean(permissionId)); + + if (permissionIdValues.length === 0) { + return; + } + + const [existingRows] = await queryInterface.sequelize.query( + `SELECT "roles_permissionsId", "permissionId" + FROM "rolesPermissionsPermissions" + WHERE "roles_permissionsId" IN (:teacherIds) + AND "permissionId" IN (:permissionIds)`, + { replacements: { teacherIds, permissionIds: permissionIdValues } }, + ); + const existingLinks = new Set( + rows(existingRows) + .filter((row): row is { roles_permissionsId: string; permissionId: string } => ( + row !== null + && typeof row === 'object' + && 'roles_permissionsId' in row + && typeof row.roles_permissionsId === 'string' + && 'permissionId' in row + && typeof row.permissionId === 'string' + )) + .map((row) => `${row.roles_permissionsId}:${row.permissionId}`), + ); + const missingLinks = teacherIds.flatMap((teacherId) => ( + permissionIdValues + .filter((permissionId) => !existingLinks.has(`${teacherId}:${permissionId}`)) + .map((permissionId) => ({ + createdAt: now, + updatedAt: now, + roles_permissionsId: teacherId, + permissionId, + })) + )); + + if (missingLinks.length > 0) { + await queryInterface.bulkInsert('rolesPermissionsPermissions', missingLinks); + } + }, + + down: async () => { + // Keep permission grants on rollback; the user service enforces class/student scope. + }, +}; diff --git a/backend/src/db/migrations/20260623091000-backfill-all-teacher-student-user-management.ts b/backend/src/db/migrations/20260623091000-backfill-all-teacher-student-user-management.ts new file mode 100644 index 0000000..6110508 --- /dev/null +++ b/backend/src/db/migrations/20260623091000-backfill-all-teacher-student-user-management.ts @@ -0,0 +1 @@ +export { default } from './20260623090000-grant-teacher-student-user-management'; diff --git a/backend/src/db/migrations/20260623092000-backfill-teacher-guardian-link-permission.ts b/backend/src/db/migrations/20260623092000-backfill-teacher-guardian-link-permission.ts new file mode 100644 index 0000000..6110508 --- /dev/null +++ b/backend/src/db/migrations/20260623092000-backfill-teacher-guardian-link-permission.ts @@ -0,0 +1 @@ +export { default } from './20260623090000-grant-teacher-student-user-management'; diff --git a/backend/src/db/models/users.ts b/backend/src/db/models/users.ts index 63b723f..4bd6cee 100644 --- a/backend/src/db/models/users.ts +++ b/backend/src/db/models/users.ts @@ -24,6 +24,7 @@ import type { HasManySetAssociationsMixin, } from 'sequelize'; import type { Campuses } from './campuses'; +import type { ClassEnrollments } from './class_enrollments'; import type { File } from './file'; import type { Messages } from './messages'; import type { Organizations } from './organizations'; @@ -71,6 +72,7 @@ export class Users extends Model< declare campus?: NonAttribute; declare school?: NonAttribute; declare class?: NonAttribute; + declare class_enrollments_student?: NonAttribute; declare custom_permissions?: NonAttribute; declare custom_permissions_filter?: NonAttribute; declare avatar?: NonAttribute; @@ -165,6 +167,14 @@ export class Users extends Model< constraints: false, }); + db.users.hasMany(db.class_enrollments, { + as: 'class_enrollments_student', + foreignKey: { + name: 'studentId', + }, + constraints: false, + }); + db.users.hasMany(db.file, { as: 'avatar', foreignKey: 'belongsToId', diff --git a/backend/src/db/seeders/20200430130760-user-roles.ts b/backend/src/db/seeders/20200430130760-user-roles.ts index 2b25141..6c67165 100644 --- a/backend/src/db/seeders/20200430130760-user-roles.ts +++ b/backend/src/db/seeders/20200430130760-user-roles.ts @@ -169,8 +169,12 @@ export function buildSeededPermissionNamesForRole(role: RoleName): readonly stri } if (READ_ONLY_ROLES.includes(role)) { + const extraEntityPermissions = role === ROLE_NAMES.TEACHER + ? ['CREATE_USERS', 'UPDATE_USERS', 'CREATE_GUARDIAN_STUDENTS'] + : []; return uniquePermissionNames([ ...entityPermissionNames.filter((name) => name.startsWith('READ_')), + ...extraEntityPermissions, ...(MODULE_PERMISSIONS_BY_ROLE[role] ?? []), ]); } diff --git a/backend/src/db/seeders/user-roles.test.ts b/backend/src/db/seeders/user-roles.test.ts index 524fb47..cb9a1c0 100644 --- a/backend/src/db/seeders/user-roles.test.ts +++ b/backend/src/db/seeders/user-roles.test.ts @@ -129,6 +129,38 @@ describe('user-role seed permission contract', () => { ]); }); + test('teacher user-management grants are limited to student roster writes', () => { + const teacherPermissions = granted(ROLE_NAMES.TEACHER); + const supportStaffPermissions = granted(ROLE_NAMES.SUPPORT_STAFF); + + assert.equal(teacherPermissions.includes('READ_USERS'), true); + assert.equal(teacherPermissions.includes('CREATE_USERS'), true); + assert.equal(teacherPermissions.includes('UPDATE_USERS'), true); + assert.equal(teacherPermissions.includes('CREATE_GUARDIAN_STUDENTS'), true); + assert.equal(teacherPermissions.includes('DELETE_USERS'), false); + assert.equal(supportStaffPermissions.includes('CREATE_USERS'), false); + assert.equal(supportStaffPermissions.includes('UPDATE_USERS'), false); + }); + + test('attendance notification eligibility is seeded through fill or report permissions', () => { + const attendanceNotificationRoles = Object.values(ROLE_NAMES).filter((role) => { + const permissions = granted(role); + return permissions.includes(FEATURE_PERMISSIONS.FILL_ATTENDANCE) + || permissions.includes(FEATURE_PERMISSIONS.READ_STAFF_ATTENDANCE_REPORTS); + }); + + assert.deepEqual(attendanceNotificationRoles, [ + ROLE_NAMES.SYSTEM_ADMIN, + ROLE_NAMES.OWNER, + ROLE_NAMES.SUPERINTENDENT, + ROLE_NAMES.PRINCIPAL, + ROLE_NAMES.REGISTRAR, + ROLE_NAMES.DIRECTOR, + ROLE_NAMES.OFFICE_MANAGER, + ROLE_NAMES.TEACHER, + ]); + }); + test('ESA funding management grants match campus and parent-scope managers', () => { const esaManagers = Object.values(ROLE_NAMES).filter((role) => granted(role).includes(FEATURE_PERMISSIONS.MANAGE_ESA_FUNDING_CONTENT), diff --git a/backend/src/services/file.ts b/backend/src/services/file.ts index f667f62..abaddb4 100644 --- a/backend/src/services/file.ts +++ b/backend/src/services/file.ts @@ -101,6 +101,10 @@ function downloadLocal(req: Request, res: Response): void { res.sendStatus(403); return; } + if (!fs.existsSync(resolved)) { + res.sendStatus(404); + return; + } res.download(resolved); } diff --git a/backend/src/services/guardian_students.test.ts b/backend/src/services/guardian_students.test.ts new file mode 100644 index 0000000..34121ad --- /dev/null +++ b/backend/src/services/guardian_students.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, mock, test } from 'node:test'; +import assert from 'node:assert/strict'; + +import db from '@/db/models'; +import GuardianStudentsService from '@/services/guardian_students'; +import ValidationError from '@/shared/errors/validation'; +import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles'; +import { createTestUser } from '@/test-utils'; + +function permission(name: string) { + return { name }; +} + +afterEach(() => { + mock.restoreAll(); +}); + +describe('GuardianStudentsService teacher class scope', () => { + test('links a guardian to a student enrolled in the teacher class', async () => { + const organizationId = '22222222-2222-4222-8222-222222222222'; + const classId = '33333333-3333-4333-8333-333333333333'; + const teacher = createTestUser({ + id: '11111111-1111-4111-8111-111111111111', + organizationId, + organizations: { id: organizationId }, + classId, + app_role: { + name: ROLE_NAMES.TEACHER, + scope: ROLE_SCOPES.CLASS, + globalAccess: false, + permissions: [permission('CREATE_GUARDIAN_STUDENTS')], + }, + }); + let createCalled = false; + + mock.method(db.sequelize, 'transaction', async () => ({ + commit: async () => undefined, + rollback: async () => undefined, + })); + mock.method(db.users, 'findOne', async (options: { where?: { id?: string } }) => { + if (options.where?.id === 'guardian-id') { + return { id: 'guardian-id' }; + } + if (options.where?.id === 'student-id') { + return { id: 'student-id', classId: null }; + } + return null; + }); + mock.method(db.users, 'findByPk', async () => ({ id: 'student-id', classId: null })); + mock.method(db.class_enrollments, 'findOne', async () => ({ id: 'enrollment-id' })); + mock.method(db.guardian_students, 'findOne', async () => null); + mock.method(db.guardian_students, 'create', async () => { + createCalled = true; + return { + get: () => ({ + id: 'link-id', + guardianId: 'guardian-id', + studentId: 'student-id', + relationship: null, + }), + }; + }); + + const result = await GuardianStudentsService.link( + { guardianId: 'guardian-id', studentId: 'student-id' }, + teacher, + ); + + assert.equal(createCalled, true); + assert.equal(result.id, 'link-id'); + }); + + test('rejects linking a guardian to a student outside the teacher class', async () => { + const organizationId = '22222222-2222-4222-8222-222222222222'; + const teacher = createTestUser({ + id: '11111111-1111-4111-8111-111111111111', + organizationId, + organizations: { id: organizationId }, + classId: '33333333-3333-4333-8333-333333333333', + app_role: { + name: ROLE_NAMES.TEACHER, + scope: ROLE_SCOPES.CLASS, + globalAccess: false, + permissions: [permission('CREATE_GUARDIAN_STUDENTS')], + }, + }); + let createCalled = false; + + mock.method(db.sequelize, 'transaction', async () => ({ + commit: async () => undefined, + rollback: async () => undefined, + })); + mock.method(db.users, 'findOne', async (options: { where?: { id?: string } }) => { + if (options.where?.id === 'guardian-id') { + return { id: 'guardian-id' }; + } + if (options.where?.id === 'student-id') { + return { id: 'student-id', classId: null }; + } + return null; + }); + mock.method(db.users, 'findByPk', async () => ({ id: 'student-id', classId: null })); + mock.method(db.class_enrollments, 'findOne', async () => null); + mock.method(db.guardian_students, 'create', async () => { + createCalled = true; + return null; + }); + + await assert.rejects( + () => GuardianStudentsService.link( + { guardianId: 'guardian-id', studentId: 'student-id' }, + teacher, + ), + ValidationError, + ); + assert.equal(createCalled, false); + }); +}); diff --git a/backend/src/services/guardian_students.ts b/backend/src/services/guardian_students.ts index 933afd1..40eae5a 100644 --- a/backend/src/services/guardian_students.ts +++ b/backend/src/services/guardian_students.ts @@ -2,6 +2,8 @@ import db from '@/db/models'; import { withTransaction } from '@/db/with-transaction'; import ValidationError from '@/shared/errors/validation'; import { getOrganizationIdOrGlobal } from '@/services/shared/access'; +import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles'; +import type { Transaction } from 'sequelize'; import type { GuardianStudents } from '@/db/models/guardian_students'; import type { CurrentUser } from '@/db/api/types'; @@ -21,10 +23,44 @@ function orgFilter(currentUser?: CurrentUser): { organizationId?: string } { return organizationId ? { organizationId } : {}; } +async function studentBelongsToClass( + studentId: string, + classId: string, + organizationId: string | undefined, + transaction: Transaction, +): Promise { + const student = await db.users.findByPk(studentId, { + attributes: ['id', 'classId'], + transaction, + }); + if (student?.classId === classId) { + return true; + } + + const enrollment = await db.class_enrollments.findOne({ + where: { + studentId, + classId, + ...(organizationId ? { organizationId } : {}), + }, + attributes: ['id'], + transaction, + }); + return Boolean(enrollment); +} + function toDto(row: GuardianStudents) { const plain = row.get({ plain: true }) as Record & { - guardian?: { id?: string; firstName?: string; lastName?: string } | null; - student?: { id?: string; firstName?: string; lastName?: string } | null; + guardian?: { + id?: string; + name_prefix?: string | null; + firstName?: string | null; + lastName?: string | null; + email?: string; + phoneNumber?: string | null; + avatar?: readonly { privateUrl?: string | null }[]; + } | null; + student?: { id?: string; firstName?: string | null; lastName?: string | null } | null; }; return { id: plain.id, @@ -34,8 +70,12 @@ function toDto(row: GuardianStudents) { guardian: plain.guardian ? { id: plain.guardian.id, + name_prefix: plain.guardian.name_prefix, firstName: plain.guardian.firstName, lastName: plain.guardian.lastName, + email: plain.guardian.email, + phoneNumber: plain.guardian.phoneNumber, + avatar: plain.guardian.avatar, } : null, student: plain.student @@ -59,6 +99,30 @@ class GuardianStudentsService { const where = orgFilter(currentUser); return withTransaction(async (transaction) => { + if (currentUser?.app_role?.scope === ROLE_SCOPES.CLASS) { + if (!currentUser.classId) { + throw new ValidationError('auth.forbidden'); + } + const [guardian, student] = await Promise.all([ + db.users.findOne({ + where: { id: guardianId, ...where }, + include: [{ model: db.roles, as: 'app_role', where: { name: ROLE_NAMES.GUARDIAN }, required: true }], + transaction, + }), + db.users.findOne({ + where: { id: studentId, ...where }, + include: [{ model: db.roles, as: 'app_role', where: { name: ROLE_NAMES.STUDENT }, required: true }], + transaction, + }), + ]); + const studentInClass = student + ? await studentBelongsToClass(student.id, currentUser.classId, where.organizationId, transaction) + : false; + if (!guardian || !student || !studentInClass) { + throw new ValidationError('auth.forbidden'); + } + } + const existing = await db.guardian_students.findOne({ where: { guardianId, studentId, ...where }, transaction, diff --git a/backend/src/services/iam_capabilities.test.ts b/backend/src/services/iam_capabilities.test.ts index 4295434..2f65ae3 100644 --- a/backend/src/services/iam_capabilities.test.ts +++ b/backend/src/services/iam_capabilities.test.ts @@ -77,7 +77,6 @@ test('read-only and external roles cannot create tenants or manage users', () => for (const role of [ ROLE_NAMES.REGISTRAR, ROLE_NAMES.OFFICE_MANAGER, - ROLE_NAMES.TEACHER, ROLE_NAMES.SUPPORT_STAFF, ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN, @@ -90,3 +89,12 @@ test('read-only and external roles cannot create tenants or manage users', () => assert.deepEqual(caps.manageableRoleNames, []); } }); + +test('teacher capabilities expose student and guardian management without tenant creation', () => { + const caps = IamCapabilitiesService.current( + user(ROLE_NAMES.TEACHER, ROLE_SCOPES.CLASS, ['READ_USERS', 'CREATE_USERS', 'UPDATE_USERS']), + ); + + assert.deepEqual(caps.creatableTenantTypes, []); + assert.deepEqual(caps.manageableRoleNames, [ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN]); +}); diff --git a/backend/src/services/shared/role-policy.test.ts b/backend/src/services/shared/role-policy.test.ts index 47adac9..511c16d 100644 --- a/backend/src/services/shared/role-policy.test.ts +++ b/backend/src/services/shared/role-policy.test.ts @@ -89,13 +89,20 @@ test('registrar (read-only) manages nobody', () => { } }); -test('campus staff (e.g. teacher) cannot manage any user', () => { +test('teacher manages student and guardian users only', () => { const teacher = actor(ROLE_NAMES.TEACHER); - for (const role of Object.values(ROLE_NAMES)) { + for (const role of Object.values(ROLE_NAMES).filter((item) => ( + item !== ROLE_NAMES.STUDENT && item !== ROLE_NAMES.GUARDIAN + ))) { assert.equal(canManageUserWithRole(teacher, role), false); } - // ...including a roleless target. assert.equal(canManageUserWithRole(teacher, null), false); + assert.equal(canManageUserWithRole(teacher, ROLE_NAMES.STUDENT), true); + assert.equal(canManageUserWithRole(teacher, ROLE_NAMES.GUARDIAN), true); + assert.equal(canCreateUserWithRole(teacher, ROLE_NAMES.STUDENT), true); + assert.equal(canCreateUserWithRole(teacher, ROLE_NAMES.GUARDIAN), true); + assert.equal(canUpdateUserWithRole(teacher, ROLE_NAMES.STUDENT), true); + assert.equal(canUpdateUserWithRole(teacher, ROLE_NAMES.GUARDIAN), true); }); test('a manager may act on a roleless (null) target', () => { diff --git a/backend/src/services/shared/role-policy.ts b/backend/src/services/shared/role-policy.ts index 58fb658..466d80b 100644 --- a/backend/src/services/shared/role-policy.ts +++ b/backend/src/services/shared/role-policy.ts @@ -24,6 +24,7 @@ const ASSIGNABLE_ROLE_NAMES: readonly RoleName[] = Object.values(ROLE_NAMES).fil * external), not org/system roles nor another principal. * - `registrar`: nobody (read-only/audit assistant). * - `director`: campus + external roles only (not director/principal/superintendent/owner/admins). + * - `teacher`: student and guardian accounts only; services also enforce own-class scope. * - everyone else: nobody. */ const MANAGEABLE_ROLES_BY_ACTOR: Record = { @@ -55,7 +56,7 @@ const MANAGEABLE_ROLES_BY_ACTOR: Record = { ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN, ], [ROLE_NAMES.OFFICE_MANAGER]: [], - [ROLE_NAMES.TEACHER]: [], + [ROLE_NAMES.TEACHER]: [ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN], [ROLE_NAMES.SUPPORT_STAFF]: [], [ROLE_NAMES.STUDENT]: [], [ROLE_NAMES.GUARDIAN]: [], @@ -92,6 +93,7 @@ export function canManageUserWithRole( if (!actor) return false; const manageable = MANAGEABLE_ROLES_BY_ACTOR[actor]; if (manageable.length === 0) return false; + if (actor === ROLE_NAMES.TEACHER && !isRoleName(targetRole)) return false; if (!isRoleName(targetRole)) return true; return manageable.includes(targetRole); } diff --git a/backend/src/services/staff_attendance.test.ts b/backend/src/services/staff_attendance.test.ts index 0e238ed..7bfb69c 100644 --- a/backend/src/services/staff_attendance.test.ts +++ b/backend/src/services/staff_attendance.test.ts @@ -52,6 +52,47 @@ describe('StaffAttendanceService', () => { assert.equal(Object.getOwnPropertySymbols(capturedWhere).includes(Op.or), true); }); + test('uses scope records for fill-only staff attendance summaries', async () => { + const organizationId = '11111111-1111-4111-8111-111111111111'; + const campusId = '33333333-3333-4333-8333-333333333333'; + const actor = createTestUser({ + organizationId, + organizations: { id: organizationId }, + campusId, + app_role: { + name: ROLE_NAMES.OFFICE_MANAGER, + scope: ROLE_SCOPES.CAMPUS, + globalAccess: false, + permissions: [permission(FEATURE_PERMISSIONS.FILL_ATTENDANCE)], + }, + }); + const recordScopes: unknown[] = []; + + mock.method(db.staff_attendance_records, 'count', async (options: unknown) => { + if (isRecord(options)) { + recordScopes.push(options.where); + } + return 0; + }); + mock.method(db.users, 'count', async () => 3); + + await StaffAttendanceService.summary( + { startDate: '2026-06-23', endDate: '2026-06-23' }, + actor, + ); + + assert.equal(recordScopes.length, 3); + for (const where of recordScopes) { + assert.equal(isRecord(where), true); + if (!isRecord(where)) { + continue; + } + assert.equal(where.organizationId, organizationId); + assert.equal(where.campusId, campusId); + assert.equal('userId' in where, false); + } + }); + test('upserts a school office staff attendance record inside school scope', async () => { const organizationId = '11111111-1111-4111-8111-111111111111'; const schoolId = '22222222-2222-4222-8222-222222222222'; diff --git a/backend/src/services/staff_attendance.ts b/backend/src/services/staff_attendance.ts index 5e4b901..482e3a7 100644 --- a/backend/src/services/staff_attendance.ts +++ b/backend/src/services/staff_attendance.ts @@ -115,6 +115,35 @@ function visibilityScope(currentUser?: CurrentUser) { return campusDimensionScope(currentUser); } +function summaryRecordScope(currentUser?: CurrentUser) { + if ( + !hasFeaturePermission( + currentUser, + FEATURE_PERMISSIONS.READ_STAFF_ATTENDANCE_REPORTS, + ) + && !hasFeaturePermission(currentUser, FEATURE_PERMISSIONS.FILL_ATTENDANCE) + ) { + return { userId: requireUserId(currentUser) }; + } + + if (getRoleScope(currentUser) === ROLE_SCOPES.SCHOOL) { + const schoolId = getSchoolId(currentUser); + const campusSubquery = schoolId ? schoolCampusIdSubquery(schoolId) : null; + const userSubquery = schoolId ? schoolUserIdSubquery(schoolId) : null; + + if (campusSubquery && userSubquery) { + return { + [Op.or]: [ + { campusId: { [Op.in]: campusSubquery } }, + { userId: { [Op.in]: userSubquery } }, + ], + }; + } + } + + return campusDimensionScope(currentUser); +} + function staffCountScope(currentUser?: CurrentUser): WhereOptions { if (getRoleScope(currentUser) === ROLE_SCOPES.SCHOOL) { const schoolId = getSchoolId(currentUser); @@ -282,7 +311,7 @@ class StaffAttendanceService { // large row transfer. const recordsWhere = { organizationId: requireOrganizationId(currentUser), - ...visibilityScope(currentUser), + ...summaryRecordScope(currentUser), ...dateFilter(filter), }; diff --git a/backend/src/services/users.test.ts b/backend/src/services/users.test.ts new file mode 100644 index 0000000..ffe0eaa --- /dev/null +++ b/backend/src/services/users.test.ts @@ -0,0 +1,402 @@ +import { afterEach, describe, mock, test } from 'node:test'; +import assert from 'node:assert/strict'; + +import db from '@/db/models'; +import UsersDBApi from '@/db/api/users'; +import UsersService from '@/services/users'; +import ValidationError from '@/shared/errors/validation'; +import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles'; +import { createTestUser } from '@/test-utils'; + +function permission(name: string) { + return { name }; +} + +afterEach(() => { + mock.restoreAll(); +}); + +describe('UsersService teacher class roster management', () => { + test('creates a student inside the teacher class scope', async () => { + const organizationId = '22222222-2222-4222-8222-222222222222'; + const campusId = '55555555-5555-4555-8555-555555555555'; + const classId = '33333333-3333-4333-8333-333333333333'; + const teacher = createTestUser({ + id: '11111111-1111-4111-8111-111111111111', + organizationId, + organizations: { id: organizationId }, + classId, + app_role: { + name: ROLE_NAMES.TEACHER, + scope: ROLE_SCOPES.CLASS, + globalAccess: false, + permissions: [permission('CREATE_USERS')], + }, + }); + let createPayload: unknown = null; + + mock.method(db.sequelize, 'transaction', async () => ({ + commit: async () => undefined, + rollback: async () => undefined, + })); + mock.method(UsersDBApi, 'findBy', async () => null); + mock.method(db.roles, 'findByPk', async () => ({ name: ROLE_NAMES.STUDENT })); + mock.method(db.classes, 'findByPk', async () => ({ + id: classId, + campusId, + organizationId, + })); + mock.method(db.campuses, 'findByPk', async () => ({ + id: campusId, + schoolId: null, + organizationId, + })); + mock.method(UsersDBApi, 'create', async (payload: unknown) => { + createPayload = payload; + return { id: 'created-student-id' }; + }); + + const result = await UsersService.create( + { + email: 'new-student@example.com', + firstName: 'New', + lastName: 'Student', + app_role: 'student-role-id', + classId, + }, + teacher, + false, + ); + + assert.deepEqual(result, { id: 'created-student-id', organizationId: null }); + assert.equal(typeof createPayload, 'object'); + assert.notEqual(createPayload, null); + }); + + test('rejects creating a student outside the teacher class scope', async () => { + const teacher = createTestUser({ + id: '11111111-1111-4111-8111-111111111111', + organizationId: '22222222-2222-4222-8222-222222222222', + organizations: { id: '22222222-2222-4222-8222-222222222222' }, + classId: '33333333-3333-4333-8333-333333333333', + app_role: { + name: ROLE_NAMES.TEACHER, + scope: ROLE_SCOPES.CLASS, + globalAccess: false, + permissions: [permission('CREATE_USERS')], + }, + }); + let createCalled = false; + + mock.method(db.sequelize, 'transaction', async () => ({ + commit: async () => undefined, + rollback: async () => undefined, + })); + mock.method(UsersDBApi, 'findBy', async () => null); + mock.method(db.roles, 'findByPk', async () => ({ name: ROLE_NAMES.STUDENT })); + mock.method(UsersDBApi, 'create', async () => { + createCalled = true; + return { id: 'created-student-id' }; + }); + + await assert.rejects( + () => UsersService.create( + { + email: 'new-student@example.com', + firstName: 'New', + lastName: 'Student', + app_role: 'student-role-id', + classId: '44444444-4444-4444-8444-444444444444', + }, + teacher, + false, + ), + ValidationError, + ); + assert.equal(createCalled, false); + }); + + test('updates a student inside the teacher class scope', async () => { + const organizationId = '22222222-2222-4222-8222-222222222222'; + const campusId = '55555555-5555-4555-8555-555555555555'; + const classId = '33333333-3333-4333-8333-333333333333'; + const teacher = createTestUser({ + id: '11111111-1111-4111-8111-111111111111', + organizationId, + organizations: { id: organizationId }, + classId, + app_role: { + name: ROLE_NAMES.TEACHER, + scope: ROLE_SCOPES.CLASS, + globalAccess: false, + permissions: [permission('UPDATE_USERS')], + }, + }); + let updateCalled = false; + + mock.method(db.sequelize, 'transaction', async () => ({ + commit: async () => undefined, + rollback: async () => undefined, + })); + mock.method(UsersDBApi, 'findBy', async () => ({ + id: 'student-id', + organizationId, + classId, + app_role: { name: ROLE_NAMES.STUDENT }, + })); + mock.method(db.roles, 'findByPk', async () => ({ name: ROLE_NAMES.STUDENT })); + mock.method(db.classes, 'findByPk', async () => ({ + id: classId, + campusId, + organizationId, + })); + mock.method(db.campuses, 'findByPk', async () => ({ + id: campusId, + schoolId: null, + organizationId, + })); + mock.method(UsersDBApi, 'update', async () => { + updateCalled = true; + return null; + }); + + await UsersService.update( + { + firstName: 'Updated', + app_role: 'student-role-id', + classId, + }, + 'student-id', + teacher, + ); + + assert.equal(updateCalled, true); + }); + + test('rejects updating a student outside the teacher class scope', async () => { + const teacher = createTestUser({ + id: '11111111-1111-4111-8111-111111111111', + organizationId: '22222222-2222-4222-8222-222222222222', + organizations: { id: '22222222-2222-4222-8222-222222222222' }, + classId: '33333333-3333-4333-8333-333333333333', + app_role: { + name: ROLE_NAMES.TEACHER, + scope: ROLE_SCOPES.CLASS, + globalAccess: false, + permissions: [permission('UPDATE_USERS')], + }, + }); + let updateCalled = false; + + mock.method(db.sequelize, 'transaction', async () => ({ + commit: async () => undefined, + rollback: async () => undefined, + })); + mock.method(UsersDBApi, 'findBy', async () => ({ + id: 'student-id', + organizationId: '22222222-2222-4222-8222-222222222222', + classId: '44444444-4444-4444-8444-444444444444', + app_role: { name: ROLE_NAMES.STUDENT }, + })); + mock.method(db.class_enrollments, 'findOne', async () => null); + mock.method(UsersDBApi, 'update', async () => { + updateCalled = true; + return null; + }); + + await assert.rejects( + () => UsersService.update( + { + firstName: 'Updated', + }, + 'student-id', + teacher, + ), + ValidationError, + ); + assert.equal(updateCalled, false); + }); + + test('updates an enrolled student when the user row has no direct class scope', async () => { + const organizationId = '22222222-2222-4222-8222-222222222222'; + const classId = '33333333-3333-4333-8333-333333333333'; + const teacher = createTestUser({ + id: '11111111-1111-4111-8111-111111111111', + organizationId, + organizations: { id: organizationId }, + classId, + app_role: { + name: ROLE_NAMES.TEACHER, + scope: ROLE_SCOPES.CLASS, + globalAccess: false, + permissions: [permission('UPDATE_USERS')], + }, + }); + let updateCalled = false; + + mock.method(db.sequelize, 'transaction', async () => ({ + commit: async () => undefined, + rollback: async () => undefined, + })); + mock.method(UsersDBApi, 'findBy', async () => ({ + id: 'student-id', + organizationId, + classId: null, + app_role: { name: ROLE_NAMES.STUDENT }, + })); + mock.method(db.class_enrollments, 'findOne', async () => ({ id: 'enrollment-id' })); + mock.method(UsersDBApi, 'update', async () => { + updateCalled = true; + return null; + }); + + await UsersService.update( + { + firstName: 'Updated', + }, + 'student-id', + teacher, + ); + + assert.equal(updateCalled, true); + }); + + test('creates a guardian from the teacher class scope', async () => { + const organizationId = '22222222-2222-4222-8222-222222222222'; + const teacher = createTestUser({ + id: '11111111-1111-4111-8111-111111111111', + organizationId, + organizations: { id: organizationId }, + classId: '33333333-3333-4333-8333-333333333333', + app_role: { + name: ROLE_NAMES.TEACHER, + scope: ROLE_SCOPES.CLASS, + globalAccess: false, + permissions: [permission('CREATE_USERS')], + }, + }); + let createCalled = false; + + mock.method(db.sequelize, 'transaction', async () => ({ + commit: async () => undefined, + rollback: async () => undefined, + })); + mock.method(UsersDBApi, 'findBy', async () => null); + mock.method(db.roles, 'findByPk', async () => ({ name: ROLE_NAMES.GUARDIAN })); + mock.method(UsersDBApi, 'create', async () => { + createCalled = true; + return { id: 'created-guardian-id' }; + }); + + const result = await UsersService.create( + { + email: 'guardian@example.com', + firstName: 'Pat', + lastName: 'Guardian', + app_role: 'guardian-role-id', + }, + teacher, + false, + ); + + assert.deepEqual(result, { id: 'created-guardian-id', organizationId: null }); + assert.equal(createCalled, true); + }); + + test('updates a linked guardian from the teacher class scope', async () => { + const organizationId = '22222222-2222-4222-8222-222222222222'; + const classId = '33333333-3333-4333-8333-333333333333'; + const teacher = createTestUser({ + id: '11111111-1111-4111-8111-111111111111', + organizationId, + organizations: { id: organizationId }, + classId, + app_role: { + name: ROLE_NAMES.TEACHER, + scope: ROLE_SCOPES.CLASS, + globalAccess: false, + permissions: [permission('UPDATE_USERS')], + }, + }); + let updateCalled = false; + + mock.method(db.sequelize, 'transaction', async () => ({ + commit: async () => undefined, + rollback: async () => undefined, + })); + mock.method(UsersDBApi, 'findBy', async () => ({ + id: 'guardian-id', + organizationId, + classId: null, + app_role: { name: ROLE_NAMES.GUARDIAN }, + })); + mock.method(db.roles, 'findByPk', async () => ({ name: ROLE_NAMES.GUARDIAN })); + mock.method(db.guardian_students, 'findOne', async () => ({ id: 'guardian-link-id' })); + mock.method(UsersDBApi, 'update', async () => { + updateCalled = true; + return null; + }); + + await UsersService.update( + { + firstName: 'Updated', + app_role: 'guardian-role-id', + }, + 'guardian-id', + teacher, + ); + + assert.equal(updateCalled, true); + }); + + test('updates a guardian linked to an enrolled student in the teacher class scope', async () => { + const organizationId = '22222222-2222-4222-8222-222222222222'; + const classId = '33333333-3333-4333-8333-333333333333'; + const teacher = createTestUser({ + id: '11111111-1111-4111-8111-111111111111', + organizationId, + organizations: { id: organizationId }, + classId, + app_role: { + name: ROLE_NAMES.TEACHER, + scope: ROLE_SCOPES.CLASS, + globalAccess: false, + permissions: [permission('UPDATE_USERS')], + }, + }); + let updateCalled = false; + let guardianStudentLookup = 0; + + mock.method(db.sequelize, 'transaction', async () => ({ + commit: async () => undefined, + rollback: async () => undefined, + })); + mock.method(UsersDBApi, 'findBy', async () => ({ + id: 'guardian-id', + organizationId, + classId: null, + app_role: { name: ROLE_NAMES.GUARDIAN }, + })); + mock.method(db.roles, 'findByPk', async () => ({ name: ROLE_NAMES.GUARDIAN })); + mock.method(db.guardian_students, 'findOne', async () => { + guardianStudentLookup += 1; + return guardianStudentLookup === 1 ? null : { id: 'guardian-link-id' }; + }); + mock.method(UsersDBApi, 'update', async () => { + updateCalled = true; + return null; + }); + + await UsersService.update( + { + firstName: 'Updated', + app_role: 'guardian-role-id', + }, + 'guardian-id', + teacher, + ); + + assert.equal(updateCalled, true); + assert.equal(guardianStudentLookup, 2); + }); +}); diff --git a/backend/src/services/users.ts b/backend/src/services/users.ts index a9eda3d..b6ea137 100644 --- a/backend/src/services/users.ts +++ b/backend/src/services/users.ts @@ -13,7 +13,7 @@ import { assertCanUpdateUserWithRole, } from '@/services/shared/role-policy'; import { getOrganizationId, hasGlobalAccess } from '@/services/shared/access'; -import { ROLE_NAMES } from '@/shared/constants/roles'; +import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles'; import type { AuthenticatedUser, CurrentUser, FileInput } from '@/db/api/types'; /** @@ -103,6 +103,150 @@ function normalizeAvatarInput(data: CreateData | UpdateData): void { data.avatar = [{ new: true, name, privateUrl, publicUrl: privateUrl }] satisfies FileInput[]; } +function assertNoCustomPermissionChanges(data: CreateData | UpdateData): void { + if ( + data.custom_permissions !== undefined + || data.custom_permissions_filter !== undefined + ) { + throw new ValidationError('auth.forbidden'); + } +} + +function assertClassScopedUserCreate( + currentUser: CurrentUser | undefined, + targetRole: string | null | undefined, + data: CreateData, +): void { + if (currentUser?.app_role?.scope !== ROLE_SCOPES.CLASS) { + return; + } + if (!currentUser.classId) { + throw new ValidationError('auth.forbidden'); + } + if (targetRole === ROLE_NAMES.STUDENT && data.classId === currentUser.classId) { + assertNoCustomPermissionChanges(data); + return; + } + if ( + targetRole === ROLE_NAMES.GUARDIAN + && data.classId === undefined + && data.campusId === undefined + && data.schoolId === undefined + && (data.organizations === undefined || data.organizations === null) + ) { + assertNoCustomPermissionChanges(data); + return; + } + throw new ValidationError('auth.forbidden'); +} + +async function isGuardianLinkedToCurrentClass( + guardianId: string, + classId: string, + transaction: Transaction, +): Promise { + const directClassLink = await db.guardian_students.findOne({ + where: { guardianId }, + include: [ + { + model: db.users, + as: 'student', + attributes: ['id', 'classId'], + where: { classId }, + required: true, + }, + ], + transaction, + }); + if (directClassLink) { + return true; + } + + const link = await db.guardian_students.findOne({ + where: { guardianId }, + include: [ + { + model: db.users, + as: 'student', + attributes: ['id'], + required: true, + include: [ + { + model: db.class_enrollments, + as: 'class_enrollments_student', + attributes: ['id'], + where: { classId }, + required: true, + }, + ], + }, + ], + transaction, + }); + return Boolean(link); +} + +async function isStudentInClass( + studentId: string, + classId: string, + transaction: Transaction, +): Promise { + const enrollment = await db.class_enrollments.findOne({ + where: { studentId, classId }, + attributes: ['id'], + transaction, + }); + return Boolean(enrollment); +} + +async function assertClassScopedUserUpdate( + currentUser: CurrentUser | undefined, + target: AuthenticatedUser, + nextRole: string | null | undefined, + data: UpdateData, + transaction: Transaction, +): Promise { + if (currentUser?.app_role?.scope !== ROLE_SCOPES.CLASS) { + return; + } + if (!currentUser.classId) { + throw new ValidationError('auth.forbidden'); + } + const targetRole = target.app_role?.name ?? null; + const targetClassId = target.classId ?? null; + const requestedClassId = data.classId !== undefined ? data.classId : targetClassId; + const targetIsInCurrentClass = targetRole === ROLE_NAMES.STUDENT && ( + targetClassId === currentUser.classId + || await isStudentInClass(target.id, currentUser.classId, transaction) + ); + const requestedClassIsAllowed = + requestedClassId === null || requestedClassId === currentUser.classId; + + if ( + targetRole === ROLE_NAMES.STUDENT + && nextRole === ROLE_NAMES.STUDENT + && targetIsInCurrentClass + && requestedClassIsAllowed + ) { + assertNoCustomPermissionChanges(data); + return; + } + if ( + targetRole === ROLE_NAMES.GUARDIAN + && nextRole === ROLE_NAMES.GUARDIAN + && data.classId === undefined + && data.campusId === undefined + && data.schoolId === undefined + && data.organizations === undefined + && await isGuardianLinkedToCurrentClass(target.id, currentUser.classId, transaction) + ) { + assertNoCustomPermissionChanges(data); + return; + } + assertNoCustomPermissionChanges(data); + throw new ValidationError('auth.forbidden'); +} + class UsersService { static async create( data: CreateData, @@ -131,6 +275,7 @@ class UsersService { transaction, }); assertCanCreateUserWithRole(currentUser, newRole?.name ?? null); + assertClassScopedUserCreate(currentUser, newRole?.name ?? null, data); // §3.4 provisioning: creating an `owner` auto-creates the company and // links the owner to it. The org starts minimal; the owner fills it in. @@ -142,6 +287,8 @@ class UsersService { data.organizations = organization.id; createdOrganizationId = organization.id; } + } else { + assertClassScopedUserCreate(currentUser, null, data); } // Non-global actors create users only within their own organization. @@ -263,8 +410,10 @@ class UsersService { // new role if it is being reassigned). assertSameTenant(currentUser, users); assertCanUpdateUserWithRole(currentUser, users.app_role?.name ?? null); - if (data.app_role) { - const newRole = await db.roles.findByPk(data.app_role, { transaction }); + const newRole = data.app_role + ? await db.roles.findByPk(data.app_role, { transaction }) + : null; + if (data.app_role !== undefined) { assertCanAssignUserRole( currentUser, users.app_role?.name ?? null, @@ -272,6 +421,13 @@ class UsersService { ); } await normalizeTenantAssignment(data, transaction); + await assertClassScopedUserUpdate( + currentUser, + users, + data.app_role !== undefined ? newRole?.name ?? null : users.app_role?.name ?? null, + data, + transaction, + ); normalizeAvatarInput(data); const updatedUser = await UsersDBApi.update(id, data, globalAccess, { diff --git a/frontend/docs/campus-attendance-integration.md b/frontend/docs/campus-attendance-integration.md index af49aab..aceb514 100644 --- a/frontend/docs/campus-attendance-integration.md +++ b/frontend/docs/campus-attendance-integration.md @@ -2,7 +2,10 @@ ## Purpose -Campus attendance config and daily aggregate summaries follow the frontend three-layer architecture. +Attendance now tracks staff attendance only. The page still reads legacy campus +attendance config/summary endpoints where existing report plumbing requires +them, but visible entry, rollups, details, and dashboard-facing metrics use +`staff_attendance_records`. ```text View -> Business Logic -> API/Data Access -> Backend @@ -42,57 +45,49 @@ API/data access layer: - Attendance links load from `GET /api/campus_attendance/configs`. - Attendance links save through `PUT /api/campus_attendance/configs/:campusKey`. -- Daily campus summaries load from `GET /api/campus_attendance/summaries`. -- Daily campus summaries save through `PUT /api/campus_attendance/summaries/:campusKey/:date`. +- The active UI no longer reads or writes `GET /api/campus_attendance/summaries`; those backend + endpoints remain legacy plumbing outside the staff-only attendance workflow. - Staff attendance records load from `GET /api/staff_attendance/records` when the user has - `READ_STAFF_ATTENDANCE_REPORTS`. Campus views group these records by campus and date so student - attendance and staff attendance remain separate in history tables and reporting totals. + `READ_STAFF_ATTENDANCE_REPORTS`. Campus views group these records by campus and date. - The page derives its mode from the effective scope, not from the signed-in role label: - - campus/class effective scope shows campus-only attendance. - - school effective scope aggregates all scoped campus summaries and school staff attendance. - - organization effective scope aggregates all scoped school/campus summaries plus organization staff attendance. -- Users with `FILL_ATTENDANCE` can enter daily student attendance from organization, school, - campus, or class effective scope. Campus/class scope can render Present/Late/Absent controls - per student when a roster is available and derives the aggregate campus summary from those rows. - Late students count as present and increment the tardy count. When no roster is available, the - form falls back to manual aggregate totals. -- Class effective scope resolves the class's parent campus for the campus attendance summary, but - loads the student roster with `users?classId=...` so the classroom form shows only students in - that classroom. -- Organization/school screens render a student attendance rollup table for every scoped child - campus. Each row is prefilled from the campus summary for the selected date; campuses without - child data show empty inputs. Saving writes valid edited rows back to - `campus_attendance_summaries` per campus. Organization and school totals are computed from those - campus rows plus staff attendance reports. -- Organization and school percentage cards include every scoped child row in the denominator. - A child scope without a saved report counts as incomplete instead of being ignored, and staff - attendance percentages use the scoped staff count as the minimum denominator. + - campus effective scope shows campus staff attendance. + - class effective scope is read-only for attendance entry. + - school effective scope aggregates scoped campus staff attendance plus school staff attendance. + - organization effective scope aggregates scoped school/campus staff attendance plus organization staff attendance. +- Users with `FILL_ATTENDANCE` can enter daily staff attendance from organization, school, or + campus effective scope. Class effective scope no longer renders attendance entry controls. +- Organization office attendance targets organization-owned users without school/campus assignment. + School office attendance targets school-owned users without campus assignment. Campus attendance + targets campus-bound and class-scoped staff inside the campus. +- School and organization percentage cards use staff attendance only. Campus attendance entered by + office managers appears in school-level rollups; school-level staff attendance appears in + organization-level rollups with staff records from other schools and campuses. +- The top-bar notification uses the current staff summary as a completion signal. Users with + `FILL_ATTENDANCE` or `READ_STAFF_ATTENDANCE_REPORTS` are nudged at organization, school, or + campus effective scope until today's staff attendance record count reaches the current scope's + staff count. Class effective scope does not receive this attendance reminder. - Aggregate cards follow the tenant hierarchy: organization scope shows school cards, and school scope shows campus cards. Clicking a child card opens - `/attendance/details/:level/:tenantId`. The details page shows separate tables for that child - scope's student attendance summaries and staff attendance records. -- Organization and school screens also expose office staff attendance entry as a batch table. - Organization office attendance targets organization-owned users without school/campus - assignment. School office attendance targets school-owned users without campus assignment. - Each staff row has Present/Late/Absent controls, and saving writes one record per user through - `PUT /api/staff_attendance/records/:userId/:date`. These records feed the staff attendance - summary. -- Campus screens expose the same staff attendance table for campus-bound and class-scoped staff - inside the campus. -- Staff summary loads from `GET /api/staff_attendance/summary?startDate=today&endDate=today` only when the user has `READ_STAFF_ATTENDANCE_REPORTS`. -- Campus screens show combined summary cards with student/staff breakdowns, a single recent - attendance history table with Students/Staff/Total rows per date, and print reports with the - same combined report structure. -- Aggregate views render only campus cards represented by scoped attendance/config rows, because the campus catalog endpoint is not the source of scoped reporting data. + `/attendance/details/:level/:tenantId`. The details page shows staff attendance records for that + child scope. +- Each staff row has Present/Late/Absent controls, and saving writes one record per user through + `PUT /api/staff_attendance/records/:userId/:date`. Late staff count as present for attendance + percentage and remain visible as late exceptions. +- Attendance page staff summary cards load from + `GET /api/staff_attendance/summary?startDate=today&endDate=today` when the user has + `READ_STAFF_ATTENDANCE_REPORTS`; the top-bar completion reminder uses the same summary endpoint + for users with `FILL_ATTENDANCE` or `READ_STAFF_ATTENDANCE_REPORTS`. +- Campus screens show staff-only summary cards and recent staff attendance history. +- Aggregate views render only campus cards represented by scoped campus tenants or attendance config rows, because the campus catalog endpoint is not the source of scoped reporting data. - The backend calculates the attendance percentage. - `CampusAttendance.tsx` is a thin composition wrapper. -- CampusAttendance uses typed business hooks/selectors for access, form state, today, weekly, campus, overall summary calculations, and print report generation. +- CampusAttendance uses typed business hooks/selectors for access, form state, today, weekly staff rollups, campus summary calculations, and print report generation. - Print report generation escapes dynamic strings before writing report HTML. - Blocked print popups return an explicit print result and show a visible attendance status error. ## Verification -- `frontend/src/business/campus-attendance/selectors.test.ts` covers attendance calculations, scope titles, staff daily summaries, and combined student/staff summary selectors. +- `frontend/src/business/campus-attendance/selectors.test.ts` covers attendance calculations, scope titles, staff daily summaries, and staff-only summary selectors. - `frontend/src/business/campus-attendance/printReport.test.ts` covers printable report generation with separate student, staff, and combined attendance totals. - `frontend/src/business/campus-attendance/printReport.test.ts` covers blocked-popup handling for attendance report printing. - `frontend/src/business/campus-attendance/mappers.test.ts` covers API DTO mapping. diff --git a/frontend/docs/director-dashboard-integration.md b/frontend/docs/director-dashboard-integration.md index 7124bd6..a582828 100644 --- a/frontend/docs/director-dashboard-integration.md +++ b/frontend/docs/director-dashboard-integration.md @@ -46,8 +46,10 @@ Constants: - QBS safety quiz completion loads through `useSafetyQuizResults`. - Emotional Intelligence and Personality Type completion loads through `usePersonalityCompletion`. - Daily Zone Check-In completion loads through `useZoneCheckInCompletion`. -- Staff attendance records and summary load through staff attendance business - hooks with the selected period range. +- Staff attendance records load through staff attendance business hooks with + today's date as both `startDate` and `endDate`. Attendance risk areas and the + Staff Attendance overview card always reflect today's staff attendance, not + the selected Week / Month / Quarter period. - Policy acknowledgment report loads through `usePolicyAcknowledgmentReport`. - Overview metrics, risk areas, unified quiz result rows, and FRAME previews are derived in business selectors. - Document acknowledgment tracking renders as a collapsible section in the main diff --git a/frontend/docs/frontend-architecture.md b/frontend/docs/frontend-architecture.md index bd2c94e..97be4ad 100644 --- a/frontend/docs/frontend-architecture.md +++ b/frontend/docs/frontend-architecture.md @@ -207,8 +207,9 @@ The active frontend already has: - Safety quiz results under `frontend/src/business/safety-quiz/`, with typed API calls in `frontend/src/shared/api/safetyQuizResults.ts`. - Walk-through check-ins under `frontend/src/business/walkthrough/`, with typed API calls in `frontend/src/shared/api/walkthrough.ts`, shared constants in `frontend/src/shared/constants/walkthrough.ts`, and summary calculations in typed selectors. - Communications under `frontend/src/business/communications/`, with typed API calls in `frontend/src/shared/api/communications.ts` for parent messages, internal alerts, and dashboard upcoming events. +- My Class under `frontend/src/business/my-class/` and `frontend/src/pages/modules/MyClassPage.tsx`, with class roster queries plus student-only create/edit payload selectors that reuse the shared Users API while backend service guards enforce own-class scope. - EI/personality results under `frontend/src/business/personality/`, with typed API calls in `frontend/src/shared/api/personality.ts`, DTO mappers, distribution selectors, workflow-specific hook files, and explicit loading/error states in the view. -- Campus attendance config and daily summaries under `frontend/src/business/campus-attendance/`, with typed API calls in `frontend/src/shared/api/campusAttendance.ts`, DTO mappers, summary selectors, and explicit loading/error states in the view. +- Campus attendance config and staff-only attendance rollups under `frontend/src/business/campus-attendance/`, with typed API calls through `frontend/src/shared/api/campusAttendance.ts` for config and `frontend/src/shared/api/staffAttendance.ts` for daily staff records, DTO mappers, staff rollup selectors, and explicit loading/error states in the view. - Staff attendance snapshot and director staff counts under `frontend/src/business/staff-attendance/`, with typed API calls in `frontend/src/shared/api/staffAttendance.ts`, DTO mappers, rollup selectors, and explicit loading/error states in the view. - Handbook policies and safety protocols under `frontend/src/business/policies/` and `frontend/src/business/safety-protocols/`, backed by the unified `policy_documents` store (`frontend/src/shared/api/policyDocuments.ts` + `policyAcknowledgments.ts`), with DTO mappers, selectors, persistent acknowledgment, and explicit loading/error states in the view. - Classroom timer sounds are a unified library: hardcoded built-ins (local Web Audio), generated `recipe` rows, and uploaded `file`/`url` rows from `frontend/src/business/audio-files/`. Generation uses a local stub (`business/audio-files/generate.ts`) pending an AI key; playback branches by kind. diff --git a/frontend/docs/my-class-integration.md b/frontend/docs/my-class-integration.md new file mode 100644 index 0000000..05d1c0b --- /dev/null +++ b/frontend/docs/my-class-integration.md @@ -0,0 +1,49 @@ +# My Class Integration + +## Purpose + +`/my-class` gives class-scoped staff a roster view for their assigned class. The page lists students, +linked guardians, and assigned class staff. Teacher users with the required user-management +permissions can add and edit student user accounts directly from the Students section, including +linked guardian accounts for each student. + +## Slice Files + +- Page: `frontend/src/pages/modules/MyClassPage.tsx` +- Business API re-exports: `frontend/src/business/my-class/api.ts` +- Business selectors: `frontend/src/business/my-class/selectors.ts` +- Shared APIs reused from User Admin: `frontend/src/shared/api/users.ts`, `frontend/src/shared/api/roles.ts` +- Shared UI reused from User Admin: `ImageUpload`, `UserAvatar`, common inputs/selects/buttons + +## Behavior + +- The page requires the signed-in user to have a `classId`; otherwise it shows the existing + unassigned-class message. +- The Students section lists class-scoped student users from `GET /api/users?classId=`. +- The add/edit form is collapsible. It is fixed to the current class, the seeded `student` role id, + and the seeded `guardian` role id. It does not show arbitrary role, tenant, or custom-permission + controls. +- Form fields mirror the reusable User Admin identity fields: avatar, title, first name, last name, + email, and phone number. +- Creating a student calls `POST /api/users` with `app_role=` and the current + `classId`; editing calls `PUT /api/users/:id` with the same fixed role and class scope. +- The same form includes a repeatable Guardians section with photo upload. When guardian fields are + entered, the UI creates or updates each `guardian` user through the shared user API, then calls + `POST /api/guardian_students` to link that guardian to the student. The link endpoint is + idempotent. +- On edit, all existing guardian links for the student are loaded into the Guardians section. The + teacher can add additional unsaved guardian rows from the same form. +- The UI enables create/edit controls only when the current user has `CREATE_USERS` or + `UPDATE_USERS`, a current `classId`, and the `student` / `guardian` role ids have loaded. +- Backend user service guards remain authoritative: class-scoped users can only manage student + accounts in their own class and guardian accounts linked to students in their own class. The + guardian-student link service also verifies that class-scoped links connect a `guardian` user to + a `student` user in the teacher's own class. Class-scoped user management cannot add custom + permissions or permission exclusions. + +## Tests + +- `frontend/src/business/my-class/selectors.test.ts` covers payload normalization and permission / + class / role gating. +- Backend coverage lives in `backend/src/services/users.test.ts`, + `backend/src/services/shared/role-policy.test.ts`, and `backend/src/db/seeders/user-roles.test.ts`. diff --git a/frontend/docs/top-bar-integration.md b/frontend/docs/top-bar-integration.md index 7543a38..516047b 100644 --- a/frontend/docs/top-bar-integration.md +++ b/frontend/docs/top-bar-integration.md @@ -47,8 +47,9 @@ Shared config: - Manager reminders are permission-gated and derived from current status queries: `MANAGE_CONTENT_CATALOG` organization users are nudged to select the current week's Sign of the Week, `MANAGE_FRAME` users are nudged when the current - week has no F.R.A.M.E. entry, and `FILL_ATTENDANCE` users are nudged when no - attendance row exists for today in their current attendance scope. + week has no F.R.A.M.E. entry, and users with `FILL_ATTENDANCE` or + `READ_STAFF_ATTENDANCE_REPORTS` are nudged until today's staff attendance + records cover the current scope's staff count. - Selectors handle initials, campus label fallback, shared role labels, and unread notification count. - **Header search** (`TopBarSearch`) is a combobox over the user's **accessible modules** (permission- and scope-filtered via `getScopedModules`) **plus their product content** from the content catalog (classroom strategies, sign-language signs, regulation zones). Content is fetched **lazily** (only once the user types, and only for modules available in the current effective scope) via `useContentCatalogPayload({ enabled })`; results are combined by `buildTopBarSearchResults` (modules first, then content, capped). Selecting a result navigates to its module (`setCurrentModule`) and clears the query. Keyboard: ↑/↓ to move, Enter to open, Esc to close; the dropdown closes on outside click (`useOnClickOutside`). The backend `/api/search` is a separate admin SIS-record search and is intentionally **not** used here. - View components receive a prepared page model and do not call API/data access modules. diff --git a/frontend/src/business/app-shell/selectors.test.ts b/frontend/src/business/app-shell/selectors.test.ts index 641632f..0c39fc6 100644 --- a/frontend/src/business/app-shell/selectors.test.ts +++ b/frontend/src/business/app-shell/selectors.test.ts @@ -54,6 +54,7 @@ const scopedModules: readonly Module[] = [ { id: 'platform-dashboard', name: 'Platform', icon: 'chart', permissions: ['READ_PLATFORM_DASHBOARD'], color: '', routePath: '/platform-dashboard' }, { id: 'dashboard', name: 'Dashboard', icon: 'home', permissions: ['READ_DASHBOARD'], color: '', routePath: '/dashboard' }, { id: 'classroom', name: 'Classroom Support', icon: 'book', permissions: ['READ_CLASSROOM'], color: '', routePath: '/classroom-support' }, + { id: 'attendance', name: 'Attendance', icon: 'clock', permissions: ['READ_ATTENDANCE'], color: '', routePath: '/attendance' }, { id: 'timer', name: 'Timer', icon: 'timer', permissions: ['READ_TIMER'], color: '', routePath: '/timer' }, { id: 'qbs', name: 'Behavior Management', icon: 'shield', permissions: ['READ_QBS'], color: '', routePath: '/qbs-safety' }, { id: 'zones', name: 'Zones', icon: 'layers', permissions: ['READ_ZONES'], color: '', routePath: '/zones-of-regulation' }, @@ -105,6 +106,15 @@ describe('app-shell selectors', () => { expect(getScopedModules(scopedModules, behaviorUser, 'class', false).map((m) => m.id)).toEqual(['qbs']); }); + it('hides Attendance from class scope', () => { + const attendanceUser = user(['READ_ATTENDANCE']); + + expect(getScopedModules(scopedModules, attendanceUser, 'organization', false).map((m) => m.id)).toEqual(['attendance']); + expect(getScopedModules(scopedModules, attendanceUser, 'school', false).map((m) => m.id)).toEqual(['attendance']); + expect(getScopedModules(scopedModules, attendanceUser, 'campus', false).map((m) => m.id)).toEqual(['attendance']); + expect(getScopedModules(scopedModules, attendanceUser, 'class', false).map((m) => m.id)).toEqual([]); + }); + it('never shows the Director Dashboard via drill-down', () => { expect( getScopedModules(scopedModules, user(['READ_DASHBOARD', 'READ_DIRECTOR_DASHBOARD']), 'campus', true).map((m) => m.id), diff --git a/frontend/src/business/app-shell/selectors.ts b/frontend/src/business/app-shell/selectors.ts index 0aba7eb..6d9eb79 100644 --- a/frontend/src/business/app-shell/selectors.ts +++ b/frontend/src/business/app-shell/selectors.ts @@ -32,6 +32,7 @@ const MODULE_SCOPE_TIERS: Partial> = { 'organization-management': ['global', 'organization', 'school', 'campus'], 'user-admin': ['global', 'organization', 'school', 'campus'], class: ['class'], + attendance: ['organization', 'school', 'campus'], classroom: ['organization', 'school', 'campus', 'class'], timer: ['class'], qbs: ['organization', 'school', 'campus', 'class'], diff --git a/frontend/src/business/campus-attendance/hooks.ts b/frontend/src/business/campus-attendance/hooks.ts index 2fc9ce3..3acb944 100644 --- a/frontend/src/business/campus-attendance/hooks.ts +++ b/frontend/src/business/campus-attendance/hooks.ts @@ -2,32 +2,19 @@ import { useQuery } from '@tanstack/react-query'; import { useMemo, useState } from 'react'; import { listCampusAttendanceConfigs, - listCampusAttendanceSummaries, saveCampusAttendanceConfig, - saveCampusAttendanceSummary, } from '@/shared/api/campusAttendance'; import { useCampusCatalog } from '@/business/campuses/hooks'; import { CAMPUS_ATTENDANCE_QUERY_KEYS } from '@/shared/constants/campusAttendance'; import { findCampusByNameOrCode } from '@/shared/constants/campusDisplay'; import { UI_FEEDBACK_CLEAR_DELAY_MS } from '@/shared/constants/ui'; -import type { - CampusAttendanceCampusKey, - CampusAttendanceListFilter, -} from '@/shared/types/campusAttendance'; +import type { CampusAttendanceCampusKey } from '@/shared/types/campusAttendance'; +import { toCampusAttendanceConfigViewModel } from '@/business/campus-attendance/mappers'; import { - toCampusAttendanceConfigViewModel, - toCampusAttendanceSummaryMutationDto, - toCampusAttendanceSummaryViewModel, -} from '@/business/campus-attendance/mappers'; -import { - buildAttendanceEntryInput, buildCampusAttendanceScopeModel, buildCampusAttendanceStats, buildCombinedAttendanceStats, - buildOverallAttendanceStats, getToday, - getTodayPercentage, - getWeeklyAverage, getWeekEnd, getWeekStart, } from '@/business/campus-attendance/selectors'; @@ -42,9 +29,6 @@ import { } from '@/business/campus-attendance/printReport'; import type { CampusAttendanceChildStats, - CampusAttendanceEntryDraft, - CampusAttendanceEntryInput, - CampusAttendanceRollupDraft, AttendanceRosterStatus, StaffAttendanceEntryDraft, } from '@/business/campus-attendance/types'; @@ -63,7 +47,6 @@ import { getClass } from '@/shared/api/classes'; import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types'; const EMPTY_CONFIGS: ReturnType[] = []; -const EMPTY_SUMMARIES: ReturnType[] = []; const EMPTY_STAFF_RECORDS: readonly StaffAttendanceRecordViewModel[] = []; const EMPTY_CAMPUSES: CampusInfo[] = []; const EMPTY_TENANT_CHILDREN: TenantChild[] = []; @@ -96,28 +79,6 @@ export function useSaveCampusAttendanceConfig() { }); } -export function useCampusAttendanceSummaries(filter?: CampusAttendanceListFilter, enabled = true) { - return useQuery({ - queryKey: [CAMPUS_ATTENDANCE_QUERY_KEYS.summaries, filter], - enabled, - queryFn: () => mapApiListRows( - listCampusAttendanceSummaries(filter), - toCampusAttendanceSummaryViewModel, - ), - }); -} - -export function useSaveCampusAttendanceSummary() { - return useInvalidatingMutation({ - mutationFn: (input: CampusAttendanceEntryInput) => saveCampusAttendanceSummary( - input.campusId, - input.date, - toCampusAttendanceSummaryMutationDto(input), - ), - invalidateQueryKey: [CAMPUS_ATTENDANCE_QUERY_KEYS.summaries], - }); -} - export function useAttendanceDetailsChildCampuses( level: TenantLevel | undefined, tenantId: string | undefined, @@ -149,16 +110,6 @@ type UseCampusAttendancePageInput = { readonly userName: string; }; -const emptyEntryDraft = (date: string, campusId: CampusId = ''): CampusAttendanceEntryDraft => ({ - date, - campusId, - enrolled: '', - present: '', - absent: '', - tardy: '', - notes: '', -}); - const emptyStaffEntryDraft = (date: string): StaffAttendanceEntryDraft => ({ date, userId: '', @@ -167,10 +118,6 @@ const emptyStaffEntryDraft = (date: string): StaffAttendanceEntryDraft => ({ }); type AttendanceStatusMap = Record; -type StudentRollupOverrideMap = Record>>; function userDisplayName(user: AdminUserRow): string { const fullName = `${user.firstName ?? ''} ${user.lastName ?? ''}`.trim(); @@ -188,28 +135,6 @@ function reconcileAttendanceStatuses( return next; } -function isBlankRollupRow(row: CampusAttendanceRollupDraft): boolean { - return !row.enrolled && !row.present && !row.absent && !row.tardy && !row.notes.trim(); -} - -function toRollupEntryInput( - row: CampusAttendanceRollupDraft, - date: string, -): CampusAttendanceEntryInput | null { - return buildAttendanceEntryInput( - { - date, - campusId: row.campusId, - enrolled: row.enrolled, - present: row.present, - absent: row.absent, - tardy: row.tardy, - notes: row.notes, - }, - row.campusId, - ); -} - function isOfficeStaffUser(user: AdminUserRow, mode: 'organization' | 'school' | 'campus'): boolean { const role = user.app_role?.name; if (role === 'student' || role === 'guardian') { @@ -294,14 +219,6 @@ async function listScopedAttendanceChildren( }; } -function percentageFromRecords( - records: readonly ReturnType[], -): number | null { - const enrolled = records.reduce((sum, record) => sum + record.total_enrolled, 0); - const present = records.reduce((sum, record) => sum + record.total_present, 0); - return enrolled > 0 ? Number(((present / enrolled) * 100).toFixed(2)) : null; -} - function percentageFromStaffDailySummaries( records: readonly { readonly total_staff: number; readonly total_present: number }[], ): number | null { @@ -310,6 +227,17 @@ function percentageFromStaffDailySummaries( return staff > 0 ? Number(((present / staff) * 100).toFixed(2)) : null; } +function averageStaffAttendancePercentage( + records: readonly { readonly date: string; readonly attendance_percentage: number }[], + weekStart: string, + weekEnd: string, +): number | null { + const weekRecords = records.filter((record) => record.date >= weekStart && record.date <= weekEnd); + return weekRecords.length > 0 + ? Number((weekRecords.reduce((sum, record) => sum + record.attendance_percentage, 0) / weekRecords.length).toFixed(2)) + : null; +} + export function useCampusAttendancePage({ userRole, userCampus, @@ -329,7 +257,6 @@ export function useCampusAttendancePage({ effectiveTier === 'organization' || effectiveTier === 'school' || effectiveTier === 'campus' - || effectiveTier === 'class' ), canPrint: userRole === 'superintendent' || userRole === 'director' || userRole === 'office_manager' || permissions.has('READ_STAFF_ATTENDANCE_REPORTS'), canReadStaffReports: permissions.has('READ_STAFF_ATTENDANCE_REPORTS'), @@ -356,7 +283,6 @@ export function useCampusAttendancePage({ campusInfo?.fullName || userCampus, ); const attendanceCampusId = scopeModel.campusId; - const attendanceSummaryFilter = attendanceCampusId ? { campusKey: attendanceCampusId } : undefined; const scopedAttendanceChildrenQuery = useQuery({ queryKey: ['attendance-scoped-children', effectiveTier, effectiveTenant?.id ?? null], enabled: Boolean( @@ -371,10 +297,6 @@ export function useCampusAttendancePage({ attendanceCampusId ?? undefined, hasAttendanceScope, ); - const summariesQuery = useCampusAttendanceSummaries( - attendanceSummaryFilter, - hasAttendanceScope, - ); const staffSummaryQuery = useStaffAttendanceSummary( { startDate: today, endDate: today }, roleAccess.canReadStaffReports && hasAttendanceScope, @@ -389,11 +311,9 @@ export function useCampusAttendancePage({ queryFn: ({ signal }) => listUsers({ limit: 500, field: 'name', sort: 'asc' }, { signal }), }); const saveConfigMutation = useSaveCampusAttendanceConfig(); - const saveSummaryMutation = useSaveCampusAttendanceSummary(); const saveStaffAttendanceMutation = useSaveStaffAttendanceRecord(); const configs = configsQuery.data ?? EMPTY_CONFIGS; - const attendanceData = summariesQuery.data ?? EMPTY_SUMMARIES; const staffSummary = staffSummaryQuery.data ?? null; const staffRecords = staffRecordsQuery.data ?? EMPTY_STAFF_RECORDS; const officeStaffUsers = useMemo(() => ( @@ -418,21 +338,19 @@ export function useCampusAttendancePage({ const loading = campusCatalog.isLoading || (effectiveTier === 'class' && classQuery.isLoading) || configsQuery.isLoading - || summariesQuery.isLoading || (roleAccess.canReadStaffReports && staffSummaryQuery.isLoading) || (roleAccess.canReadStaffReports && staffRecordsQuery.isLoading) || (roleAccess.canEnterData && officeStaffQuery.isLoading) || (roleAccess.canSeeAllCampuses && scopedAttendanceChildrenQuery.isLoading); - const saving = saveConfigMutation.isPending || saveSummaryMutation.isPending || saveStaffAttendanceMutation.isPending; + const saving = saveConfigMutation.isPending || saveStaffAttendanceMutation.isPending; const loadError = campusCatalog.error ?? (effectiveTier === 'class' ? classQuery.error : null) ?? configsQuery.error - ?? summariesQuery.error ?? (roleAccess.canReadStaffReports ? staffSummaryQuery.error : null) ?? (roleAccess.canReadStaffReports ? staffRecordsQuery.error : null) ?? (roleAccess.canEnterData ? officeStaffQuery.error : null) ?? (roleAccess.canSeeAllCampuses ? scopedAttendanceChildrenQuery.error : null); - const saveError = saveConfigMutation.error ?? saveSummaryMutation.error ?? saveStaffAttendanceMutation.error; + const saveError = saveConfigMutation.error ?? saveStaffAttendanceMutation.error; const [successMessage, setSuccessMessage] = useState(''); const [printError, setPrintError] = useState(null); const errorMessage = printError ?? getOptionalErrorMessage(loadError ?? saveError); @@ -440,17 +358,13 @@ export function useCampusAttendancePage({ const [linkValue, setLinkValue] = useState(''); const [showEntryForm, setShowEntryForm] = useState(false); const [expandedCampus, setExpandedCampus] = useState(null); - const [entryDraft, setEntryDraft] = useState(() => emptyEntryDraft(today, attendanceCampusId ?? '')); const [staffEntryDraft, setStaffEntryDraft] = useState(() => emptyStaffEntryDraft(today)); - const [entryError, setEntryError] = useState(null); const [staffEntryError, setStaffEntryError] = useState(null); - const [studentAttendanceStatusOverrides, setStudentAttendanceStatusOverrides] = useState({}); const [staffAttendanceStatusOverrides, setStaffAttendanceStatusOverrides] = useState({}); - const [studentRollupOverrides, setStudentRollupOverrides] = useState({}); const campusStats = useMemo( - () => buildCampusAttendanceStats(campusCatalog.campuses, configs, attendanceData, today, weekStart, weekEnd, staffRecords), - [attendanceData, campusCatalog.campuses, configs, staffRecords, today, weekEnd, weekStart], + () => buildCampusAttendanceStats(campusCatalog.campuses, configs, today, weekStart, weekEnd, staffRecords), + [campusCatalog.campuses, configs, staffRecords, today, weekEnd, weekStart], ); const visibleCampusStats = useMemo(() => { if (scopeModel.mode === 'campus') { @@ -462,11 +376,10 @@ export function useCampusAttendancePage({ const scopedCampusIds = new Set([ ...scopedCampusOptions.map((campus) => campus.id), ...configs.map((config) => config.campus_id), - ...attendanceData.map((record) => record.campus_id), ]); return campusStats.filter((campus) => scopedCampusIds.has(campus.id)); - }, [attendanceCampusId, attendanceData, campusStats, configs, scopeModel.mode, scopedCampusOptions]); + }, [attendanceCampusId, campusStats, configs, scopeModel.mode, scopedCampusOptions]); const attendanceChildStats = useMemo((): readonly CampusAttendanceChildStats[] => { if (scopeModel.mode === 'campus') { return visibleCampusStats.map((campus) => ({ @@ -476,10 +389,8 @@ export function useCampusAttendancePage({ fullName: campus.fullName, bgGradient: campus.bgGradient, isOnline: campus.isOnline, - todayPct: campus.todayPct, - weekAvg: campus.weekAvg, - recentData: campus.recentData, - todayRecord: campus.todayRecord, + todayPct: campus.todayStaffRecord?.attendance_percentage ?? null, + weekAvg: averageStaffAttendancePercentage(campus.recentStaffData, weekStart, weekEnd), childCampusIds: [campus.id], recentStaffData: campus.recentStaffData, todayStaffRecord: campus.todayStaffRecord, @@ -499,10 +410,8 @@ export function useCampusAttendancePage({ fullName: campus?.fullName ?? child.name ?? 'Campus', bgGradient: campus?.bgGradient ?? SCHOOL_TILE_GRADIENTS[index % SCHOOL_TILE_GRADIENTS.length], isOnline: campus?.isOnline, - todayPct: campus?.todayPct ?? null, - weekAvg: campus?.weekAvg ?? null, - recentData: campus?.recentData ?? [], - todayRecord: campus?.todayRecord ?? null, + todayPct: campus?.todayStaffRecord?.attendance_percentage ?? null, + weekAvg: averageStaffAttendancePercentage(campus?.recentStaffData ?? [], weekStart, weekEnd), childCampusIds: campus ? [campus.id] : [], recentStaffData: campus?.recentStaffData ?? [], todayStaffRecord: campus?.todayStaffRecord ?? null, @@ -517,14 +426,6 @@ export function useCampusAttendancePage({ .map((campusChild) => campusCatalog.campuses.find((campus) => campus.tenantId === campusChild.id)) .filter((campus): campus is CampusInfo => Boolean(campus)); const childCampusIds = childCampuses.map((campus) => campus.id); - const childTodayRecords = attendanceData.filter((record) => ( - childCampusIds.includes(record.campus_id) && record.date === today - )); - const childWeekRecords = attendanceData.filter((record) => ( - childCampusIds.includes(record.campus_id) - && record.date >= weekStart - && record.date <= weekEnd - )); const childStaffRecords = campusStats .filter((campus) => childCampusIds.includes(campus.id)) .flatMap((campus) => campus.recentStaffData); @@ -542,12 +443,7 @@ export function useCampusAttendancePage({ notes: null, } : null; - const weekDays = Array.from(new Set(childWeekRecords.map((record) => record.date))); - const weekAvg = weekDays.length > 0 - ? Number((weekDays.reduce((sum, day) => ( - sum + (percentageFromRecords(childWeekRecords.filter((record) => record.date === day)) ?? 0) - ), 0) / weekDays.length).toFixed(2)) - : null; + const weekAvg = averageStaffAttendancePercentage(childStaffRecords, weekStart, weekEnd); return { id: child.id, @@ -555,17 +451,14 @@ export function useCampusAttendancePage({ mascot: child.name ?? 'School', fullName: child.name ?? 'School', bgGradient: SCHOOL_TILE_GRADIENTS[index % SCHOOL_TILE_GRADIENTS.length], - todayPct: percentageFromRecords(childTodayRecords), + todayPct: todayStaffRecord?.attendance_percentage ?? null, weekAvg, - recentData: childWeekRecords.slice(0, 10), - todayRecord: null, childCampusIds, recentStaffData: childStaffRecords.slice(0, 10), todayStaffRecord, }; }); }, [ - attendanceData, campusCatalog.campuses, campusChildrenByParentId, campusStats, @@ -576,58 +469,16 @@ export function useCampusAttendancePage({ weekEnd, weekStart, ]); - const overallStats = useMemo( - () => buildOverallAttendanceStats(attendanceData, today, weekStart, weekEnd), - [attendanceData, today, weekEnd, weekStart], - ); const combinedStats = useMemo( () => buildCombinedAttendanceStats( - overallStats, staffSummary, scopeModel.mode === 'campus' ? undefined : attendanceChildStats, ), - [attendanceChildStats, overallStats, scopeModel.mode, staffSummary], + [attendanceChildStats, scopeModel.mode, staffSummary], ); const myCampusConfig = attendanceCampusId ? configs.find((config) => config.campus_id === attendanceCampusId) : undefined; - const myCampusData = attendanceCampusId ? attendanceData.filter((record) => record.campus_id === attendanceCampusId) : []; const myCampusStats = attendanceCampusId ? visibleCampusStats.find((campus) => campus.id === attendanceCampusId) : undefined; const myStaffData = myCampusStats?.recentStaffData ?? []; - const myTodayPct = attendanceCampusId ? getTodayPercentage(attendanceData, attendanceCampusId, today) : null; - const myWeekAvg = attendanceCampusId ? getWeeklyAverage(attendanceData, attendanceCampusId, weekStart, weekEnd) : null; - const selectedEntryCampus = scopedCampusOptions.find((campus) => campus.id === entryDraft.campusId); - const selectedEntryCampusTenantId = scopeModel.mode === 'campus' - ? scopedCampusTenantId - : selectedEntryCampus?.tenantId ?? null; - const selectedEntryClassId = scopeModel.mode === 'campus' ? selectedClassId : null; - const attendanceStudentsQuery = useQuery({ - queryKey: ['attendance-student-users', selectedEntryCampusTenantId, selectedEntryClassId], - enabled: roleAccess.canEnterData - && showEntryForm - && Boolean(selectedEntryClassId || selectedEntryCampusTenantId), - queryFn: ({ signal }) => listUsers({ - app_role: 'student', - classId: selectedEntryClassId ?? undefined, - campusId: selectedEntryClassId ? undefined : selectedEntryCampusTenantId ?? undefined, - limit: 1000, - field: 'name', - sort: 'asc', - }, { signal }), - }); - const attendanceStudents = useMemo(() => ( - (attendanceStudentsQuery.data?.rows ?? []).map((user) => ({ - id: user.id, - name: userDisplayName(user), - role: user.app_role?.name ?? null, - })) - ), [attendanceStudentsQuery.data?.rows]); - - const studentAttendanceStatuses = useMemo( - () => reconcileAttendanceStatuses( - studentAttendanceStatusOverrides, - attendanceStudents.map((student) => student.id), - ), - [attendanceStudents, studentAttendanceStatusOverrides], - ); const staffAttendanceStatuses = useMemo( () => reconcileAttendanceStatuses( staffAttendanceStatusOverrides, @@ -635,77 +486,23 @@ export function useCampusAttendancePage({ ), [officeStaffUsers, staffAttendanceStatusOverrides], ); - const studentRollupRows = useMemo((): readonly CampusAttendanceRollupDraft[] => { - if (scopeModel.mode === 'campus') { - return []; - } - - return scopedCampusOptions.map((campus) => { - const todayRecord = attendanceData.find((record) => ( - record.campus_id === campus.id && record.date === entryDraft.date - )); - const override = studentRollupOverrides[campus.id] ?? {}; - - return { - campusId: campus.id, - campusName: campus.fullName, - enrolled: override.enrolled ?? (todayRecord ? String(todayRecord.total_enrolled) : ''), - present: override.present ?? (todayRecord ? String(todayRecord.total_present) : ''), - absent: override.absent ?? (todayRecord ? String(todayRecord.total_absent) : ''), - tardy: override.tardy ?? (todayRecord ? String(todayRecord.total_tardy) : ''), - notes: override.notes ?? todayRecord?.notes ?? '', - hasRecordedData: Boolean(todayRecord), - }; - }); - }, [attendanceData, entryDraft.date, scopeModel.mode, scopedCampusOptions, studentRollupOverrides]); const showSuccess = (message: string) => { setPrintError(null); setSuccessMessage(message); window.setTimeout(() => setSuccessMessage(''), UI_FEEDBACK_CLEAR_DELAY_MS); }; - const updateEntryDraft = (patch: Partial) => { - setEntryDraft((currentDraft) => ({ ...currentDraft, ...patch })); - setEntryError(null); - }; - const updateStaffEntryDraft = (patch: Partial) => { setStaffEntryDraft((currentDraft) => ({ ...currentDraft, ...patch })); setStaffEntryError(null); }; - const updateStudentAttendanceStatus = (userId: string, status: AttendanceRosterStatus) => { - setStudentAttendanceStatusOverrides((currentStatuses) => ({ ...currentStatuses, [userId]: status })); - setEntryError(null); - }; - - const updateStudentRollupDraft = ( - campusIdToUpdate: CampusId, - patch: Partial>, - ) => { - setStudentRollupOverrides((currentOverrides) => ({ - ...currentOverrides, - [campusIdToUpdate]: { - ...currentOverrides[campusIdToUpdate], - ...patch, - }, - })); - setEntryError(null); - }; - const updateStaffAttendanceStatus = (userId: string, status: AttendanceRosterStatus) => { setStaffAttendanceStatusOverrides((currentStatuses) => ({ ...currentStatuses, [userId]: status })); setStaffEntryError(null); }; const setEntryFormVisibility = (nextShowEntryForm: boolean) => { - if (nextShowEntryForm) { - setEntryDraft((currentDraft) => ({ - ...currentDraft, - campusId: attendanceCampusId ?? currentDraft.campusId, - })); - } - setEntryError(null); setStaffEntryError(null); setShowEntryForm(nextShowEntryForm); }; @@ -720,77 +517,6 @@ export function useCampusAttendancePage({ setEditingLink(null); }; - const saveStudentAttendance = async (): Promise => { - if (scopeModel.mode !== 'campus') { - const rowsToSave: CampusAttendanceEntryInput[] = []; - let hasInvalidRow = false; - - for (const row of studentRollupRows) { - if (isBlankRollupRow(row)) { - continue; - } - - const input = toRollupEntryInput(row, entryDraft.date); - if (!input) { - hasInvalidRow = true; - break; - } - rowsToSave.push(input); - } - - if (hasInvalidRow) { - setEntryError('Each campus row needs enrolled, present, and absent counts before saving.'); - return false; - } - - if (rowsToSave.length === 0) { - setEntryError('Enter at least one campus attendance row before saving.'); - return false; - } - - for (const input of rowsToSave) { - await saveSummaryMutation.mutateAsync(input); - } - - setStudentRollupOverrides({}); - return true; - } - - const targetCampusId = attendanceCampusId ?? entryDraft.campusId; - - if (!targetCampusId) { - setEntryError('Select a campus before saving attendance.'); - return false; - } - - const input = attendanceStudents.length > 0 - ? { - campusId: targetCampusId, - date: entryDraft.date, - totalEnrolled: attendanceStudents.length, - totalPresent: attendanceStudents.filter((student) => ( - (studentAttendanceStatuses[student.id] ?? 'present') !== 'absent' - )).length, - totalAbsent: attendanceStudents.filter((student) => ( - (studentAttendanceStatuses[student.id] ?? 'present') === 'absent' - )).length, - totalTardy: attendanceStudents.filter((student) => ( - (studentAttendanceStatuses[student.id] ?? 'present') === 'late' - )).length, - notes: entryDraft.notes.trim() || null, - } - : buildAttendanceEntryInput(entryDraft, targetCampusId); - - if (!input) { - setEntryError('Enter enrolled, present, and absent counts before saving.'); - return false; - } - - await saveSummaryMutation.mutateAsync(input); - setEntryDraft(emptyEntryDraft(today, attendanceCampusId ?? targetCampusId)); - return true; - }; - const saveStaffBatchAttendance = async ( requireStaff: boolean, input: Pick = staffEntryDraft, @@ -815,31 +541,6 @@ export function useCampusAttendancePage({ return true; }; - const handleSubmitEntry = async () => { - setPrintError(null); - - const saved = await saveStudentAttendance(); - if (!saved) { - return; - } - - showSuccess('Student attendance saved!'); - setShowEntryForm(false); - }; - - const handleSubmitStaffEntry = async () => { - setPrintError(null); - - if (!staffEntryDraft.userId) { - setStaffEntryError('Select an office staff member before saving attendance.'); - return; - } - - await saveStaffAttendanceMutation.mutateAsync(staffEntryDraft); - showSuccess('Office staff attendance saved!'); - setStaffEntryDraft(emptyStaffEntryDraft(today)); - }; - const handleSubmitStaffBatch = async () => { setPrintError(null); @@ -849,25 +550,6 @@ export function useCampusAttendancePage({ } showSuccess('Staff attendance saved!'); - }; - - const handleSubmitAttendanceForm = async () => { - setPrintError(null); - - const studentSaved = await saveStudentAttendance(); - if (!studentSaved) { - return; - } - - const staffSaved = await saveStaffBatchAttendance(false, { - date: entryDraft.date, - note: staffEntryDraft.note, - }); - if (!staffSaved) { - return; - } - - showSuccess('Attendance saved!'); setShowEntryForm(false); }; @@ -878,23 +560,13 @@ export function useCampusAttendancePage({ const reportTitle = roleAccess.canSeeAllCampuses ? `${scopeModel.reportLabel} Attendance Report` : `${campusInfo?.fullName || userCampus} Attendance Report`; - const printTodayRecords = roleAccess.canSeeAllCampuses - ? overallStats.todayRecords - : attendanceData.filter((record) => record.campus_id === attendanceCampusId && record.date === today); - const printWeekRecords = roleAccess.canSeeAllCampuses - ? overallStats.weekRecords - : attendanceData.filter((record) => record.campus_id === attendanceCampusId && record.date >= weekStart && record.date <= weekEnd); - const printResult = openCampusAttendancePrintReport({ input: { reportTitle, generatedByName: userName, generatedByRole: `${userRole.charAt(0).toUpperCase()}${userRole.slice(1)}`, today, - weekStart, campusesToPrint, - printTodayRecords, - printWeekRecords, staffSummary, }, }); @@ -917,7 +589,6 @@ export function useCampusAttendancePage({ weekStart, weekEnd, configs, - attendanceData, staffRecords, loading, saving, @@ -927,20 +598,13 @@ export function useCampusAttendancePage({ linkValue, showEntryForm, expandedCampus, - entryDraft, - entryError, staffEntryDraft, staffEntryError, officeStaffUsers, staffAttendanceStatuses, - studentRollupRows, - attendanceStudents, - studentAttendanceStatuses, - attendanceStudentsLoading: attendanceStudentsQuery.isLoading, scopedCampusOptions, campusStats: visibleCampusStats, attendanceChildStats, - overallStats, combinedStats, staffSummary: { summary: staffSummary, @@ -948,10 +612,7 @@ export function useCampusAttendancePage({ error: staffSummaryQuery.error, }, myCampusConfig, - myCampusData, myStaffData, - myTodayPct, - myWeekAvg, userCampus, }, actions: { @@ -959,16 +620,10 @@ export function useCampusAttendancePage({ setLinkValue, setShowEntryForm: setEntryFormVisibility, setExpandedCampus, - updateEntryDraft, updateStaffEntryDraft, - updateStudentAttendanceStatus, - updateStudentRollupDraft, updateStaffAttendanceStatus, handleSaveLink, - handleSubmitEntry, - handleSubmitStaffEntry, handleSubmitStaffBatch, - handleSubmitAttendanceForm, handlePrint, }, }; diff --git a/frontend/src/business/campus-attendance/mappers.test.ts b/frontend/src/business/campus-attendance/mappers.test.ts index 77d8d2e..26a5a77 100644 --- a/frontend/src/business/campus-attendance/mappers.test.ts +++ b/frontend/src/business/campus-attendance/mappers.test.ts @@ -1,14 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - toCampusAttendanceConfigViewModel, - toCampusAttendanceSummaryMutationDto, - toCampusAttendanceSummaryViewModel, -} from '@/business/campus-attendance/mappers'; -import type { CampusAttendanceEntryInput } from '@/business/campus-attendance/types'; -import type { - CampusAttendanceConfigDto, - CampusAttendanceSummaryDto, -} from '@/shared/types/campusAttendance'; +import { toCampusAttendanceConfigViewModel } from '@/business/campus-attendance/mappers'; +import type { CampusAttendanceConfigDto } from '@/shared/types/campusAttendance'; describe('campus attendance mappers', () => { it('maps backend config DTO fields into the frontend view model shape', () => { @@ -33,58 +25,4 @@ describe('campus attendance mappers', () => { updated_at: '2026-06-08T09:00:00.000Z', }); }); - - it('maps backend summary DTO fields into the frontend view model shape', () => { - const dto: CampusAttendanceSummaryDto = { - id: 'summary-1', - campus_key: 'gators', - date: '2026-06-08', - total_enrolled: 80, - total_present: 72, - total_absent: 6, - total_tardy: 2, - attendance_percentage: 90, - recorded_by_label: 'Director', - notes: 'Two late bus arrivals', - organizationId: 'org-1', - campusId: 'campus-2', - createdById: 'user-3', - updatedById: 'user-3', - createdAt: '2026-06-08T10:00:00.000Z', - updatedAt: '2026-06-08T10:15:00.000Z', - }; - - expect(toCampusAttendanceSummaryViewModel(dto)).toEqual({ - id: 'summary-1', - campus_id: 'gators', - date: '2026-06-08', - total_enrolled: 80, - total_present: 72, - total_absent: 6, - total_tardy: 2, - attendance_percentage: 90, - recorded_by: 'Director', - notes: 'Two late bus arrivals', - }); - }); - - it('maps entry input back into the backend mutation DTO shape', () => { - const input: CampusAttendanceEntryInput = { - campusId: 'hawks', - date: '2026-06-08', - totalEnrolled: 42, - totalPresent: 39, - totalAbsent: 2, - totalTardy: 1, - notes: null, - }; - - expect(toCampusAttendanceSummaryMutationDto(input)).toEqual({ - total_enrolled: 42, - total_present: 39, - total_absent: 2, - total_tardy: 1, - notes: null, - }); - }); }); diff --git a/frontend/src/business/campus-attendance/mappers.ts b/frontend/src/business/campus-attendance/mappers.ts index 2d59f97..d4d0dd0 100644 --- a/frontend/src/business/campus-attendance/mappers.ts +++ b/frontend/src/business/campus-attendance/mappers.ts @@ -1,12 +1,8 @@ import type { CampusAttendanceConfigDto, - CampusAttendanceSummaryDto, - CampusAttendanceSummaryMutationDto, } from '@/shared/types/campusAttendance'; import type { CampusAttendanceConfigViewModel, - CampusAttendanceEntryInput, - CampusAttendanceSummaryViewModel, } from '@/business/campus-attendance/types'; export function toCampusAttendanceConfigViewModel( @@ -20,32 +16,3 @@ export function toCampusAttendanceConfigViewModel( updated_at: dto.updatedAt, }; } - -export function toCampusAttendanceSummaryViewModel( - dto: CampusAttendanceSummaryDto, -): CampusAttendanceSummaryViewModel { - return { - id: dto.id, - campus_id: dto.campus_key, - date: dto.date, - total_enrolled: dto.total_enrolled, - total_present: dto.total_present, - total_absent: dto.total_absent, - total_tardy: dto.total_tardy, - attendance_percentage: dto.attendance_percentage, - recorded_by: dto.recorded_by_label, - notes: dto.notes, - }; -} - -export function toCampusAttendanceSummaryMutationDto( - input: CampusAttendanceEntryInput, -): CampusAttendanceSummaryMutationDto { - return { - total_enrolled: input.totalEnrolled, - total_present: input.totalPresent, - total_absent: input.totalAbsent, - total_tardy: input.totalTardy, - notes: input.notes, - }; -} diff --git a/frontend/src/business/campus-attendance/printReport.test.ts b/frontend/src/business/campus-attendance/printReport.test.ts index 08ea361..18dfea9 100644 --- a/frontend/src/business/campus-attendance/printReport.test.ts +++ b/frontend/src/business/campus-attendance/printReport.test.ts @@ -28,15 +28,13 @@ describe('campus attendance print report', () => { `Printed by: ${CAMPUS_ATTENDANCE_TEST_SEED.generatedByName} (${CAMPUS_ATTENDANCE_TEST_SEED.generatedByRoleEscaped})`, ); expect(html).toContain(CAMPUS_ATTENDANCE_TEST_SEED.campusFullNameEscaped); - expect(html).toContain(CAMPUS_ATTENDANCE_TEST_SEED.todayNotesEscaped); + expect(html).toContain('One staff late'); expect(html).toContain('
90%
'); - expect(html).toContain('
85%
'); - expect(html).toContain('Students 90% · Staff 90%'); - expect(html).toContain('
People
'); - expect(html).toContain('
60
'); + expect(html).toContain('Present or late'); + expect(html).toContain('
Staff Records
'); + expect(html).toContain('
10
'); expect(html).toContain("Today's report"); expect(html).toContain('Attendance history'); - expect(html).toContain('Students'); expect(html).toContain('Staff'); expect(html).toContain('Total'); expect(html).toContain('90.0%'); @@ -51,14 +49,10 @@ describe('campus attendance print report', () => { ...campusAttendanceStatsSeed, todayPct: null, weekAvg: null, - recentData: [], - todayRecord: null, recentStaffData: [], todayStaffRecord: null, }, ], - printTodayRecords: [], - printWeekRecords: [], staffSummary: null, }); diff --git a/frontend/src/business/campus-attendance/printReport.ts b/frontend/src/business/campus-attendance/printReport.ts index 7274189..62be1de 100644 --- a/frontend/src/business/campus-attendance/printReport.ts +++ b/frontend/src/business/campus-attendance/printReport.ts @@ -52,37 +52,14 @@ const inlinePercentageClass = (percentage: number | null): string => { }; function buildTodayReportRows(campus: CampusAttendanceStats): readonly CombinedAttendanceHistoryRow[] { - const date = campus.todayRecord?.date ?? campus.todayStaffRecord?.date ?? ''; - const studentRecord = campus.todayRecord; + const date = campus.todayStaffRecord?.date ?? ''; const staffRecord = campus.todayStaffRecord; - const studentTotal = studentRecord?.total_enrolled ?? 0; const staffTotal = staffRecord?.total_staff ?? 0; - const studentPresent = studentRecord?.total_present ?? 0; const staffPresent = staffRecord?.total_present ?? 0; - const studentAbsent = studentRecord?.total_absent ?? 0; const staffAbsent = staffRecord?.total_absent ?? 0; - const studentLate = studentRecord?.total_tardy ?? 0; const staffLate = staffRecord?.total_late ?? 0; - const combinedTotal = studentTotal + staffTotal; - const combinedPresent = studentPresent + staffPresent; - const combinedPercentage = combinedTotal > 0 - ? Number(((combinedPresent / combinedTotal) * 100).toFixed(2)) - : null; - const notes = [studentRecord?.notes, staffRecord?.notes].filter((note): note is string => Boolean(note)); return [ - { - id: `${campus.id}:today:students`, - date, - group: 'students', - label: 'Students', - total: studentTotal, - present: studentPresent, - absent: studentAbsent, - late: studentLate, - attendancePercentage: studentRecord?.attendance_percentage ?? null, - notes: studentRecord?.notes ?? null, - }, { id: `${campus.id}:today:staff`, date, @@ -100,12 +77,12 @@ function buildTodayReportRows(campus: CampusAttendanceStats): readonly CombinedA date, group: 'total', label: 'Total', - total: combinedTotal, - present: combinedPresent, - absent: studentAbsent + staffAbsent, - late: studentLate + staffLate, - attendancePercentage: combinedPercentage, - notes: notes.length > 0 ? notes.join('; ') : null, + total: staffTotal, + present: staffPresent, + absent: staffAbsent, + late: staffLate, + attendancePercentage: staffRecord?.attendance_percentage ?? null, + notes: staffRecord?.notes ?? null, }, ]; } @@ -113,7 +90,7 @@ function buildTodayReportRows(campus: CampusAttendanceStats): readonly CombinedA function renderAttendanceRows(rows: readonly CombinedAttendanceHistoryRow[]): string { return rows.map((record) => ` - ${record.group === 'students' ? formatAttendanceDate(record.date) : ''} + ${record.group === 'staff' ? formatAttendanceDate(record.date) : ''} ${record.label} ${record.total} ${record.present} @@ -126,7 +103,7 @@ function renderAttendanceRows(rows: readonly CombinedAttendanceHistoryRow[]): st } function renderAttendanceHistoryTable(campus: CampusAttendanceStats): string { - const historyRows = buildCombinedAttendanceHistoryRows(campus.recentData, campus.recentStaffData); + const historyRows = buildCombinedAttendanceHistoryRows(campus.recentStaffData); if (historyRows.length === 0) { return ''; @@ -191,9 +168,6 @@ export function openCampusAttendancePrintReport({ export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput): string { const printStats = buildPrintAttendanceStats(input); - const staffTotal = input.staffSummary - ? input.staffSummary.present + input.staffSummary.late + input.staffSummary.absent - : 0; const generatedAtDate = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', @@ -251,19 +225,19 @@ export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput
-
Today's Attendance
-
${printStats.combinedPct !== null ? `${printStats.combinedPct}%` : 'No data'}
-
Students ${printStats.todayPct !== null ? `${printStats.todayPct}%` : 'N/A'} · Staff ${printStats.staffPct !== null ? `${printStats.staffPct}%` : 'N/A'}
+
Today's Staff Attendance
+
${printStats.staffPct !== null ? `${printStats.staffPct}%` : 'No data'}
+
Present or late
-
People
-
${input.printTodayRecords.reduce((sum, record) => sum + record.total_enrolled, 0) + staffTotal}
-
Students ${input.printTodayRecords.reduce((sum, record) => sum + record.total_enrolled, 0)} · Staff ${staffTotal}
+
Staff Records
+
${printStats.staffTotal}
+
Recorded today
-
This Week's Student Average
-
${printStats.weekPct !== null ? `${printStats.weekPct}%` : 'No data'}
-
Week of ${formatAttendanceDate(input.weekStart)}
+
Report Date
+
${formatAttendanceDate(input.today)}
+
Staff attendance
${input.campusesToPrint.map((campus) => ` @@ -275,7 +249,7 @@ export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput Week Avg: ${campus.weekAvg !== null ? `${campus.weekAvg}%` : 'N/A'} - ${campus.todayRecord || campus.todayStaffRecord ? ` + ${campus.todayStaffRecord ? ` diff --git a/frontend/src/business/campus-attendance/selectors.test.ts b/frontend/src/business/campus-attendance/selectors.test.ts index 52f81d9..45013b3 100644 --- a/frontend/src/business/campus-attendance/selectors.test.ts +++ b/frontend/src/business/campus-attendance/selectors.test.ts @@ -1,62 +1,17 @@ import { describe, expect, it } from 'vitest'; import { - buildAttendanceEntryInput, buildCampusAttendanceScopeModel, buildCampusAttendanceStats, buildCombinedAttendanceHistoryRows, buildCombinedAttendanceStats, - buildOverallAttendanceStats, buildStaffAttendanceDailySummaries, getWeekEnd, getWeekStart, } from '@/business/campus-attendance/selectors'; -import type { - CampusAttendanceChildStats, - CampusAttendanceEntryDraft, - CampusAttendanceSummaryViewModel, -} from '@/business/campus-attendance/types'; +import type { CampusAttendanceChildStats } from '@/business/campus-attendance/types'; import type { CampusInfo } from '@/shared/types/app'; import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types'; -const summaries: readonly CampusAttendanceSummaryViewModel[] = [ - { - id: 'summary-1', - campus_id: 'tigers', - date: '2026-06-08', - total_enrolled: 100, - total_present: 90, - total_absent: 10, - total_tardy: 3, - attendance_percentage: 90, - recorded_by: 'director-1', - notes: null, - }, - { - id: 'summary-2', - campus_id: 'gators', - date: '2026-06-08', - total_enrolled: 50, - total_present: 40, - total_absent: 10, - total_tardy: 1, - attendance_percentage: 80, - recorded_by: 'director-1', - notes: null, - }, - { - id: 'summary-3', - campus_id: 'tigers', - date: '2026-06-09', - total_enrolled: 100, - total_present: 95, - total_absent: 5, - total_tardy: 2, - attendance_percentage: 95, - recorded_by: 'director-1', - notes: null, - }, -]; - const staffRecords: readonly StaffAttendanceRecordViewModel[] = [ { id: 'staff-1', @@ -114,53 +69,6 @@ describe('campus attendance selectors', () => { expect(getWeekEnd(date)).toBe('2026-06-12'); }); - it('builds validated attendance input and normalizes optional notes', () => { - const draft: CampusAttendanceEntryDraft = { - date: '2026-06-08', - campusId: 'tigers', - enrolled: '25', - present: '21', - absent: '4', - tardy: '', - notes: ' ', - }; - - expect(buildAttendanceEntryInput(draft, 'tigers')).toEqual({ - campusId: 'tigers', - date: '2026-06-08', - totalEnrolled: 25, - totalPresent: 21, - totalAbsent: 4, - totalTardy: 0, - notes: null, - }); - }); - - it('rejects attendance input without a positive enrollment count', () => { - const draft: CampusAttendanceEntryDraft = { - date: '2026-06-08', - campusId: 'tigers', - enrolled: '0', - present: '0', - absent: '0', - tardy: '0', - notes: 'Closed', - }; - - expect(buildAttendanceEntryInput(draft, 'tigers')).toBeNull(); - }); - - it('aggregates daily and weekly attendance percentages across campuses', () => { - expect( - buildOverallAttendanceStats(summaries, '2026-06-08', '2026-06-08', '2026-06-12'), - ).toMatchObject({ - todayEnrolled: 150, - todayPresent: 130, - todayPct: 86.67, - weekPct: 90.83, - }); - }); - it('builds scope-aware attendance titles and descriptions', () => { expect( buildCampusAttendanceScopeModel( @@ -212,11 +120,9 @@ describe('campus attendance selectors', () => { }); }); - it('combines student attendance aggregates with staff attendance summary', () => { - const overallStats = buildOverallAttendanceStats(summaries, '2026-06-08', '2026-06-08', '2026-06-12'); - + it('builds combined attendance from staff attendance summary only', () => { expect( - buildCombinedAttendanceStats(overallStats, { + buildCombinedAttendanceStats({ staffCount: 12, recordsCount: 10, present: 8, @@ -224,15 +130,12 @@ describe('campus attendance selectors', () => { absent: 1, }), ).toEqual({ - studentTodayPct: 86.67, - studentWeekPct: 90.83, staffTodayPct: 75, - combinedTodayPct: 85.8, + combinedTodayPct: 75, }); }); - it('counts missing child attendance reports as incomplete in aggregate percentages', () => { - const overallStats = buildOverallAttendanceStats(summaries, '2026-06-08', '2026-06-08', '2026-06-12'); + it('uses scoped child staff records for aggregate percentages', () => { const childStats: readonly CampusAttendanceChildStats[] = [ { id: 'school-1', @@ -242,11 +145,19 @@ describe('campus attendance selectors', () => { bgGradient: 'from-emerald-500 to-green-500', todayPct: 100, weekAvg: 100, - recentData: [], - todayRecord: null, childCampusIds: ['tigers'], recentStaffData: [], - todayStaffRecord: null, + todayStaffRecord: { + id: 'staff:school-1:2026-06-08', + campus_id: null, + date: '2026-06-08', + total_staff: 10, + total_present: 9, + total_absent: 1, + total_late: 1, + attendance_percentage: 90, + notes: null, + }, }, { id: 'school-2', @@ -256,19 +167,15 @@ describe('campus attendance selectors', () => { bgGradient: 'from-orange-600 to-amber-500', todayPct: null, weekAvg: null, - recentData: [], - todayRecord: null, childCampusIds: ['gators'], recentStaffData: [], todayStaffRecord: null, }, ]; - expect(buildCombinedAttendanceStats(overallStats, null, childStats)).toEqual({ - studentTodayPct: 50, - studentWeekPct: 50, - staffTodayPct: null, - combinedTodayPct: 50, + expect(buildCombinedAttendanceStats(null, childStats)).toEqual({ + staffTodayPct: 45, + combinedTodayPct: 45, }); }); @@ -292,14 +199,14 @@ describe('campus attendance selectors', () => { const [campus] = buildCampusAttendanceStats( campusInfo, [], - summaries, '2026-06-08', '2026-06-08', '2026-06-12', staffRecords, ); - expect(campus?.todayRecord?.total_enrolled).toBe(100); + expect(campus?.todayPct).toBe(66.67); + expect(campus?.weekAvg).toBe(66.67); expect(campus?.todayStaffRecord).toMatchObject({ total_staff: 3, total_present: 2, @@ -309,20 +216,8 @@ describe('campus attendance selectors', () => { expect(campus?.recentStaffData).toHaveLength(1); }); - it('builds combined attendance history with students, staff, and total rows per date', () => { - expect(buildCombinedAttendanceHistoryRows([summaries[0]], buildStaffAttendanceDailySummaries(staffRecords))).toEqual([ - { - id: '2026-06-08:students', - date: '2026-06-08', - group: 'students', - label: 'Students', - total: 100, - present: 90, - absent: 10, - late: 3, - attendancePercentage: 90, - notes: null, - }, + it('builds staff-only attendance history with staff and total rows per date', () => { + expect(buildCombinedAttendanceHistoryRows(buildStaffAttendanceDailySummaries(staffRecords))).toEqual([ { id: '2026-06-08:staff', date: '2026-06-08', @@ -340,11 +235,11 @@ describe('campus attendance selectors', () => { date: '2026-06-08', group: 'total', label: 'Total', - total: 103, - present: 92, - absent: 11, - late: 4, - attendancePercentage: 89.32, + total: 3, + present: 2, + absent: 1, + late: 1, + attendancePercentage: 66.67, notes: 'Traffic', }, ]); diff --git a/frontend/src/business/campus-attendance/selectors.ts b/frontend/src/business/campus-attendance/selectors.ts index dc54f6b..1e17eb0 100644 --- a/frontend/src/business/campus-attendance/selectors.ts +++ b/frontend/src/business/campus-attendance/selectors.ts @@ -5,14 +5,11 @@ import type { AttendanceScopeMode, CampusAttendanceCombinedStats, CampusAttendanceChildStats, - CampusAttendanceEntryDraft, CampusAttendanceConfigViewModel, CombinedAttendanceHistoryRow, - CampusAttendanceOverallStats, CampusAttendancePrintInput, CampusAttendanceScopeModel, CampusAttendanceStats, - CampusAttendanceSummaryViewModel, StaffAttendanceDailySummaryViewModel, } from '@/business/campus-attendance/types'; import type { @@ -50,91 +47,9 @@ export function formatAttendanceDate(date: string): string { }); } -export function parseAttendanceCount(value: string): number | null { - const parsed = Number.parseInt(value, 10); - return Number.isNaN(parsed) ? null : parsed; -} - -export function buildAttendanceEntryInput(draft: CampusAttendanceEntryDraft, campusId: CampusId) { - const totalEnrolled = parseAttendanceCount(draft.enrolled); - const totalPresent = parseAttendanceCount(draft.present); - const totalAbsent = parseAttendanceCount(draft.absent); - const totalTardy = parseAttendanceCount(draft.tardy) ?? 0; - - if (totalEnrolled === null || totalPresent === null || totalAbsent === null || totalEnrolled <= 0) { - return null; - } - - return { - campusId, - date: draft.date, - totalEnrolled, - totalPresent, - totalAbsent, - totalTardy, - notes: draft.notes.trim() || null, - }; -} - -export function getDraftAttendancePercentage(draft: CampusAttendanceEntryDraft): number | null { - const totalEnrolled = parseAttendanceCount(draft.enrolled); - const totalPresent = parseAttendanceCount(draft.present); - - if (totalEnrolled === null || totalPresent === null || totalEnrolled <= 0) { - return null; - } - - return (totalPresent / totalEnrolled) * 100; -} - -export function getTodayData( - summaries: readonly CampusAttendanceSummaryViewModel[], - campusId: CampusId, - today: string, -): readonly CampusAttendanceSummaryViewModel[] { - return summaries.filter((record) => record.campus_id === campusId && record.date === today); -} - -export function getWeekData( - summaries: readonly CampusAttendanceSummaryViewModel[], - campusId: CampusId, - weekStart: string, - weekEnd: string, -): readonly CampusAttendanceSummaryViewModel[] { - return summaries.filter( - (record) => record.campus_id === campusId && record.date >= weekStart && record.date <= weekEnd, - ); -} - -export function getWeeklyAverage( - summaries: readonly CampusAttendanceSummaryViewModel[], - campusId: CampusId, - weekStart: string, - weekEnd: string, -): number | null { - const weekData = getWeekData(summaries, campusId, weekStart, weekEnd); - - if (weekData.length === 0) { - return null; - } - - const avg = weekData.reduce((sum, record) => sum + record.attendance_percentage, 0) / weekData.length; - return Number(avg.toFixed(2)); -} - -export function getTodayPercentage( - summaries: readonly CampusAttendanceSummaryViewModel[], - campusId: CampusId, - today: string, -): number | null { - const todayData = getTodayData(summaries, campusId, today); - return todayData[0]?.attendance_percentage ?? null; -} - export function buildCampusAttendanceStats( campuses: readonly CampusInfo[], configs: readonly CampusAttendanceConfigViewModel[], - summaries: readonly CampusAttendanceSummaryViewModel[], today: string, weekStart: string, weekEnd: string, @@ -143,60 +58,24 @@ export function buildCampusAttendanceStats( const staffDailySummaries = buildStaffAttendanceDailySummaries(staffRecords); return campuses.map((campus) => { - const todayPct = getTodayPercentage(summaries, campus.id, today); - const weekAvg = getWeeklyAverage(summaries, campus.id, weekStart, weekEnd); const config = configs.find((item) => item.campus_id === campus.id) ?? null; - const recentData = summaries.filter((record) => record.campus_id === campus.id).slice(0, 10); - const todayRecord = getTodayData(summaries, campus.id, today)[0] ?? null; const recentStaffData = staffDailySummaries .filter((record) => isStaffSummaryForCampus(record, campus)) .slice(0, 10); const todayStaffRecord = recentStaffData.find((record) => record.date === today) ?? null; + const weekAvg = averageStaffAttendancePercentage(recentStaffData, weekStart, weekEnd); return { ...campus, - todayPct, + todayPct: todayStaffRecord?.attendance_percentage ?? null, weekAvg, config, - recentData, - todayRecord, recentStaffData, todayStaffRecord, }; }); } -export function buildOverallAttendanceStats( - summaries: readonly CampusAttendanceSummaryViewModel[], - today: string, - weekStart: string, - weekEnd: string, -): CampusAttendanceOverallStats { - const todayRecords = summaries.filter((record) => record.date === today); - const todayEnrolled = todayRecords.reduce((sum, record) => sum + record.total_enrolled, 0); - const todayPresent = todayRecords.reduce((sum, record) => sum + record.total_present, 0); - const todayPct = todayEnrolled > 0 ? Number(((todayPresent / todayEnrolled) * 100).toFixed(2)) : null; - const weekRecords = summaries.filter((record) => record.date >= weekStart && record.date <= weekEnd); - const weekDays = Array.from(new Set(weekRecords.map((record) => record.date))); - const weekPct = weekDays.length > 0 - ? Number((weekDays.reduce((sum, day) => { - const dayRecords = weekRecords.filter((record) => record.date === day); - const dayEnrolled = dayRecords.reduce((daySum, record) => daySum + record.total_enrolled, 0); - const dayPresent = dayRecords.reduce((daySum, record) => daySum + record.total_present, 0); - return sum + (dayEnrolled > 0 ? (dayPresent / dayEnrolled) * 100 : 0); - }, 0) / weekDays.length).toFixed(2)) - : null; - - return { - todayRecords, - todayEnrolled, - todayPresent, - todayPct, - weekRecords, - weekPct, - }; -} - function getAttendanceScopeMode(tier: ScopeTier): AttendanceScopeMode { if (tier === 'organization') { return 'organization'; @@ -289,6 +168,17 @@ function percentageFromScopedChildren( return percentageFromCounts(presentEquivalent, total); } +function averageStaffAttendancePercentage( + records: readonly StaffAttendanceDailySummaryViewModel[], + weekStart: string, + weekEnd: string, +): number | null { + const weekRecords = records.filter((record) => record.date >= weekStart && record.date <= weekEnd); + return weekRecords.length > 0 + ? Number((weekRecords.reduce((sum, record) => sum + record.attendance_percentage, 0) / weekRecords.length).toFixed(2)) + : null; +} + function staffAttendancePercentage(present: number, late: number, total: number): number { return percentageFromCounts(present + late, total) ?? 0; } @@ -349,7 +239,6 @@ export function buildStaffAttendanceDailySummaries( } export function buildCombinedAttendanceStats( - overallStats: CampusAttendanceOverallStats, staffSummary: StaffAttendanceSummaryViewModel | null, childStats?: readonly CampusAttendanceChildStats[], ): CampusAttendanceCombinedStats { @@ -359,21 +248,15 @@ export function buildCombinedAttendanceStats( : 0; const staffTotal = staffSummary ? Math.max(staffSummary.staffCount, recordedStaffTotal) : 0; const staffPresent = staffSummary ? staffSummary.present + staffSummary.late : 0; - const scopedStudentTodayPct = percentageFromScopedChildren(scopedChildren, (child) => child.todayPct); - const scopedStudentWeekPct = percentageFromScopedChildren(scopedChildren, (child) => child.weekAvg); - const hasScopedChildren = scopedChildren.length > 0; - const studentTodayPct = scopedStudentTodayPct ?? overallStats.todayPct; - const studentWeekPct = scopedStudentWeekPct ?? overallStats.weekPct; - const studentTotal = hasScopedChildren ? scopedChildren.length : overallStats.todayEnrolled; - const studentPresent = hasScopedChildren - ? scopedChildren.reduce((sum, child) => sum + ((child.todayPct ?? 0) / 100), 0) - : overallStats.todayPresent; + const scopedStaffTodayPct = percentageFromScopedChildren( + scopedChildren, + (child) => child.todayStaffRecord?.attendance_percentage ?? null, + ); + const staffTodayPct = scopedStaffTodayPct ?? percentageFromCounts(staffPresent, staffTotal); return { - studentTodayPct, - studentWeekPct, - staffTodayPct: percentageFromCounts(staffPresent, staffTotal), - combinedTodayPct: percentageFromCounts(studentPresent + staffPresent, studentTotal + staffTotal), + staffTodayPct, + combinedTodayPct: staffTodayPct, }; } @@ -382,52 +265,21 @@ function notesFromParts(...parts: readonly (string | null | undefined)[]): strin return notes.length > 0 ? notes.join('; ') : null; } -function combinedAttendancePercentage( - studentRecord: CampusAttendanceSummaryViewModel | null, - staffRecord: StaffAttendanceDailySummaryViewModel | null, -): number | null { - const studentTotal = studentRecord?.total_enrolled ?? 0; - const staffTotal = staffRecord?.total_staff ?? 0; - const studentPresent = studentRecord?.total_present ?? 0; - const staffPresent = staffRecord?.total_present ?? 0; - - return percentageFromCounts(studentPresent + staffPresent, studentTotal + staffTotal); -} - export function buildCombinedAttendanceHistoryRows( - studentRecords: readonly CampusAttendanceSummaryViewModel[], staffRecords: readonly StaffAttendanceDailySummaryViewModel[], ): readonly CombinedAttendanceHistoryRow[] { const dates = Array.from(new Set([ - ...studentRecords.map((record) => record.date), ...staffRecords.map((record) => record.date), ])).sort((left, right) => right.localeCompare(left)); return dates.flatMap((date) => { - const studentRecord = studentRecords.find((record) => record.date === date) ?? null; const staffRecord = staffRecords.find((record) => record.date === date) ?? null; - const studentTotal = studentRecord?.total_enrolled ?? 0; const staffTotal = staffRecord?.total_staff ?? 0; - const studentPresent = studentRecord?.total_present ?? 0; const staffPresent = staffRecord?.total_present ?? 0; - const studentAbsent = studentRecord?.total_absent ?? 0; const staffAbsent = staffRecord?.total_absent ?? 0; - const studentLate = studentRecord?.total_tardy ?? 0; const staffLate = staffRecord?.total_late ?? 0; return [ - { - id: `${date}:students`, - date, - group: 'students', - label: 'Students', - total: studentTotal, - present: studentPresent, - absent: studentAbsent, - late: studentLate, - attendancePercentage: studentRecord?.attendance_percentage ?? null, - notes: studentRecord?.notes ?? null, - }, { id: `${date}:staff`, date, @@ -445,21 +297,18 @@ export function buildCombinedAttendanceHistoryRows( date, group: 'total', label: 'Total', - total: studentTotal + staffTotal, - present: studentPresent + staffPresent, - absent: studentAbsent + staffAbsent, - late: studentLate + staffLate, - attendancePercentage: combinedAttendancePercentage(studentRecord, staffRecord), - notes: notesFromParts(studentRecord?.notes, staffRecord?.notes), + total: staffTotal, + present: staffPresent, + absent: staffAbsent, + late: staffLate, + attendancePercentage: staffRecord?.attendance_percentage ?? null, + notes: notesFromParts(staffRecord?.notes), }, ] satisfies readonly CombinedAttendanceHistoryRow[]; }); } export function buildPrintAttendanceStats(input: CampusAttendancePrintInput) { - const printEnrolled = input.printTodayRecords.reduce((sum, record) => sum + record.total_enrolled, 0); - const printPresent = input.printTodayRecords.reduce((sum, record) => sum + record.total_present, 0); - const todayPct = printEnrolled > 0 ? Number(((printPresent / printEnrolled) * 100).toFixed(2)) : null; const staffTotal = input.staffSummary ? Math.max(input.staffSummary.staffCount, input.staffSummary.present + input.staffSummary.late + input.staffSummary.absent) : 0; @@ -467,21 +316,9 @@ export function buildPrintAttendanceStats(input: CampusAttendancePrintInput) { ? input.staffSummary.present + input.staffSummary.late : 0; const staffPct = percentageFromCounts(staffPresent, staffTotal); - const combinedPct = percentageFromCounts(printPresent + staffPresent, printEnrolled + staffTotal); - const weekDays = Array.from(new Set(input.printWeekRecords.map((record) => record.date))); - const weekPct = weekDays.length > 0 - ? Number((weekDays.reduce((sum, day) => { - const dayRecords = input.printWeekRecords.filter((record) => record.date === day); - const dayEnrolled = dayRecords.reduce((daySum, record) => daySum + record.total_enrolled, 0); - const dayPresent = dayRecords.reduce((daySum, record) => daySum + record.total_present, 0); - return sum + (dayEnrolled > 0 ? (dayPresent / dayEnrolled) * 100 : 0); - }, 0) / weekDays.length).toFixed(2)) - : null; return { - todayPct, staffPct, - combinedPct, - weekPct, + staffTotal, }; } diff --git a/frontend/src/business/campus-attendance/types.ts b/frontend/src/business/campus-attendance/types.ts index 7d31cf7..7674914 100644 --- a/frontend/src/business/campus-attendance/types.ts +++ b/frontend/src/business/campus-attendance/types.ts @@ -14,35 +14,10 @@ export interface CampusAttendanceConfigViewModel { readonly updated_at: string; } -export interface CampusAttendanceSummaryViewModel { - readonly id: string; - readonly campus_id: CampusId; - readonly date: string; - readonly total_enrolled: number; - readonly total_present: number; - readonly total_absent: number; - readonly total_tardy: number; - readonly attendance_percentage: number; - readonly recorded_by: string | null; - readonly notes: string | null; -} - -export interface CampusAttendanceEntryInput { - readonly campusId: CampusId; - readonly date: string; - readonly totalEnrolled: number; - readonly totalPresent: number; - readonly totalAbsent: number; - readonly totalTardy: number; - readonly notes: string | null; -} - export interface CampusAttendanceStats extends CampusInfo { readonly todayPct: number | null; readonly weekAvg: number | null; readonly config: CampusAttendanceConfigViewModel | null; - readonly recentData: readonly CampusAttendanceSummaryViewModel[]; - readonly todayRecord: CampusAttendanceSummaryViewModel | null; readonly recentStaffData: readonly StaffAttendanceDailySummaryViewModel[]; readonly todayStaffRecord: StaffAttendanceDailySummaryViewModel | null; } @@ -56,32 +31,11 @@ export interface CampusAttendanceChildStats { readonly isOnline?: boolean; readonly todayPct: number | null; readonly weekAvg: number | null; - readonly recentData: readonly CampusAttendanceSummaryViewModel[]; - readonly todayRecord: CampusAttendanceSummaryViewModel | null; readonly childCampusIds: readonly CampusId[]; readonly recentStaffData: readonly StaffAttendanceDailySummaryViewModel[]; readonly todayStaffRecord: StaffAttendanceDailySummaryViewModel | null; } -export interface CampusAttendanceOverallStats { - readonly todayRecords: readonly CampusAttendanceSummaryViewModel[]; - readonly todayEnrolled: number; - readonly todayPresent: number; - readonly todayPct: number | null; - readonly weekRecords: readonly CampusAttendanceSummaryViewModel[]; - readonly weekPct: number | null; -} - -export interface CampusAttendanceEntryDraft { - readonly date: string; - readonly campusId: CampusId; - readonly enrolled: string; - readonly present: string; - readonly absent: string; - readonly tardy: string; - readonly notes: string; -} - export interface StaffAttendanceEntryDraft { readonly date: string; readonly userId: string; @@ -131,8 +85,6 @@ export interface CampusAttendanceScopeModel { } export interface CampusAttendanceCombinedStats { - readonly studentTodayPct: number | null; - readonly studentWeekPct: number | null; readonly staffTodayPct: number | null; readonly combinedTodayPct: number | null; } @@ -149,7 +101,7 @@ export interface StaffAttendanceDailySummaryViewModel { readonly notes: string | null; } -export type CombinedAttendanceGroup = 'students' | 'staff' | 'total'; +export type CombinedAttendanceGroup = 'staff' | 'total'; export interface CombinedAttendanceHistoryRow { readonly id: string; @@ -175,9 +127,6 @@ export interface CampusAttendancePrintInput { readonly generatedByName: string; readonly generatedByRole: string; readonly today: string; - readonly weekStart: string; readonly campusesToPrint: readonly CampusAttendanceStats[]; - readonly printTodayRecords: readonly CampusAttendanceSummaryViewModel[]; - readonly printWeekRecords: readonly CampusAttendanceSummaryViewModel[]; readonly staffSummary: StaffAttendanceSummaryViewModel | null; } diff --git a/frontend/src/business/director-dashboard/hooks.ts b/frontend/src/business/director-dashboard/hooks.ts index 1efca6a..12bb6ce 100644 --- a/frontend/src/business/director-dashboard/hooks.ts +++ b/frontend/src/business/director-dashboard/hooks.ts @@ -7,7 +7,6 @@ import { usePersonalityCompletion } from '@/business/personality/queryHooks'; import { useZoneCheckInCompletion } from '@/business/zone-checkin/hooks'; import { useStaffAttendanceRecords, - useStaffAttendanceSummary, } from '@/business/staff-attendance/hooks'; import { usePolicyAcknowledgmentReport } from '@/business/policies/hooks'; import { @@ -80,13 +79,14 @@ export function useDirectorDashboardPage(): DirectorDashboardPage { const scopeKey = activeTenant ? `${activeTenant.level}:${activeTenant.id}` : null; const [timeRange, setTimeRangeState] = useState(readStoredTimeRange); const periodFilter = getDirectorDashboardDateRange(timeRange); + const today = format(new Date(), 'yyyy-MM-dd'); + const todayFilter = { startDate: today, endDate: today }; const safetyQuizWeek = getCurrentSafetyQuizWeek(new Date()); const frameEntriesQuery = useFrameEntries(periodFilter); const quizCompletionQuery = useSafetyQuizCompliance(safetyQuizWeek, true); const emotionalIntelligenceCompletionQuery = usePersonalityCompletion(true); const zoneCheckinCompletionQuery = useZoneCheckInCompletion(true, scopeKey); - const staffAttendanceRecordsQuery = useStaffAttendanceRecords(periodFilter); - const staffAttendanceSummaryQuery = useStaffAttendanceSummary(periodFilter); + const staffAttendanceRecordsQuery = useStaffAttendanceRecords(todayFilter); const acknowledgmentReportQuery = usePolicyAcknowledgmentReport(); const frameEntries = frameEntriesQuery.data ?? []; const quizRows = quizCompletionQuery.data?.rows ?? []; @@ -105,14 +105,12 @@ export function useDirectorDashboardPage(): DirectorDashboardPage { || emotionalIntelligenceCompletionQuery.isLoading || zoneCheckinCompletionQuery.isLoading || staffAttendanceRecordsQuery.isLoading - || staffAttendanceSummaryQuery.isLoading || acknowledgmentReportQuery.isLoading; const error = frameEntriesQuery.error ?? quizCompletionQuery.error ?? emotionalIntelligenceCompletionQuery.error ?? zoneCheckinCompletionQuery.error ?? staffAttendanceRecordsQuery.error - ?? staffAttendanceSummaryQuery.error ?? acknowledgmentReportQuery.error; return { diff --git a/frontend/src/business/director-dashboard/selectors.test.ts b/frontend/src/business/director-dashboard/selectors.test.ts index a04d08c..9a486ad 100644 --- a/frontend/src/business/director-dashboard/selectors.test.ts +++ b/frontend/src/business/director-dashboard/selectors.test.ts @@ -323,7 +323,7 @@ describe('director dashboard selectors', () => { action: 'openAcknowledgments', }, { - issue: '4 staff attendance exceptions this period (1 late, 3 absent)', + issue: '4 staff attendance exceptions today (1 late, 3 absent)', severity: 'high', module: 'attendance', }, diff --git a/frontend/src/business/director-dashboard/selectors.ts b/frontend/src/business/director-dashboard/selectors.ts index 204ed68..7eee953 100644 --- a/frontend/src/business/director-dashboard/selectors.ts +++ b/frontend/src/business/director-dashboard/selectors.ts @@ -52,7 +52,7 @@ export function buildDirectorOverviewCards( { label: 'Staff Attendance', value: `${attendanceRate}%`, - change: `${attendanceRecords.length} records`, + change: `${attendanceRecords.length} today`, trend: 'up', iconId: 'clock', tone: 'orange', @@ -179,7 +179,7 @@ export function buildDirectorRiskAreas( if (staffAttendanceExceptionCount > 0) { risks.push({ - issue: `${staffAttendanceExceptionCount} ${pluralize('staff attendance exception', staffAttendanceExceptionCount)} this period (${lateCount} late, ${absenceCount} absent)`, + issue: `${staffAttendanceExceptionCount} ${pluralize('staff attendance exception', staffAttendanceExceptionCount)} today (${lateCount} late, ${absenceCount} absent)`, severity: 'high', module: 'attendance', }); diff --git a/frontend/src/business/my-class/api.ts b/frontend/src/business/my-class/api.ts index 81d44eb..f123f88 100644 --- a/frontend/src/business/my-class/api.ts +++ b/frontend/src/business/my-class/api.ts @@ -1,7 +1,11 @@ export { getClass } from '@/shared/api/classes'; -export { - getClassAttendanceSummary, - upsertClassAttendance, -} from '@/shared/api/classAttendance'; export { listGuardianStudents } from '@/shared/api/guardianStudents'; -export { listUsers, type AdminUserRow } from '@/shared/api/users'; +export { listRoles, type RoleRow } from '@/shared/api/roles'; +export { + createUser, + linkGuardianStudent, + listUsers, + updateUser, + type AdminUserRow, + type SaveUserData, +} from '@/shared/api/users'; diff --git a/frontend/src/business/my-class/selectors.test.ts b/frontend/src/business/my-class/selectors.test.ts new file mode 100644 index 0000000..4b9fbc7 --- /dev/null +++ b/frontend/src/business/my-class/selectors.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { + buildMyClassGuardianSaveData, + buildMyClassStudentSaveData, + canManageMyClassStudents, + hasMyClassGuardianFormValues, +} from '@/business/my-class/selectors'; + +describe('my-class selectors', () => { + it('builds a scoped student user payload from form values', () => { + expect(buildMyClassStudentSaveData( + { + namePrefix: '', + firstName: ' Emma ', + lastName: ' Clark ', + email: ' student@example.com ', + phoneNumber: ' ', + avatar: 'users/avatar/student.png', + guardians: [], + }, + 'class-1', + 'role-student', + )).toEqual({ + name_prefix: null, + firstName: 'Emma', + lastName: 'Clark', + email: 'student@example.com', + phoneNumber: null, + avatar: 'users/avatar/student.png', + app_role: 'role-student', + classId: 'class-1', + }); + }); + + it('builds a guardian payload and detects whether guardian fields were entered', () => { + const values = { + key: 'guardian-1', + id: null, + namePrefix: ' Mr ', + firstName: ' Pat ', + lastName: ' Adams ', + email: ' guardian@example.com ', + phoneNumber: ' 555-1000 ', + avatar: 'users/avatar/guardian.png', + }; + + expect(hasMyClassGuardianFormValues(values)).toBe(true); + expect(buildMyClassGuardianSaveData(values, 'guardian-role')).toEqual({ + name_prefix: 'Mr', + firstName: 'Pat', + lastName: 'Adams', + email: 'guardian@example.com', + phoneNumber: '555-1000', + avatar: 'users/avatar/guardian.png', + app_role: 'guardian-role', + }); + expect(hasMyClassGuardianFormValues({ + ...values, + namePrefix: '', + firstName: '', + lastName: '', + email: '', + phoneNumber: '', + avatar: null, + })).toBe(false); + }); + + it('requires permission, class scope, and the student role id before enabling roster management', () => { + expect(canManageMyClassStudents({ + hasUserPermission: true, + classId: 'class-1', + studentRoleId: 'role-student', + })).toBe(true); + expect(canManageMyClassStudents({ + hasUserPermission: false, + classId: 'class-1', + studentRoleId: 'role-student', + })).toBe(false); + expect(canManageMyClassStudents({ + hasUserPermission: true, + classId: null, + studentRoleId: 'role-student', + })).toBe(false); + expect(canManageMyClassStudents({ + hasUserPermission: true, + classId: 'class-1', + studentRoleId: null, + })).toBe(false); + }); +}); diff --git a/frontend/src/business/my-class/selectors.ts b/frontend/src/business/my-class/selectors.ts new file mode 100644 index 0000000..a7efa11 --- /dev/null +++ b/frontend/src/business/my-class/selectors.ts @@ -0,0 +1,75 @@ +import type { SaveUserData } from '@/business/my-class/api'; + +export interface MyClassStudentFormValues { + readonly namePrefix: string; + readonly firstName: string; + readonly lastName: string; + readonly email: string; + readonly phoneNumber: string; + readonly avatar: string | null; + readonly guardians: readonly MyClassGuardianFormValues[]; +} + +export interface MyClassGuardianFormValues { + readonly key: string; + readonly id: string | null; + readonly namePrefix: string; + readonly firstName: string; + readonly lastName: string; + readonly email: string; + readonly phoneNumber: string; + readonly avatar: string | null; +} + +export function buildMyClassStudentSaveData( + values: MyClassStudentFormValues, + classId: string, + studentRoleId: string, +): SaveUserData { + return { + name_prefix: values.namePrefix === '' ? null : values.namePrefix, + firstName: values.firstName.trim(), + lastName: values.lastName.trim(), + email: values.email.trim(), + phoneNumber: values.phoneNumber.trim() || null, + avatar: values.avatar, + app_role: studentRoleId, + classId, + }; +} + +export function hasMyClassGuardianFormValues(values: MyClassGuardianFormValues): boolean { + return Boolean( + values.namePrefix.trim() + || values.firstName.trim() + || values.lastName.trim() + || values.email.trim() + || values.phoneNumber.trim() + || values.avatar, + ); +} + +export function buildMyClassGuardianSaveData( + values: MyClassGuardianFormValues, + guardianRoleId: string, +): SaveUserData { + return { + name_prefix: values.namePrefix.trim() === '' ? null : values.namePrefix.trim(), + firstName: values.firstName.trim(), + lastName: values.lastName.trim(), + email: values.email.trim(), + phoneNumber: values.phoneNumber.trim() || null, + avatar: values.avatar, + app_role: guardianRoleId, + }; +} + +export function canManageMyClassStudents(input: { + readonly hasUserPermission: boolean; + readonly classId: string | null; + readonly studentRoleId: string | null; +}): boolean { + return input.hasUserPermission + && Boolean(input.classId) + && Boolean(input.studentRoleId); +} diff --git a/frontend/src/business/staff-attendance/selectors.test.ts b/frontend/src/business/staff-attendance/selectors.test.ts index a1dde41..40fe5dd 100644 --- a/frontend/src/business/staff-attendance/selectors.test.ts +++ b/frontend/src/business/staff-attendance/selectors.test.ts @@ -22,8 +22,8 @@ describe('staff attendance selectors', () => { expect(countStaffAttendanceStatus(records, 'absent')).toBe(3); }); - it('calculates present attendance rate from the whole record set', () => { - expect(staffAttendanceRate(records)).toBe(20); + it('calculates present-or-late attendance rate from the whole record set', () => { + expect(staffAttendanceRate(records)).toBe(40); expect(staffAttendanceRate([])).toBe(0); }); diff --git a/frontend/src/business/staff-attendance/selectors.ts b/frontend/src/business/staff-attendance/selectors.ts index c522638..3cbbcd8 100644 --- a/frontend/src/business/staff-attendance/selectors.ts +++ b/frontend/src/business/staff-attendance/selectors.ts @@ -15,7 +15,8 @@ export function staffAttendanceRate(records: readonly StaffAttendanceRecordViewM return 0; } - return Math.round((countStaffAttendanceStatus(records, 'present') / records.length) * 100); + const presentOrLate = countStaffAttendanceStatus(records, 'present') + countStaffAttendanceStatus(records, 'late'); + return Math.round((presentOrLate / records.length) * 100); } export function recentStaffAttendanceRecords( diff --git a/frontend/src/business/top-bar/hooks.ts b/frontend/src/business/top-bar/hooks.ts index 763a3a3..fc3afe2 100644 --- a/frontend/src/business/top-bar/hooks.ts +++ b/frontend/src/business/top-bar/hooks.ts @@ -1,14 +1,16 @@ import { useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { listCampusAttendanceSummaries } from '@/shared/api/campusAttendance'; import { listFrameEntries } from '@/shared/api/frame'; +import { getStaffAttendanceSummary } from '@/shared/api/staffAttendance'; import { buildTopBarNotifications, + canQueryDailyStaffAttendanceForScope, countUnreadTopBarNotifications, getTopBarCampusLabel, getTopBarInitials, getTopBarRoleLabel, + shouldShowDailyStaffAttendanceNotification, } from '@/business/top-bar/selectors'; import { buildTopBarSearchResults, @@ -33,7 +35,7 @@ import { useCurrentPersonalityResult } from '@/business/personality/queryHooks'; import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks'; import { canPersistPersonalScopeResults } from '@/business/scope/selectors'; import { useSafetyProtocols } from '@/business/safety-protocols/hooks'; -import { hasPermission } from '@/business/auth/permissions'; +import { hasAnyPermission, hasPermission } from '@/business/auth/permissions'; import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; import { MODULES } from '@/shared/constants/appData'; import { PERSONALITY_QUIZ_KINDS } from '@/shared/constants/personality'; @@ -129,27 +131,15 @@ export function useTopBarPage({ return response.rows; }, }); - const canManageDailyAttendance = hasPermission(user, 'FILL_ATTENDANCE') + const canMonitorDailyAttendance = hasAnyPermission(user, ['FILL_ATTENDANCE', 'READ_STAFF_ATTENDANCE_REPORTS']) && accessibleModuleIds.has('attendance'); - const attendanceCampusKey = - effectiveTier === 'campus' || effectiveTier === 'class' - ? campusInfo?.id - : undefined; const attendanceContentQuery = useQuery({ - queryKey: ['top-bar-daily-attendance-content', today, attendanceCampusKey ?? null, effectiveTier], - enabled: canManageDailyAttendance && Boolean(today) && ( - effectiveTier === 'organization' - || effectiveTier === 'school' - || Boolean(attendanceCampusKey) - ), - queryFn: async () => { - const response = await listCampusAttendanceSummaries({ - ...(attendanceCampusKey ? { campusKey: attendanceCampusKey } : {}), - startDate: today, - endDate: today, - }); - return response.rows; - }, + queryKey: ['top-bar-daily-staff-attendance-content', today, effectiveTier, selectedTenant?.id ?? null, campusInfo?.id ?? null], + enabled: canMonitorDailyAttendance && Boolean(today) && canQueryDailyStaffAttendanceForScope(effectiveTier), + queryFn: () => getStaffAttendanceSummary({ + startDate: today, + endDate: today, + }), }); const canReceiveEmotionalIntelligenceNotifications = canPersistPersonalResults && hasPermission(user, 'TAKE_QUIZ') @@ -242,10 +232,14 @@ export function useTopBarPage({ && !frameContentQuery.isLoading && !frameContentQuery.error && (frameContentQuery.data?.length ?? 0) === 0; - const needsDailyAttendanceContent = canManageDailyAttendance - && !attendanceContentQuery.isLoading - && !attendanceContentQuery.error - && (attendanceContentQuery.data?.length ?? 0) === 0; + const needsDailyAttendanceContent = shouldShowDailyStaffAttendanceNotification({ + canMonitorDailyAttendance, + tier: effectiveTier, + loading: attendanceContentQuery.isLoading, + hasError: Boolean(attendanceContentQuery.error), + staffCount: attendanceContentQuery.data?.staffCount ?? 0, + recordsCount: attendanceContentQuery.data?.recordsCount ?? 0, + }); const notifications = buildTopBarNotifications({ needsZoneCheckIn, needsSafetyQuiz, diff --git a/frontend/src/business/top-bar/selectors.test.ts b/frontend/src/business/top-bar/selectors.test.ts index 66723f9..ba03d5d 100644 --- a/frontend/src/business/top-bar/selectors.test.ts +++ b/frontend/src/business/top-bar/selectors.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest'; import { buildTopBarNotifications, + canQueryDailyStaffAttendanceForScope, countUnreadTopBarNotifications, getTopBarCampusLabel, getTopBarInitials, getTopBarRoleLabel, + shouldShowDailyStaffAttendanceNotification, } from '@/business/top-bar/selectors'; import { APP_ROUTE_PATHS } from '@/shared/constants/routes'; import type { CommunicationEventDto } from '@/shared/types/communications'; @@ -119,7 +121,7 @@ describe('top bar selectors', () => { }, { id: 'daily-attendance-content', - text: "Submit today's attendance", + text: "Today's staff attendance is incomplete", time: 'Today', unread: true, href: APP_ROUTE_PATHS.attendance, @@ -127,6 +129,71 @@ describe('top bar selectors', () => { ]); }); + it('derives the daily staff attendance reminder from permission, scope, and completion state', () => { + expect(canQueryDailyStaffAttendanceForScope('organization')).toBe(true); + expect(canQueryDailyStaffAttendanceForScope('school')).toBe(true); + expect(canQueryDailyStaffAttendanceForScope('campus')).toBe(true); + expect(canQueryDailyStaffAttendanceForScope('class')).toBe(false); + expect(canQueryDailyStaffAttendanceForScope('global')).toBe(false); + + expect(shouldShowDailyStaffAttendanceNotification({ + canMonitorDailyAttendance: true, + tier: 'school', + loading: false, + hasError: false, + staffCount: 10, + recordsCount: 9, + })).toBe(true); + expect(shouldShowDailyStaffAttendanceNotification({ + canMonitorDailyAttendance: true, + tier: 'school', + loading: false, + hasError: false, + staffCount: 10, + recordsCount: 10, + })).toBe(false); + expect(shouldShowDailyStaffAttendanceNotification({ + canMonitorDailyAttendance: true, + tier: 'school', + loading: false, + hasError: false, + staffCount: 0, + recordsCount: 0, + })).toBe(false); + expect(shouldShowDailyStaffAttendanceNotification({ + canMonitorDailyAttendance: false, + tier: 'school', + loading: false, + hasError: false, + staffCount: 10, + recordsCount: 0, + })).toBe(false); + expect(shouldShowDailyStaffAttendanceNotification({ + canMonitorDailyAttendance: true, + tier: 'class', + loading: false, + hasError: false, + staffCount: 10, + recordsCount: 0, + })).toBe(false); + expect(shouldShowDailyStaffAttendanceNotification({ + canMonitorDailyAttendance: true, + tier: 'school', + loading: true, + hasError: false, + staffCount: 10, + recordsCount: 0, + })).toBe(false); + expect(shouldShowDailyStaffAttendanceNotification({ + canMonitorDailyAttendance: true, + tier: 'school', + loading: false, + hasError: true, + staffCount: 10, + recordsCount: 0, + })).toBe(false); + }); + it('surfaces EI self-assessment and personality quiz completion reminders', () => { const reminders = buildTopBarNotifications({ needsZoneCheckIn: false, diff --git a/frontend/src/business/top-bar/selectors.ts b/frontend/src/business/top-bar/selectors.ts index 64c51f8..37e847a 100644 --- a/frontend/src/business/top-bar/selectors.ts +++ b/frontend/src/business/top-bar/selectors.ts @@ -5,6 +5,7 @@ import type { CampusInfo, UserRole, } from '@/shared/types/app'; +import type { ScopeTier } from '@/business/scope/selectors'; import type { TopBarNotification } from '@/business/top-bar/types'; import type { CommunicationEventDto } from '@/shared/types/communications'; import type { PolicyViewModel } from '@/business/policies/types'; @@ -38,6 +39,26 @@ export function countUnreadTopBarNotifications( return notifications.filter((notification) => notification.unread).length; } +export function canQueryDailyStaffAttendanceForScope(tier: ScopeTier): boolean { + return tier === 'organization' || tier === 'school' || tier === 'campus'; +} + +export function shouldShowDailyStaffAttendanceNotification(input: { + readonly canMonitorDailyAttendance: boolean; + readonly tier: ScopeTier; + readonly loading: boolean; + readonly hasError: boolean; + readonly staffCount: number; + readonly recordsCount: number; +}): boolean { + return input.canMonitorDailyAttendance + && canQueryDailyStaffAttendanceForScope(input.tier) + && !input.loading + && !input.hasError + && input.staffCount > 0 + && input.recordsCount < input.staffCount; +} + const ZONE_CHECKIN_NOTIFICATION_ID = 'zone-checkin-today'; const SAFETY_QUIZ_NOTIFICATION_ID = 'safety-quiz-weekly'; const SIGN_OF_WEEK_NOTIFICATION_ID = 'sign-of-week'; @@ -122,7 +143,7 @@ export function buildTopBarNotifications(input: { if (input.needsDailyAttendanceContent) { notifications.push({ id: DAILY_ATTENDANCE_NOTIFICATION_ID, - text: "Submit today's attendance", + text: "Today's staff attendance is incomplete", time: 'Today', unread: true, href: APP_ROUTE_PATHS.attendance, diff --git a/frontend/src/business/user-admin/api.ts b/frontend/src/business/user-admin/api.ts index d855dd3..286dbb3 100644 --- a/frontend/src/business/user-admin/api.ts +++ b/frontend/src/business/user-admin/api.ts @@ -1,4 +1,5 @@ export { fileDownloadUrl } from '@/shared/api/files'; +export { listGuardianStudents } from '@/shared/api/guardianStudents'; export { listPermissions } from '@/shared/api/permissions'; export { listRoles, type RoleRow } from '@/shared/api/roles'; export { diff --git a/frontend/src/components/campus-attendance/CampusAttendanceEntryForm.tsx b/frontend/src/components/campus-attendance/CampusAttendanceEntryForm.tsx index 69c0588..b13934e 100644 --- a/frontend/src/components/campus-attendance/CampusAttendanceEntryForm.tsx +++ b/frontend/src/components/campus-attendance/CampusAttendanceEntryForm.tsx @@ -1,28 +1,18 @@ -import { Calendar, Loader2, Save, X } from 'lucide-react'; -import { generatePath, Link } from 'react-router-dom'; +import { Calendar, Save, X } from 'lucide-react'; -import { getDraftAttendancePercentage } from '@/business/campus-attendance/selectors'; import type { CampusAttendancePageActions, CampusAttendancePageState, } from '@/components/campus-attendance/types'; import type { StaffAttendanceStatus } from '@/shared/types/staffAttendance'; -import { percentageTextClass } from '@/components/campus-attendance/styles'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { useScopeContext } from '@/contexts/scope-context'; -import { APP_ROUTE_PATHS } from '@/shared/constants/routes'; -import type { ActiveTenant } from '@/shared/types/scope'; type CampusAttendanceEntryFormProps = { state: CampusAttendancePageState; actions: CampusAttendancePageActions; }; -type AttendanceRouteState = { - readonly __scope: ActiveTenant | null; -}; - const STAFF_ATTENDANCE_STATUS_OPTIONS: readonly { value: StaffAttendanceStatus; label: string; @@ -44,28 +34,14 @@ function formatRoleName(role: string | null): string { } export function CampusAttendanceEntryForm({ state, actions }: CampusAttendanceEntryFormProps) { - const { selectedTenant } = useScopeContext(); const { - campusInfo, - entryDraft, - entryError, saving, scopeModel, staffEntryDraft, staffEntryError, officeStaffUsers, staffAttendanceStatuses, - studentRollupRows, - attendanceStudents, - studentAttendanceStatuses, - attendanceStudentsLoading, } = state; - const draftPercentage = getDraftAttendancePercentage(entryDraft); - const showStudentRollup = scopeModel.mode !== 'campus'; - const entryTargetLabel = showStudentRollup - ? scopeModel.reportLabel - : scopeModel.tenantName || campusInfo?.fullName || 'Current Scope'; - const hasStudentRoster = attendanceStudents.length > 0; const staffAttendanceLabel = scopeModel.tier === 'class' ? 'Classroom Staff Attendance' : scopeModel.mode === 'campus' @@ -76,21 +52,13 @@ export function CampusAttendanceEntryForm({ state, actions }: CampusAttendanceEn : scopeModel.mode === 'campus' ? 'Record attendance for campus staff.' : `Record attendance for ${scopeModel.reportLabel.toLowerCase()} office staff.`; - const noStudentRosterText = scopeModel.tier === 'class' - ? 'No students are available for this classroom.' - : 'No students are available for this campus.'; - const scopeRouteState: AttendanceRouteState = { __scope: selectedTenant }; - const studentAttendanceSaveDisabled = attendanceStudentsLoading - || (showStudentRollup - ? studentRollupRows.length === 0 - : !hasStudentRoster && (!entryDraft.enrolled || !entryDraft.present || !entryDraft.absent)); return (

- Enter Attendance for {entryTargetLabel} + Enter {staffAttendanceLabel}

- {showStudentRollup && ( -
-

Student Attendance Rollup

-

- Review and edit totals collected from campuses inside this {scopeModel.mode}. -

-
- )} -
- actions.updateEntryDraft({ date: value })} /> - {!showStudentRollup && ( - actions.updateEntryDraft({ notes: value })} /> - )} -
- {showStudentRollup ? ( - <> - - - ) : attendanceStudentsLoading ? ( -
- - Loading student roster... -
- ) : hasStudentRoster ? ( - - ) : ( -
- actions.updateEntryDraft({ enrolled: value })} /> - actions.updateEntryDraft({ present: value })} /> - actions.updateEntryDraft({ absent: value })} /> - actions.updateEntryDraft({ tardy: value })} /> -
- )} - {!hasStudentRoster && draftPercentage !== null && ( -
-

- Calculated Attendance:{' '} - - {draftPercentage.toFixed(1)}% - -

-
- )} - {entryError &&

{entryError}

} -
+

{staffAttendanceLabel}

@@ -200,8 +113,8 @@ export function CampusAttendanceEntryForm({ state, actions }: CampusAttendanceEn

- - - - - - - - - - - - {rows.map((row) => ( - - - onChange(row.campusId, { enrolled: value })} - /> - onChange(row.campusId, { present: value })} - /> - onChange(row.campusId, { absent: value })} - /> - onChange(row.campusId, { tardy: value })} - /> - - - ))} - - - - - - - - - - - -
CampusEnrolledPresentAbsentTardyNotes
- - {row.campusName} - -
- {row.hasRecordedData ? 'Recorded for selected date' : 'No child data yet'} -
-
- onChange(row.campusId, { notes: event.target.value })} - aria-label={`Notes for ${row.campusName}`} - placeholder="Any notes..." - className="w-full px-3 py-2 bg-slate-800/80 border border-slate-600/50 rounded-lg text-sm text-white focus:ring-2 focus:ring-orange-500/50 outline-none placeholder:text-slate-500" - /> -
Current totals{hasAnyValue ? totals.enrolled : ''}{hasAnyValue ? totals.present : ''}{hasAnyValue ? totals.absent : ''}{hasAnyValue ? totals.tardy : ''}Saved per campus
- - - ); -} - -type StudentRollupInputProps = { - label: string; - value: string; - onChange: (value: string) => void; -}; - -function StudentRollupInput({ label, value, onChange }: StudentRollupInputProps) { - return ( - - onChange(event.target.value)} - aria-label={label} - className="w-full px-3 py-2 bg-slate-800/80 border border-slate-600/50 rounded-lg text-sm text-white focus:ring-2 focus:ring-orange-500/50 outline-none placeholder:text-slate-500" - /> - - ); -} - type AttendanceRosterTableProps = { users: readonly { id: string; diff --git a/frontend/src/components/campus-attendance/IndividualCampusAttendanceView.tsx b/frontend/src/components/campus-attendance/IndividualCampusAttendanceView.tsx index 196e808..9449c02 100644 --- a/frontend/src/components/campus-attendance/IndividualCampusAttendanceView.tsx +++ b/frontend/src/components/campus-attendance/IndividualCampusAttendanceView.tsx @@ -29,43 +29,29 @@ export function IndividualCampusAttendanceView({ state, actions }: IndividualCam today, weekStart, weekEnd, - myWeekAvg, - myCampusData, myStaffData, myCampusConfig, roleAccess, campusInfo, scopeModel, } = state; - const todayRecord = myCampusData.find((record) => record.date === today); const todayStaffRecord = myStaffData.find((record) => record.date === today); const historyTitle = campusInfo?.fullName || scopeModel.tenantName || 'Current Campus'; - const combinedHistoryRows = buildCombinedAttendanceHistoryRows(myCampusData, myStaffData); - const todayStudentTotal = todayRecord?.total_enrolled ?? 0; + const combinedHistoryRows = buildCombinedAttendanceHistoryRows(myStaffData); const todayStaffTotal = todayStaffRecord?.total_staff ?? 0; - const todayStudentPresent = todayRecord?.total_present ?? 0; const todayStaffPresent = todayStaffRecord?.total_present ?? 0; - const combinedTodayTotal = todayStudentTotal + todayStaffTotal; - const combinedTodayPresent = todayStudentPresent + todayStaffPresent; - const combinedTodayPct = combinedTodayTotal > 0 - ? Number(((combinedTodayPresent / combinedTodayTotal) * 100).toFixed(2)) - : null; - const weekStudentDates = new Set(myCampusData.map((record) => record.date)); const weekStaffDates = new Set(myStaffData.map((record) => record.date)); - const weekDates = Array.from(new Set([...weekStudentDates, ...weekStaffDates])) + const weekDates = Array.from(weekStaffDates) .filter((date) => date >= weekStart && date <= weekEnd); - const combinedWeekPct = weekDates.length > 0 + const staffWeekPct = weekDates.length > 0 ? Number((weekDates.reduce((sum, date) => { - const studentRecord = myCampusData.find((record) => record.date === date) ?? null; const staffRecord = myStaffData.find((record) => record.date === date) ?? null; - const total = (studentRecord?.total_enrolled ?? 0) + (staffRecord?.total_staff ?? 0); - const present = (studentRecord?.total_present ?? 0) + (staffRecord?.total_present ?? 0); + const total = staffRecord?.total_staff ?? 0; + const present = staffRecord?.total_present ?? 0; return sum + (total > 0 ? (present / total) * 100 : 0); }, 0) / weekDates.length).toFixed(2)) : null; - const studentTodayLabel = todayRecord ? `${todayRecord.total_present}/${todayRecord.total_enrolled} students` : 'No student data'; const staffTodayLabel = todayStaffRecord ? `${todayStaffRecord.total_present}/${todayStaffRecord.total_staff} staff` : 'No staff data'; - const weekStudentLabel = myWeekAvg !== null ? `Students ${myWeekAvg}%` : 'Students N/A'; const weekStaffRecords = myStaffData.filter((record) => record.date >= weekStart && record.date <= weekEnd); const weekStaffAvg = weekStaffRecords.length > 0 ? Number((weekStaffRecords.reduce((sum, record) => sum + record.attendance_percentage, 0) / weekStaffRecords.length).toFixed(2)) @@ -76,23 +62,23 @@ export function IndividualCampusAttendanceView({ state, actions }: IndividualCam <>
@@ -143,7 +129,7 @@ export function IndividualCampusAttendanceView({ state, actions }: IndividualCam .filter((record) => record.date === today || record.group === 'total') .slice(0, 45) .map((record) => { - const shouldShowDate = record.date !== today || record.group === 'students'; + const shouldShowDate = record.date !== today || record.group === 'staff'; return (
- - -
- -
- -
- {child.todayRecord && ( + {child.todayStaffRecord && (
-

Enrolled

-

{child.todayRecord.total_enrolled}

+

Staff

+

{child.todayStaffRecord.total_staff}

Present

-

{child.todayRecord.total_present}

+

{child.todayStaffRecord.total_present}

Absent

-

{child.todayRecord.total_absent}

+

{child.todayStaffRecord.total_absent}

)} @@ -179,13 +146,13 @@ export function SuperintendentAttendanceView({ state, actions }: SuperintendentA {expandedCampus === child.id ? : } {expandedCampus === child.id ? 'Hide History' : 'View History'} - {expandedCampus === child.id && child.recentData.length > 0 && ( + {expandedCampus === child.id && child.recentStaffData.length > 0 && (
- {child.recentData.map((record) => ( + {child.recentStaffData.map((record) => (
{formatAttendanceDate(record.date)}
- {record.total_present}/{record.total_enrolled} + {record.total_present}/{record.total_staff} {record.attendance_percentage.toFixed(1)}% diff --git a/frontend/src/components/common/ImageUpload.tsx b/frontend/src/components/common/ImageUpload.tsx index f91dfd3..5699f34 100644 --- a/frontend/src/components/common/ImageUpload.tsx +++ b/frontend/src/components/common/ImageUpload.tsx @@ -16,6 +16,7 @@ interface ImageUploadProps { readonly field: string; readonly label?: string; readonly shape?: 'square' | 'circle'; + readonly previewSize?: 'sm' | 'lg'; } /** Reusable logo/avatar uploader: pick → upload → report the stored URL. */ @@ -26,6 +27,7 @@ export function ImageUpload({ field, label = 'Image', shape = 'square', + previewSize = 'sm', }: ImageUploadProps) { const inputRef = useRef(null); const [uploading, setUploading] = useState(false); @@ -48,11 +50,12 @@ export function ImageUpload({ return (
-
+
{uploading ? ( @@ -63,10 +66,10 @@ export function ImageUpload({ ) : value ? ( {label} ) : ( - + )}
-
+
(null); + const showImage = Boolean(avatarUrl && failedAvatarUrl !== avatarUrl); + return ( - {avatarUrl ? ( + {showImage && avatarUrl ? ( setFailedAvatarUrl(avatarUrl)} className={cn('h-full w-full object-cover', imageClassName)} /> ) : ( diff --git a/frontend/src/components/esa-funding/EsaFundingImpactRoles.tsx b/frontend/src/components/esa-funding/EsaFundingImpactRoles.tsx index 7b5dcf0..1176edd 100644 --- a/frontend/src/components/esa-funding/EsaFundingImpactRoles.tsx +++ b/frontend/src/components/esa-funding/EsaFundingImpactRoles.tsx @@ -16,48 +16,55 @@ export function EsaFundingImpactRoles({ schoolImpactEditor, staffRoleEditor, }: EsaFundingImpactRolesProps) { - if (schoolImpactItems.length === 0 && staffRoleItems.length === 0 && !schoolImpactEditor && !staffRoleEditor) { + const showSchoolImpact = schoolImpactItems.length > 0 || Boolean(schoolImpactEditor); + const showStaffRole = staffRoleItems.length > 0 || Boolean(staffRoleEditor); + + if (!showSchoolImpact && !showStaffRole) { return null; } return ( -
-
-
- -

Why This Matters for Our School

+
+ {showSchoolImpact && ( +
+
+ +

Why This Matters for Our School

+
+
+ {schoolImpactItems.map((item) => ( +
+ + {item.text} +
+ ))} +
+ {schoolImpactEditor}
-
- {schoolImpactItems.map((item) => ( -
- - {item.text} -
- ))} -
- {schoolImpactEditor} -
+ )} -
-
- -

Your Role as Staff

-
-
- {staffRoleItems.map((item, index) => ( -
-
- {index + 1} + {showStaffRole && ( +
+
+ +

Your Role as Staff

+
+
+ {staffRoleItems.map((item, index) => ( +
+
+ {index + 1} +
+
+ {item.title} +

{item.description}

+
-
- {item.title} -

{item.description}

-
-
- ))} + ))} +
+ {staffRoleEditor}
- {staffRoleEditor} -
+ )}
); } diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 49b9fe1..b6f9bd8 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -76,10 +76,13 @@ function ReadOnlyField({ label, value }: { label: string; value: string }) { export default function ProfilePage() { const { user, profile, refreshUser } = useAuth(); const capabilitiesQuery = useIamCapabilities(); - const safetyQuizStatus = useMySafetyQuizStatus(undefined, Boolean(user)); - const personalityHistoryStatus = useCurrentPersonalityResultHistory(Boolean(user)); + const isExternalProfileUser = + user?.app_role?.name === 'student' || user?.app_role?.name === 'guardian'; + const canShowQuizResults = Boolean(user) && !isExternalProfileUser; + const safetyQuizStatus = useMySafetyQuizStatus(undefined, canShowQuizResults); + const personalityHistoryStatus = useCurrentPersonalityResultHistory(canShowQuizResults); const canUseZoneCheckin = canZoneCheckIn(user); - const zoneCheckinStatus = useTodayZoneCheckIn({ enabled: canUseZoneCheckin }); + const zoneCheckinStatus = useTodayZoneCheckIn({ enabled: canShowQuizResults && canUseZoneCheckin }); const canReadAcknowledgedDocuments = hasPermission(user, 'ACK_POLICY'); const policyAcknowledgmentsStatus = usePolicyAcknowledgments(canReadAcknowledgedDocuments); const handbookPoliciesStatus = usePolicies(canReadAcknowledgedDocuments); @@ -273,7 +276,8 @@ export default function ProfilePage() { table="users" field="avatar" label="Avatar" - shape="circle" + shape="square" + previewSize="lg" />
@@ -404,53 +408,55 @@ export default function ProfilePage() { )}
- - - - - Quiz results - - - -
- {safetyQuizStatus.isLoading || personalityHistoryStatus.isLoading || zoneCheckinStatus.isLoading ? ( -

Loading quiz results...

- ) : ( -
- - - - Quiz - Category - Result - Completed - - - - {quizResultRows.map((result) => ( - - -

{result.quiz}

-
- {result.category} - - {result.result} - - {result.completed} -
- ))} -
-
-
- )} -
-
-
+ {canShowQuizResults && ( + + + + + Quiz results + + + +
+ {safetyQuizStatus.isLoading || personalityHistoryStatus.isLoading || zoneCheckinStatus.isLoading ? ( +

Loading quiz results...

+ ) : ( +
+ + + + Quiz + Category + Result + Completed + + + + {quizResultRows.map((result) => ( + + +

{result.quiz}

+
+ {result.category} + + {result.result} + + {result.completed} +
+ ))} +
+
+
+ )} +
+
+
+ )} {canReadAcknowledgedDocuments && ( diff --git a/frontend/src/pages/modules/CampusAttendanceDetailsPage.tsx b/frontend/src/pages/modules/CampusAttendanceDetailsPage.tsx index 63232bc..1a54991 100644 --- a/frontend/src/pages/modules/CampusAttendanceDetailsPage.tsx +++ b/frontend/src/pages/modules/CampusAttendanceDetailsPage.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, Calendar, UserCheck, Users } from 'lucide-react'; +import { ArrowLeft, UserCheck, Users } from 'lucide-react'; import { Link, useParams } from 'react-router-dom'; import { useShellOutletContext } from '@/app/shellOutletContext'; import { @@ -8,7 +8,6 @@ import { import { formatAttendanceDate } from '@/business/campus-attendance/selectors'; import { useStaffAttendanceRecords } from '@/business/staff-attendance/hooks'; import { CampusAttendanceLoadingState } from '@/components/campus-attendance/CampusAttendanceStatus'; -import { percentageTextClass } from '@/components/campus-attendance/styles'; import { Table, TableBody, @@ -89,7 +88,6 @@ export default function CampusAttendanceDetailsPage() { selectedCampus?.id, selectedCampus?.tenantId, ].filter((id): id is string => Boolean(id))); - const studentRecords = state.attendanceData.filter((record) => selectedCampusIds.has(record.campus_id)); const staffRecords = (staffRecordsQuery.data ?? []).filter((record) => ( record.campusId ? selectedStaffCampusIds.has(record.campusId) : false )); @@ -136,59 +134,13 @@ export default function CampusAttendanceDetailsPage() {

{displayName} Attendance Details

- Separate student and staff attendance records for this child scope. + Staff attendance records for this child scope.

-
-
-

- - Student Attendance -

-
- {studentRecords.length > 0 ? ( - - - - Date - Enrolled - Present - Absent - Tardy - Attendance % - Notes - - - - {studentRecords.map((record) => ( - - {formatAttendanceDate(record.date)} - {record.total_enrolled} - {record.total_present} - {record.total_absent} - {record.total_tardy} - - {record.attendance_percentage.toFixed(1)}% - - - {record.notes || '-'} - - - ))} - -
- ) : ( -
- -

No student attendance records are saved for this child scope.

-
- )} -
-

diff --git a/frontend/src/pages/modules/MyClassPage.tsx b/frontend/src/pages/modules/MyClassPage.tsx index 0a796e6..65a5efd 100644 --- a/frontend/src/pages/modules/MyClassPage.tsx +++ b/frontend/src/pages/modules/MyClassPage.tsx @@ -1,56 +1,117 @@ +import type { FormEvent } from 'react'; import { useMemo, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { ClipboardCheck, GraduationCap, Loader2, Users } from 'lucide-react'; +import { ChevronDown, GraduationCap, Loader2, Pencil, Plus, UserPlus, Users, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { ImageUpload } from '@/components/common/ImageUpload'; +import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { ModuleHeader } from '@/components/ui/module-header'; +import { NativeSelect } from '@/components/ui/native-select'; import { PageSkeleton } from '@/components/ui/page-skeleton'; +import { UserAvatar } from '@/components/common/UserAvatar'; import { useAuth } from '@/contexts/useAuth'; +import { usePermissions } from '@/hooks/usePermissions'; import { + createUser, getClass, - getClassAttendanceSummary, + linkGuardianStudent, listGuardianStudents, + listRoles, listUsers, + updateUser, type AdminUserRow, - upsertClassAttendance, } from '@/business/my-class/api'; +import { + buildMyClassStudentSaveData, + buildMyClassGuardianSaveData, + canManageMyClassStudents, + hasMyClassGuardianFormValues, + type MyClassGuardianFormValues, + type MyClassStudentFormValues, +} from '@/business/my-class/selectors'; +import { USER_NAME_PREFIX_OPTIONS } from '@/shared/constants/users'; import { getErrorMessage } from '@/shared/errors/errorMessages'; +import { cn } from '@/lib/utils'; function personName(row: { firstName?: string | null; lastName?: string | null; email?: string }): string { return [row.firstName, row.lastName].filter(Boolean).join(' ').trim() || row.email || '—'; } -const today = () => new Date().toISOString().slice(0, 10); - -type StudentAttendanceStatus = 'present' | 'late' | 'absent'; - -const STUDENT_ATTENDANCE_STATUS_OPTIONS: readonly { - value: StudentAttendanceStatus; - label: string; -}[] = [ - { value: 'present', label: 'Present' }, - { value: 'late', label: 'Late' }, - { value: 'absent', label: 'Absent' }, -]; - -function reconcileStudentStatuses( - current: Record, - students: readonly AdminUserRow[], -): Record { - const next: Record = {}; - for (const student of students) { - next[student.id] = current[student.id] ?? 'present'; - } - return next; +interface StatusMessage { + readonly type: 'success' | 'error'; + readonly text: string; } +const emptyStudentForm = (): MyClassStudentFormValues => ({ + namePrefix: '', + firstName: '', + lastName: '', + email: '', + phoneNumber: '', + avatar: null, + guardians: [emptyGuardianForm()], +}); + +function emptyGuardianForm(): MyClassGuardianFormValues { + return { + key: `guardian-${Date.now()}-${Math.random().toString(36).slice(2)}`, + id: null, + namePrefix: '', + firstName: '', + lastName: '', + email: '', + phoneNumber: '', + avatar: null, + }; +} + +function studentFormFromRow( + row: AdminUserRow, + guardians: readonly GuardianStudentLink[] = [], +): MyClassStudentFormValues { + const guardianForms = guardians + .map((link): MyClassGuardianFormValues | null => { + const guardian = link.guardian; + if (!guardian?.id) return null; + return { + key: link.id, + id: guardian.id, + namePrefix: guardian.name_prefix ?? '', + firstName: guardian.firstName ?? '', + lastName: guardian.lastName ?? '', + email: guardian.email ?? '', + phoneNumber: guardian.phoneNumber ?? '', + avatar: guardian.avatar?.[0]?.privateUrl ?? null, + }; + }) + .filter((guardian): guardian is MyClassGuardianFormValues => Boolean(guardian)); + + return { + namePrefix: row.name_prefix ?? '', + firstName: row.firstName ?? '', + lastName: row.lastName ?? '', + email: row.email, + phoneNumber: row.phoneNumber ?? '', + avatar: row.avatar?.[0]?.privateUrl ?? null, + guardians: guardianForms.length > 0 ? guardianForms : [emptyGuardianForm()], + }; +} + +type GuardianStudentLink = NonNullable>['rows'][number]>; + export default function MyClassPage() { const { user } = useAuth(); + const permissions = usePermissions(); const queryClient = useQueryClient(); const classId = user?.classId ?? null; - const canFill = (user?.permissions ?? []).includes('FILL_ATTENDANCE'); + const [studentForm, setStudentForm] = useState(() => emptyStudentForm()); + const [editingStudentId, setEditingStudentId] = useState(null); + const [isStudentFormOpen, setIsStudentFormOpen] = useState(false); + const [studentFormSaving, setStudentFormSaving] = useState(false); + const [studentFormStatus, setStudentFormStatus] = useState(null); const classQuery = useQuery({ queryKey: ['my-class', classId], @@ -67,10 +128,10 @@ export default function MyClassPage() { queryFn: () => listGuardianStudents(), enabled: Boolean(classId), }); - const summaryQuery = useQuery({ - queryKey: ['my-class-attendance', classId], - queryFn: () => getClassAttendanceSummary(), - enabled: Boolean(classId), + const rolesQuery = useQuery({ + queryKey: ['roles'], + queryFn: listRoles, + enabled: Boolean(classId) && (permissions.has('CREATE_USERS') || permissions.has('UPDATE_USERS')), }); const members = useMemo( @@ -85,6 +146,24 @@ export default function MyClassPage() { () => members.filter((m) => m.app_role?.name === 'teacher' || m.app_role?.name === 'support_staff'), [members], ); + const studentRoleId = useMemo( + () => rolesQuery.data?.rows.find((role) => role.name === 'student')?.id ?? null, + [rolesQuery.data], + ); + const guardianRoleId = useMemo( + () => rolesQuery.data?.rows.find((role) => role.name === 'guardian')?.id ?? null, + [rolesQuery.data], + ); + const canCreateStudents = canManageMyClassStudents({ + hasUserPermission: permissions.has('CREATE_USERS'), + classId, + studentRoleId, + }); + const canUpdateStudents = canManageMyClassStudents({ + hasUserPermission: permissions.has('UPDATE_USERS'), + classId, + studentRoleId, + }); // studentId → guardian display names. const guardiansByStudent = useMemo(() => { @@ -98,50 +177,126 @@ export default function MyClassPage() { } return map; }, [guardiansQuery.data]); + const guardianLinksByStudent = useMemo(() => { + const map = new Map(); + for (const row of guardiansQuery.data?.rows ?? []) { + const current = map.get(row.studentId) ?? []; + map.set(row.studentId, [...current, row]); + } + return map; + }, [guardiansQuery.data]); - const [date, setDate] = useState(today()); - const [studentStatusOverrides, setStudentStatusOverrides] = useState>({}); - const [saving, setSaving] = useState(false); - const [status, setStatus] = useState<{ type: 'success' | 'error'; text: string } | null>(null); - const studentStatuses = useMemo( - () => reconcileStudentStatuses(studentStatusOverrides, students), - [studentStatusOverrides, students], - ); + const updateStudentForm = (patch: Partial) => { + setStudentForm((current) => ({ ...current, ...patch })); + setStudentFormStatus(null); + }; + const updateGuardianForm = (guardianKey: string, patch: Partial) => { + setStudentForm((current) => ({ + ...current, + guardians: current.guardians.map((guardian) => ( + guardian.key === guardianKey ? { ...guardian, ...patch } : guardian + )), + })); + setStudentFormStatus(null); + }; + const addGuardianForm = () => { + setStudentForm((current) => ({ + ...current, + guardians: [...current.guardians, emptyGuardianForm()], + })); + setStudentFormStatus(null); + }; + const removeGuardianForm = (guardianKey: string) => { + setStudentForm((current) => ({ + ...current, + guardians: current.guardians.length === 1 + ? [emptyGuardianForm()] + : current.guardians.filter((guardian) => guardian.key !== guardianKey), + })); + setStudentFormStatus(null); + }; - async function handleAttendance() { - setStatus(null); - if (!classId) return; - if (students.length === 0) { - setStatus({ type: 'error', text: 'No students are available for attendance.' }); + const resetStudentForm = () => { + setStudentForm(emptyStudentForm()); + setEditingStudentId(null); + setIsStudentFormOpen(false); + }; + + const startCreateStudent = () => { + setStudentFormStatus(null); + if (isStudentFormOpen && !editingStudentId) { + resetStudentForm(); + return; + } + setStudentForm(emptyStudentForm()); + setEditingStudentId(null); + setIsStudentFormOpen(true); + }; + + const startEditStudent = (student: AdminUserRow) => { + const guardians = guardianLinksByStudent.get(student.id) ?? []; + setStudentForm(studentFormFromRow(student, guardians)); + setEditingStudentId(student.id); + setStudentFormStatus(null); + setIsStudentFormOpen(true); + }; + + const handleStudentSubmit = async (event: FormEvent) => { + event.preventDefault(); + setStudentFormStatus(null); + + if (!classId || !studentRoleId || !guardianRoleId) { + setStudentFormStatus({ type: 'error', text: 'Student access is not available for this class.' }); + return; + } + const guardiansToSave = studentForm.guardians.filter(hasMyClassGuardianFormValues); + if (guardiansToSave.some((guardian) => guardian.email.trim() === '')) { + setStudentFormStatus({ type: 'error', text: 'Guardian email is required for each entered guardian.' }); return; } - const totalPresent = students.filter((student) => ( - (studentStatuses[student.id] ?? 'present') !== 'absent' - )).length; - const totalAbsent = students.filter((student) => ( - (studentStatuses[student.id] ?? 'present') === 'absent' - )).length; - const totalTardy = students.filter((student) => ( - (studentStatuses[student.id] ?? 'present') === 'late' - )).length; + const payload = buildMyClassStudentSaveData(studentForm, classId, studentRoleId); - setSaving(true); + setStudentFormSaving(true); try { - await upsertClassAttendance(classId, date, { - total_enrolled: students.length, - total_present: totalPresent, - total_absent: totalAbsent, - total_tardy: totalTardy, + let studentId = editingStudentId; + if (editingStudentId) { + await updateUser(editingStudentId, payload); + } else { + const created = await createUser(payload); + studentId = created.id; + } + + if (studentId) { + for (const guardian of guardiansToSave) { + const guardianPayload = buildMyClassGuardianSaveData(guardian, guardianRoleId); + const guardianId = guardian.id + ? guardian.id + : (await createUser(guardianPayload)).id; + if (guardian.id) { + await updateUser(guardian.id, guardianPayload); + } + if (guardianId) { + await linkGuardianStudent(guardianId, studentId); + } + } + } + + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['my-class-members', classId] }), + queryClient.invalidateQueries({ queryKey: ['my-class-guardians'] }), + ]); + setStudentFormStatus({ + type: 'success', + text: editingStudentId ? 'Student updated.' : 'Student created.', }); - await queryClient.invalidateQueries({ queryKey: ['my-class-attendance', classId] }); - setStatus({ type: 'success', text: 'Attendance saved.' }); + resetStudentForm(); } catch (error) { - setStatus({ type: 'error', text: getErrorMessage(error, 'Could not save attendance') }); + setStudentFormStatus({ type: 'error', text: getErrorMessage(error, 'Could not save student') }); } finally { - setSaving(false); + setStudentFormSaving(false); } - } + }; if (!classId) { return ( @@ -153,15 +308,18 @@ export default function MyClassPage() { ); } - if (classQuery.isLoading || membersQuery.isLoading) { + if (classQuery.isLoading || membersQuery.isLoading || rolesQuery.isLoading) { return ; } + const formControlClassName = + 'border-slate-600 bg-slate-950/80 text-slate-100 placeholder:text-slate-500 focus-visible:ring-lime-400 focus-visible:ring-offset-slate-950'; + return (
@@ -169,12 +327,249 @@ export default function MyClassPage() {
- - - Students ({students.length}) - +
+ + + Students ({students.length}) + + {canCreateStudents && ( + + )} +
- + + {studentFormStatus && ( +
+ {studentFormStatus.text} +
+ )} + + {isStudentFormOpen && ( +
+
+
+

+ {editingStudentId ? 'Edit student' : 'Add student'} +

+
+ +
+ +
+ updateStudentForm({ avatar })} + table="users" + field="avatar" + label="Avatar" + shape="square" + previewSize="lg" + /> +
+
+ + updateStudentForm({ namePrefix: event.target.value })} + > + + {USER_NAME_PREFIX_OPTIONS.map((option) => ( + + ))} + +
+
+ + updateStudentForm({ firstName: event.target.value })} + /> +
+
+ + updateStudentForm({ lastName: event.target.value })} + /> +
+
+ + updateStudentForm({ email: event.target.value })} + required + /> +
+
+ + updateStudentForm({ phoneNumber: event.target.value })} + /> +
+
+
+ +
+
+

Guardians

+ +
+ {studentForm.guardians.map((guardian, index) => ( +
+
+

Guardian {index + 1}

+ {guardian.id ? ( + Linked guardian + ) : ( + + )} +
+
+ updateGuardianForm(guardian.key, { avatar })} + table="users" + field="avatar" + label="Photo" + shape="square" + previewSize="lg" + /> +
+
+ + updateGuardianForm(guardian.key, { namePrefix: event.target.value })} + > + + {USER_NAME_PREFIX_OPTIONS.map((option) => ( + + ))} + +
+
+ + updateGuardianForm(guardian.key, { firstName: event.target.value })} + /> +
+
+ + updateGuardianForm(guardian.key, { lastName: event.target.value })} + /> +
+
+ + updateGuardianForm(guardian.key, { email: event.target.value })} + /> +
+
+ + updateGuardianForm(guardian.key, { phoneNumber: event.target.value })} + /> +
+
+
+
+ ))} +
+ +
+ +
+
+ )} + {students.length === 0 ? (

No students in this class yet.

) : ( @@ -182,11 +577,32 @@ export default function MyClassPage() { {students.map((s) => { const guardians = guardiansByStudent.get(s.id) ?? []; return ( -
  • -

    {personName(s)}

    -

    - {guardians.length > 0 ? `Guardians: ${guardians.join(', ')}` : 'No guardian linked'} -

    +
  • +
    + +
    +

    {personName(s)}

    +

    + {guardians.length > 0 ? `Guardians: ${guardians.join(', ')}` : 'No guardian linked'} +

    +
    +
    + {canUpdateStudents && ( + + )}
  • ); })} @@ -218,144 +634,6 @@ export default function MyClassPage() {
    - - - - - Daily attendance - - - - {canFill ? ( -
    -
    - - setDate(e.target.value)} - required - className="flex h-10 w-full rounded-md border border-border/60 bg-background px-3 py-2 text-sm text-slate-100 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" - /> -
    - - { - setStudentStatusOverrides((current) => ({ ...current, [studentId]: nextStatus })); - setStatus(null); - }} - /> - - {status && ( -
    - {status.text} -
    - )} - -
    - -
    -
    - ) : ( -

    You have read-only access to attendance.

    - )} - -
    -

    Recent

    - {(summaryQuery.data?.rows ?? []).length === 0 ? ( -

    No attendance recorded yet.

    - ) : ( -
      - {(summaryQuery.data?.rows ?? []).slice(0, 7).map((row) => ( -
    • - {row.date} - - {row.total_present}/{row.total_enrolled} present ({row.attendance_percentage}%) - -
    • - ))} -
    - )} -
    -
    -
    -
    - ); -} - -function StudentAttendanceTable({ - students, - guardiansByStudent, - statuses, - onStatusChange, -}: { - students: readonly AdminUserRow[]; - guardiansByStudent: ReadonlyMap; - statuses: Readonly>; - onStatusChange: (studentId: string, status: StudentAttendanceStatus) => void; -}) { - if (students.length === 0) { - return ( -
    - No students in this class yet. -
    - ); - } - - return ( -
    - - - - - {STUDENT_ATTENDANCE_STATUS_OPTIONS.map((option) => ( - - ))} - - - - {students.map((student) => { - const currentStatus = statuses[student.id] ?? 'present'; - const guardians = guardiansByStudent.get(student.id) ?? []; - return ( - - - {STUDENT_ATTENDANCE_STATUS_OPTIONS.map((option) => ( - - ))} - - ); - })} - -
    Student - {option.label} -
    -

    {personName(student)}

    -

    - {guardians.length > 0 ? `Guardians: ${guardians.join(', ')}` : 'No guardian linked'} -

    -
    - onStatusChange(student.id, option.value)} - aria-label={`${option.label} for ${personName(student)}`} - className="h-4 w-4 rounded border-slate-500 bg-slate-800 text-lime-500 focus:ring-lime-500" - /> -
    ); } diff --git a/frontend/src/pages/modules/UserAdminPage.tsx b/frontend/src/pages/modules/UserAdminPage.tsx index 5528e66..932ae1c 100644 --- a/frontend/src/pages/modules/UserAdminPage.tsx +++ b/frontend/src/pages/modules/UserAdminPage.tsx @@ -1,7 +1,7 @@ import type { FormEvent } from 'react'; import { useCallback, useMemo, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { ArrowDown, ArrowUp, ArrowUpDown, ChevronDown, Loader2, Pencil, Search, Trash2, UserPlus, Users, X } from 'lucide-react'; +import { ArrowDown, ArrowUp, ArrowUpDown, Check, ChevronDown, Loader2, Pencil, Search, Trash2, UserPlus, Users, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -10,9 +10,19 @@ import { Label } from '@/components/ui/label'; import { ModuleHeader } from '@/components/ui/module-header'; import { NativeSelect } from '@/components/ui/native-select'; import { PageSkeleton } from '@/components/ui/page-skeleton'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { TenantParentPicker } from '@/components/tenant-create/TenantParentPicker'; import { ImageUpload } from '@/components/common/ImageUpload'; import { TenantLogo } from '@/components/common/TenantLogo'; +import { UserAvatar } from '@/components/common/UserAvatar'; import { useScopeContext } from '@/contexts/scope-context'; import { useAuth } from '@/contexts/useAuth'; import { useTenantChildren } from '@/business/scope/queries'; @@ -27,8 +37,8 @@ import { createOwnerWithOrganization, createUser, deleteUser, - fileDownloadUrl, linkGuardianStudent, + listGuardianStudents, listPermissions, listRoles, listUsers, @@ -55,9 +65,9 @@ type UserListSortField = | 'name' | 'email' | 'phoneNumber' - | 'organization' | 'school' | 'campus' + | 'class' | 'role'; type UserListSortDirection = 'asc' | 'desc'; @@ -94,6 +104,43 @@ function locationCell(value?: { name?: string | null; logo?: string | null } | n ); } +function classNamesForUser( + row: AdminUserRow, + guardianClassNamesById: ReadonlyMap, +): readonly string[] { + const names: string[] = []; + if (row.class?.name) { + names.push(row.class.name); + } + for (const enrollment of row.class_enrollments_student ?? []) { + const className = enrollment.class?.name; + if (className && !names.includes(className)) { + names.push(className); + } + } + for (const className of guardianClassNamesById.get(row.id) ?? []) { + if (!names.includes(className)) { + names.push(className); + } + } + return names; +} + +function classCell( + row: AdminUserRow, + guardianClassNamesById: ReadonlyMap, +) { + const classNames = classNamesForUser(row, guardianClassNamesById); + if (classNames.length === 0) { + return '—'; + } + return ( + + {classNames.join(', ')} + + ); +} + function sortText(value: string | null | undefined): string { return value?.trim().toLocaleLowerCase() || ''; } @@ -102,6 +149,16 @@ function permissionLabel(name: string | null | undefined, id: string): string { return (name?.trim() || id).replace(/_/g, ' '); } +function studentPickerLabel(students: readonly AdminUserRow[], selectedIds: readonly string[]): string { + if (selectedIds.length === 0) return 'Select students...'; + const names = students + .filter((student) => selectedIds.includes(student.id)) + .map(userName); + if (names.length === 0) return `${selectedIds.length} selected`; + if (names.length <= 2) return names.join(', '); + return `${names.slice(0, 2).join(', ')} +${names.length - 2}`; +} + export default function UserAdminPage() { const { tier, ownTenant } = useScopeContext(); const { user } = useAuth(); @@ -110,12 +167,17 @@ export default function UserAdminPage() { const capabilitiesQuery = useIamCapabilities(); const rolesQuery = useQuery({ queryKey: ['roles'], queryFn: listRoles }); const permissionsQuery = useQuery({ queryKey: ['permissions'], queryFn: listPermissions }); + const guardianStudentsQuery = useQuery({ + queryKey: ['guardian-students'], + queryFn: () => listGuardianStudents(), + }); const [usersPage, setUsersPage] = useState(0); const [usersSearchDraft, setUsersSearchDraft] = useState(''); const [usersSearch, setUsersSearch] = useState(''); const [usersSortField, setUsersSortField] = useState('name'); const [usersSortDirection, setUsersSortDirection] = useState('asc'); const [isUserFormOpen, setIsUserFormOpen] = useState(false); + const [isStudentPickerOpen, setIsStudentPickerOpen] = useState(false); const usersQuery = useQuery({ queryKey: ['admin-users', usersSearch], queryFn: () => @@ -204,6 +266,11 @@ export default function UserAdminPage() { [allPermissions, effectivePermissionIds], ); const tenantInput = selectedRole ? getRoleTenantInput(selectedRole) : 'none'; + const canManageAdvancedPermissions = + canEditPermissions + && selectedRole !== 'super_admin' + && selectedRole !== 'student' + && selectedRole !== 'guardian'; const canAutoCreateOwnerOrganization = selectedRole === 'owner' && capabilitiesQuery.data?.canCreateOwnerWithOrganization === true; const targetLevel = @@ -224,12 +291,12 @@ export default function UserAdminPage() { ); // Students for guardian linking (role 'student' in the actor's scope). - const studentRoleId = roleIdByName.get('student'); const studentsQuery = useQuery({ - queryKey: ['admin-users', 'students', studentRoleId], - queryFn: () => listUsers({ app_role: studentRoleId }), - enabled: tenantInput === 'guardian' && Boolean(studentRoleId), + queryKey: ['admin-users', 'students', 'student'], + queryFn: () => listUsers({ app_role: 'student' }), + enabled: tenantInput === 'guardian', }); + const guardianStudentOptions = studentsQuery.data?.rows ?? []; const handlePickerChange = useCallback((id: string | null) => { setPickedTenantId(id); @@ -281,6 +348,29 @@ export default function UserAdminPage() { }, [rolePermissionIds]); const fetchedRows = usersQuery.data?.rows; + const classNameByStudentId = useMemo(() => { + const map = new Map(); + for (const row of fetchedRows ?? []) { + const className = classNamesForUser(row, new Map()).join(', '); + if (row.app_role?.name === 'student' && className) { + map.set(row.id, className); + } + } + return map; + }, [fetchedRows]); + const guardianClassNamesById = useMemo(() => { + const map = new Map(); + for (const link of guardianStudentsQuery.data?.rows ?? []) { + const className = classNameByStudentId.get(link.studentId); + if (!className) continue; + const current = map.get(link.guardianId) ?? []; + if (!current.includes(className)) { + current.push(className); + map.set(link.guardianId, current); + } + } + return map; + }, [classNameByStudentId, guardianStudentsQuery.data]); const sortedRows = useMemo(() => { const collator = new Intl.Collator(undefined, { numeric: true, @@ -295,12 +385,12 @@ export default function UserAdminPage() { return sortText(row.email); case 'phoneNumber': return sortText(row.phoneNumber); - case 'organization': - return sortText(locationName(row.organizations)); case 'school': return sortText(locationName(row.school)); case 'campus': return sortText(locationName(row.campus)); + case 'class': + return sortText(classNamesForUser(row, guardianClassNamesById).join(' ')); case 'role': return sortText( row.app_role?.name ? getAuthRoleLabel(row.app_role.name as UserRole) : '—', @@ -320,7 +410,7 @@ export default function UserAdminPage() { return collator.compare(sortText(userName(left)), sortText(userName(right))); }); - }, [fetchedRows, usersSortDirection, usersSortField]); + }, [fetchedRows, guardianClassNamesById, usersSortDirection, usersSortField]); const usersTotal = usersQuery.data?.count ?? sortedRows.length; const rows = useMemo(() => { const start = usersPage * USER_LIST_PAGE_SIZE; @@ -404,8 +494,8 @@ export default function UserAdminPage() { email: email.trim(), avatar, app_role: roleId ?? null, - custom_permissions: grantPerms, - custom_permissions_filter: excludePerms, + custom_permissions: canManageAdvancedPermissions ? grantPerms : [], + custom_permissions_filter: canManageAdvancedPermissions ? excludePerms : [], ...buildTenantPayload(), }; @@ -422,6 +512,7 @@ export default function UserAdminPage() { } } await queryClient.invalidateQueries({ queryKey: ['admin-users'] }); + await queryClient.invalidateQueries({ queryKey: ['guardian-students'] }); setUsersPage(0); setStatus({ type: 'success', text: editingId ? 'User updated.' : 'User created (invite sent).' }); resetForm(); @@ -523,7 +614,8 @@ export default function UserAdminPage() { table="users" field="avatar" label="Avatar" - shape="circle" + shape="square" + previewSize="lg" />
    @@ -603,6 +695,10 @@ export default function UserAdminPage() { setPickedTenantId(null); setClassId(''); setStudentIds([]); + if (e.target.value === 'student' || e.target.value === 'guardian') { + setGrantPerms([]); + setExcludePerms([]); + } setStatus(null); }} > @@ -646,31 +742,69 @@ export default function UserAdminPage() { {tenantInput === 'guardian' && !editingId && (
    -
    - {(studentsQuery.data?.rows ?? []).length === 0 ? ( -

    No students found in your scope.

    - ) : ( - (studentsQuery.data?.rows ?? []).map((s) => ( - - )) - )} -
    + + + + + + + + + No students found in your scope. + + {guardianStudentOptions.map((student) => { + const selected = studentIds.includes(student.id); + return ( + { + setStudentIds((prev) => + selected + ? prev.filter((id) => id !== student.id) + : [...prev, student.id], + ); + }} + className="flex cursor-pointer items-center gap-2 text-slate-100" + > + + + {userName(student)} + {student.email} + + + ); + })} + + + + +
    )}
    - {canEditPermissions && selectedRole !== 'super_admin' && ( + {canManageAdvancedPermissions && (
    Advanced permissions (optional) @@ -784,7 +918,7 @@ export default function UserAdminPage() { setUsersSearchDraft(event.target.value)} - placeholder="Search by name, email, phone, organization, school, campus, or role" + placeholder="Search by name, email, phone, school, campus, class, or role" className="pl-9" />
    @@ -817,9 +951,9 @@ export default function UserAdminPage() { ['name', 'User'], ['email', 'Email'], ['phoneNumber', 'Phone'], - ['organization', 'Organization'], ['school', 'School'], ['campus', 'Campus'], + ['class', 'Class'], ['role', 'Role'], ] as const).map(([field, label]) => ( @@ -850,17 +984,11 @@ export default function UserAdminPage() {
    -
    - {row.avatar?.[0]?.privateUrl ? ( - - ) : ( - userName(row).slice(0, 2).toUpperCase() - )} -
    +

    {userName(row)}

    @@ -868,9 +996,11 @@ export default function UserAdminPage() { {row.email || '—'} {row.phoneNumber || '—'} - {locationCell(row.organizations)} {locationCell(row.school)} {locationCell(row.campus)} + + {classCell(row, guardianClassNamesById)} + {roleName ? getAuthRoleLabel(roleName as UserRole) : '—'} diff --git a/frontend/src/shared/api/guardianStudents.ts b/frontend/src/shared/api/guardianStudents.ts index e5cce7b..f0425b9 100644 --- a/frontend/src/shared/api/guardianStudents.ts +++ b/frontend/src/shared/api/guardianStudents.ts @@ -5,7 +5,15 @@ export interface GuardianStudentRow { readonly guardianId: string; readonly studentId: string; readonly relationship?: string | null; - readonly guardian?: { id: string; firstName?: string | null; lastName?: string | null } | null; + readonly guardian?: { + readonly id: string; + readonly name_prefix?: string | null; + readonly firstName?: string | null; + readonly lastName?: string | null; + readonly email?: string; + readonly phoneNumber?: string | null; + readonly avatar?: readonly { readonly privateUrl?: string | null }[]; + } | null; readonly student?: { id: string; firstName?: string | null; lastName?: string | null } | null; } diff --git a/frontend/src/shared/api/users.ts b/frontend/src/shared/api/users.ts index 7f89eeb..6ce0cb8 100644 --- a/frontend/src/shared/api/users.ts +++ b/frontend/src/shared/api/users.ts @@ -19,6 +19,12 @@ export interface AdminUserRow { readonly school?: { id: string; name?: string | null; logo?: string | null } | null; readonly campus?: { id: string; name?: string | null; logo?: string | null } | null; readonly class?: { id: string; name?: string | null; logo?: string | null } | null; + readonly class_enrollments_student?: readonly { + readonly id: string; + readonly classId?: string | null; + readonly studentId?: string | null; + readonly class?: { id: string; name?: string | null; logo?: string | null } | null; + }[]; readonly campusId?: string | null; readonly schoolId?: string | null; readonly classId?: string | null; @@ -54,7 +60,7 @@ export interface ListUsersParams { readonly campusId?: string; readonly limit?: number; readonly page?: number; - readonly field?: 'name' | 'email' | 'phoneNumber' | 'organization' | 'school' | 'campus' | 'role'; + readonly field?: 'name' | 'email' | 'phoneNumber' | 'school' | 'campus' | 'class' | 'role'; readonly sort?: 'asc' | 'desc'; } diff --git a/frontend/src/test-seeds/campusAttendance.ts b/frontend/src/test-seeds/campusAttendance.ts index 5577a96..27b8747 100644 --- a/frontend/src/test-seeds/campusAttendance.ts +++ b/frontend/src/test-seeds/campusAttendance.ts @@ -1,7 +1,6 @@ import type { CampusAttendancePrintInput, CampusAttendanceStats, - CampusAttendanceSummaryViewModel, StaffAttendanceDailySummaryViewModel, } from '@/business/campus-attendance/types'; import type { StaffAttendanceSummaryViewModel } from '@/business/staff-attendance/types'; @@ -15,35 +14,8 @@ export const CAMPUS_ATTENDANCE_TEST_SEED = { generatedByRole: 'Director & Campus Lead', generatedByRoleEscaped: 'Director & Campus Lead', reportTitleEscaped: 'Attendance & Daily <Report>', - todayNotesEscaped: 'Needs <support> & follow-up', } as const; -export const campusAttendanceTodayRecord: CampusAttendanceSummaryViewModel = { - id: 'summary-1', - campus_id: CAMPUS_ATTENDANCE_TEST_SEED.campusId, - date: '2026-06-08', - total_enrolled: 50, - total_present: 45, - total_absent: 5, - total_tardy: 1, - attendance_percentage: 90, - recorded_by: 'director-1', - notes: 'Needs & follow-up', -}; - -export const campusAttendanceWeekRecord: CampusAttendanceSummaryViewModel = { - id: 'summary-2', - campus_id: CAMPUS_ATTENDANCE_TEST_SEED.campusId, - date: '2026-06-09', - total_enrolled: 50, - total_present: 40, - total_absent: 10, - total_tardy: 2, - attendance_percentage: 80, - recorded_by: 'director-1', - notes: null, -}; - export const campusStaffAttendanceTodayRecord: StaffAttendanceDailySummaryViewModel = { id: 'staff:tigers:2026-06-08', campus_id: CAMPUS_ATTENDANCE_TEST_SEED.campusId, @@ -89,8 +61,6 @@ export const campusAttendanceStatsSeed: CampusAttendanceStats = { todayPct: 90, weekAvg: 85, config: null, - recentData: [campusAttendanceTodayRecord, campusAttendanceWeekRecord], - todayRecord: campusAttendanceTodayRecord, recentStaffData: [campusStaffAttendanceTodayRecord, campusStaffAttendanceWeekRecord], todayStaffRecord: campusStaffAttendanceTodayRecord, }; @@ -100,9 +70,6 @@ export const campusAttendancePrintInputSeed: CampusAttendancePrintInput = { generatedByName: CAMPUS_ATTENDANCE_TEST_SEED.generatedByName, generatedByRole: CAMPUS_ATTENDANCE_TEST_SEED.generatedByRole, today: '2026-06-08', - weekStart: '2026-06-08', campusesToPrint: [campusAttendanceStatsSeed], - printTodayRecords: [campusAttendanceTodayRecord], - printWeekRecords: [campusAttendanceTodayRecord, campusAttendanceWeekRecord], staffSummary: campusStaffAttendanceSummary, };