From 2b496033cfed89fd616ac962b99171ee428e1209 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Fri, 19 Jun 2026 08:44:30 +0200 Subject: [PATCH] improved zones of regulation functionality --- backend/docs/content-catalog.md | 5 +- backend/docs/test-coverage.md | 2 + backend/docs/zone-checkin.md | 63 +++-- .../controllers/zone_checkins.controller.ts | 5 + ...00-grant-zone-checkin-report-permission.ts | 91 +++++++ ...grant-registrar-zone-checkin-permission.ts | 72 ++++++ .../db/seeders/20200430130760-user-roles.ts | 3 +- .../content-catalog-seed-payloads.ts | 8 - backend/src/db/seeders/user-roles.test.ts | 41 +++- backend/src/routes/zone_checkins.ts | 10 + .../src/services/content_catalog_seed.test.ts | 56 +++++ backend/src/services/zone-checkin.test.ts | 84 +++++++ backend/src/services/zone-checkin.ts | 231 ++++++++++++++++-- .../shared/constants/product-permissions.ts | 2 + frontend/docs/content-catalog-integration.md | 2 +- frontend/docs/dashboard-integration.md | 1 + .../docs/director-dashboard-integration.md | 17 +- frontend/docs/test-coverage.md | 3 +- frontend/docs/zone-checkin-integration.md | 61 +++-- .../docs/zones-of-regulation-integration.md | 12 +- frontend/src/business/dashboard/hooks.ts | 7 +- .../src/business/director-dashboard/hooks.ts | 14 +- .../director-dashboard/selectors.test.ts | 91 ++++++- .../business/director-dashboard/selectors.ts | 85 ++++++- .../src/business/profile/selectors.test.ts | 18 ++ frontend/src/business/profile/selectors.ts | 31 +++ frontend/src/business/top-bar/hooks.ts | 2 +- frontend/src/business/zone-checkin/hooks.ts | 22 +- .../src/business/zone-checkin/selectors.ts | 5 +- frontend/src/business/zones/hooks.ts | 38 ++- frontend/src/business/zones/types.ts | 5 +- .../director-dashboard/DirectorRiskList.tsx | 8 +- .../frameworks/ZonesOfRegulation.tsx | 12 +- .../zone-checkin/ZoneCheckInCard.tsx | 7 +- .../zone-checkin/ZoneCheckInSection.tsx | 7 +- .../zones-of-regulation/ZoneOverviewCard.tsx | 25 +- .../zones-of-regulation/ZonesHeader.tsx | 3 +- .../ZonesOfRegulationView.tsx | 10 +- .../zones-of-regulation/ZonesOverviewGrid.tsx | 33 ++- .../zones-of-regulation/ZonesQuickFlow.tsx | 66 +++-- frontend/src/pages/ProfilePage.tsx | 23 +- frontend/src/shared/api/zoneCheckins.test.ts | 32 +++ frontend/src/shared/api/zoneCheckins.ts | 5 + frontend/src/shared/auth/permissions.ts | 2 +- frontend/src/shared/types/app.ts | 9 - frontend/src/shared/types/zoneCheckins.ts | 26 ++ 46 files changed, 1150 insertions(+), 205 deletions(-) create mode 100644 backend/src/db/migrations/20260619070000-grant-zone-checkin-report-permission.ts create mode 100644 backend/src/db/migrations/20260619071000-grant-registrar-zone-checkin-permission.ts diff --git a/backend/docs/content-catalog.md b/backend/docs/content-catalog.md index d39dfd5..33ec35e 100644 --- a/backend/docs/content-catalog.md +++ b/backend/docs/content-catalog.md @@ -39,6 +39,7 @@ Content records can be tenant-scoped through nullable `organizationId`, `schoolI - Org-scoped types read the caller's organization row. `classroom-strategies` is org-scoped: every organization gets one preset editable strategy library, while school, campus, and classroom views read that organization library instead of owning duplicate rows. - `safety-qbs-quiz` is org-scoped for the same reason: there is one weekly QBS quiz payload per organization, and descendant scopes read the organization payload. - Emotional Intelligence self-assessment and Personality Type quiz payloads are org-scoped for the same reason: each organization owns its active quiz versions and descendant scopes read the organization payloads. +- Zones of Regulation content (`regulation-zones` and `zones-of-regulation-page-content`) is org-scoped. New organizations receive one preset card/content library; school, campus, and class users read that organization library while daily check-in results remain in `user_progress`. - Shared/global types use all-null tenant ids. Management list excludes tenant-scoped content because those records are edited through dedicated per-type editors. @@ -63,7 +64,7 @@ The seeder (`20260608103000-content-catalog.ts`) loads the following `content_ty The migration `20260614090000-drop-global-content-catalog-rows.ts` removes the former global static rows: `personality-*` and `classroom-timer-*`. The migration `20260616170000-backfill-dashboard-content-scopes.ts` backfills per-tenant dashboard catalog defaults for existing organization, school, and campus records. The migration `20260618131000-backfill-emotional-intelligence-quiz-content.ts` backfills missing Emotional Intelligence and Personality Type quiz content for existing organizations. -New tenant creation uses `ContentCatalogSeedService.seedDefaultContentForTenant`: org creation presets org-scoped content such as `classroom-strategies`, `safety-qbs-quiz`, Emotional Intelligence self-assessment questions, and the Personality Type quiz; school creation presets school-scoped content; campus creation presets only per-tenant campus content. This keeps shared libraries and editable organization-wide quizzes owned at the organization level. +New tenant creation uses `ContentCatalogSeedService.seedDefaultContentForTenant`: org creation presets org-scoped content such as `classroom-strategies`, `safety-qbs-quiz`, Emotional Intelligence self-assessment questions, the Personality Type quiz, and Zones of Regulation content; school creation presets school-scoped content; campus creation presets only per-tenant campus content. This keeps shared libraries and editable organization-wide content owned at the organization level. ### Content authoring rules - Add editable or tenant-scoped production content records to backend seed payloads, not frontend constants. @@ -71,7 +72,7 @@ New tenant creation uses `ContentCatalogSeedService.seedDefaultContentForTenant` - If a catalog needs complex workflow state, approvals, or per-campus variants, replace the generic catalog entry with typed, tenant-scoped backend tables and CRUD APIs. ## Tests -Coverage lives in `src/services/content_catalog.test.ts` and `src/services/content_catalog_seed.test.ts`. It covers tenant read scoping, organization-only management for organization-owned content, and organization-only seeding for preset organization-owned content. Personality result tests cover active quiz consumption, reporting, and parent-drill save blocking. +Coverage lives in `src/services/content_catalog.test.ts` and `src/services/content_catalog_seed.test.ts`. It covers tenant read scoping, organization-only management for organization-owned content, and organization-only seeding for preset organization-owned content, including Zones of Regulation content. Personality result tests cover active quiz consumption, reporting, and parent-drill save blocking. ## Related - Frontend: `frontend/docs/content-catalog-integration.md`. diff --git a/backend/docs/test-coverage.md b/backend/docs/test-coverage.md index 91db6cb..457011f 100644 --- a/backend/docs/test-coverage.md +++ b/backend/docs/test-coverage.md @@ -96,6 +96,8 @@ const req = createMockRequest({ | `services/shared/crud-service.test.ts` | CRUD factory | ~20 | | `services/shared/role-policy.test.ts` | Role constraints | ~10 | | `services/shared/audio-access.test.ts` | Audio-library visibility/management rules | ~12 | +| `services/zone-checkin.test.ts` | Daily Zone Check-in permission gates, parent-drill mutation blocking, scoped completion reporting, and non-green counts | ~5 | +| `services/content_catalog_seed.test.ts` | New-tenant content presets, including organization-owned Zones of Regulation content | ~5 | | `services/refresh-token-maintenance.test.ts` | Refresh-token retention cutoff + cleanup orchestration (mocked DB API) | ~4 | ### Domain constants / pure rules diff --git a/backend/docs/zone-checkin.md b/backend/docs/zone-checkin.md index 6b39159..7973e93 100644 --- a/backend/docs/zone-checkin.md +++ b/backend/docs/zone-checkin.md @@ -1,17 +1,17 @@ # Daily Zone Check-in -Workstream 16 — campus staff log a daily self-regulation "zone" (Zones of -Regulation: blue/green/yellow/red). History is retained per day, and an eligible -user who has not checked in today is nudged on the dashboard, the -`/zones-of-regulation` page, and in the notification dropdown. +Workstream 16 — staff log a daily self-regulation "zone" (Zones of Regulation: +blue/green/yellow/red). History is retained per day, and an eligible user who has +not checked in today is nudged on the dashboard, the `/zones-of-regulation` +page, and in the notification dropdown. ## "Today" is server-computed in the campus timezone The check-in date is **not** decided by the client. Each campus carries a required IANA `timezone` (`campuses.timezone`); the service computes the -campus-local date (`localDateInTimezone`, native `Intl`, DST-correct) so "today" -is independent of the caller's device clock/zone and correct across -organizations, campuses, and timezones. +campus-local date (`localDateInTimezone`, native `Intl`, DST-correct). Users +without a campus use UTC for the daily key, so organization- and school-scope +staff can still complete the same daily workflow. ## Storage (reuses `user_progress`) @@ -25,28 +25,37 @@ logic, keeping the generic `user_progress` endpoint generic. ## Routes (`/api/zone_checkins`) -All require explicit `ZONE_CHECKIN`; `globalAccess` alone does not imply this -personal workflow permission. +Personal routes require explicit `ZONE_CHECKIN`; `globalAccess` alone does not +imply this workflow permission. The scoped completion route requires +`READ_ZONE_CHECKIN_REPORTS`. - `GET /today` → `{ date, zone, isCheckedInToday }` (campus-local date). - `POST /` → record today's zone. Body `{ data: { zone } }` (blue|green|yellow|red). - If the caller is a parent-scope user acting through a drilled child scope, the request is accepted - as a no-op and returns today's existing personal state without saving a new row. + Parent-scope users acting through a drilled child scope receive `403 Forbidden` + instead of creating reportable rows in the child scope. - `DELETE /today` → clear today's check-in. - `GET /?from=&to=` → the caller's history (`{ rows: [{ date, zone }], count }`). +- `GET /completion` → scoped staff report for leaders with + `READ_ZONE_CHECKIN_REPORTS`; returns `{ summary, rows }` for today's server + computed dates per staff member. ## Authorization -- `ZONE_CHECKIN` is seeded for `director`, `office_manager`, `teacher`, and - `support_staff`. Other users can receive or lose it only through effective - permissions (`custom_permissions` / `custom_permissions_filter`). The frontend - and backend both gate this workflow by permission, not by role name. +- `ZONE_CHECKIN` is seeded for staff roles that must complete the daily workflow, + including organization, school, campus, and class scope staff (`owner`, + `superintendent`, `principal`, `registrar`, `director`, `office_manager`, + `teacher`, and `support_staff`). Users can receive or lose it only through + effective permissions (`custom_permissions` / `custom_permissions_filter`). The + frontend and backend both gate this workflow by permission, not by role name. Reads/writes are scoped to the caller's own `userId` by `UserProgressService`. +- `READ_ZONE_CHECKIN_REPORTS` is seeded for scope leaders and report readers. The + completion report applies the caller's effective organization/school/campus/class + scope and includes pending rows for staff without today's check-in. - Check-ins persist only when the active scope is the user's own scope. Parent users drilled into a child school/campus/classroom do not see the personal check-in UI there, and the backend does not create reportable `user_progress` rows for that child scope. -- A user with no campus has no campus-local "today" — the service rejects with a - validation error (only campus staff reach these routes). +- Users with no campus use UTC for "today"; campus users use their campus + timezone. ## Tests @@ -54,15 +63,21 @@ personal workflow permission. incl. Phoenix no-DST + a DST zone; `isValidIanaTimezone`) and `shared/constants/zone-checkin.test.ts` (`isZoneCheckinColor`). - **Frontend unit** (`vitest`): `business/zone-checkin/selectors.test.ts` - (eligibility + nudge) and the top-bar notification builder (incl. the zones - `href`). + (eligibility + nudge), top-bar notification builder (incl. the zones `href`), + profile unified result rows, and director-dashboard unified result/risk rows. - **Seeded e2e** (`frontend/tests/e2e/zone-checkins.seeded.e2e.ts`, `npm run test:e2e:content`): a campus-staff record/read-back/clear of today's zone, invalid-zone rejection, and external-role lockout. -## Open / deferred +## Content -- A manager-facing aggregate (campus self-regulation trends across staff) would - need a cross-user report endpoint (`user_progress` is self-scoped) — deferred. -- Editing a campus `timezone` is part of the (design-gated) campus admin UI; - for now it is seeded and validated at the API. +Regulation zone cards and page copy are organization-preset content catalog +records (`regulation-zones` and `zones-of-regulation-page-content`). New +organizations receive those records from the content-catalog seeder/backfill; the +daily check-in result history remains in `user_progress`. + +Existing deployments receive report grants through +`20260619070000-grant-zone-checkin-report-permission.ts` and the registrar +personal workflow grant through +`20260619071000-grant-registrar-zone-checkin-permission.ts`; both migrations are +idempotent. diff --git a/backend/src/api/controllers/zone_checkins.controller.ts b/backend/src/api/controllers/zone_checkins.controller.ts index a109466..95c4b6a 100644 --- a/backend/src/api/controllers/zone_checkins.controller.ts +++ b/backend/src/api/controllers/zone_checkins.controller.ts @@ -11,6 +11,11 @@ export async function history(req: Request, res: Response): Promise { res.status(200).send(payload); } +export async function completion(req: Request, res: Response): Promise { + const payload = await ZoneCheckinService.completion(req.currentUser); + res.status(200).send(payload); +} + export async function checkIn(req: Request, res: Response): Promise { const payload = await ZoneCheckinService.checkIn(req.body.data, req.currentUser); res.status(200).send(payload); diff --git a/backend/src/db/migrations/20260619070000-grant-zone-checkin-report-permission.ts b/backend/src/db/migrations/20260619070000-grant-zone-checkin-report-permission.ts new file mode 100644 index 0000000..3026842 --- /dev/null +++ b/backend/src/db/migrations/20260619070000-grant-zone-checkin-report-permission.ts @@ -0,0 +1,91 @@ +import { v4 as uuid } from 'uuid'; +import type { QueryInterface } from 'sequelize'; +import { ROLE_NAMES, type RoleName } from '@/shared/constants/roles'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; + +const GRANTED_ROLES: readonly RoleName[] = Object.freeze([ + ROLE_NAMES.OWNER, + ROLE_NAMES.SUPERINTENDENT, + ROLE_NAMES.PRINCIPAL, + ROLE_NAMES.REGISTRAR, + ROLE_NAMES.DIRECTOR, +]); + +function isIdRow(value: unknown): value is { id: string } { + return ( + value !== null + && typeof value === 'object' + && 'id' in value + && typeof value.id === 'string' + ); +} + +function firstId(value: unknown): string | null { + return Array.isArray(value) ? value.find(isIdRow)?.id ?? null : null; +} + +function resultRows(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +export default { + up: async (queryInterface: QueryInterface) => { + const now = new Date(); + const [permissionRows] = await queryInterface.sequelize.query( + 'SELECT "id" FROM "permissions" WHERE "name" = :name LIMIT 1', + { replacements: { name: FEATURE_PERMISSIONS.READ_ZONE_CHECKIN_REPORTS } }, + ); + let permissionId = firstId(permissionRows); + + if (!permissionId) { + permissionId = uuid(); + await queryInterface.bulkInsert('permissions', [{ + id: permissionId, + name: FEATURE_PERMISSIONS.READ_ZONE_CHECKIN_REPORTS, + createdAt: now, + updatedAt: now, + }]); + } + + const [roleRows] = await queryInterface.sequelize.query( + 'SELECT "id", "name" FROM "roles" WHERE "name" IN (:names)', + { replacements: { names: GRANTED_ROLES } }, + ); + const links: Array<{ + createdAt: Date; + updatedAt: Date; + roles_permissionsId: string; + permissionId: string; + }> = []; + + for (const row of resultRows(roleRows)) { + if (!isIdRow(row)) { + continue; + } + + const [existingRows] = await queryInterface.sequelize.query( + `SELECT 1 FROM "rolesPermissionsPermissions" + WHERE "roles_permissionsId" = :roleId AND "permissionId" = :permissionId + LIMIT 1`, + { replacements: { roleId: row.id, permissionId } }, + ); + + if (resultRows(existingRows).length === 0) { + links.push({ + createdAt: now, + updatedAt: now, + roles_permissionsId: row.id, + permissionId, + }); + } + } + + if (links.length > 0) { + await queryInterface.bulkInsert('rolesPermissionsPermissions', links); + } + }, + + down: async () => { + // Keep permission grants on rollback; permissions may be assigned manually. + }, +}; diff --git a/backend/src/db/migrations/20260619071000-grant-registrar-zone-checkin-permission.ts b/backend/src/db/migrations/20260619071000-grant-registrar-zone-checkin-permission.ts new file mode 100644 index 0000000..40137a8 --- /dev/null +++ b/backend/src/db/migrations/20260619071000-grant-registrar-zone-checkin-permission.ts @@ -0,0 +1,72 @@ +import { v4 as uuid } from 'uuid'; +import type { QueryInterface } from 'sequelize'; +import { ROLE_NAMES } from '@/shared/constants/roles'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; + +function isIdRow(value: unknown): value is { id: string } { + return ( + value !== null + && typeof value === 'object' + && 'id' in value + && typeof value.id === 'string' + ); +} + +function firstId(value: unknown): string | null { + return Array.isArray(value) ? value.find(isIdRow)?.id ?? null : null; +} + +function resultRows(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +export default { + up: async (queryInterface: QueryInterface) => { + const now = new Date(); + const [permissionRows] = await queryInterface.sequelize.query( + 'SELECT "id" FROM "permissions" WHERE "name" = :name LIMIT 1', + { replacements: { name: FEATURE_PERMISSIONS.ZONE_CHECKIN } }, + ); + let permissionId = firstId(permissionRows); + + if (!permissionId) { + permissionId = uuid(); + await queryInterface.bulkInsert('permissions', [{ + id: permissionId, + name: FEATURE_PERMISSIONS.ZONE_CHECKIN, + createdAt: now, + updatedAt: now, + }]); + } + + const [roleRows] = await queryInterface.sequelize.query( + 'SELECT "id" FROM "roles" WHERE "name" = :name LIMIT 1', + { replacements: { name: ROLE_NAMES.REGISTRAR } }, + ); + const roleId = firstId(roleRows); + + if (!roleId) { + return; + } + + const [existingRows] = await queryInterface.sequelize.query( + `SELECT 1 FROM "rolesPermissionsPermissions" + WHERE "roles_permissionsId" = :roleId AND "permissionId" = :permissionId + LIMIT 1`, + { replacements: { roleId, permissionId } }, + ); + + if (resultRows(existingRows).length === 0) { + await queryInterface.bulkInsert('rolesPermissionsPermissions', [{ + createdAt: now, + updatedAt: now, + roles_permissionsId: roleId, + permissionId, + }]); + } + }, + + down: async () => { + // Keep permission grants on rollback; permissions may be assigned manually. + }, +}; diff --git a/backend/src/db/seeders/20200430130760-user-roles.ts b/backend/src/db/seeders/20200430130760-user-roles.ts index 546bc0e..d82b551 100644 --- a/backend/src/db/seeders/20200430130760-user-roles.ts +++ b/backend/src/db/seeders/20200430130760-user-roles.ts @@ -48,7 +48,6 @@ export const EXTRA_PERMISSIONS = ['READ_API_DOCS', 'CREATE_SEARCH'] as const; export const FULL_ACCESS_EXCLUDED_FOR_ALL = ['READ_PARENT_COMM'] as const; export const FULL_ACCESS_EXCLUDED_FOR_NON_CAMPUS_STAFF = [ 'ACK_POLICY', - 'ZONE_CHECKIN', ] as const; /** Roles granted every permission (full CRUD within their tenant/scope). */ @@ -97,8 +96,10 @@ export const MODULE_PERMISSIONS_BY_ROLE: Partial { assert.equal(permissions.includes(FEATURE_PERMISSIONS.READ_PARENT_COMM), false); assert.equal(permissions.includes(FEATURE_PERMISSIONS.READ_POLICY_ACKNOWLEDGMENT_REPORTS), true); assert.equal(permissions.includes(FEATURE_PERMISSIONS.READ_STAFF_ATTENDANCE_REPORTS), true); + assert.equal(permissions.includes(FEATURE_PERMISSIONS.READ_ZONE_CHECKIN_REPORTS), true); + assert.equal(permissions.includes(FEATURE_PERMISSIONS.ZONE_CHECKIN), true); + }); + + test('daily zone check-in is seeded for tenant staff roles across scopes', () => { + const zoneCheckinRoles = Object.values(ROLE_NAMES).filter((role) => + granted(role).includes(FEATURE_PERMISSIONS.ZONE_CHECKIN), + ); + + assert.deepEqual(zoneCheckinRoles, [ + 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, + ROLE_NAMES.SUPPORT_STAFF, + ]); + }); + + test('zone check-in reports are seeded for leadership and report-reader roles', () => { + const reportRoles = Object.values(ROLE_NAMES).filter((role) => + granted(role).includes(FEATURE_PERMISSIONS.READ_ZONE_CHECKIN_REPORTS), + ); + + assert.deepEqual(reportRoles, [ + ROLE_NAMES.SYSTEM_ADMIN, + ROLE_NAMES.OWNER, + ROLE_NAMES.SUPERINTENDENT, + ROLE_NAMES.PRINCIPAL, + ROLE_NAMES.REGISTRAR, + ROLE_NAMES.DIRECTOR, + ]); }); test('internal alerts read/manage grants match tenant leadership and staff expectations', () => { @@ -58,7 +93,7 @@ describe('user-role seed permission contract', () => { ]); }); - test('leadership full-access grants do not inherit personal workflow permissions', () => { + test('leadership full-access grants include zone check-in but not parent communication or policy acknowledgment', () => { const ownerPermissions = granted(ROLE_NAMES.OWNER); const principalPermissions = granted(ROLE_NAMES.PRINCIPAL); const directorPermissions = granted(ROLE_NAMES.DIRECTOR); @@ -66,10 +101,10 @@ describe('user-role seed permission contract', () => { assert.equal(ownerPermissions.includes(FEATURE_PERMISSIONS.READ_PARENT_COMM), false); assert.equal(ownerPermissions.includes(FEATURE_PERMISSIONS.ACK_POLICY), false); - assert.equal(ownerPermissions.includes(FEATURE_PERMISSIONS.ZONE_CHECKIN), false); + assert.equal(ownerPermissions.includes(FEATURE_PERMISSIONS.ZONE_CHECKIN), true); assert.equal(principalPermissions.includes(FEATURE_PERMISSIONS.READ_PARENT_COMM), false); assert.equal(principalPermissions.includes(FEATURE_PERMISSIONS.ACK_POLICY), false); - assert.equal(principalPermissions.includes(FEATURE_PERMISSIONS.ZONE_CHECKIN), false); + assert.equal(principalPermissions.includes(FEATURE_PERMISSIONS.ZONE_CHECKIN), true); assert.equal(directorPermissions.includes(FEATURE_PERMISSIONS.READ_PARENT_COMM), false); assert.equal(directorPermissions.includes(FEATURE_PERMISSIONS.ACK_POLICY), true); assert.equal(directorPermissions.includes(FEATURE_PERMISSIONS.ZONE_CHECKIN), true); diff --git a/backend/src/routes/zone_checkins.ts b/backend/src/routes/zone_checkins.ts index 02ffcd6..2c563e2 100644 --- a/backend/src/routes/zone_checkins.ts +++ b/backend/src/routes/zone_checkins.ts @@ -7,6 +7,7 @@ import * as zone_checkins from '@/api/controllers/zone_checkins.controller'; const router = express.Router(); const canCheckIn = permissions.checkPermissions(FEATURE_PERMISSIONS.ZONE_CHECKIN); +const canReadReports = permissions.checkPermissions(FEATURE_PERMISSIONS.READ_ZONE_CHECKIN_REPORTS); /** * @openapi @@ -38,8 +39,17 @@ const canCheckIn = permissions.checkPermissions(FEATURE_PERMISSIONS.ZONE_CHECKIN * 200: { description: '{ date, zone, isCheckedInToday }' } * 400: { $ref: '#/components/responses/ValidationError' } * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/zone_checkins/completion: + * get: + * tags: [Zone Check-in] + * summary: Staff zone check-in completion for today's scoped dates + * description: Requires READ_ZONE_CHECKIN_REPORTS. Dates are computed server-side per staff campus timezone. + * responses: + * 200: { description: '{ summary, rows }' } + * 403: { $ref: '#/components/responses/ForbiddenError' } */ router.get('/today', canCheckIn, wrapAsync(zone_checkins.today)); +router.get('/completion', canReadReports, wrapAsync(zone_checkins.completion)); router.get('/', canCheckIn, wrapAsync(zone_checkins.history)); router.post('/', canCheckIn, wrapAsync(zone_checkins.checkIn)); router.delete('/today', canCheckIn, wrapAsync(zone_checkins.clearToday)); diff --git a/backend/src/services/content_catalog_seed.test.ts b/backend/src/services/content_catalog_seed.test.ts index 3927865..7d4a908 100644 --- a/backend/src/services/content_catalog_seed.test.ts +++ b/backend/src/services/content_catalog_seed.test.ts @@ -169,4 +169,60 @@ describe('seedDefaultContentForTenant', () => { ], ); }); + + test('seeds Zones of Regulation content only at organization scope', async () => { + const createdRows: Array> = []; + + mock.method(db.content_catalog, 'findOne', (async () => null) as typeof db.content_catalog.findOne); + mock.method(db.content_catalog, 'create', (async (payload: Record) => { + createdRows.push(payload); + return payload; + }) as typeof db.content_catalog.create); + + await seedDefaultContentForTenant({ + level: 'organization', + organizationId: 'org-1', + }); + await seedDefaultContentForTenant({ + level: 'school', + organizationId: 'org-1', + schoolId: 'school-1', + }); + await seedDefaultContentForTenant({ + level: 'campus', + organizationId: 'org-1', + schoolId: 'school-1', + campusId: 'campus-1', + }); + + const zoneRows = createdRows.filter((row) => + row.content_type === 'regulation-zones' || + row.content_type === 'zones-of-regulation-page-content', + ); + + assert.equal(zoneRows.length, 2); + assert.deepEqual( + zoneRows.map((row) => ({ + content_type: row.content_type, + organizationId: row.organizationId, + schoolId: row.schoolId, + campusId: row.campusId, + })).sort((left, right) => String(left.content_type).localeCompare(String(right.content_type))), + [ + { + content_type: 'regulation-zones', + organizationId: 'org-1', + schoolId: null, + campusId: null, + }, + { + content_type: 'zones-of-regulation-page-content', + organizationId: 'org-1', + schoolId: null, + campusId: null, + }, + ], + ); + assert.ok(Array.isArray(zoneRows.find((row) => row.content_type === 'regulation-zones')?.payload)); + }); }); diff --git a/backend/src/services/zone-checkin.test.ts b/backend/src/services/zone-checkin.test.ts index 4eac7bb..81e2725 100644 --- a/backend/src/services/zone-checkin.test.ts +++ b/backend/src/services/zone-checkin.test.ts @@ -7,6 +7,7 @@ import UserProgressService from '@/services/user_progress'; import ForbiddenError from '@/shared/errors/forbidden'; import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; import { ROLE_NAMES } from '@/shared/constants/roles'; +import { localDateInTimezone } from '@/shared/constants/timezone'; import { createGlobalAccessUser, createTestUser } from '@/test-utils'; import type { CurrentUser } from '@/db/api/types'; @@ -69,4 +70,87 @@ describe('ZoneCheckinService role eligibility', () => { assert.equal(result.isCheckedInToday, false); assert.match(result.date, /^\d{4}-\d{2}-\d{2}$/); }); + + test('blocks parent-drill zone mutations', async () => { + const currentUser = createTestUser({ + organizationId: 'org-1', + organizations: { id: 'org-1' }, + campusId: null, + app_role: { + name: ROLE_NAMES.OWNER, + scope: 'organization', + globalAccess: false, + permissions: [{ name: FEATURE_PERMISSIONS.ZONE_CHECKIN }], + }, + activeScope: { + level: 'campus', + organizationId: 'org-1', + schoolId: 'school-1', + campusId: 'campus-1', + classId: null, + }, + }); + + await assert.rejects( + () => ZoneCheckinService.checkIn({ zone: 'green' }, currentUser), + ForbiddenError, + ); + await assert.rejects( + () => ZoneCheckinService.clearToday(currentUser), + ForbiddenError, + ); + }); +}); + +describe('ZoneCheckinService completion report', () => { + test('builds scoped daily rows and counts non-green zones', async () => { + const currentUser = createTestUser({ + organizationId: 'org-1', + app_role: { + name: ROLE_NAMES.DIRECTOR, + scope: 'campus', + globalAccess: false, + permissions: [{ name: FEATURE_PERMISSIONS.READ_ZONE_CHECKIN_REPORTS }], + }, + campusId: 'campus-1', + }); + mock.method(db.users, 'findAll', (async () => [ + { + id: 'user-1', + firstName: 'Ava', + lastName: 'Lee', + email: 'ava@example.test', + campusId: 'campus-1', + app_role: { name: ROLE_NAMES.TEACHER }, + }, + { + id: 'user-2', + firstName: 'Ben', + lastName: 'Stone', + email: 'ben@example.test', + campusId: 'campus-1', + app_role: { name: ROLE_NAMES.SUPPORT_STAFF }, + }, + ]) as typeof db.users.findAll); + mock.method(db.campuses, 'findAll', (async () => [ + { id: 'campus-1', timezone: 'UTC' }, + ]) as typeof db.campuses.findAll); + mock.method(db.user_progress, 'findAll', (async () => [ + { + userId: 'user-1', + item_id: localDateInTimezone('UTC'), + value: 'yellow', + }, + ]) as typeof db.user_progress.findAll); + + const report = await ZoneCheckinService.completion(currentUser); + + assert.equal(report.summary.totalStaff, 2); + assert.equal(report.summary.completedCount, 1); + assert.equal(report.summary.pendingCount, 1); + assert.equal(report.summary.nonGreenCount, 1); + assert.equal(report.rows[0].result, 'Yellow Zone'); + assert.equal(report.rows[0].riskLevel, 'medium'); + assert.equal(report.rows[1].status, 'pending'); + }); }); diff --git a/backend/src/services/zone-checkin.ts b/backend/src/services/zone-checkin.ts index df31149..c0da5ac 100644 --- a/backend/src/services/zone-checkin.ts +++ b/backend/src/services/zone-checkin.ts @@ -1,14 +1,20 @@ +import { Op } from 'sequelize'; import db from '@/db/models'; import ValidationError from '@/shared/errors/validation'; import UserProgressService from '@/services/user_progress'; import { assertAuthenticatedTenantUser, getCampusId, + getClassId, hasFeaturePermission, isActingInOwnScope, + getOrganizationIdOrGlobal, + getRoleScope, + getSchoolId, } from '@/services/shared/access'; import ForbiddenError from '@/shared/errors/forbidden'; import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; +import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles'; import { localDateInTimezone } from '@/shared/constants/timezone'; import { USER_PROGRESS_TYPES } from '@/shared/constants/user-progress'; import { @@ -16,6 +22,8 @@ import { isZoneCheckinColor, } from '@/shared/constants/zone-checkin'; import type { CurrentUser } from '@/db/api/types'; +import type { Users } from '@/db/models/users'; +import type { UserProgress } from '@/db/models/user_progress'; /** * Daily Zone check-in service (Workstream 16). "Today" is computed server-side @@ -37,28 +45,141 @@ export interface ZoneCheckinHistoryEntry { readonly zone: string; } +export interface ZoneCheckinCompletionRow { + readonly userId: string; + readonly name: string; + readonly email: string | null; + readonly role: string | null; + readonly date: string; + readonly status: 'complete' | 'pending'; + readonly zone: string | null; + readonly riskLevel: 'none' | 'medium' | 'pending'; + readonly result: string; +} + function assertCanZoneCheckIn(currentUser?: CurrentUser): void { if (!hasFeaturePermission(currentUser, FEATURE_PERMISSIONS.ZONE_CHECKIN)) { throw new ForbiddenError(); } } +function assertCanMutateZoneCheckIn(currentUser?: CurrentUser): void { + assertCanZoneCheckIn(currentUser); + if (!isActingInOwnScope(currentUser)) { + throw new ForbiddenError(); + } +} + +function assertCanReadCompletion(currentUser?: CurrentUser): void { + assertAuthenticatedTenantUser(currentUser); + if ( + hasFeaturePermission( + currentUser, + FEATURE_PERMISSIONS.READ_ZONE_CHECKIN_REPORTS, + ) + ) { + return; + } + throw new ForbiddenError(); +} + +const REPORT_STAFF_ROLES = Object.freeze([ + ROLE_NAMES.OWNER, + ROLE_NAMES.SUPERINTENDENT, + ROLE_NAMES.PRINCIPAL, + ROLE_NAMES.REGISTRAR, + ROLE_NAMES.DIRECTOR, + ROLE_NAMES.OFFICE_MANAGER, + ROLE_NAMES.TEACHER, + ROLE_NAMES.SUPPORT_STAFF, +]); + async function resolveCampusTimezone(currentUser?: CurrentUser): Promise { const campusId = getCampusId(currentUser); if (!campusId) { - // Zone check-in is campus-staff-only; a user without a campus has no - // "today" to compute. - throw new ValidationError('zoneCheckinNoCampus'); + return 'UTC'; } const campus = await db.campuses.findByPk(campusId, { attributes: ['timezone'], }); if (!campus) { - throw new ValidationError('zoneCheckinNoCampus'); + return 'UTC'; } return campus.timezone; } +async function staffScopeWhere(currentUser?: CurrentUser) { + const organizationId = getOrganizationIdOrGlobal(currentUser); + const base = organizationId ? { organizationId } : {}; + const scope = getRoleScope(currentUser); + + if (scope === ROLE_SCOPES.SCHOOL) { + const schoolId = getSchoolId(currentUser); + if (!schoolId) { + return base; + } + const campuses = await db.campuses.findAll({ + attributes: ['id'], + where: { + schoolId, + ...(organizationId ? { organizationId } : {}), + }, + }); + const campusIds = campuses.map((campus) => campus.id); + const classes = campusIds.length > 0 + ? await db.classes.findAll({ + attributes: ['id'], + where: { + campusId: campusIds, + ...(organizationId ? { organizationId } : {}), + }, + }) + : []; + const classIds = classes.map((classroom) => classroom.id); + + return { + ...base, + [Op.or]: [ + { schoolId }, + ...(campusIds.length > 0 ? [{ campusId: { [Op.in]: campusIds } }] : []), + ...(classIds.length > 0 ? [{ classId: { [Op.in]: classIds } }] : []), + ], + }; + } + + if (scope === ROLE_SCOPES.CAMPUS) { + const campusId = getCampusId(currentUser); + return campusId ? { ...base, campusId } : base; + } + + if (scope === ROLE_SCOPES.CLASS) { + const classId = getClassId(currentUser); + return classId ? { ...base, classId } : base; + } + + return base; +} + +function displayNameOf(user: Users): string { + return [user.firstName, user.lastName].filter(Boolean).join(' ').trim() + || user.email + || 'Staff Member'; +} + +function latestProgressByUserAndDate( + rows: readonly UserProgress[], +): ReadonlyMap { + const byUserAndDate = new Map(); + for (const row of rows) { + const key = `${row.userId}:${row.item_id}`; + if (byUserAndDate.has(key)) { + continue; + } + byUserAndDate.set(key, row); + } + return byUserAndDate; +} + class ZoneCheckinService { /** Today's check-in for the caller (campus-local date). */ static async today(currentUser?: CurrentUser): Promise { @@ -81,22 +202,13 @@ class ZoneCheckinService { currentUser?: CurrentUser, ): Promise { assertAuthenticatedTenantUser(currentUser); - assertCanZoneCheckIn(currentUser); + assertCanMutateZoneCheckIn(currentUser); if (!isZoneCheckinColor(data.zone)) { throw new ValidationError('zoneCheckinInvalidZone'); } const timezone = await resolveCampusTimezone(currentUser); const date = localDateInTimezone(timezone); - if (!isActingInOwnScope(currentUser)) { - const { rows } = await UserProgressService.list( - { progress_type: USER_PROGRESS_TYPES.ZONE_CHECKIN, item_id: date }, - currentUser, - ); - const zone = rows[0]?.value ?? null; - return { date, zone, isCheckedInToday: zone !== null }; - } - await UserProgressService.upsert( { progress_type: USER_PROGRESS_TYPES.ZONE_CHECKIN, @@ -111,7 +223,7 @@ class ZoneCheckinService { /** Clears the caller's check-in for today (campus-local date). */ static async clearToday(currentUser?: CurrentUser): Promise { assertAuthenticatedTenantUser(currentUser); - assertCanZoneCheckIn(currentUser); + assertCanMutateZoneCheckIn(currentUser); const timezone = await resolveCampusTimezone(currentUser); const date = localDateInTimezone(timezone); @@ -149,6 +261,95 @@ class ZoneCheckinService { return { rows: entries, count: entries.length }; } + + /** Staff completion report for today's check-in across the caller's scope. */ + static async completion(currentUser?: CurrentUser) { + assertCanReadCompletion(currentUser); + const staffUsers = await db.users.findAll({ + where: { + disabled: false, + ...(await staffScopeWhere(currentUser)), + }, + include: [ + { + model: db.roles, + as: 'app_role', + required: true, + where: { name: REPORT_STAFF_ROLES }, + }, + ], + order: [ + ['lastName', 'asc'], + ['firstName', 'asc'], + ['email', 'asc'], + ], + }); + const campusIds = [...new Set(staffUsers.map((user) => user.campusId).filter((id): id is string => Boolean(id)))]; + const campuses = campusIds.length > 0 + ? await db.campuses.findAll({ + attributes: ['id', 'timezone'], + where: { id: { [Op.in]: campusIds } }, + }) + : []; + const timezoneByCampus = new Map(campuses.map((campus) => [campus.id, campus.timezone])); + const dateByUser = new Map( + staffUsers.map((user) => { + const timezone = user.campusId ? timezoneByCampus.get(user.campusId) : null; + return [user.id, localDateInTimezone(timezone ?? 'UTC')] as const; + }), + ); + const userIds = staffUsers.map((user) => user.id); + const dates = [...new Set(dateByUser.values())]; + const organizationId = getOrganizationIdOrGlobal(currentUser); + const progressRows = userIds.length > 0 && dates.length > 0 + ? await db.user_progress.findAll({ + where: { + ...(organizationId ? { organizationId } : {}), + userId: { [Op.in]: userIds }, + progress_type: USER_PROGRESS_TYPES.ZONE_CHECKIN, + item_id: { [Op.in]: dates }, + }, + order: [['updatedAt', 'desc']], + }) + : []; + const progressByUserAndDate = latestProgressByUserAndDate(progressRows); + const rows: ZoneCheckinCompletionRow[] = staffUsers.map((user) => { + const date = dateByUser.get(user.id) ?? localDateInTimezone('UTC'); + const progress = progressByUserAndDate.get(`${user.id}:${date}`); + const zone = progress?.value ?? null; + const status = zone ? 'complete' : 'pending'; + const riskLevel = !zone ? 'pending' : zone === 'green' ? 'none' : 'medium'; + + return { + userId: user.id, + name: displayNameOf(user), + email: user.email, + role: user.app_role?.name ?? null, + date, + status, + zone, + riskLevel, + result: zone ? `${zone[0].toUpperCase()}${zone.slice(1)} Zone` : 'Pending', + }; + }); + const completedCount = rows.filter((row) => row.status === 'complete').length; + const greenCount = rows.filter((row) => row.zone === 'green').length; + const nonGreenCount = rows.filter((row) => row.zone && row.zone !== 'green').length; + + return { + summary: { + totalStaff: rows.length, + completedCount, + pendingCount: Math.max(rows.length - completedCount, 0), + greenCount, + nonGreenCount, + completionRate: rows.length > 0 + ? Math.round((completedCount / rows.length) * 100) + : 0, + }, + rows, + }; + } } export default ZoneCheckinService; diff --git a/backend/src/shared/constants/product-permissions.ts b/backend/src/shared/constants/product-permissions.ts index 164d9e6..17d35cc 100644 --- a/backend/src/shared/constants/product-permissions.ts +++ b/backend/src/shared/constants/product-permissions.ts @@ -66,6 +66,7 @@ export const MODULE_MANAGEMENT_PERMISSIONS = [ 'READ_STAFF_ATTENDANCE_REPORTS', 'READ_SAFETY_QUIZ_REPORTS', 'READ_PERSONALITY_REPORTS', + 'READ_ZONE_CHECKIN_REPORTS', 'READ_POLICY_ACKNOWLEDGMENT_REPORTS', ] as const; @@ -109,6 +110,7 @@ export const FEATURE_PERMISSIONS = Object.freeze({ READ_STAFF_ATTENDANCE_REPORTS: 'READ_STAFF_ATTENDANCE_REPORTS', READ_SAFETY_QUIZ_REPORTS: 'READ_SAFETY_QUIZ_REPORTS', READ_PERSONALITY_REPORTS: 'READ_PERSONALITY_REPORTS', + READ_ZONE_CHECKIN_REPORTS: 'READ_ZONE_CHECKIN_REPORTS', READ_POLICY_ACKNOWLEDGMENT_REPORTS: 'READ_POLICY_ACKNOWLEDGMENT_REPORTS', READ_AUDIO_FILES: 'READ_AUDIO_FILES', MANAGE_AUDIO_FILES: 'MANAGE_AUDIO_FILES', diff --git a/frontend/docs/content-catalog-integration.md b/frontend/docs/content-catalog-integration.md index d5e912a..b9af53e 100644 --- a/frontend/docs/content-catalog-integration.md +++ b/frontend/docs/content-catalog-integration.md @@ -118,7 +118,7 @@ Sign records, teaching tips, video URLs, GIF URLs, step instructions, and page-l ## Editable Zones Of Regulation Content -Regulation zone records, behaviors, strategies, matching signs, QBS safety connection copy, and quick de-escalation flow content are part of the `regulation-zones` and `zones-of-regulation-page-content` content catalog payloads. The frontend renders these payloads and does not keep zone content or flow copy in shared constants. +Regulation zone records, behaviors, strategies, matching signs, and QBS safety connection copy are part of the `regulation-zones` and `zones-of-regulation-page-content` content catalog payloads. The frontend renders these payloads and keeps the stable Quick Behavior Management Flow as static component content because it is not tenant-editable. ## Editable Dashboard Content diff --git a/frontend/docs/dashboard-integration.md b/frontend/docs/dashboard-integration.md index 7d167c1..7708537 100644 --- a/frontend/docs/dashboard-integration.md +++ b/frontend/docs/dashboard-integration.md @@ -50,6 +50,7 @@ Feature APIs: - F.R.A.M.E. entries through `useFrameEntries` - Communication events through `useCommunicationEvents` - Current-user daily Emotional Zone check-in through `useTodayZoneCheckIn` (explicit `ZONE_CHECKIN` only; see `zone-checkin-integration.md`) +- Leader-scoped Daily Zone Check-In completion through `useZoneCheckInCompletion` on leadership dashboards. ## Behavior diff --git a/frontend/docs/director-dashboard-integration.md b/frontend/docs/director-dashboard-integration.md index f7d4ecf..36003cc 100644 --- a/frontend/docs/director-dashboard-integration.md +++ b/frontend/docs/director-dashboard-integration.md @@ -2,7 +2,7 @@ ## Purpose -Director dashboard follows the frontend three-layer architecture and aggregates backend-backed FRAME, quiz completion, staff attendance, and policy acknowledgment data. +Director dashboard follows the frontend three-layer architecture and aggregates backend-backed FRAME, quiz completion, daily zone check-ins, staff attendance, and policy acknowledgment data. ```text View -> Business Logic -> API/Data Access -> Backend @@ -26,6 +26,7 @@ API/data access layer: - `frontend/src/shared/api/frame.ts` - `frontend/src/shared/api/safetyQuizResults.ts` - `frontend/src/shared/api/personality.ts` +- `frontend/src/shared/api/zoneCheckins.ts` - `frontend/src/shared/api/staffAttendance.ts` - `frontend/src/shared/api/policyAcknowledgments.ts` @@ -39,14 +40,18 @@ Constants: - FRAME entries load through `useFrameEntries`. - 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. - Policy acknowledgment summary loads through `usePolicyAcknowledgmentReport`. - Overview metrics, risk areas, unified quiz result rows, and FRAME previews are derived in business selectors. -- The dashboard quiz results table combines Behavior Management, EI Self-Assessment, and - Personality Type Quiz rows. EI self-assessment rows reflect the current Sunday-start week; - personality type rows reflect each user's latest saved type. -- Risk areas include high/medium/low QBS safety quiz completion, low-risk EI self-assessment - pending counts, low-risk Personality Type pending counts, and attendance risk. +- The dashboard quiz results table combines Behavior Management, EI Self-Assessment, + Personality Type Quiz, and Daily Zone Check-In rows. EI self-assessment rows + reflect the current Sunday-start week; personality type rows reflect each user's + latest saved type; zone check-in rows reflect today's backend-computed date for + each staff member. +- Risk areas include high/medium/low QBS safety quiz completion, low-risk EI + self-assessment pending counts, low-risk Personality Type pending counts, + medium-risk non-green Daily Zone Check-In counts, and attendance risk. - View components use shared `Button`, `Table`, `StatePanel`, and `ModuleHeader` primitives. - Loading, empty, and error states are explicit. diff --git a/frontend/docs/test-coverage.md b/frontend/docs/test-coverage.md index 09d0ed7..55a64e0 100644 --- a/frontend/docs/test-coverage.md +++ b/frontend/docs/test-coverage.md @@ -71,6 +71,7 @@ Current coverage includes architecture guardrails, API/data-access behavior, and - `frontend/src/shared/api/safetyQuizResults.test.ts` - `frontend/src/shared/api/staffAttendance.test.ts` - `frontend/src/shared/api/userProgress.test.ts` +- `frontend/src/shared/api/zoneCheckins.test.ts` - `frontend/src/shared/api/walkthrough.test.ts` - `frontend/src/shared/architecture/import-boundaries.test.ts` - `frontend/src/shared/business/apiListRows.test.ts` @@ -80,7 +81,7 @@ Current coverage includes architecture guardrails, API/data-access behavior, and - `frontend/src/hooks/usePermissions.test.tsx` - `frontend/src/components/sign-in-modal/SignInForm.test.tsx` -These tests verify import-boundary enforcement, centralized network access through `shared/api/httpClient`, alias-only source imports, required API contract-test coverage, HTTP client request normalization, cookie-backed auth refresh behavior, JSON body serialization, empty response handling, backend error propagation, API wrapper endpoint/query/body contracts, shared list/query/error helpers, route config, module route metadata, module access correction, sidebar navigation selectors, mobile sidebar overlay visibility, auth display/profile mapping, signup validation, campus mapping, dashboard and director dashboard selectors, classroom support selectors including search over titles/descriptions and favorites-only filtering, timer formatting/progress/threshold parsing, DTO mapping, FRAME edit access, campus attendance mapping, calculations, and printable report output, staff attendance mapping and rollups, walkthrough create DTO mapping, walkthrough filtering, walkthrough check-in stats/history mapping, walkthrough scoring, generated summaries, repeated low-rating flags, communication role visibility, community catalog filtering and stats, ESA funding selectors, policy mapping/filtering/validation, safety-protocol mapping and authoring (steps/considerations) validation, audio-library management gating and the local recipe-generation stub, daily Zone check-in eligibility/nudge, QBS/EI/personality completion reminders, header search over accessible modules + their content, outside-click dismissal, the shared American (Sunday-start) week canonicalization, F.R.A.M.E. week/label mapping, safety quiz compliance mapping and editable payload validation, unified profile quiz result rows, sign language selectors, top bar display selectors, user progress normalization including Classroom Support favorite list/upsert API contracts, vocational zip/category/search/stat calculations, zones selectors, personality DTO mapping, personality distribution grouping, EI thresholds, personality quiz progress, and MBTI-derived communication guidance. +These tests verify import-boundary enforcement, centralized network access through `shared/api/httpClient`, alias-only source imports, required API contract-test coverage, HTTP client request normalization, cookie-backed auth refresh behavior, JSON body serialization, empty response handling, backend error propagation, API wrapper endpoint/query/body contracts, shared list/query/error helpers, route config, module route metadata, module access correction, sidebar navigation selectors, mobile sidebar overlay visibility, auth display/profile mapping, signup validation, campus mapping, dashboard and director dashboard selectors, classroom support selectors including search over titles/descriptions and favorites-only filtering, timer formatting/progress/threshold parsing, DTO mapping, FRAME edit access, campus attendance mapping, calculations, and printable report output, staff attendance mapping and rollups, walkthrough create DTO mapping, walkthrough filtering, walkthrough check-in stats/history mapping, walkthrough scoring, generated summaries, repeated low-rating flags, communication role visibility, community catalog filtering and stats, ESA funding selectors, policy mapping/filtering/validation, safety-protocol mapping and authoring (steps/considerations) validation, audio-library management gating and the local recipe-generation stub, daily Zone check-in eligibility/nudge/completion API, QBS/EI/personality completion reminders, header search over accessible modules + their content, outside-click dismissal, the shared American (Sunday-start) week canonicalization, F.R.A.M.E. week/label mapping, safety quiz compliance mapping and editable payload validation, unified profile quiz result rows including Daily Zone Check-In, sign language selectors, top bar display selectors, user progress normalization including Classroom Support favorite list/upsert API contracts, vocational zip/category/search/stat calculations, zones selectors, personality DTO mapping, personality distribution grouping, EI thresholds, personality quiz progress, and MBTI-derived communication guidance. The component and hook tests verify SignInForm rendering, loading states, user interactions, form submission, password visibility toggling, auth session initialization, sign-in/sign-out flows, AuthExpiredError handling, modal workflow state management, and permissions hook behavior including has(), hasAny(), hasAll() methods with globalAccess support and explicit personal-workflow exclusions. diff --git a/frontend/docs/zone-checkin-integration.md b/frontend/docs/zone-checkin-integration.md index 78f7c90..a4d6f8e 100644 --- a/frontend/docs/zone-checkin-integration.md +++ b/frontend/docs/zone-checkin-integration.md @@ -2,56 +2,77 @@ ## Purpose -Campus staff log a daily self-regulation "Emotional Zone" (blue/green/yellow/red). -The same state drives three surfaces: the dashboard check-in card, the -`/zones-of-regulation` page (reminder banner + card), and a notification-dropdown -nudge when an eligible user has not checked in today. +Staff log a daily self-regulation "Emotional Zone" (blue/green/yellow/red). +The same state drives the dashboard check-in card, the `/zones-of-regulation` +overview cards, the profile unified results table, leader dashboard +completion/risk sections, and a notification-dropdown nudge when an eligible +user has not checked in today. ## Backend contract -`/api/zone_checkins` requires explicit `ZONE_CHECKIN`. This personal workflow -permission is not implied by `globalAccess`. The client never computes the date; -"today" is the campus-local date computed server-side from `campuses.timezone`. +`/api/zone_checkins` requires explicit `ZONE_CHECKIN` for personal reads/writes. +This personal workflow permission is not implied by `globalAccess`. The client +never computes the date; "today" is computed server-side from the user's campus +timezone, falling back to UTC for organization/school-scope staff without a +campus. - `GET /today` → `{ date, zone, isCheckedInToday }` - `POST /` `{ data: { zone } }` → record today's zone (upsert) - `DELETE /today` → clear today's zone - `GET /?from=&to=` → history `{ rows: [{ date, zone }], count }` +- `GET /completion` → leader/report scoped staff completion `{ summary, rows }` + with `READ_ZONE_CHECKIN_REPORTS`. ## Frontend Structure - API/types: `shared/api/zoneCheckins.ts`, `shared/types/zoneCheckins.ts` - Business: `business/zone-checkin/hooks.ts` (`useTodayZoneCheckIn`, - `useZoneCheckInHistory`), `business/zone-checkin/selectors.ts` + `useZoneCheckInHistory`, `useZoneCheckInCompletion`), + `business/zone-checkin/selectors.ts` (`canZoneCheckIn`, `shouldNudgeZoneCheckIn`) -- Components: `components/zone-checkin/ZoneCheckInCard.tsx` (shared card), - `ZoneCheckInReminder.tsx` (banner), `ZoneCheckInSection.tsx` (page section) +- Components: `components/zone-checkin/ZoneCheckInCard.tsx` (shared dashboard + card), `ZoneCheckInReminder.tsx` (banner), `ZoneCheckInSection.tsx` (composed + section), and the `/zones-of-regulation` overview cards. ## Behavior - **Eligibility/nudge gating** requires the effective `ZONE_CHECKIN` permission. - Seed data grants it only to the campus staff workflow audience - (`director`, `office_manager`, `teacher`, `support_staff`), but custom - permissions can extend or remove it per user. Global/full-access leadership - users do not see self-state nudges unless they receive explicit `ZONE_CHECKIN`. + Seed data grants it to staff users expected to complete the daily workflow + across organization, school, campus, and class scopes; custom permissions can + extend or remove it per user. - **Dashboard**: the card is wired through `useDashboardPage` (which exposes `showZoneCheckIn` + `needsZoneCheckIn`) with an optimistic shell value for snappy selection. -- **Zones page**: `ZoneCheckInSection` is self-contained (`useTodayZoneCheckIn`) - and renders above the regulation content. +- **Zones page**: the main zone overview cards call `useTodayZoneCheckIn` + through `useZonesOfRegulationPage`; selecting blue/green/yellow/red both opens + the zone detail panel and saves the eligible user's daily check-in. Parent + drill-down keeps the selection local and does not persist personal state; the + backend also rejects personal zone mutations in child scopes. - **Notifications**: `business/top-bar` derives a single unread notification from `shouldNudgeZoneCheckIn` (`buildTopBarNotifications`) — there is no backend notifications store. The notification carries an `href` (`APP_ROUTE_PATHS.zones`); clicking it navigates to `/zones-of-regulation` (a react-router `Link`) and closes the dropdown. -- `useTodayZoneCheckIn` is disabled for users without `ZONE_CHECKIN`. Its `error` surfaces - **only** save/clear failures; non-eligible users should not trigger the - `/today` request. +- `useTodayZoneCheckIn` is disabled for users without `ZONE_CHECKIN` or when the + user is drilled into a child scope. Disabled callers receive empty local state + instead of stale cached check-in data. Its `error` surfaces **only** + save/clear failures; non-eligible users should not trigger the `/today` + request. +- **Leader dashboards**: `useDirectorDashboardPage` loads `useZoneCheckInCompletion` + with the effective tenant in its query key and appends Daily Zone Check-In rows + to the unified completion table. Non-green completed zones are shown as medium + risk in Risk Areas. +- **Profile**: the unified quiz/results table includes today's Daily Zone + Check-In for eligible users. +- **Content**: zone cards and page copy are backend-owned content catalog presets + (`regulation-zones`, `zones-of-regulation-page-content`) for new organizations. ## Tests - `business/zone-checkin/selectors.test.ts` (eligibility + nudge), - `business/top-bar/selectors.test.ts` (notification builder + zones `href`). + `business/top-bar/selectors.test.ts` (notification builder + zones `href`), + `business/director-dashboard/selectors.test.ts`, `business/profile/selectors.test.ts`, + and `shared/api/zoneCheckins.test.ts`. - Seeded e2e: `frontend/tests/e2e/zone-checkins.seeded.e2e.ts` (record / read-back / clear today, invalid-zone rejection, external-role lockout). diff --git a/frontend/docs/zones-of-regulation-integration.md b/frontend/docs/zones-of-regulation-integration.md index acff0ef..d479dbe 100644 --- a/frontend/docs/zones-of-regulation-integration.md +++ b/frontend/docs/zones-of-regulation-integration.md @@ -8,7 +8,7 @@ View -> Business Logic -> API/Data Access -> Backend ``` -Runtime zone records, behaviors, strategies, matching signs, safety connections, and quick de-escalation flow content belong to backend content catalog payloads. The frontend owns only UI state, tab config, style-token mappings, and presentation. +Runtime zone records, behaviors, strategies, matching signs, and safety connections belong to backend content catalog payloads. The frontend owns UI state, tab config, style-token mappings, presentation, and the static Quick Behavior Management Flow block. ## Frontend Layers @@ -47,7 +47,7 @@ The page reads: Content payloads are seeded in: -- `backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.js` +- `backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts` ## Behavior @@ -55,11 +55,13 @@ Content payloads are seeded in: - Selectors handle expanded-zone toggling, selected-zone lookup, safety connection lookup, and active-tab wording. - View components receive a prepared page model and do not call API/data access modules. - Loading and error states are explicit through `StatePanel`. -- Selecting a zone expands its details. The page also renders the daily Emotional Zone check-in (`ZoneCheckInSection`: reminder banner + `ZoneCheckInCard`) above the content for users with explicit `ZONE_CHECKIN` — see [`zone-checkin-integration.md`](zone-checkin-integration.md). +- Selecting a zone in the main overview grid expands its details and, for eligible users in their own scope, saves the daily Zone check-in through `useTodayZoneCheckIn`. The page does not render a duplicate check-in card above the content; see [`zone-checkin-integration.md`](zone-checkin-integration.md). +- `ZonesQuickFlow` renders the static Quick Behavior Management Flow. This copy and styling are not editable content catalog data. +- New organizations receive `regulation-zones` and `zones-of-regulation-page-content` presets at organization scope. School, campus, and classroom users read the organization preset instead of separate duplicated rows. ## Data Ownership Rules -- Do not add zone records, QBS safety connection copy, or de-escalation flow content to frontend constants. +- Do not add zone records or QBS safety connection copy to frontend constants. - Do not add frontend fallback zone payloads. -- Keep frontend constants limited to tab labels, default UI state, gradients, and ring classes. +- Keep frontend constants limited to tab labels, default UI state, gradients, ring classes, and stable non-editable UI copy. - Test-only fixtures may live in selector tests or `frontend/src/test-seeds/`. diff --git a/frontend/src/business/dashboard/hooks.ts b/frontend/src/business/dashboard/hooks.ts index c08a2b4..623a589 100644 --- a/frontend/src/business/dashboard/hooks.ts +++ b/frontend/src/business/dashboard/hooks.ts @@ -15,6 +15,7 @@ import type { } from '@/business/dashboard/types'; import { useFrameEntries } from '@/business/frame/hooks'; import { getScopedModules } from '@/business/app-shell/selectors'; +import { canPersistPersonalScopeResults } from '@/business/scope/selectors'; import { useTodayZoneCheckIn } from '@/business/zone-checkin/hooks'; import { canZoneCheckIn, shouldNudgeZoneCheckIn } from '@/business/zone-checkin/selectors'; import { useScopeContext } from '@/shared/app/scope-context'; @@ -38,7 +39,7 @@ export function useDashboardPage({ setZoneCheckIn, }: DashboardProps): DashboardPage { const { user } = useAuth(); - const { tier, selectedTenant } = useScopeContext(); + const { tier, ownTenant, selectedTenant } = useScopeContext(); const [dashboardDate] = useState(() => new Date()); const quotesQuery = useContentCatalogPayload( CONTENT_CATALOG_TYPES.dashboardEncouragingQuotes, @@ -53,7 +54,7 @@ export function useDashboardPage({ null, ); const frameEntriesQuery = useFrameEntries(); - const canUseZoneCheckIn = canZoneCheckIn(user); + const canUseZoneCheckIn = canPersistPersonalScopeResults(ownTenant, selectedTenant) && canZoneCheckIn(user); const zoneCheckInState = useTodayZoneCheckIn({ enabled: canUseZoneCheckIn }); const communicationEventsQuery = useCommunicationEvents(); const upcomingEvents = useMemo( @@ -62,7 +63,7 @@ export function useDashboardPage({ ); const activeZone = selectDashboardActiveZone(zoneCheckIn, zoneCheckInState.todayZone); const needsZoneCheckIn = shouldNudgeZoneCheckIn( - user, + canUseZoneCheckIn ? user : null, zoneCheckInState.isLoading, zoneCheckInState.isCheckedInToday, ); diff --git a/frontend/src/business/director-dashboard/hooks.ts b/frontend/src/business/director-dashboard/hooks.ts index 8f74b0f..779fb52 100644 --- a/frontend/src/business/director-dashboard/hooks.ts +++ b/frontend/src/business/director-dashboard/hooks.ts @@ -3,6 +3,7 @@ import { useState } from 'react'; import { useFrameEntries } from '@/business/frame/hooks'; import { useSafetyQuizCompliance } from '@/business/safety-quiz/hooks'; import { usePersonalityCompletion } from '@/business/personality/queryHooks'; +import { useZoneCheckInCompletion } from '@/business/zone-checkin/hooks'; import { useStaffAttendanceRecords, useStaffAttendanceSummary, @@ -22,25 +23,31 @@ import { import { useAuth } from '@/shared/app/useAuth'; import { getLeadershipDashboardName } from '@/business/app-shell/selectors'; import { getActiveTenant } from '@/business/scope/selectors'; +import { useScopeContext } from '@/shared/app/scope-context'; import { DEFAULT_PRODUCT_ROLE } from '@/shared/constants/roles'; import { getCurrentSafetyQuizWeek } from '@/business/safety-quiz/selectors'; export function useDirectorDashboardPage(): DirectorDashboardPage { const { user, profile } = useAuth(); + const { effectiveTenant } = useScopeContext(); const role = profile?.role ?? DEFAULT_PRODUCT_ROLE; const title = getLeadershipDashboardName(role); - const scopeLabel = getActiveTenant(user)?.name ?? ''; + const activeTenant = effectiveTenant ?? getActiveTenant(user); + const scopeLabel = activeTenant?.name ?? ''; + const scopeKey = activeTenant ? `${activeTenant.level}:${activeTenant.id}` : null; const [timeRange, setTimeRangeState] = useState('month'); const safetyQuizWeek = getCurrentSafetyQuizWeek(new Date()); const frameEntriesQuery = useFrameEntries(); const quizCompletionQuery = useSafetyQuizCompliance(safetyQuizWeek, true); const emotionalIntelligenceCompletionQuery = usePersonalityCompletion(true); + const zoneCheckinCompletionQuery = useZoneCheckInCompletion(true, scopeKey); const staffAttendanceRecordsQuery = useStaffAttendanceRecords(); const staffAttendanceSummaryQuery = useStaffAttendanceSummary(); const acknowledgmentReportQuery = usePolicyAcknowledgmentReport(); const frameEntries = frameEntriesQuery.data ?? []; const quizRows = quizCompletionQuery.data?.rows ?? []; const emotionalIntelligenceCompletion = emotionalIntelligenceCompletionQuery.data ?? null; + const zoneCheckinCompletion = zoneCheckinCompletionQuery.data ?? null; const quizSummary = quizCompletionQuery.data?.summary ?? { totalStaff: 0, completedCount: 0, @@ -51,12 +58,14 @@ export function useDirectorDashboardPage(): DirectorDashboardPage { const isLoading = frameEntriesQuery.isLoading || quizCompletionQuery.isLoading || 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; @@ -75,10 +84,11 @@ export function useDirectorDashboardPage(): DirectorDashboardPage { attendanceRecords, quizSummary, emotionalIntelligenceCompletion, + zoneCheckinCompletion, ), framePreviews: buildDirectorFramePreviews(frameEntries), quickActions: DIRECTOR_DASHBOARD_QUICK_ACTIONS, - quizResults: buildDirectorQuizResults(quizRows, emotionalIntelligenceCompletion), + quizResults: buildDirectorQuizResults(quizRows, emotionalIntelligenceCompletion, zoneCheckinCompletion), isLoading, error, setTimeRange: setTimeRangeState, diff --git a/frontend/src/business/director-dashboard/selectors.test.ts b/frontend/src/business/director-dashboard/selectors.test.ts index d3ea8dc..53f486a 100644 --- a/frontend/src/business/director-dashboard/selectors.test.ts +++ b/frontend/src/business/director-dashboard/selectors.test.ts @@ -14,6 +14,7 @@ import type { SafetyQuizComplianceRow, } from '@/business/safety-quiz/types'; import type { PersonalityCompletionDto } from '@/shared/types/personality'; +import type { ZoneCheckinCompletionDto } from '@/shared/types/zoneCheckins'; function createAttendanceRecord( overrides: Partial = {}, @@ -128,6 +129,35 @@ function createPersonalityCompletion( }; } +function createZoneCheckinCompletion( + overrides: Partial = {}, +): ZoneCheckinCompletionDto { + return { + summary: { + totalStaff: 2, + completedCount: 1, + pendingCount: 1, + greenCount: 0, + nonGreenCount: 1, + completionRate: 50, + }, + rows: [ + { + userId: 'user-1', + name: 'Ava Lee', + email: 'ava@example.test', + role: 'Teacher', + date: '2026-06-18', + status: 'complete', + zone: 'yellow', + riskLevel: 'medium', + result: 'Yellow Zone', + }, + ], + ...overrides, + }; +} + describe('director dashboard selectors', () => { it('calculates quiz completion rate with empty staff protection', () => { expect(calculateQuizCompletionRate(createQuizSummary({ completionRate: 0 }))).toBe(0); @@ -172,6 +202,8 @@ describe('director dashboard selectors', () => { createAttendanceRecord({ id: '4', status: 'absent' }), ], createQuizSummary({ completedCount: 1, pendingCount: 5, totalStaff: 6, completionRate: 17 }), + null, + createZoneCheckinCompletion(), ); expect(risks).toEqual([ @@ -181,14 +213,14 @@ describe('director dashboard selectors', () => { module: 'qbs', }, { - issue: "0 staff haven't completed EI self-assessment", - severity: 'low', - module: 'ei', + issue: "1 staff haven't completed daily zone check-in", + severity: 'medium', + module: 'zones', }, { - issue: "0 staff haven't completed personality type quiz", - severity: 'low', - module: 'ei', + issue: 'Non-green regulation zones: Ava Lee (Yellow Zone)', + severity: 'medium', + module: 'zones', }, { issue: '4 absences recorded this period', @@ -198,7 +230,49 @@ describe('director dashboard selectors', () => { ]); }); - it('combines safety, EI assessment, and personality quiz results in one list', () => { + it('hides resolved risk cards with zero counts', () => { + const risks = buildDirectorRiskAreas( + [], + createQuizSummary({ completedCount: 2, pendingCount: 0, totalStaff: 2, completionRate: 100 }), + createPersonalityCompletion({ + summary: { + totalStaff: 2, + completedCount: 2, + pendingCount: 0, + selfAssessmentCompletedCount: 2, + personalityCompletedCount: 2, + completionRate: 100, + }, + }), + createZoneCheckinCompletion({ + summary: { + totalStaff: 2, + completedCount: 2, + pendingCount: 0, + greenCount: 2, + nonGreenCount: 0, + completionRate: 100, + }, + rows: [ + { + userId: 'user-1', + name: 'Ava Lee', + email: 'ava@example.test', + role: 'Teacher', + date: '2026-06-18', + status: 'complete', + zone: 'green', + riskLevel: 'none', + result: 'Green Zone', + }, + ], + }), + ); + + expect(risks).toEqual([]); + }); + + it('combines safety, EI assessment, personality, and zone check-in results in one list', () => { const rows = buildDirectorQuizResults( [ { @@ -211,17 +285,20 @@ describe('director dashboard selectors', () => { } satisfies SafetyQuizComplianceRow, ], createPersonalityCompletion(), + createZoneCheckinCompletion(), ); expect(rows.map((row) => row.quiz)).toEqual([ 'Behavior Management', 'EI Self-Assessment', 'Personality Type Quiz', + 'Daily Zone Check-In', ]); expect(rows.map((row) => row.result)).toEqual([ '3/5', 'Developing Awareness (14/32)', 'ENFP', + 'Yellow Zone', ]); }); diff --git a/frontend/src/business/director-dashboard/selectors.ts b/frontend/src/business/director-dashboard/selectors.ts index 819dc6c..a75e7bd 100644 --- a/frontend/src/business/director-dashboard/selectors.ts +++ b/frontend/src/business/director-dashboard/selectors.ts @@ -26,6 +26,7 @@ import type { PersonalityCompletionDto, PersonalityQuizResultDto, } from '@/shared/types/personality'; +import type { ZoneCheckinCompletionDto } from '@/shared/types/zoneCheckins'; export function calculateQuizCompletionRate( quizSummary: SafetyQuizCompletionSummary, @@ -96,6 +97,7 @@ export function buildDirectorRiskAreas( attendanceRecords: readonly StaffAttendanceRecordViewModel[], quizSummary: SafetyQuizCompletionSummary, emotionalIntelligenceCompletion?: PersonalityCompletionDto | null, + zoneCheckinCompletion?: ZoneCheckinCompletionDto | null, ): readonly DirectorRiskArea[] { const incompleteStaffCount = quizSummary.pendingCount; const absenceCount = countStaffAttendanceStatus(attendanceRecords, 'absent'); @@ -107,34 +109,84 @@ export function buildDirectorRiskAreas( ? emotionalIntelligenceCompletion.summary.totalStaff - emotionalIntelligenceCompletion.summary.personalityCompletedCount : 0; + const zonePendingCount = zoneCheckinCompletion?.summary.pendingCount ?? 0; + const nonGreenZoneNote = getNonGreenZoneNote(zoneCheckinCompletion); - return [ - { + const risks: DirectorRiskArea[] = []; + + if (incompleteStaffCount > 0) { + risks.push({ issue: `${incompleteStaffCount} staff haven't completed de-escalation quiz`, severity: incompleteStaffCount > DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD ? 'high' : 'medium', module: 'qbs', - }, - { + }); + } + + if (selfAssessmentPendingCount > 0) { + risks.push({ issue: `${selfAssessmentPendingCount} staff haven't completed EI self-assessment`, severity: 'low', module: 'ei', - }, - { + }); + } + + if (personalityPendingCount > 0) { + risks.push({ issue: `${personalityPendingCount} staff haven't completed personality type quiz`, severity: 'low', module: 'ei', - }, - { + }); + } + + if (zonePendingCount > 0) { + risks.push({ + issue: `${zonePendingCount} staff haven't completed daily zone check-in`, + severity: 'medium', + module: 'zones', + }); + } + + if (nonGreenZoneNote) { + risks.push({ + issue: nonGreenZoneNote, + severity: 'medium', + module: 'zones', + }); + } + + if (absenceCount > 0) { + risks.push({ issue: `${absenceCount} absences recorded this period`, severity: absenceCount > DIRECTOR_DASHBOARD_HIGH_ABSENCE_THRESHOLD ? 'high' : 'low', module: 'attendance', - }, - ]; + }); + } + + return risks; +} + +function getNonGreenZoneNote( + zoneCheckinCompletion?: ZoneCheckinCompletionDto | null, +): string | null { + const nonGreenRows = zoneCheckinCompletion?.rows + .filter((row) => row.status === 'complete' && row.zone !== null && row.zone !== 'green') + ?? []; + + if (nonGreenRows.length === 0) { + return null; + } + + const staffNotes = nonGreenRows + .map((row) => `${row.name} (${row.result})`) + .join(', '); + + return `Non-green regulation zones: ${staffNotes}`; } export function buildDirectorQuizResults( safetyRows: readonly SafetyQuizComplianceRow[], emotionalIntelligenceCompletion?: PersonalityCompletionDto | null, + zoneCheckinCompletion?: ZoneCheckinCompletionDto | null, ): readonly DirectorQuizResultRow[] { const behaviorRows = safetyRows.map((row): DirectorQuizResultRow => ({ id: `${row.userId}-behavior-management`, @@ -166,8 +218,19 @@ export function buildDirectorQuizResults( status: row.personality ? 'complete' as const : 'pending' as const, }, ]) ?? []; + const zoneRows = zoneCheckinCompletion?.rows.map((row): DirectorQuizResultRow => ({ + id: `${row.userId}-daily-zone-check-in`, + staffName: row.name, + role: row.role ?? 'Staff', + quiz: 'Daily Zone Check-In', + result: row.result, + date: row.status === 'complete' + ? new Date(`${row.date}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + : 'Not completed', + status: row.status, + })) ?? []; - return [...behaviorRows, ...emotionalIntelligenceRows]; + return [...behaviorRows, ...emotionalIntelligenceRows, ...zoneRows]; } export function buildDirectorFramePreviews( diff --git a/frontend/src/business/profile/selectors.test.ts b/frontend/src/business/profile/selectors.test.ts index 2ea10b2..8389b75 100644 --- a/frontend/src/business/profile/selectors.test.ts +++ b/frontend/src/business/profile/selectors.test.ts @@ -63,11 +63,13 @@ describe('profile selectors', () => { 'Behavior Management Review', 'EI Self-Assessment', 'Personality Type Quiz', + 'Daily Zone Check-In', ]); expect(rows.map((row) => row.result)).toEqual([ '3/5', 'Developing Awareness (14/32)', 'ENFP', + 'Pending', ]); }); @@ -78,6 +80,22 @@ describe('profile selectors', () => { { quiz: 'Behavior Management', result: 'Pending', status: 'pending' }, { quiz: 'EI Self-Assessment', result: 'Pending', status: 'pending' }, { quiz: 'Personality Type Quiz', result: 'Pending', status: 'pending' }, + { quiz: 'Daily Zone Check-In', result: 'Pending', status: 'pending' }, ]); }); + + it('shows the daily zone check-in when present', () => { + const rows = buildProfileQuizResultRows(null, [], { + date: '2026-06-18', + zone: 'yellow', + isCheckedInToday: true, + }); + + expect(rows[rows.length - 1]).toMatchObject({ + quiz: 'Daily Zone Check-In', + category: 'Regulation zone', + result: 'Yellow Zone', + status: 'complete', + }); + }); }); diff --git a/frontend/src/business/profile/selectors.ts b/frontend/src/business/profile/selectors.ts index 22f0740..3eb0989 100644 --- a/frontend/src/business/profile/selectors.ts +++ b/frontend/src/business/profile/selectors.ts @@ -1,6 +1,7 @@ import { PERSONALITY_QUIZ_KINDS } from '@/shared/constants/personality'; import type { PersonalityQuizResultViewModel } from '@/business/personality/types'; import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz'; +import type { ZoneCheckinTodayDto } from '@/shared/types/zoneCheckins'; export interface ProfileQuizResultRow { readonly id: string; @@ -14,6 +15,8 @@ export interface ProfileQuizResultRow { export function buildProfileQuizResultRows( safetyQuizResult: SafetyQuizResultDto | null, personalityResults: readonly PersonalityQuizResultViewModel[], + zoneCheckinToday: ZoneCheckinTodayDto | null = null, + includeZoneCheckin = true, ): readonly ProfileQuizResultRow[] { const rows: ProfileQuizResultRow[] = [ toProfileSafetyQuizRow(safetyQuizResult), @@ -28,6 +31,10 @@ export function buildProfileQuizResultRows( rows.push(toProfilePendingPersonalityQuizRow(PERSONALITY_QUIZ_KINDS.personalityType)); } + if (includeZoneCheckin) { + rows.push(toProfileZoneCheckinRow(zoneCheckinToday)); + } + return rows; } @@ -106,3 +113,27 @@ function toProfilePendingPersonalityQuizRow( status: 'pending', }; } + +function toProfileZoneCheckinRow( + checkin: ZoneCheckinTodayDto | null, +): ProfileQuizResultRow { + if (!checkin?.zone) { + return { + id: 'daily-zone-check-in-pending', + quiz: 'Daily Zone Check-In', + category: 'Regulation zone', + result: 'Pending', + completed: 'Not completed', + status: 'pending', + }; + } + + return { + id: `daily-zone-check-in-${checkin.date}`, + quiz: 'Daily Zone Check-In', + category: 'Regulation zone', + result: `${checkin.zone[0].toUpperCase()}${checkin.zone.slice(1)} Zone`, + completed: new Date(`${checkin.date}T00:00:00`).toLocaleDateString(), + status: 'complete', + }; +} diff --git a/frontend/src/business/top-bar/hooks.ts b/frontend/src/business/top-bar/hooks.ts index 0419afb..f98da23 100644 --- a/frontend/src/business/top-bar/hooks.ts +++ b/frontend/src/business/top-bar/hooks.ts @@ -74,7 +74,7 @@ export function useTopBarPage({ const canUseZoneCheckIn = canPersistPersonalResults && canZoneCheckIn(user); const zoneCheckIn = useTodayZoneCheckIn({ enabled: canUseZoneCheckIn }); const needsZoneCheckIn = shouldNudgeZoneCheckIn( - user, + canUseZoneCheckIn ? user : null, zoneCheckIn.isLoading, zoneCheckIn.isCheckedInToday, ); diff --git a/frontend/src/business/zone-checkin/hooks.ts b/frontend/src/business/zone-checkin/hooks.ts index 635d219..9ec7060 100644 --- a/frontend/src/business/zone-checkin/hooks.ts +++ b/frontend/src/business/zone-checkin/hooks.ts @@ -3,6 +3,7 @@ import { checkInZone, clearTodayZoneCheckin, getTodayZoneCheckin, + getZoneCheckinCompletion, listZoneCheckinHistory, } from '@/shared/api/zoneCheckins'; import { getApiListRows } from '@/shared/business/apiListRows'; @@ -12,6 +13,7 @@ import type { ZoneColor } from '@/shared/types/app'; export const ZONE_CHECKIN_QUERY_KEYS = { today: ['zoneCheckin', 'today'], history: ['zoneCheckin', 'history'], + completion: (scopeKey: string | null) => ['zoneCheckin', 'completion', scopeKey], } as const; /** @@ -19,10 +21,11 @@ export const ZONE_CHECKIN_QUERY_KEYS = { * campus timezone, so this hook never computes a date. */ export function useTodayZoneCheckIn(options: { readonly enabled?: boolean } = {}) { + const enabled = options.enabled ?? true; const todayQuery = useQuery({ queryKey: ZONE_CHECKIN_QUERY_KEYS.today, queryFn: getTodayZoneCheckin, - enabled: options.enabled ?? true, + enabled, retry: false, }); @@ -37,9 +40,10 @@ export function useTodayZoneCheckIn(options: { readonly enabled?: boolean } = {} }); return { - todayZone: todayQuery.data?.zone ?? null, - isCheckedInToday: todayQuery.data?.isCheckedInToday ?? false, - isLoading: todayQuery.isLoading, + todayDate: enabled ? todayQuery.data?.date ?? null : null, + todayZone: enabled ? todayQuery.data?.zone ?? null : null, + isCheckedInToday: enabled ? todayQuery.data?.isCheckedInToday ?? false : false, + isLoading: enabled && todayQuery.isLoading, isSaving: saveMutation.isPending || clearMutation.isPending, // Only surface actionable mutation (save/clear) errors. The today-load query // can 403 for a non-eligible role or before seeding; that is non-actionable @@ -58,3 +62,13 @@ export function useZoneCheckInHistory() { retry: false, }); } + +/** Today's zone check-in completion report for the current leadership scope. */ +export function useZoneCheckInCompletion(enabled: boolean, scopeKey: string | null) { + return useQuery({ + queryKey: ZONE_CHECKIN_QUERY_KEYS.completion(scopeKey), + queryFn: getZoneCheckinCompletion, + enabled, + retry: false, + }); +} diff --git a/frontend/src/business/zone-checkin/selectors.ts b/frontend/src/business/zone-checkin/selectors.ts index 4cc5887..b2c781f 100644 --- a/frontend/src/business/zone-checkin/selectors.ts +++ b/frontend/src/business/zone-checkin/selectors.ts @@ -1,10 +1,7 @@ import { hasPermission } from '@/business/auth/permissions'; import type { CurrentUser } from '@/shared/types/auth'; -/** - * Daily zone check-in is a campus-staff workflow. Global/full-access roles may - * technically pass permission checks, but they should not get self-state nudges. - */ +/** Daily zone check-in requires the explicit effective ZONE_CHECKIN permission. */ export function canZoneCheckIn(user: CurrentUser | null | undefined): boolean { return hasPermission(user, 'ZONE_CHECKIN'); } diff --git a/frontend/src/business/zones/hooks.ts b/frontend/src/business/zones/hooks.ts index 013633a..e792361 100644 --- a/frontend/src/business/zones/hooks.ts +++ b/frontend/src/business/zones/hooks.ts @@ -1,31 +1,42 @@ import { useMemo, useState } from 'react'; import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; +import { canPersistPersonalScopeResults } from '@/business/scope/selectors'; +import { useTodayZoneCheckIn } from '@/business/zone-checkin/hooks'; +import { canZoneCheckIn } from '@/business/zone-checkin/selectors'; import { getSelectedZone, getZonesSafetyConnection, - toggleExpandedZone as toggleExpandedZoneValue, } from '@/business/zones/selectors'; import type { ZonesOfRegulationPage } from '@/business/zones/types'; +import { useScopeContext } from '@/shared/app/scope-context'; import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; import { DEFAULT_EXPANDED_ZONE, DEFAULT_ZONES_TAB, } from '@/shared/constants/zonesOfRegulation'; import type { ZonesOfRegulationTab } from '@/shared/constants/zonesOfRegulation'; +import type { + CurrentUser, +} from '@/shared/types/auth'; import type { ZoneColor, ZoneInfo, ZonesOfRegulationPageContent, } from '@/shared/types/app'; +import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; const EMPTY_ZONES_PAGE_CONTENT: ZonesOfRegulationPageContent = { safetyConnections: [], - quickDeEscalationFlowTitle: '', - quickDeEscalationFlow: [], }; -export function useZonesOfRegulationPage(): ZonesOfRegulationPage { +interface UseZonesOfRegulationPageOptions { + readonly user: CurrentUser | null | undefined; +} + +export function useZonesOfRegulationPage(options: UseZonesOfRegulationPageOptions): ZonesOfRegulationPage { + const { user } = options; + const { ownTenant, selectedTenant } = useScopeContext(); const zonesQuery = useContentCatalogPayload( CONTENT_CATALOG_TYPES.regulationZones, [], @@ -36,32 +47,41 @@ export function useZonesOfRegulationPage(): ZonesOfRegulationPage { ); const [expandedZone, setExpandedZone] = useState(DEFAULT_EXPANDED_ZONE); const [activeTab, setActiveTab] = useState(DEFAULT_ZONES_TAB); + const canUseZoneCheckIn = canPersistPersonalScopeResults(ownTenant, selectedTenant) && canZoneCheckIn(user); + const zoneCheckIn = useTodayZoneCheckIn({ enabled: canUseZoneCheckIn }); const zones = zonesQuery.payload; const pageContent = pageContentQuery.payload; + const checkedInZone = zoneCheckIn.todayZone; const selectedZone = useMemo( - () => getSelectedZone(zones, expandedZone), - [expandedZone, zones], + () => getSelectedZone(zones, expandedZone ?? checkedInZone), + [checkedInZone, expandedZone, zones], ); const safetyConnection = useMemo( () => getZonesSafetyConnection(pageContent.safetyConnections, selectedZone?.color ?? null), [pageContent.safetyConnections, selectedZone?.color], ); - function toggleExpandedZone(zone: ZoneColor) { - setExpandedZone((currentZone) => toggleExpandedZoneValue(currentZone, zone)); + async function selectZone(zone: ZoneColor) { + setExpandedZone(zone); + if (canUseZoneCheckIn) { + await zoneCheckIn.setZone(zone); + } } return { zones, pageContent, expandedZone, + checkedInZone, selectedZone, activeTab, safetyConnection, isLoading: zonesQuery.isLoading || pageContentQuery.isLoading, + isZoneSaving: zoneCheckIn.isSaving, zonesError: zonesQuery.error, pageContentError: pageContentQuery.error, + zoneCheckInErrorMessage: getOptionalErrorMessage(zoneCheckIn.error), setActiveTab, - toggleExpandedZone, + selectZone, }; } diff --git a/frontend/src/business/zones/types.ts b/frontend/src/business/zones/types.ts index 162c14f..e27d9ee 100644 --- a/frontend/src/business/zones/types.ts +++ b/frontend/src/business/zones/types.ts @@ -10,12 +10,15 @@ export interface ZonesOfRegulationPage { readonly zones: readonly ZoneInfo[]; readonly pageContent: ZonesOfRegulationPageContent; readonly expandedZone: ZoneColor | null; + readonly checkedInZone: ZoneColor | null; readonly selectedZone: ZoneInfo | null; readonly activeTab: ZonesOfRegulationTab; readonly safetyConnection: ZonesSafetyConnection | null; readonly isLoading: boolean; + readonly isZoneSaving: boolean; readonly zonesError: Error | null; readonly pageContentError: Error | null; + readonly zoneCheckInErrorMessage: string | null; readonly setActiveTab: (tab: ZonesOfRegulationTab) => void; - readonly toggleExpandedZone: (zone: ZoneColor) => void; + readonly selectZone: (zone: ZoneColor) => Promise; } diff --git a/frontend/src/components/director-dashboard/DirectorRiskList.tsx b/frontend/src/components/director-dashboard/DirectorRiskList.tsx index c20f190..32740b9 100644 --- a/frontend/src/components/director-dashboard/DirectorRiskList.tsx +++ b/frontend/src/components/director-dashboard/DirectorRiskList.tsx @@ -31,15 +31,15 @@ export function DirectorRiskList({ key={`${risk.module}-${risk.issue}`} type="button" onClick={() => onOpenModule(risk.module)} - className={`w-full h-auto text-left p-4 rounded-xl border ${directorRiskSeverityClasses[risk.severity]} flex items-center justify-between hover:shadow-sm transition-all`} + className={`w-full h-auto text-left p-4 rounded-xl border ${directorRiskSeverityClasses[risk.severity]} flex items-center justify-between gap-3 hover:shadow-sm transition-all`} > -
+
{risk.severity} -

{risk.issue}

+

{risk.issue}

- + ))}
diff --git a/frontend/src/components/frameworks/ZonesOfRegulation.tsx b/frontend/src/components/frameworks/ZonesOfRegulation.tsx index 2989b45..920f780 100644 --- a/frontend/src/components/frameworks/ZonesOfRegulation.tsx +++ b/frontend/src/components/frameworks/ZonesOfRegulation.tsx @@ -1,16 +1,12 @@ import { useZonesOfRegulationPage } from '@/business/zones/hooks'; import { ZonesOfRegulationView } from '@/components/zones-of-regulation/ZonesOfRegulationView'; -import { ZoneCheckInSection } from '@/components/zone-checkin/ZoneCheckInSection'; +import { useAuth } from '@/contexts/useAuth'; const ZonesOfRegulation = () => { - const page = useZonesOfRegulationPage(); + const { user } = useAuth(); + const page = useZonesOfRegulationPage({ user }); - return ( -
- - -
- ); + return ; }; export default ZonesOfRegulation; diff --git a/frontend/src/components/zone-checkin/ZoneCheckInCard.tsx b/frontend/src/components/zone-checkin/ZoneCheckInCard.tsx index 9525a58..3bafef3 100644 --- a/frontend/src/components/zone-checkin/ZoneCheckInCard.tsx +++ b/frontend/src/components/zone-checkin/ZoneCheckInCard.tsx @@ -57,15 +57,14 @@ export function ZoneCheckInCard({
{zones.map((zone) => ( - + ))}
{errorMessage &&

{errorMessage}

} diff --git a/frontend/src/components/zone-checkin/ZoneCheckInSection.tsx b/frontend/src/components/zone-checkin/ZoneCheckInSection.tsx index 618f128..005426c 100644 --- a/frontend/src/components/zone-checkin/ZoneCheckInSection.tsx +++ b/frontend/src/components/zone-checkin/ZoneCheckInSection.tsx @@ -9,9 +9,10 @@ import { ZoneCheckInCard } from '@/components/zone-checkin/ZoneCheckInCard'; import { ZoneCheckInReminder } from '@/components/zone-checkin/ZoneCheckInReminder'; /** - * Self-contained daily Zone check-in (reminder banner + card) for the Zones of - * Regulation page. Owns its own state via `useTodayZoneCheckIn`; rendered only - * when the current user has `ZONE_CHECKIN`. + * Self-contained daily Zone check-in (reminder banner + card) for surfaces that + * need the composed check-in widget. Owns its own state via + * `useTodayZoneCheckIn`; rendered only when the user can save in the current + * scope. */ export function ZoneCheckInSection() { const { user } = useAuth(); diff --git a/frontend/src/components/zones-of-regulation/ZoneOverviewCard.tsx b/frontend/src/components/zones-of-regulation/ZoneOverviewCard.tsx index 6d8bddc..9ac9331 100644 --- a/frontend/src/components/zones-of-regulation/ZoneOverviewCard.tsx +++ b/frontend/src/components/zones-of-regulation/ZoneOverviewCard.tsx @@ -1,4 +1,3 @@ -import { Button } from '@/components/ui/button'; import { ZONE_GRADIENT_CLASSES, ZONE_RING_CLASSES, @@ -12,32 +11,36 @@ import { cn } from '@/lib/utils'; interface ZoneOverviewCardProps { readonly zone: ZoneInfo; readonly expandedZone: ZoneColor | null; - readonly onToggle: (zone: ZoneColor) => void; + readonly checkedInZone: ZoneColor | null; + readonly isSaving: boolean; + readonly onSelect: (zone: ZoneColor) => Promise; } export function ZoneOverviewCard({ zone, expandedZone, - onToggle, + checkedInZone, + isSaving, + onSelect, }: ZoneOverviewCardProps) { - const selected = expandedZone === zone.color; + const selected = (expandedZone ?? checkedInZone) === zone.color; return ( - + ); } diff --git a/frontend/src/components/zones-of-regulation/ZonesHeader.tsx b/frontend/src/components/zones-of-regulation/ZonesHeader.tsx index 28decb5..8a6baea 100644 --- a/frontend/src/components/zones-of-regulation/ZonesHeader.tsx +++ b/frontend/src/components/zones-of-regulation/ZonesHeader.tsx @@ -9,8 +9,7 @@ export function ZonesHeader() { description="A whole-campus emotional regulation system for students and staff" icon={Layers} iconClassName="bg-gradient-to-br from-teal-400 to-teal-600" - titleClassName="text-gray-800" - descriptionClassName="text-gray-500" + iconShadowClassName="shadow-teal-500/25" /> ); } diff --git a/frontend/src/components/zones-of-regulation/ZonesOfRegulationView.tsx b/frontend/src/components/zones-of-regulation/ZonesOfRegulationView.tsx index 7cf6c14..5d51099 100644 --- a/frontend/src/components/zones-of-regulation/ZonesOfRegulationView.tsx +++ b/frontend/src/components/zones-of-regulation/ZonesOfRegulationView.tsx @@ -46,7 +46,10 @@ export function ZonesOfRegulationView({ page }: ZonesOfRegulationViewProps) { )} - + ); } diff --git a/frontend/src/components/zones-of-regulation/ZonesOverviewGrid.tsx b/frontend/src/components/zones-of-regulation/ZonesOverviewGrid.tsx index 5f7362e..af9165a 100644 --- a/frontend/src/components/zones-of-regulation/ZonesOverviewGrid.tsx +++ b/frontend/src/components/zones-of-regulation/ZonesOverviewGrid.tsx @@ -7,24 +7,35 @@ import type { interface ZonesOverviewGridProps { readonly zones: readonly ZoneInfo[]; readonly expandedZone: ZoneColor | null; - readonly onToggleZone: (zone: ZoneColor) => void; + readonly checkedInZone: ZoneColor | null; + readonly isSaving: boolean; + readonly errorMessage: string | null; + readonly onSelectZone: (zone: ZoneColor) => Promise; } export function ZonesOverviewGrid({ zones, expandedZone, - onToggleZone, + checkedInZone, + isSaving, + errorMessage, + onSelectZone, }: ZonesOverviewGridProps) { return ( -
- {zones.map((zone) => ( - - ))} +
+
+ {zones.map((zone) => ( + + ))} +
+ {errorMessage &&

{errorMessage}

}
); } diff --git a/frontend/src/components/zones-of-regulation/ZonesQuickFlow.tsx b/frontend/src/components/zones-of-regulation/ZonesQuickFlow.tsx index 4393d6d..d19af82 100644 --- a/frontend/src/components/zones-of-regulation/ZonesQuickFlow.tsx +++ b/frontend/src/components/zones-of-regulation/ZonesQuickFlow.tsx @@ -1,28 +1,60 @@ -import type { ZonesDeEscalationStep } from '@/shared/types/app'; -import { cn } from '@/lib/utils'; - -interface ZonesQuickFlowProps { - readonly title: string; - readonly steps: readonly ZonesDeEscalationStep[]; -} - -export function ZonesQuickFlow({ title, steps }: ZonesQuickFlowProps) { - if (!title || steps.length === 0) { - return null; - } +const QUICK_FLOW_STEPS = [ + { + step: '1', + label: 'Notice the Zone', + description: 'Identify current zone', + cardClassName: 'border-teal-200 bg-teal-100 text-teal-800', + badgeClassName: 'bg-teal-50 text-teal-800', + }, + { + step: '2', + label: 'Reduce Demands', + description: 'Lower expectations immediately', + cardClassName: 'border-blue-200 bg-blue-100 text-blue-800', + badgeClassName: 'bg-blue-50 text-blue-800', + }, + { + step: '3', + label: 'Offer Support', + description: 'Signs, choices, or space', + cardClassName: 'border-amber-200 bg-amber-100 text-amber-800', + badgeClassName: 'bg-amber-50 text-amber-800', + }, + { + step: '4', + label: 'Wait & Monitor', + description: 'Give processing time', + cardClassName: 'border-violet-200 bg-violet-100 text-violet-800', + badgeClassName: 'bg-violet-50 text-violet-800', + }, + { + step: '5', + label: 'Reconnect', + description: 'Return to Green Zone', + cardClassName: 'border-emerald-200 bg-emerald-100 text-emerald-800', + badgeClassName: 'bg-emerald-50 text-emerald-800', + }, +] as const; +export function ZonesQuickFlow() { return (
-

{title}

+

Quick Behavior Management Flow

- {steps.map((step) => ( + {QUICK_FLOW_STEPS.map((step) => (
-
-
+
+
{step.step}

{step.label}

-

{step.description}

+

{step.description}

))} diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index e582441..875eafd 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -26,6 +26,8 @@ import { USER_NAME_PREFIX_OPTIONS } from '@/shared/constants/users'; import { ImageUpload } from '@/components/common/ImageUpload'; import { useCurrentPersonalityResultHistory } from '@/business/personality/queryHooks'; import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks'; +import { useTodayZoneCheckIn } from '@/business/zone-checkin/hooks'; +import { canZoneCheckIn } from '@/business/zone-checkin/selectors'; interface StatusMessage { readonly type: 'success' | 'error'; @@ -70,6 +72,8 @@ export default function ProfilePage() { const capabilitiesQuery = useIamCapabilities(); const safetyQuizStatus = useMySafetyQuizStatus(undefined, Boolean(user)); const personalityHistoryStatus = useCurrentPersonalityResultHistory(Boolean(user)); + const canUseZoneCheckin = canZoneCheckIn(user); + const zoneCheckinStatus = useTodayZoneCheckIn({ enabled: canUseZoneCheckin }); const [namePrefix, setNamePrefix] = useState(user?.name_prefix ?? ''); const [firstName, setFirstName] = useState(user?.firstName ?? ''); @@ -112,8 +116,23 @@ export default function ProfilePage() { () => buildProfileQuizResultRows( safetyQuizStatus.data?.result ?? null, personalityHistoryStatus.data ?? [], + zoneCheckinStatus.isCheckedInToday && zoneCheckinStatus.todayZone + ? { + date: zoneCheckinStatus.todayDate ?? new Date().toISOString().slice(0, 10), + zone: zoneCheckinStatus.todayZone, + isCheckedInToday: true, + } + : null, + canUseZoneCheckin, ), - [personalityHistoryStatus.data, safetyQuizStatus.data?.result], + [ + personalityHistoryStatus.data, + safetyQuizStatus.data?.result, + zoneCheckinStatus.isCheckedInToday, + zoneCheckinStatus.todayDate, + zoneCheckinStatus.todayZone, + canUseZoneCheckin, + ], ); if (!user) { @@ -372,7 +391,7 @@ export default function ProfilePage() {
- {safetyQuizStatus.isLoading || personalityHistoryStatus.isLoading ? ( + {safetyQuizStatus.isLoading || personalityHistoryStatus.isLoading || zoneCheckinStatus.isLoading ? (

Loading quiz results...

) : (
diff --git a/frontend/src/shared/api/zoneCheckins.test.ts b/frontend/src/shared/api/zoneCheckins.test.ts index a5bf991..77ff9c7 100644 --- a/frontend/src/shared/api/zoneCheckins.test.ts +++ b/frontend/src/shared/api/zoneCheckins.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { checkInZone, clearTodayZoneCheckin, + getZoneCheckinCompletion, getTodayZoneCheckin, listZoneCheckinHistory, } from '@/shared/api/zoneCheckins'; @@ -77,4 +78,35 @@ describe('zoneCheckins API', () => { expect(apiRequestMock).toHaveBeenCalledWith('/zone_checkins'); }); + + it('fetches scoped zone check-in completion', async () => { + const completion = { + summary: { + totalStaff: 2, + completedCount: 1, + pendingCount: 1, + greenCount: 0, + nonGreenCount: 1, + completionRate: 50, + }, + rows: [ + { + userId: 'user-1', + name: 'Ava Lee', + email: 'ava@example.test', + role: 'teacher', + date: '2026-06-18', + status: 'complete', + zone: 'yellow', + riskLevel: 'medium', + result: 'Yellow Zone', + }, + ], + } as const; + apiRequestMock.mockResolvedValueOnce(completion); + + await expect(getZoneCheckinCompletion()).resolves.toEqual(completion); + + expect(apiRequestMock).toHaveBeenCalledWith('/zone_checkins/completion'); + }); }); diff --git a/frontend/src/shared/api/zoneCheckins.ts b/frontend/src/shared/api/zoneCheckins.ts index 5725ba6..ca415e2 100644 --- a/frontend/src/shared/api/zoneCheckins.ts +++ b/frontend/src/shared/api/zoneCheckins.ts @@ -3,6 +3,7 @@ import type { ApiListResponse } from '@/shared/types/api'; import type { ZoneColor } from '@/shared/types/app'; import type { ZoneCheckinHistoryEntryDto, + ZoneCheckinCompletionDto, ZoneCheckinTodayDto, } from '@/shared/types/zoneCheckins'; @@ -32,3 +33,7 @@ export function listZoneCheckinHistory(): Promise< ZONE_CHECKINS_PATH, ); } + +export function getZoneCheckinCompletion(): Promise { + return apiRequest(`${ZONE_CHECKINS_PATH}/completion`); +} diff --git a/frontend/src/shared/auth/permissions.ts b/frontend/src/shared/auth/permissions.ts index 2577193..3457789 100644 --- a/frontend/src/shared/auth/permissions.ts +++ b/frontend/src/shared/auth/permissions.ts @@ -33,7 +33,7 @@ export const MODULE_PERMISSIONS = [ 'MANAGE_FRAME', 'MANAGE_WALKTHROUGH', 'MANAGE_INTERNAL_COMM', 'MANAGE_CONTENT_CATALOG', 'READ_STAFF_ATTENDANCE_REPORTS', 'READ_SAFETY_QUIZ_REPORTS', 'READ_PERSONALITY_REPORTS', - 'READ_POLICY_ACKNOWLEDGMENT_REPORTS', + 'READ_ZONE_CHECKIN_REPORTS', 'READ_POLICY_ACKNOWLEDGMENT_REPORTS', 'READ_AUDIO_FILES', 'MANAGE_AUDIO_FILES', ] as const; diff --git a/frontend/src/shared/types/app.ts b/frontend/src/shared/types/app.ts index 6fbf53a..01ced13 100644 --- a/frontend/src/shared/types/app.ts +++ b/frontend/src/shared/types/app.ts @@ -150,17 +150,8 @@ export interface ZonesSafetyConnection { readonly description: string; } -export interface ZonesDeEscalationStep { - readonly step: string; - readonly label: string; - readonly description: string; - readonly colorClass: string; -} - export interface ZonesOfRegulationPageContent { readonly safetyConnections: readonly ZonesSafetyConnection[]; - readonly quickDeEscalationFlowTitle: string; - readonly quickDeEscalationFlow: readonly ZonesDeEscalationStep[]; } export type ModuleId = diff --git a/frontend/src/shared/types/zoneCheckins.ts b/frontend/src/shared/types/zoneCheckins.ts index ec4e232..a985ca5 100644 --- a/frontend/src/shared/types/zoneCheckins.ts +++ b/frontend/src/shared/types/zoneCheckins.ts @@ -15,3 +15,29 @@ export interface ZoneCheckinHistoryEntryDto { readonly date: string; readonly zone: ZoneColor; } + +export interface ZoneCheckinCompletionSummaryDto { + readonly totalStaff: number; + readonly completedCount: number; + readonly pendingCount: number; + readonly greenCount: number; + readonly nonGreenCount: number; + readonly completionRate: number; +} + +export interface ZoneCheckinCompletionRowDto { + readonly userId: string; + readonly name: string; + readonly email: string | null; + readonly role: string | null; + readonly date: string; + readonly status: 'complete' | 'pending'; + readonly zone: ZoneColor | null; + readonly riskLevel: 'none' | 'medium' | 'pending'; + readonly result: string; +} + +export interface ZoneCheckinCompletionDto { + readonly summary: ZoneCheckinCompletionSummaryDto; + readonly rows: readonly ZoneCheckinCompletionRowDto[]; +}