improved zones of regulation functionality

This commit is contained in:
Dmitri 2026-06-19 08:44:30 +02:00
parent c397e97b9f
commit 2b496033cf
46 changed files with 1150 additions and 205 deletions

View File

@ -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. - 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. - `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. - 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. - Shared/global types use all-null tenant ids.
Management list excludes tenant-scoped content because those records are edited through dedicated per-type editors. 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. 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 ### Content authoring rules
- Add editable or tenant-scoped production content records to backend seed payloads, not frontend constants. - 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. - 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 ## 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 ## Related
- Frontend: `frontend/docs/content-catalog-integration.md`. - Frontend: `frontend/docs/content-catalog-integration.md`.

View File

@ -96,6 +96,8 @@ const req = createMockRequest({
| `services/shared/crud-service.test.ts` | CRUD factory | ~20 | | `services/shared/crud-service.test.ts` | CRUD factory | ~20 |
| `services/shared/role-policy.test.ts` | Role constraints | ~10 | | `services/shared/role-policy.test.ts` | Role constraints | ~10 |
| `services/shared/audio-access.test.ts` | Audio-library visibility/management rules | ~12 | | `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 | | `services/refresh-token-maintenance.test.ts` | Refresh-token retention cutoff + cleanup orchestration (mocked DB API) | ~4 |
### Domain constants / pure rules ### Domain constants / pure rules

View File

@ -1,17 +1,17 @@
# Daily Zone Check-in # Daily Zone Check-in
Workstream 16 — campus staff log a daily self-regulation "zone" (Zones of Workstream 16 — staff log a daily self-regulation "zone" (Zones of Regulation:
Regulation: blue/green/yellow/red). History is retained per day, and an eligible blue/green/yellow/red). History is retained per day, and an eligible user who has
user who has not checked in today is nudged on the dashboard, the not checked in today is nudged on the dashboard, the `/zones-of-regulation`
`/zones-of-regulation` page, and in the notification dropdown. page, and in the notification dropdown.
## "Today" is server-computed in the campus timezone ## "Today" is server-computed in the campus timezone
The check-in date is **not** decided by the client. Each campus carries a The check-in date is **not** decided by the client. Each campus carries a
required IANA `timezone` (`campuses.timezone`); the service computes the required IANA `timezone` (`campuses.timezone`); the service computes the
campus-local date (`localDateInTimezone`, native `Intl`, DST-correct) so "today" campus-local date (`localDateInTimezone`, native `Intl`, DST-correct). Users
is independent of the caller's device clock/zone and correct across without a campus use UTC for the daily key, so organization- and school-scope
organizations, campuses, and timezones. staff can still complete the same daily workflow.
## Storage (reuses `user_progress`) ## Storage (reuses `user_progress`)
@ -25,28 +25,37 @@ logic, keeping the generic `user_progress` endpoint generic.
## Routes (`/api/zone_checkins`) ## Routes (`/api/zone_checkins`)
All require explicit `ZONE_CHECKIN`; `globalAccess` alone does not imply this Personal routes require explicit `ZONE_CHECKIN`; `globalAccess` alone does not
personal workflow permission. imply this workflow permission. The scoped completion route requires
`READ_ZONE_CHECKIN_REPORTS`.
- `GET /today``{ date, zone, isCheckedInToday }` (campus-local date). - `GET /today``{ date, zone, isCheckedInToday }` (campus-local date).
- `POST /` → record today's zone. Body `{ data: { zone } }` (blue|green|yellow|red). - `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 Parent-scope users acting through a drilled child scope receive `403 Forbidden`
as a no-op and returns today's existing personal state without saving a new row. instead of creating reportable rows in the child scope.
- `DELETE /today` → clear today's check-in. - `DELETE /today` → clear today's check-in.
- `GET /?from=&to=` → the caller's history (`{ rows: [{ date, zone }], count }`). - `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 ## Authorization
- `ZONE_CHECKIN` is seeded for `director`, `office_manager`, `teacher`, and - `ZONE_CHECKIN` is seeded for staff roles that must complete the daily workflow,
`support_staff`. Other users can receive or lose it only through effective including organization, school, campus, and class scope staff (`owner`,
permissions (`custom_permissions` / `custom_permissions_filter`). The frontend `superintendent`, `principal`, `registrar`, `director`, `office_manager`,
and backend both gate this workflow by permission, not by role name. `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`. 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 - 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 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. create reportable `user_progress` rows for that child scope.
- A user with no campus has no campus-local "today" — the service rejects with a - Users with no campus use UTC for "today"; campus users use their campus
validation error (only campus staff reach these routes). timezone.
## Tests ## Tests
@ -54,15 +63,21 @@ personal workflow permission.
incl. Phoenix no-DST + a DST zone; `isValidIanaTimezone`) and incl. Phoenix no-DST + a DST zone; `isValidIanaTimezone`) and
`shared/constants/zone-checkin.test.ts` (`isZoneCheckinColor`). `shared/constants/zone-checkin.test.ts` (`isZoneCheckinColor`).
- **Frontend unit** (`vitest`): `business/zone-checkin/selectors.test.ts` - **Frontend unit** (`vitest`): `business/zone-checkin/selectors.test.ts`
(eligibility + nudge) and the top-bar notification builder (incl. the zones (eligibility + nudge), top-bar notification builder (incl. the zones `href`),
`href`). profile unified result rows, and director-dashboard unified result/risk rows.
- **Seeded e2e** (`frontend/tests/e2e/zone-checkins.seeded.e2e.ts`, - **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 `npm run test:e2e:content`): a campus-staff record/read-back/clear of today's
zone, invalid-zone rejection, and external-role lockout. zone, invalid-zone rejection, and external-role lockout.
## Open / deferred ## Content
- A manager-facing aggregate (campus self-regulation trends across staff) would Regulation zone cards and page copy are organization-preset content catalog
need a cross-user report endpoint (`user_progress` is self-scoped) — deferred. records (`regulation-zones` and `zones-of-regulation-page-content`). New
- Editing a campus `timezone` is part of the (design-gated) campus admin UI; organizations receive those records from the content-catalog seeder/backfill; the
for now it is seeded and validated at the API. 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.

View File

@ -11,6 +11,11 @@ export async function history(req: Request, res: Response): Promise<void> {
res.status(200).send(payload); res.status(200).send(payload);
} }
export async function completion(req: Request, res: Response): Promise<void> {
const payload = await ZoneCheckinService.completion(req.currentUser);
res.status(200).send(payload);
}
export async function checkIn(req: Request, res: Response): Promise<void> { export async function checkIn(req: Request, res: Response): Promise<void> {
const payload = await ZoneCheckinService.checkIn(req.body.data, req.currentUser); const payload = await ZoneCheckinService.checkIn(req.body.data, req.currentUser);
res.status(200).send(payload); res.status(200).send(payload);

View File

@ -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.
},
};

View File

@ -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.
},
};

View File

@ -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_ALL = ['READ_PARENT_COMM'] as const;
export const FULL_ACCESS_EXCLUDED_FOR_NON_CAMPUS_STAFF = [ export const FULL_ACCESS_EXCLUDED_FOR_NON_CAMPUS_STAFF = [
'ACK_POLICY', 'ACK_POLICY',
'ZONE_CHECKIN',
] as const; ] as const;
/** Roles granted every permission (full CRUD within their tenant/scope). */ /** Roles granted every permission (full CRUD within their tenant/scope). */
@ -97,8 +96,10 @@ export const MODULE_PERMISSIONS_BY_ROLE: Partial<Record<RoleName, readonly strin
'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_POLICY_ACKNOWLEDGMENT_REPORTS', 'READ_POLICY_ACKNOWLEDGMENT_REPORTS',
'TAKE_QUIZ', 'TAKE_QUIZ',
'ZONE_CHECKIN',
'READ_AUDIO_FILES', 'READ_AUDIO_FILES',
], ],
[ROLE_NAMES.OFFICE_MANAGER]: [ [ROLE_NAMES.OFFICE_MANAGER]: [

View File

@ -365,14 +365,6 @@ const CONTENT_CATALOG_SEED_PAYLOADS = Object.freeze({
description: 'Red Zone requires safety protocols. Follow QBS guidelines: ensure safety first, clear the area if needed, use minimal words, and maintain a calm presence. Do not process during crisis.', description: 'Red Zone requires safety protocols. Follow QBS guidelines: ensure safety first, clear the area if needed, use minimal words, and maintain a calm presence. Do not process during crisis.',
}, },
], ],
quickDeEscalationFlowTitle: 'Quick De-Escalation Flow',
quickDeEscalationFlow: [
{ step: '1', label: 'Notice the Zone', description: 'Identify current zone', colorClass: 'bg-teal-100 text-teal-700 border-teal-200' },
{ step: '2', label: 'Reduce Demands', description: 'Lower expectations immediately', colorClass: 'bg-blue-100 text-blue-700 border-blue-200' },
{ step: '3', label: 'Offer Support', description: 'Signs, choices, or space', colorClass: 'bg-amber-100 text-amber-700 border-amber-200' },
{ step: '4', label: 'Wait & Monitor', description: 'Give processing time', colorClass: 'bg-violet-100 text-violet-700 border-violet-200' },
{ step: '5', label: 'Reconnect', description: 'Return to Green Zone', colorClass: 'bg-emerald-100 text-emerald-700 border-emerald-200' },
],
}, },
dashboardTeacherImages: [ dashboardTeacherImages: [
'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770656488581_0a5ecf7e.jpg', 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770656488581_0a5ecf7e.jpg',

View File

@ -28,6 +28,41 @@ describe('user-role seed permission contract', () => {
assert.equal(permissions.includes(FEATURE_PERMISSIONS.READ_PARENT_COMM), false); 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_POLICY_ACKNOWLEDGMENT_REPORTS), true);
assert.equal(permissions.includes(FEATURE_PERMISSIONS.READ_STAFF_ATTENDANCE_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', () => { 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 ownerPermissions = granted(ROLE_NAMES.OWNER);
const principalPermissions = granted(ROLE_NAMES.PRINCIPAL); const principalPermissions = granted(ROLE_NAMES.PRINCIPAL);
const directorPermissions = granted(ROLE_NAMES.DIRECTOR); 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.READ_PARENT_COMM), false);
assert.equal(ownerPermissions.includes(FEATURE_PERMISSIONS.ACK_POLICY), 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.READ_PARENT_COMM), false);
assert.equal(principalPermissions.includes(FEATURE_PERMISSIONS.ACK_POLICY), 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.READ_PARENT_COMM), false);
assert.equal(directorPermissions.includes(FEATURE_PERMISSIONS.ACK_POLICY), true); assert.equal(directorPermissions.includes(FEATURE_PERMISSIONS.ACK_POLICY), true);
assert.equal(directorPermissions.includes(FEATURE_PERMISSIONS.ZONE_CHECKIN), true); assert.equal(directorPermissions.includes(FEATURE_PERMISSIONS.ZONE_CHECKIN), true);

View File

@ -7,6 +7,7 @@ import * as zone_checkins from '@/api/controllers/zone_checkins.controller';
const router = express.Router(); const router = express.Router();
const canCheckIn = permissions.checkPermissions(FEATURE_PERMISSIONS.ZONE_CHECKIN); const canCheckIn = permissions.checkPermissions(FEATURE_PERMISSIONS.ZONE_CHECKIN);
const canReadReports = permissions.checkPermissions(FEATURE_PERMISSIONS.READ_ZONE_CHECKIN_REPORTS);
/** /**
* @openapi * @openapi
@ -38,8 +39,17 @@ const canCheckIn = permissions.checkPermissions(FEATURE_PERMISSIONS.ZONE_CHECKIN
* 200: { description: '{ date, zone, isCheckedInToday }' } * 200: { description: '{ date, zone, isCheckedInToday }' }
* 400: { $ref: '#/components/responses/ValidationError' } * 400: { $ref: '#/components/responses/ValidationError' }
* 403: { $ref: '#/components/responses/ForbiddenError' } * 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('/today', canCheckIn, wrapAsync(zone_checkins.today));
router.get('/completion', canReadReports, wrapAsync(zone_checkins.completion));
router.get('/', canCheckIn, wrapAsync(zone_checkins.history)); router.get('/', canCheckIn, wrapAsync(zone_checkins.history));
router.post('/', canCheckIn, wrapAsync(zone_checkins.checkIn)); router.post('/', canCheckIn, wrapAsync(zone_checkins.checkIn));
router.delete('/today', canCheckIn, wrapAsync(zone_checkins.clearToday)); router.delete('/today', canCheckIn, wrapAsync(zone_checkins.clearToday));

View File

@ -169,4 +169,60 @@ describe('seedDefaultContentForTenant', () => {
], ],
); );
}); });
test('seeds Zones of Regulation content only at organization scope', async () => {
const createdRows: Array<Record<string, unknown>> = [];
mock.method(db.content_catalog, 'findOne', (async () => null) as typeof db.content_catalog.findOne);
mock.method(db.content_catalog, 'create', (async (payload: Record<string, unknown>) => {
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));
});
}); });

View File

@ -7,6 +7,7 @@ import UserProgressService from '@/services/user_progress';
import ForbiddenError from '@/shared/errors/forbidden'; import ForbiddenError from '@/shared/errors/forbidden';
import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions';
import { ROLE_NAMES } from '@/shared/constants/roles'; import { ROLE_NAMES } from '@/shared/constants/roles';
import { localDateInTimezone } from '@/shared/constants/timezone';
import { createGlobalAccessUser, createTestUser } from '@/test-utils'; import { createGlobalAccessUser, createTestUser } from '@/test-utils';
import type { CurrentUser } from '@/db/api/types'; import type { CurrentUser } from '@/db/api/types';
@ -69,4 +70,87 @@ describe('ZoneCheckinService role eligibility', () => {
assert.equal(result.isCheckedInToday, false); assert.equal(result.isCheckedInToday, false);
assert.match(result.date, /^\d{4}-\d{2}-\d{2}$/); 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');
});
}); });

View File

@ -1,14 +1,20 @@
import { Op } from 'sequelize';
import db from '@/db/models'; import db from '@/db/models';
import ValidationError from '@/shared/errors/validation'; import ValidationError from '@/shared/errors/validation';
import UserProgressService from '@/services/user_progress'; import UserProgressService from '@/services/user_progress';
import { import {
assertAuthenticatedTenantUser, assertAuthenticatedTenantUser,
getCampusId, getCampusId,
getClassId,
hasFeaturePermission, hasFeaturePermission,
isActingInOwnScope, isActingInOwnScope,
getOrganizationIdOrGlobal,
getRoleScope,
getSchoolId,
} from '@/services/shared/access'; } from '@/services/shared/access';
import ForbiddenError from '@/shared/errors/forbidden'; import ForbiddenError from '@/shared/errors/forbidden';
import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions';
import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles';
import { localDateInTimezone } from '@/shared/constants/timezone'; import { localDateInTimezone } from '@/shared/constants/timezone';
import { USER_PROGRESS_TYPES } from '@/shared/constants/user-progress'; import { USER_PROGRESS_TYPES } from '@/shared/constants/user-progress';
import { import {
@ -16,6 +22,8 @@ import {
isZoneCheckinColor, isZoneCheckinColor,
} from '@/shared/constants/zone-checkin'; } from '@/shared/constants/zone-checkin';
import type { CurrentUser } from '@/db/api/types'; 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 * Daily Zone check-in service (Workstream 16). "Today" is computed server-side
@ -37,28 +45,141 @@ export interface ZoneCheckinHistoryEntry {
readonly zone: string; 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 { function assertCanZoneCheckIn(currentUser?: CurrentUser): void {
if (!hasFeaturePermission(currentUser, FEATURE_PERMISSIONS.ZONE_CHECKIN)) { if (!hasFeaturePermission(currentUser, FEATURE_PERMISSIONS.ZONE_CHECKIN)) {
throw new ForbiddenError(); 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<string> { async function resolveCampusTimezone(currentUser?: CurrentUser): Promise<string> {
const campusId = getCampusId(currentUser); const campusId = getCampusId(currentUser);
if (!campusId) { if (!campusId) {
// Zone check-in is campus-staff-only; a user without a campus has no return 'UTC';
// "today" to compute.
throw new ValidationError('zoneCheckinNoCampus');
} }
const campus = await db.campuses.findByPk(campusId, { const campus = await db.campuses.findByPk(campusId, {
attributes: ['timezone'], attributes: ['timezone'],
}); });
if (!campus) { if (!campus) {
throw new ValidationError('zoneCheckinNoCampus'); return 'UTC';
} }
return campus.timezone; 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<string, UserProgress> {
const byUserAndDate = new Map<string, UserProgress>();
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 { class ZoneCheckinService {
/** Today's check-in for the caller (campus-local date). */ /** Today's check-in for the caller (campus-local date). */
static async today(currentUser?: CurrentUser): Promise<ZoneCheckinTodayPayload> { static async today(currentUser?: CurrentUser): Promise<ZoneCheckinTodayPayload> {
@ -81,22 +202,13 @@ class ZoneCheckinService {
currentUser?: CurrentUser, currentUser?: CurrentUser,
): Promise<ZoneCheckinTodayPayload> { ): Promise<ZoneCheckinTodayPayload> {
assertAuthenticatedTenantUser(currentUser); assertAuthenticatedTenantUser(currentUser);
assertCanZoneCheckIn(currentUser); assertCanMutateZoneCheckIn(currentUser);
if (!isZoneCheckinColor(data.zone)) { if (!isZoneCheckinColor(data.zone)) {
throw new ValidationError('zoneCheckinInvalidZone'); throw new ValidationError('zoneCheckinInvalidZone');
} }
const timezone = await resolveCampusTimezone(currentUser); const timezone = await resolveCampusTimezone(currentUser);
const date = localDateInTimezone(timezone); 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( await UserProgressService.upsert(
{ {
progress_type: USER_PROGRESS_TYPES.ZONE_CHECKIN, progress_type: USER_PROGRESS_TYPES.ZONE_CHECKIN,
@ -111,7 +223,7 @@ class ZoneCheckinService {
/** Clears the caller's check-in for today (campus-local date). */ /** Clears the caller's check-in for today (campus-local date). */
static async clearToday(currentUser?: CurrentUser): Promise<ZoneCheckinTodayPayload> { static async clearToday(currentUser?: CurrentUser): Promise<ZoneCheckinTodayPayload> {
assertAuthenticatedTenantUser(currentUser); assertAuthenticatedTenantUser(currentUser);
assertCanZoneCheckIn(currentUser); assertCanMutateZoneCheckIn(currentUser);
const timezone = await resolveCampusTimezone(currentUser); const timezone = await resolveCampusTimezone(currentUser);
const date = localDateInTimezone(timezone); const date = localDateInTimezone(timezone);
@ -149,6 +261,95 @@ class ZoneCheckinService {
return { rows: entries, count: entries.length }; 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; export default ZoneCheckinService;

View File

@ -66,6 +66,7 @@ export const MODULE_MANAGEMENT_PERMISSIONS = [
'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_POLICY_ACKNOWLEDGMENT_REPORTS', 'READ_POLICY_ACKNOWLEDGMENT_REPORTS',
] as const; ] as const;
@ -109,6 +110,7 @@ export const FEATURE_PERMISSIONS = Object.freeze({
READ_STAFF_ATTENDANCE_REPORTS: 'READ_STAFF_ATTENDANCE_REPORTS', READ_STAFF_ATTENDANCE_REPORTS: 'READ_STAFF_ATTENDANCE_REPORTS',
READ_SAFETY_QUIZ_REPORTS: 'READ_SAFETY_QUIZ_REPORTS', READ_SAFETY_QUIZ_REPORTS: 'READ_SAFETY_QUIZ_REPORTS',
READ_PERSONALITY_REPORTS: 'READ_PERSONALITY_REPORTS', READ_PERSONALITY_REPORTS: 'READ_PERSONALITY_REPORTS',
READ_ZONE_CHECKIN_REPORTS: 'READ_ZONE_CHECKIN_REPORTS',
READ_POLICY_ACKNOWLEDGMENT_REPORTS: 'READ_POLICY_ACKNOWLEDGMENT_REPORTS', READ_POLICY_ACKNOWLEDGMENT_REPORTS: 'READ_POLICY_ACKNOWLEDGMENT_REPORTS',
READ_AUDIO_FILES: 'READ_AUDIO_FILES', READ_AUDIO_FILES: 'READ_AUDIO_FILES',
MANAGE_AUDIO_FILES: 'MANAGE_AUDIO_FILES', MANAGE_AUDIO_FILES: 'MANAGE_AUDIO_FILES',

View File

@ -118,7 +118,7 @@ Sign records, teaching tips, video URLs, GIF URLs, step instructions, and page-l
## Editable Zones Of Regulation Content ## 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 ## Editable Dashboard Content

View File

@ -50,6 +50,7 @@ Feature APIs:
- F.R.A.M.E. entries through `useFrameEntries` - F.R.A.M.E. entries through `useFrameEntries`
- Communication events through `useCommunicationEvents` - Communication events through `useCommunicationEvents`
- Current-user daily Emotional Zone check-in through `useTodayZoneCheckIn` (explicit `ZONE_CHECKIN` only; see `zone-checkin-integration.md`) - 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 ## Behavior

View File

@ -2,7 +2,7 @@
## Purpose ## 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 ```text
View -> Business Logic -> API/Data Access -> Backend 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/frame.ts`
- `frontend/src/shared/api/safetyQuizResults.ts` - `frontend/src/shared/api/safetyQuizResults.ts`
- `frontend/src/shared/api/personality.ts` - `frontend/src/shared/api/personality.ts`
- `frontend/src/shared/api/zoneCheckins.ts`
- `frontend/src/shared/api/staffAttendance.ts` - `frontend/src/shared/api/staffAttendance.ts`
- `frontend/src/shared/api/policyAcknowledgments.ts` - `frontend/src/shared/api/policyAcknowledgments.ts`
@ -39,14 +40,18 @@ Constants:
- FRAME entries load through `useFrameEntries`. - FRAME entries load through `useFrameEntries`.
- QBS safety quiz completion loads through `useSafetyQuizResults`. - QBS safety quiz completion loads through `useSafetyQuizResults`.
- Emotional Intelligence and Personality Type completion loads through `usePersonalityCompletion`. - 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. - Staff attendance records and summary load through staff attendance business hooks.
- Policy acknowledgment summary loads through `usePolicyAcknowledgmentReport`. - Policy acknowledgment summary loads through `usePolicyAcknowledgmentReport`.
- Overview metrics, risk areas, unified quiz result rows, and FRAME previews are derived in business selectors. - 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 - The dashboard quiz results table combines Behavior Management, EI Self-Assessment,
Personality Type Quiz rows. EI self-assessment rows reflect the current Sunday-start week; Personality Type Quiz, and Daily Zone Check-In rows. EI self-assessment rows
personality type rows reflect each user's latest saved type. reflect the current Sunday-start week; personality type rows reflect each user's
- Risk areas include high/medium/low QBS safety quiz completion, low-risk EI self-assessment latest saved type; zone check-in rows reflect today's backend-computed date for
pending counts, low-risk Personality Type pending counts, and attendance risk. 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. - View components use shared `Button`, `Table`, `StatePanel`, and `ModuleHeader` primitives.
- Loading, empty, and error states are explicit. - Loading, empty, and error states are explicit.

View File

@ -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/safetyQuizResults.test.ts`
- `frontend/src/shared/api/staffAttendance.test.ts` - `frontend/src/shared/api/staffAttendance.test.ts`
- `frontend/src/shared/api/userProgress.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/api/walkthrough.test.ts`
- `frontend/src/shared/architecture/import-boundaries.test.ts` - `frontend/src/shared/architecture/import-boundaries.test.ts`
- `frontend/src/shared/business/apiListRows.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/hooks/usePermissions.test.tsx`
- `frontend/src/components/sign-in-modal/SignInForm.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. 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.

View File

@ -2,56 +2,77 @@
## Purpose ## Purpose
Campus staff log a daily self-regulation "Emotional Zone" (blue/green/yellow/red). Staff log a daily self-regulation "Emotional Zone" (blue/green/yellow/red).
The same state drives three surfaces: the dashboard check-in card, the The same state drives the dashboard check-in card, the `/zones-of-regulation`
`/zones-of-regulation` page (reminder banner + card), and a notification-dropdown overview cards, the profile unified results table, leader dashboard
nudge when an eligible user has not checked in today. completion/risk sections, and a notification-dropdown nudge when an eligible
user has not checked in today.
## Backend contract ## Backend contract
`/api/zone_checkins` requires explicit `ZONE_CHECKIN`. This personal workflow `/api/zone_checkins` requires explicit `ZONE_CHECKIN` for personal reads/writes.
permission is not implied by `globalAccess`. The client never computes the date; This personal workflow permission is not implied by `globalAccess`. The client
"today" is the campus-local date computed server-side from `campuses.timezone`. 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 }` - `GET /today``{ date, zone, isCheckedInToday }`
- `POST /` `{ data: { zone } }` → record today's zone (upsert) - `POST /` `{ data: { zone } }` → record today's zone (upsert)
- `DELETE /today` → clear today's zone - `DELETE /today` → clear today's zone
- `GET /?from=&to=` → history `{ rows: [{ date, zone }], count }` - `GET /?from=&to=` → history `{ rows: [{ date, zone }], count }`
- `GET /completion` → leader/report scoped staff completion `{ summary, rows }`
with `READ_ZONE_CHECKIN_REPORTS`.
## Frontend Structure ## Frontend Structure
- API/types: `shared/api/zoneCheckins.ts`, `shared/types/zoneCheckins.ts` - API/types: `shared/api/zoneCheckins.ts`, `shared/types/zoneCheckins.ts`
- Business: `business/zone-checkin/hooks.ts` (`useTodayZoneCheckIn`, - Business: `business/zone-checkin/hooks.ts` (`useTodayZoneCheckIn`,
`useZoneCheckInHistory`), `business/zone-checkin/selectors.ts` `useZoneCheckInHistory`, `useZoneCheckInCompletion`),
`business/zone-checkin/selectors.ts`
(`canZoneCheckIn`, `shouldNudgeZoneCheckIn`) (`canZoneCheckIn`, `shouldNudgeZoneCheckIn`)
- Components: `components/zone-checkin/ZoneCheckInCard.tsx` (shared card), - Components: `components/zone-checkin/ZoneCheckInCard.tsx` (shared dashboard
`ZoneCheckInReminder.tsx` (banner), `ZoneCheckInSection.tsx` (page section) card), `ZoneCheckInReminder.tsx` (banner), `ZoneCheckInSection.tsx` (composed
section), and the `/zones-of-regulation` overview cards.
## Behavior ## Behavior
- **Eligibility/nudge gating** requires the effective `ZONE_CHECKIN` permission. - **Eligibility/nudge gating** requires the effective `ZONE_CHECKIN` permission.
Seed data grants it only to the campus staff workflow audience Seed data grants it to staff users expected to complete the daily workflow
(`director`, `office_manager`, `teacher`, `support_staff`), but custom across organization, school, campus, and class scopes; custom permissions can
permissions can extend or remove it per user. Global/full-access leadership extend or remove it per user.
users do not see self-state nudges unless they receive explicit `ZONE_CHECKIN`.
- **Dashboard**: the card is wired through `useDashboardPage` (which exposes - **Dashboard**: the card is wired through `useDashboardPage` (which exposes
`showZoneCheckIn` + `needsZoneCheckIn`) with an optimistic shell value for snappy `showZoneCheckIn` + `needsZoneCheckIn`) with an optimistic shell value for snappy
selection. selection.
- **Zones page**: `ZoneCheckInSection` is self-contained (`useTodayZoneCheckIn`) - **Zones page**: the main zone overview cards call `useTodayZoneCheckIn`
and renders above the regulation content. 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 - **Notifications**: `business/top-bar` derives a single unread notification from
`shouldNudgeZoneCheckIn` (`buildTopBarNotifications`) — there is no backend `shouldNudgeZoneCheckIn` (`buildTopBarNotifications`) — there is no backend
notifications store. The notification carries an `href` notifications store. The notification carries an `href`
(`APP_ROUTE_PATHS.zones`); clicking it navigates to `/zones-of-regulation` (`APP_ROUTE_PATHS.zones`); clicking it navigates to `/zones-of-regulation`
(a react-router `Link`) and closes the dropdown. (a react-router `Link`) and closes the dropdown.
- `useTodayZoneCheckIn` is disabled for users without `ZONE_CHECKIN`. Its `error` surfaces - `useTodayZoneCheckIn` is disabled for users without `ZONE_CHECKIN` or when the
**only** save/clear failures; non-eligible users should not trigger the user is drilled into a child scope. Disabled callers receive empty local state
`/today` request. 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 ## Tests
- `business/zone-checkin/selectors.test.ts` (eligibility + nudge), - `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 / - Seeded e2e: `frontend/tests/e2e/zone-checkins.seeded.e2e.ts` (record /
read-back / clear today, invalid-zone rejection, external-role lockout). read-back / clear today, invalid-zone rejection, external-role lockout).

View File

@ -8,7 +8,7 @@
View -> Business Logic -> API/Data Access -> Backend 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 ## Frontend Layers
@ -47,7 +47,7 @@ The page reads:
Content payloads are seeded in: 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 ## 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. - 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. - View components receive a prepared page model and do not call API/data access modules.
- Loading and error states are explicit through `StatePanel`. - 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 ## 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. - 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/`. - Test-only fixtures may live in selector tests or `frontend/src/test-seeds/`.

View File

@ -15,6 +15,7 @@ import type {
} from '@/business/dashboard/types'; } from '@/business/dashboard/types';
import { useFrameEntries } from '@/business/frame/hooks'; import { useFrameEntries } from '@/business/frame/hooks';
import { getScopedModules } from '@/business/app-shell/selectors'; import { getScopedModules } from '@/business/app-shell/selectors';
import { canPersistPersonalScopeResults } from '@/business/scope/selectors';
import { useTodayZoneCheckIn } from '@/business/zone-checkin/hooks'; import { useTodayZoneCheckIn } from '@/business/zone-checkin/hooks';
import { canZoneCheckIn, shouldNudgeZoneCheckIn } from '@/business/zone-checkin/selectors'; import { canZoneCheckIn, shouldNudgeZoneCheckIn } from '@/business/zone-checkin/selectors';
import { useScopeContext } from '@/shared/app/scope-context'; import { useScopeContext } from '@/shared/app/scope-context';
@ -38,7 +39,7 @@ export function useDashboardPage({
setZoneCheckIn, setZoneCheckIn,
}: DashboardProps): DashboardPage { }: DashboardProps): DashboardPage {
const { user } = useAuth(); const { user } = useAuth();
const { tier, selectedTenant } = useScopeContext(); const { tier, ownTenant, selectedTenant } = useScopeContext();
const [dashboardDate] = useState(() => new Date()); const [dashboardDate] = useState(() => new Date());
const quotesQuery = useContentCatalogPayload<readonly DashboardEncouragingQuote[]>( const quotesQuery = useContentCatalogPayload<readonly DashboardEncouragingQuote[]>(
CONTENT_CATALOG_TYPES.dashboardEncouragingQuotes, CONTENT_CATALOG_TYPES.dashboardEncouragingQuotes,
@ -53,7 +54,7 @@ export function useDashboardPage({
null, null,
); );
const frameEntriesQuery = useFrameEntries(); const frameEntriesQuery = useFrameEntries();
const canUseZoneCheckIn = canZoneCheckIn(user); const canUseZoneCheckIn = canPersistPersonalScopeResults(ownTenant, selectedTenant) && canZoneCheckIn(user);
const zoneCheckInState = useTodayZoneCheckIn({ enabled: canUseZoneCheckIn }); const zoneCheckInState = useTodayZoneCheckIn({ enabled: canUseZoneCheckIn });
const communicationEventsQuery = useCommunicationEvents(); const communicationEventsQuery = useCommunicationEvents();
const upcomingEvents = useMemo( const upcomingEvents = useMemo(
@ -62,7 +63,7 @@ export function useDashboardPage({
); );
const activeZone = selectDashboardActiveZone(zoneCheckIn, zoneCheckInState.todayZone); const activeZone = selectDashboardActiveZone(zoneCheckIn, zoneCheckInState.todayZone);
const needsZoneCheckIn = shouldNudgeZoneCheckIn( const needsZoneCheckIn = shouldNudgeZoneCheckIn(
user, canUseZoneCheckIn ? user : null,
zoneCheckInState.isLoading, zoneCheckInState.isLoading,
zoneCheckInState.isCheckedInToday, zoneCheckInState.isCheckedInToday,
); );

View File

@ -3,6 +3,7 @@ import { useState } from 'react';
import { useFrameEntries } from '@/business/frame/hooks'; import { useFrameEntries } from '@/business/frame/hooks';
import { useSafetyQuizCompliance } from '@/business/safety-quiz/hooks'; import { useSafetyQuizCompliance } from '@/business/safety-quiz/hooks';
import { usePersonalityCompletion } from '@/business/personality/queryHooks'; import { usePersonalityCompletion } from '@/business/personality/queryHooks';
import { useZoneCheckInCompletion } from '@/business/zone-checkin/hooks';
import { import {
useStaffAttendanceRecords, useStaffAttendanceRecords,
useStaffAttendanceSummary, useStaffAttendanceSummary,
@ -22,25 +23,31 @@ import {
import { useAuth } from '@/shared/app/useAuth'; import { useAuth } from '@/shared/app/useAuth';
import { getLeadershipDashboardName } from '@/business/app-shell/selectors'; import { getLeadershipDashboardName } from '@/business/app-shell/selectors';
import { getActiveTenant } from '@/business/scope/selectors'; import { getActiveTenant } from '@/business/scope/selectors';
import { useScopeContext } from '@/shared/app/scope-context';
import { DEFAULT_PRODUCT_ROLE } from '@/shared/constants/roles'; import { DEFAULT_PRODUCT_ROLE } from '@/shared/constants/roles';
import { getCurrentSafetyQuizWeek } from '@/business/safety-quiz/selectors'; import { getCurrentSafetyQuizWeek } from '@/business/safety-quiz/selectors';
export function useDirectorDashboardPage(): DirectorDashboardPage { export function useDirectorDashboardPage(): DirectorDashboardPage {
const { user, profile } = useAuth(); const { user, profile } = useAuth();
const { effectiveTenant } = useScopeContext();
const role = profile?.role ?? DEFAULT_PRODUCT_ROLE; const role = profile?.role ?? DEFAULT_PRODUCT_ROLE;
const title = getLeadershipDashboardName(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<DirectorDashboardTimeRange>('month'); const [timeRange, setTimeRangeState] = useState<DirectorDashboardTimeRange>('month');
const safetyQuizWeek = getCurrentSafetyQuizWeek(new Date()); const safetyQuizWeek = getCurrentSafetyQuizWeek(new Date());
const frameEntriesQuery = useFrameEntries(); const frameEntriesQuery = useFrameEntries();
const quizCompletionQuery = useSafetyQuizCompliance(safetyQuizWeek, true); const quizCompletionQuery = useSafetyQuizCompliance(safetyQuizWeek, true);
const emotionalIntelligenceCompletionQuery = usePersonalityCompletion(true); const emotionalIntelligenceCompletionQuery = usePersonalityCompletion(true);
const zoneCheckinCompletionQuery = useZoneCheckInCompletion(true, scopeKey);
const staffAttendanceRecordsQuery = useStaffAttendanceRecords(); const staffAttendanceRecordsQuery = useStaffAttendanceRecords();
const staffAttendanceSummaryQuery = useStaffAttendanceSummary(); const staffAttendanceSummaryQuery = useStaffAttendanceSummary();
const acknowledgmentReportQuery = usePolicyAcknowledgmentReport(); const acknowledgmentReportQuery = usePolicyAcknowledgmentReport();
const frameEntries = frameEntriesQuery.data ?? []; const frameEntries = frameEntriesQuery.data ?? [];
const quizRows = quizCompletionQuery.data?.rows ?? []; const quizRows = quizCompletionQuery.data?.rows ?? [];
const emotionalIntelligenceCompletion = emotionalIntelligenceCompletionQuery.data ?? null; const emotionalIntelligenceCompletion = emotionalIntelligenceCompletionQuery.data ?? null;
const zoneCheckinCompletion = zoneCheckinCompletionQuery.data ?? null;
const quizSummary = quizCompletionQuery.data?.summary ?? { const quizSummary = quizCompletionQuery.data?.summary ?? {
totalStaff: 0, totalStaff: 0,
completedCount: 0, completedCount: 0,
@ -51,12 +58,14 @@ export function useDirectorDashboardPage(): DirectorDashboardPage {
const isLoading = frameEntriesQuery.isLoading const isLoading = frameEntriesQuery.isLoading
|| quizCompletionQuery.isLoading || quizCompletionQuery.isLoading
|| emotionalIntelligenceCompletionQuery.isLoading || emotionalIntelligenceCompletionQuery.isLoading
|| zoneCheckinCompletionQuery.isLoading
|| staffAttendanceRecordsQuery.isLoading || staffAttendanceRecordsQuery.isLoading
|| staffAttendanceSummaryQuery.isLoading || staffAttendanceSummaryQuery.isLoading
|| acknowledgmentReportQuery.isLoading; || acknowledgmentReportQuery.isLoading;
const error = frameEntriesQuery.error const error = frameEntriesQuery.error
?? quizCompletionQuery.error ?? quizCompletionQuery.error
?? emotionalIntelligenceCompletionQuery.error ?? emotionalIntelligenceCompletionQuery.error
?? zoneCheckinCompletionQuery.error
?? staffAttendanceRecordsQuery.error ?? staffAttendanceRecordsQuery.error
?? staffAttendanceSummaryQuery.error ?? staffAttendanceSummaryQuery.error
?? acknowledgmentReportQuery.error; ?? acknowledgmentReportQuery.error;
@ -75,10 +84,11 @@ export function useDirectorDashboardPage(): DirectorDashboardPage {
attendanceRecords, attendanceRecords,
quizSummary, quizSummary,
emotionalIntelligenceCompletion, emotionalIntelligenceCompletion,
zoneCheckinCompletion,
), ),
framePreviews: buildDirectorFramePreviews(frameEntries), framePreviews: buildDirectorFramePreviews(frameEntries),
quickActions: DIRECTOR_DASHBOARD_QUICK_ACTIONS, quickActions: DIRECTOR_DASHBOARD_QUICK_ACTIONS,
quizResults: buildDirectorQuizResults(quizRows, emotionalIntelligenceCompletion), quizResults: buildDirectorQuizResults(quizRows, emotionalIntelligenceCompletion, zoneCheckinCompletion),
isLoading, isLoading,
error, error,
setTimeRange: setTimeRangeState, setTimeRange: setTimeRangeState,

View File

@ -14,6 +14,7 @@ import type {
SafetyQuizComplianceRow, SafetyQuizComplianceRow,
} from '@/business/safety-quiz/types'; } from '@/business/safety-quiz/types';
import type { PersonalityCompletionDto } from '@/shared/types/personality'; import type { PersonalityCompletionDto } from '@/shared/types/personality';
import type { ZoneCheckinCompletionDto } from '@/shared/types/zoneCheckins';
function createAttendanceRecord( function createAttendanceRecord(
overrides: Partial<StaffAttendanceRecordViewModel> = {}, overrides: Partial<StaffAttendanceRecordViewModel> = {},
@ -128,6 +129,35 @@ function createPersonalityCompletion(
}; };
} }
function createZoneCheckinCompletion(
overrides: Partial<ZoneCheckinCompletionDto> = {},
): 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', () => { describe('director dashboard selectors', () => {
it('calculates quiz completion rate with empty staff protection', () => { it('calculates quiz completion rate with empty staff protection', () => {
expect(calculateQuizCompletionRate(createQuizSummary({ completionRate: 0 }))).toBe(0); expect(calculateQuizCompletionRate(createQuizSummary({ completionRate: 0 }))).toBe(0);
@ -172,6 +202,8 @@ describe('director dashboard selectors', () => {
createAttendanceRecord({ id: '4', status: 'absent' }), createAttendanceRecord({ id: '4', status: 'absent' }),
], ],
createQuizSummary({ completedCount: 1, pendingCount: 5, totalStaff: 6, completionRate: 17 }), createQuizSummary({ completedCount: 1, pendingCount: 5, totalStaff: 6, completionRate: 17 }),
null,
createZoneCheckinCompletion(),
); );
expect(risks).toEqual([ expect(risks).toEqual([
@ -181,14 +213,14 @@ describe('director dashboard selectors', () => {
module: 'qbs', module: 'qbs',
}, },
{ {
issue: "0 staff haven't completed EI self-assessment", issue: "1 staff haven't completed daily zone check-in",
severity: 'low', severity: 'medium',
module: 'ei', module: 'zones',
}, },
{ {
issue: "0 staff haven't completed personality type quiz", issue: 'Non-green regulation zones: Ava Lee (Yellow Zone)',
severity: 'low', severity: 'medium',
module: 'ei', module: 'zones',
}, },
{ {
issue: '4 absences recorded this period', 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( const rows = buildDirectorQuizResults(
[ [
{ {
@ -211,17 +285,20 @@ describe('director dashboard selectors', () => {
} satisfies SafetyQuizComplianceRow, } satisfies SafetyQuizComplianceRow,
], ],
createPersonalityCompletion(), createPersonalityCompletion(),
createZoneCheckinCompletion(),
); );
expect(rows.map((row) => row.quiz)).toEqual([ expect(rows.map((row) => row.quiz)).toEqual([
'Behavior Management', 'Behavior Management',
'EI Self-Assessment', 'EI Self-Assessment',
'Personality Type Quiz', 'Personality Type Quiz',
'Daily Zone Check-In',
]); ]);
expect(rows.map((row) => row.result)).toEqual([ expect(rows.map((row) => row.result)).toEqual([
'3/5', '3/5',
'Developing Awareness (14/32)', 'Developing Awareness (14/32)',
'ENFP', 'ENFP',
'Yellow Zone',
]); ]);
}); });

View File

@ -26,6 +26,7 @@ import type {
PersonalityCompletionDto, PersonalityCompletionDto,
PersonalityQuizResultDto, PersonalityQuizResultDto,
} from '@/shared/types/personality'; } from '@/shared/types/personality';
import type { ZoneCheckinCompletionDto } from '@/shared/types/zoneCheckins';
export function calculateQuizCompletionRate( export function calculateQuizCompletionRate(
quizSummary: SafetyQuizCompletionSummary, quizSummary: SafetyQuizCompletionSummary,
@ -96,6 +97,7 @@ export function buildDirectorRiskAreas(
attendanceRecords: readonly StaffAttendanceRecordViewModel[], attendanceRecords: readonly StaffAttendanceRecordViewModel[],
quizSummary: SafetyQuizCompletionSummary, quizSummary: SafetyQuizCompletionSummary,
emotionalIntelligenceCompletion?: PersonalityCompletionDto | null, emotionalIntelligenceCompletion?: PersonalityCompletionDto | null,
zoneCheckinCompletion?: ZoneCheckinCompletionDto | null,
): readonly DirectorRiskArea[] { ): readonly DirectorRiskArea[] {
const incompleteStaffCount = quizSummary.pendingCount; const incompleteStaffCount = quizSummary.pendingCount;
const absenceCount = countStaffAttendanceStatus(attendanceRecords, 'absent'); const absenceCount = countStaffAttendanceStatus(attendanceRecords, 'absent');
@ -107,34 +109,84 @@ export function buildDirectorRiskAreas(
? emotionalIntelligenceCompletion.summary.totalStaff ? emotionalIntelligenceCompletion.summary.totalStaff
- emotionalIntelligenceCompletion.summary.personalityCompletedCount - emotionalIntelligenceCompletion.summary.personalityCompletedCount
: 0; : 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`, issue: `${incompleteStaffCount} staff haven't completed de-escalation quiz`,
severity: incompleteStaffCount > DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD ? 'high' : 'medium', severity: incompleteStaffCount > DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD ? 'high' : 'medium',
module: 'qbs', module: 'qbs',
}, });
{ }
if (selfAssessmentPendingCount > 0) {
risks.push({
issue: `${selfAssessmentPendingCount} staff haven't completed EI self-assessment`, issue: `${selfAssessmentPendingCount} staff haven't completed EI self-assessment`,
severity: 'low', severity: 'low',
module: 'ei', module: 'ei',
}, });
{ }
if (personalityPendingCount > 0) {
risks.push({
issue: `${personalityPendingCount} staff haven't completed personality type quiz`, issue: `${personalityPendingCount} staff haven't completed personality type quiz`,
severity: 'low', severity: 'low',
module: 'ei', 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`, issue: `${absenceCount} absences recorded this period`,
severity: absenceCount > DIRECTOR_DASHBOARD_HIGH_ABSENCE_THRESHOLD ? 'high' : 'low', severity: absenceCount > DIRECTOR_DASHBOARD_HIGH_ABSENCE_THRESHOLD ? 'high' : 'low',
module: 'attendance', 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( export function buildDirectorQuizResults(
safetyRows: readonly SafetyQuizComplianceRow[], safetyRows: readonly SafetyQuizComplianceRow[],
emotionalIntelligenceCompletion?: PersonalityCompletionDto | null, emotionalIntelligenceCompletion?: PersonalityCompletionDto | null,
zoneCheckinCompletion?: ZoneCheckinCompletionDto | null,
): readonly DirectorQuizResultRow[] { ): readonly DirectorQuizResultRow[] {
const behaviorRows = safetyRows.map((row): DirectorQuizResultRow => ({ const behaviorRows = safetyRows.map((row): DirectorQuizResultRow => ({
id: `${row.userId}-behavior-management`, id: `${row.userId}-behavior-management`,
@ -166,8 +218,19 @@ export function buildDirectorQuizResults(
status: row.personality ? 'complete' as const : 'pending' as const, 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( export function buildDirectorFramePreviews(

View File

@ -63,11 +63,13 @@ describe('profile selectors', () => {
'Behavior Management Review', 'Behavior Management Review',
'EI Self-Assessment', 'EI Self-Assessment',
'Personality Type Quiz', 'Personality Type Quiz',
'Daily Zone Check-In',
]); ]);
expect(rows.map((row) => row.result)).toEqual([ expect(rows.map((row) => row.result)).toEqual([
'3/5', '3/5',
'Developing Awareness (14/32)', 'Developing Awareness (14/32)',
'ENFP', 'ENFP',
'Pending',
]); ]);
}); });
@ -78,6 +80,22 @@ describe('profile selectors', () => {
{ quiz: 'Behavior Management', result: 'Pending', status: 'pending' }, { quiz: 'Behavior Management', result: 'Pending', status: 'pending' },
{ quiz: 'EI Self-Assessment', result: 'Pending', status: 'pending' }, { quiz: 'EI Self-Assessment', result: 'Pending', status: 'pending' },
{ quiz: 'Personality Type Quiz', 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',
});
});
}); });

View File

@ -1,6 +1,7 @@
import { PERSONALITY_QUIZ_KINDS } from '@/shared/constants/personality'; import { PERSONALITY_QUIZ_KINDS } from '@/shared/constants/personality';
import type { PersonalityQuizResultViewModel } from '@/business/personality/types'; import type { PersonalityQuizResultViewModel } from '@/business/personality/types';
import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz'; import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz';
import type { ZoneCheckinTodayDto } from '@/shared/types/zoneCheckins';
export interface ProfileQuizResultRow { export interface ProfileQuizResultRow {
readonly id: string; readonly id: string;
@ -14,6 +15,8 @@ export interface ProfileQuizResultRow {
export function buildProfileQuizResultRows( export function buildProfileQuizResultRows(
safetyQuizResult: SafetyQuizResultDto | null, safetyQuizResult: SafetyQuizResultDto | null,
personalityResults: readonly PersonalityQuizResultViewModel[], personalityResults: readonly PersonalityQuizResultViewModel[],
zoneCheckinToday: ZoneCheckinTodayDto | null = null,
includeZoneCheckin = true,
): readonly ProfileQuizResultRow[] { ): readonly ProfileQuizResultRow[] {
const rows: ProfileQuizResultRow[] = [ const rows: ProfileQuizResultRow[] = [
toProfileSafetyQuizRow(safetyQuizResult), toProfileSafetyQuizRow(safetyQuizResult),
@ -28,6 +31,10 @@ export function buildProfileQuizResultRows(
rows.push(toProfilePendingPersonalityQuizRow(PERSONALITY_QUIZ_KINDS.personalityType)); rows.push(toProfilePendingPersonalityQuizRow(PERSONALITY_QUIZ_KINDS.personalityType));
} }
if (includeZoneCheckin) {
rows.push(toProfileZoneCheckinRow(zoneCheckinToday));
}
return rows; return rows;
} }
@ -106,3 +113,27 @@ function toProfilePendingPersonalityQuizRow(
status: 'pending', 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',
};
}

View File

@ -74,7 +74,7 @@ export function useTopBarPage({
const canUseZoneCheckIn = canPersistPersonalResults && canZoneCheckIn(user); const canUseZoneCheckIn = canPersistPersonalResults && canZoneCheckIn(user);
const zoneCheckIn = useTodayZoneCheckIn({ enabled: canUseZoneCheckIn }); const zoneCheckIn = useTodayZoneCheckIn({ enabled: canUseZoneCheckIn });
const needsZoneCheckIn = shouldNudgeZoneCheckIn( const needsZoneCheckIn = shouldNudgeZoneCheckIn(
user, canUseZoneCheckIn ? user : null,
zoneCheckIn.isLoading, zoneCheckIn.isLoading,
zoneCheckIn.isCheckedInToday, zoneCheckIn.isCheckedInToday,
); );

View File

@ -3,6 +3,7 @@ import {
checkInZone, checkInZone,
clearTodayZoneCheckin, clearTodayZoneCheckin,
getTodayZoneCheckin, getTodayZoneCheckin,
getZoneCheckinCompletion,
listZoneCheckinHistory, listZoneCheckinHistory,
} from '@/shared/api/zoneCheckins'; } from '@/shared/api/zoneCheckins';
import { getApiListRows } from '@/shared/business/apiListRows'; import { getApiListRows } from '@/shared/business/apiListRows';
@ -12,6 +13,7 @@ import type { ZoneColor } from '@/shared/types/app';
export const ZONE_CHECKIN_QUERY_KEYS = { export const ZONE_CHECKIN_QUERY_KEYS = {
today: ['zoneCheckin', 'today'], today: ['zoneCheckin', 'today'],
history: ['zoneCheckin', 'history'], history: ['zoneCheckin', 'history'],
completion: (scopeKey: string | null) => ['zoneCheckin', 'completion', scopeKey],
} as const; } as const;
/** /**
@ -19,10 +21,11 @@ export const ZONE_CHECKIN_QUERY_KEYS = {
* campus timezone, so this hook never computes a date. * campus timezone, so this hook never computes a date.
*/ */
export function useTodayZoneCheckIn(options: { readonly enabled?: boolean } = {}) { export function useTodayZoneCheckIn(options: { readonly enabled?: boolean } = {}) {
const enabled = options.enabled ?? true;
const todayQuery = useQuery({ const todayQuery = useQuery({
queryKey: ZONE_CHECKIN_QUERY_KEYS.today, queryKey: ZONE_CHECKIN_QUERY_KEYS.today,
queryFn: getTodayZoneCheckin, queryFn: getTodayZoneCheckin,
enabled: options.enabled ?? true, enabled,
retry: false, retry: false,
}); });
@ -37,9 +40,10 @@ export function useTodayZoneCheckIn(options: { readonly enabled?: boolean } = {}
}); });
return { return {
todayZone: todayQuery.data?.zone ?? null, todayDate: enabled ? todayQuery.data?.date ?? null : null,
isCheckedInToday: todayQuery.data?.isCheckedInToday ?? false, todayZone: enabled ? todayQuery.data?.zone ?? null : null,
isLoading: todayQuery.isLoading, isCheckedInToday: enabled ? todayQuery.data?.isCheckedInToday ?? false : false,
isLoading: enabled && todayQuery.isLoading,
isSaving: saveMutation.isPending || clearMutation.isPending, isSaving: saveMutation.isPending || clearMutation.isPending,
// Only surface actionable mutation (save/clear) errors. The today-load query // 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 // can 403 for a non-eligible role or before seeding; that is non-actionable
@ -58,3 +62,13 @@ export function useZoneCheckInHistory() {
retry: false, 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,
});
}

View File

@ -1,10 +1,7 @@
import { hasPermission } from '@/business/auth/permissions'; import { hasPermission } from '@/business/auth/permissions';
import type { CurrentUser } from '@/shared/types/auth'; import type { CurrentUser } from '@/shared/types/auth';
/** /** Daily zone check-in requires the explicit effective ZONE_CHECKIN permission. */
* 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.
*/
export function canZoneCheckIn(user: CurrentUser | null | undefined): boolean { export function canZoneCheckIn(user: CurrentUser | null | undefined): boolean {
return hasPermission(user, 'ZONE_CHECKIN'); return hasPermission(user, 'ZONE_CHECKIN');
} }

View File

@ -1,31 +1,42 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; 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 { import {
getSelectedZone, getSelectedZone,
getZonesSafetyConnection, getZonesSafetyConnection,
toggleExpandedZone as toggleExpandedZoneValue,
} from '@/business/zones/selectors'; } from '@/business/zones/selectors';
import type { ZonesOfRegulationPage } from '@/business/zones/types'; import type { ZonesOfRegulationPage } from '@/business/zones/types';
import { useScopeContext } from '@/shared/app/scope-context';
import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog';
import { import {
DEFAULT_EXPANDED_ZONE, DEFAULT_EXPANDED_ZONE,
DEFAULT_ZONES_TAB, DEFAULT_ZONES_TAB,
} from '@/shared/constants/zonesOfRegulation'; } from '@/shared/constants/zonesOfRegulation';
import type { ZonesOfRegulationTab } from '@/shared/constants/zonesOfRegulation'; import type { ZonesOfRegulationTab } from '@/shared/constants/zonesOfRegulation';
import type {
CurrentUser,
} from '@/shared/types/auth';
import type { import type {
ZoneColor, ZoneColor,
ZoneInfo, ZoneInfo,
ZonesOfRegulationPageContent, ZonesOfRegulationPageContent,
} from '@/shared/types/app'; } from '@/shared/types/app';
import { getOptionalErrorMessage } from '@/shared/errors/errorMessages';
const EMPTY_ZONES_PAGE_CONTENT: ZonesOfRegulationPageContent = { const EMPTY_ZONES_PAGE_CONTENT: ZonesOfRegulationPageContent = {
safetyConnections: [], 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<readonly ZoneInfo[]>( const zonesQuery = useContentCatalogPayload<readonly ZoneInfo[]>(
CONTENT_CATALOG_TYPES.regulationZones, CONTENT_CATALOG_TYPES.regulationZones,
[], [],
@ -36,32 +47,41 @@ export function useZonesOfRegulationPage(): ZonesOfRegulationPage {
); );
const [expandedZone, setExpandedZone] = useState<ZoneColor | null>(DEFAULT_EXPANDED_ZONE); const [expandedZone, setExpandedZone] = useState<ZoneColor | null>(DEFAULT_EXPANDED_ZONE);
const [activeTab, setActiveTab] = useState<ZonesOfRegulationTab>(DEFAULT_ZONES_TAB); const [activeTab, setActiveTab] = useState<ZonesOfRegulationTab>(DEFAULT_ZONES_TAB);
const canUseZoneCheckIn = canPersistPersonalScopeResults(ownTenant, selectedTenant) && canZoneCheckIn(user);
const zoneCheckIn = useTodayZoneCheckIn({ enabled: canUseZoneCheckIn });
const zones = zonesQuery.payload; const zones = zonesQuery.payload;
const pageContent = pageContentQuery.payload; const pageContent = pageContentQuery.payload;
const checkedInZone = zoneCheckIn.todayZone;
const selectedZone = useMemo( const selectedZone = useMemo(
() => getSelectedZone(zones, expandedZone), () => getSelectedZone(zones, expandedZone ?? checkedInZone),
[expandedZone, zones], [checkedInZone, expandedZone, zones],
); );
const safetyConnection = useMemo( const safetyConnection = useMemo(
() => getZonesSafetyConnection(pageContent.safetyConnections, selectedZone?.color ?? null), () => getZonesSafetyConnection(pageContent.safetyConnections, selectedZone?.color ?? null),
[pageContent.safetyConnections, selectedZone?.color], [pageContent.safetyConnections, selectedZone?.color],
); );
function toggleExpandedZone(zone: ZoneColor) { async function selectZone(zone: ZoneColor) {
setExpandedZone((currentZone) => toggleExpandedZoneValue(currentZone, zone)); setExpandedZone(zone);
if (canUseZoneCheckIn) {
await zoneCheckIn.setZone(zone);
}
} }
return { return {
zones, zones,
pageContent, pageContent,
expandedZone, expandedZone,
checkedInZone,
selectedZone, selectedZone,
activeTab, activeTab,
safetyConnection, safetyConnection,
isLoading: zonesQuery.isLoading || pageContentQuery.isLoading, isLoading: zonesQuery.isLoading || pageContentQuery.isLoading,
isZoneSaving: zoneCheckIn.isSaving,
zonesError: zonesQuery.error, zonesError: zonesQuery.error,
pageContentError: pageContentQuery.error, pageContentError: pageContentQuery.error,
zoneCheckInErrorMessage: getOptionalErrorMessage(zoneCheckIn.error),
setActiveTab, setActiveTab,
toggleExpandedZone, selectZone,
}; };
} }

View File

@ -10,12 +10,15 @@ export interface ZonesOfRegulationPage {
readonly zones: readonly ZoneInfo[]; readonly zones: readonly ZoneInfo[];
readonly pageContent: ZonesOfRegulationPageContent; readonly pageContent: ZonesOfRegulationPageContent;
readonly expandedZone: ZoneColor | null; readonly expandedZone: ZoneColor | null;
readonly checkedInZone: ZoneColor | null;
readonly selectedZone: ZoneInfo | null; readonly selectedZone: ZoneInfo | null;
readonly activeTab: ZonesOfRegulationTab; readonly activeTab: ZonesOfRegulationTab;
readonly safetyConnection: ZonesSafetyConnection | null; readonly safetyConnection: ZonesSafetyConnection | null;
readonly isLoading: boolean; readonly isLoading: boolean;
readonly isZoneSaving: boolean;
readonly zonesError: Error | null; readonly zonesError: Error | null;
readonly pageContentError: Error | null; readonly pageContentError: Error | null;
readonly zoneCheckInErrorMessage: string | null;
readonly setActiveTab: (tab: ZonesOfRegulationTab) => void; readonly setActiveTab: (tab: ZonesOfRegulationTab) => void;
readonly toggleExpandedZone: (zone: ZoneColor) => void; readonly selectZone: (zone: ZoneColor) => Promise<void>;
} }

View File

@ -31,15 +31,15 @@ export function DirectorRiskList({
key={`${risk.module}-${risk.issue}`} key={`${risk.module}-${risk.issue}`}
type="button" type="button"
onClick={() => onOpenModule(risk.module)} 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`}
> >
<div className="flex items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<span className={`px-2 py-1 rounded-lg text-[10px] font-bold uppercase ${directorRiskSeverityClasses[risk.severity]}`}> <span className={`px-2 py-1 rounded-lg text-[10px] font-bold uppercase ${directorRiskSeverityClasses[risk.severity]}`}>
{risk.severity} {risk.severity}
</span> </span>
<p className="text-sm font-medium text-gray-700">{risk.issue}</p> <p className="min-w-0 break-words text-sm font-medium text-gray-700">{risk.issue}</p>
</div> </div>
<NavigateIcon size={14} className="text-gray-400" /> <NavigateIcon size={14} className="shrink-0 text-gray-400" />
</Button> </Button>
))} ))}
</div> </div>

View File

@ -1,16 +1,12 @@
import { useZonesOfRegulationPage } from '@/business/zones/hooks'; import { useZonesOfRegulationPage } from '@/business/zones/hooks';
import { ZonesOfRegulationView } from '@/components/zones-of-regulation/ZonesOfRegulationView'; import { ZonesOfRegulationView } from '@/components/zones-of-regulation/ZonesOfRegulationView';
import { ZoneCheckInSection } from '@/components/zone-checkin/ZoneCheckInSection'; import { useAuth } from '@/contexts/useAuth';
const ZonesOfRegulation = () => { const ZonesOfRegulation = () => {
const page = useZonesOfRegulationPage(); const { user } = useAuth();
const page = useZonesOfRegulationPage({ user });
return ( return <ZonesOfRegulationView page={page} />;
<div className="space-y-6">
<ZoneCheckInSection />
<ZonesOfRegulationView page={page} />
</div>
);
}; };
export default ZonesOfRegulation; export default ZonesOfRegulation;

View File

@ -57,15 +57,14 @@ export function ZoneCheckInCard({
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{zones.map((zone) => ( {zones.map((zone) => (
<Button <button
key={zone.color} key={zone.color}
type="button" type="button"
variant="ghost"
onClick={() => onCheckIn(zone.color)} onClick={() => onCheckIn(zone.color)}
disabled={isSaving} disabled={isSaving}
className={cn( className={cn(
'p-3 rounded-xl border-2 transition-all duration-200 h-auto flex-col gap-0 whitespace-normal',
zone.bgClass, zone.bgClass,
'flex h-auto flex-col items-center gap-0 rounded-xl border-2 p-3 text-center whitespace-normal transition-transform duration-200 hover:scale-[1.02] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950 disabled:pointer-events-none disabled:opacity-70',
activeZone === zone.color activeZone === zone.color
? `${zone.borderClass} ring-2 ${zone.ringClass} scale-105` ? `${zone.borderClass} ring-2 ${zone.ringClass} scale-105`
: 'border-transparent', : 'border-transparent',
@ -73,7 +72,7 @@ export function ZoneCheckInCard({
> >
<span className={cn('font-bold text-sm', zone.textClass)}>{zone.label}</span> <span className={cn('font-bold text-sm', zone.textClass)}>{zone.label}</span>
<span className="text-[10px] text-slate-500">{zone.description}</span> <span className="text-[10px] text-slate-500">{zone.description}</span>
</Button> </button>
))} ))}
</div> </div>
{errorMessage && <p className="text-xs text-red-400 mt-3">{errorMessage}</p>} {errorMessage && <p className="text-xs text-red-400 mt-3">{errorMessage}</p>}

View File

@ -9,9 +9,10 @@ import { ZoneCheckInCard } from '@/components/zone-checkin/ZoneCheckInCard';
import { ZoneCheckInReminder } from '@/components/zone-checkin/ZoneCheckInReminder'; import { ZoneCheckInReminder } from '@/components/zone-checkin/ZoneCheckInReminder';
/** /**
* Self-contained daily Zone check-in (reminder banner + card) for the Zones of * Self-contained daily Zone check-in (reminder banner + card) for surfaces that
* Regulation page. Owns its own state via `useTodayZoneCheckIn`; rendered only * need the composed check-in widget. Owns its own state via
* when the current user has `ZONE_CHECKIN`. * `useTodayZoneCheckIn`; rendered only when the user can save in the current
* scope.
*/ */
export function ZoneCheckInSection() { export function ZoneCheckInSection() {
const { user } = useAuth(); const { user } = useAuth();

View File

@ -1,4 +1,3 @@
import { Button } from '@/components/ui/button';
import { import {
ZONE_GRADIENT_CLASSES, ZONE_GRADIENT_CLASSES,
ZONE_RING_CLASSES, ZONE_RING_CLASSES,
@ -12,32 +11,36 @@ import { cn } from '@/lib/utils';
interface ZoneOverviewCardProps { interface ZoneOverviewCardProps {
readonly zone: ZoneInfo; readonly zone: ZoneInfo;
readonly expandedZone: ZoneColor | null; readonly expandedZone: ZoneColor | null;
readonly onToggle: (zone: ZoneColor) => void; readonly checkedInZone: ZoneColor | null;
readonly isSaving: boolean;
readonly onSelect: (zone: ZoneColor) => Promise<void>;
} }
export function ZoneOverviewCard({ export function ZoneOverviewCard({
zone, zone,
expandedZone, expandedZone,
onToggle, checkedInZone,
isSaving,
onSelect,
}: ZoneOverviewCardProps) { }: ZoneOverviewCardProps) {
const selected = expandedZone === zone.color; const selected = (expandedZone ?? checkedInZone) === zone.color;
return ( return (
<Button <button
type="button" type="button"
variant="ghost" onClick={() => { void onSelect(zone.color); }}
onClick={() => onToggle(zone.color)} disabled={isSaving}
className={cn( className={cn(
zone.bgClass, zone.bgClass,
'rounded-2xl p-5 border-2 transition-all duration-200 hover:scale-[1.02] text-left h-auto justify-start items-stretch flex-col whitespace-normal', 'group flex h-auto flex-col items-stretch justify-start rounded-2xl border-2 p-5 text-left shadow-sm transition-transform duration-200 hover:scale-[1.02] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950 disabled:pointer-events-none disabled:opacity-70',
selected selected
? `${zone.borderClass} ring-2 ring-offset-2 ${ZONE_RING_CLASSES[zone.color]}` ? `${zone.borderClass} ring-2 ring-offset-2 ring-offset-slate-950 ${ZONE_RING_CLASSES[zone.color]}`
: 'border-transparent', : 'border-transparent',
)} )}
aria-expanded={selected} aria-expanded={selected}
> >
<span className={cn( <span className={cn(
'w-12 h-12 rounded-xl bg-gradient-to-br flex items-center justify-center mb-3', 'w-12 h-12 rounded-xl bg-gradient-to-br flex items-center justify-center mb-3 transition-transform duration-200 group-hover:scale-105',
ZONE_GRADIENT_CLASSES[zone.color], ZONE_GRADIENT_CLASSES[zone.color],
)} )}
> >
@ -45,6 +48,6 @@ export function ZoneOverviewCard({
</span> </span>
<span className={cn('font-bold block', zone.textClass)}>{zone.name}</span> <span className={cn('font-bold block', zone.textClass)}>{zone.name}</span>
<span className="text-xs text-gray-700 mt-1 leading-relaxed block">{zone.description}</span> <span className="text-xs text-gray-700 mt-1 leading-relaxed block">{zone.description}</span>
</Button> </button>
); );
} }

View File

@ -9,8 +9,7 @@ export function ZonesHeader() {
description="A whole-campus emotional regulation system for students and staff" description="A whole-campus emotional regulation system for students and staff"
icon={Layers} icon={Layers}
iconClassName="bg-gradient-to-br from-teal-400 to-teal-600" iconClassName="bg-gradient-to-br from-teal-400 to-teal-600"
titleClassName="text-gray-800" iconShadowClassName="shadow-teal-500/25"
descriptionClassName="text-gray-500"
/> />
); );
} }

View File

@ -46,7 +46,10 @@ export function ZonesOfRegulationView({ page }: ZonesOfRegulationViewProps) {
<ZonesOverviewGrid <ZonesOverviewGrid
zones={page.zones} zones={page.zones}
expandedZone={page.expandedZone} expandedZone={page.expandedZone}
onToggleZone={page.toggleExpandedZone} checkedInZone={page.checkedInZone}
isSaving={page.isZoneSaving}
errorMessage={page.zoneCheckInErrorMessage}
onSelectZone={page.selectZone}
/> />
<ZoneDetailPanel <ZoneDetailPanel
@ -57,10 +60,7 @@ export function ZonesOfRegulationView({ page }: ZonesOfRegulationViewProps) {
</> </>
)} )}
<ZonesQuickFlow <ZonesQuickFlow />
title={page.pageContent.quickDeEscalationFlowTitle}
steps={page.pageContent.quickDeEscalationFlow}
/>
</div> </div>
); );
} }

View File

@ -7,24 +7,35 @@ import type {
interface ZonesOverviewGridProps { interface ZonesOverviewGridProps {
readonly zones: readonly ZoneInfo[]; readonly zones: readonly ZoneInfo[];
readonly expandedZone: ZoneColor | null; 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<void>;
} }
export function ZonesOverviewGrid({ export function ZonesOverviewGrid({
zones, zones,
expandedZone, expandedZone,
onToggleZone, checkedInZone,
isSaving,
errorMessage,
onSelectZone,
}: ZonesOverviewGridProps) { }: ZonesOverviewGridProps) {
return ( return (
<section className="grid grid-cols-2 md:grid-cols-4 gap-4"> <section className="space-y-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{zones.map((zone) => ( {zones.map((zone) => (
<ZoneOverviewCard <ZoneOverviewCard
key={zone.color} key={zone.color}
zone={zone} zone={zone}
expandedZone={expandedZone} expandedZone={expandedZone}
onToggle={onToggleZone} checkedInZone={checkedInZone}
isSaving={isSaving}
onSelect={onSelectZone}
/> />
))} ))}
</div>
{errorMessage && <p className="text-xs text-red-400">{errorMessage}</p>}
</section> </section>
); );
} }

View File

@ -1,28 +1,60 @@
import type { ZonesDeEscalationStep } from '@/shared/types/app'; const QUICK_FLOW_STEPS = [
import { cn } from '@/lib/utils'; {
step: '1',
interface ZonesQuickFlowProps { label: 'Notice the Zone',
readonly title: string; description: 'Identify current zone',
readonly steps: readonly ZonesDeEscalationStep[]; cardClassName: 'border-teal-200 bg-teal-100 text-teal-800',
} badgeClassName: 'bg-teal-50 text-teal-800',
},
export function ZonesQuickFlow({ title, steps }: ZonesQuickFlowProps) { {
if (!title || steps.length === 0) { step: '2',
return null; 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 ( return (
<section className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5"> <section className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5">
<h3 className="font-semibold text-gray-800 mb-4">{title}</h3> <h3 className="font-semibold text-gray-800 mb-4">Quick Behavior Management Flow</h3>
<div className="flex flex-col md:flex-row gap-3"> <div className="flex flex-col md:flex-row gap-3">
{steps.map((step) => ( {QUICK_FLOW_STEPS.map((step) => (
<div key={step.step} className="flex-1"> <div key={step.step} className="flex-1">
<div className={cn(step.colorClass, 'rounded-xl p-4 border text-center h-full')}> <div className={`rounded-xl p-4 border text-center h-full ${step.cardClassName}`}>
<div className="w-8 h-8 rounded-full bg-white/60 flex items-center justify-center mx-auto mb-2 font-bold text-sm"> <div
className={[
'w-8 h-8 rounded-full flex items-center justify-center',
'mx-auto mb-2 font-bold text-sm',
step.badgeClassName,
].join(' ')}
>
{step.step} {step.step}
</div> </div>
<p className="font-semibold text-sm">{step.label}</p> <p className="font-semibold text-sm">{step.label}</p>
<p className="text-[10px] mt-1 opacity-80">{step.description}</p> <p className="text-[10px] mt-1 font-medium opacity-85">{step.description}</p>
</div> </div>
</div> </div>
))} ))}

View File

@ -26,6 +26,8 @@ import { USER_NAME_PREFIX_OPTIONS } from '@/shared/constants/users';
import { ImageUpload } from '@/components/common/ImageUpload'; import { ImageUpload } from '@/components/common/ImageUpload';
import { useCurrentPersonalityResultHistory } from '@/business/personality/queryHooks'; import { useCurrentPersonalityResultHistory } from '@/business/personality/queryHooks';
import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks'; import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks';
import { useTodayZoneCheckIn } from '@/business/zone-checkin/hooks';
import { canZoneCheckIn } from '@/business/zone-checkin/selectors';
interface StatusMessage { interface StatusMessage {
readonly type: 'success' | 'error'; readonly type: 'success' | 'error';
@ -70,6 +72,8 @@ export default function ProfilePage() {
const capabilitiesQuery = useIamCapabilities(); const capabilitiesQuery = useIamCapabilities();
const safetyQuizStatus = useMySafetyQuizStatus(undefined, Boolean(user)); const safetyQuizStatus = useMySafetyQuizStatus(undefined, Boolean(user));
const personalityHistoryStatus = useCurrentPersonalityResultHistory(Boolean(user)); const personalityHistoryStatus = useCurrentPersonalityResultHistory(Boolean(user));
const canUseZoneCheckin = canZoneCheckIn(user);
const zoneCheckinStatus = useTodayZoneCheckIn({ enabled: canUseZoneCheckin });
const [namePrefix, setNamePrefix] = useState<string>(user?.name_prefix ?? ''); const [namePrefix, setNamePrefix] = useState<string>(user?.name_prefix ?? '');
const [firstName, setFirstName] = useState<string>(user?.firstName ?? ''); const [firstName, setFirstName] = useState<string>(user?.firstName ?? '');
@ -112,8 +116,23 @@ export default function ProfilePage() {
() => buildProfileQuizResultRows( () => buildProfileQuizResultRows(
safetyQuizStatus.data?.result ?? null, safetyQuizStatus.data?.result ?? null,
personalityHistoryStatus.data ?? [], 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) { if (!user) {
@ -372,7 +391,7 @@ export default function ProfilePage() {
</CardHeader> </CardHeader>
<CardContent className="px-4 pb-4 md:px-5"> <CardContent className="px-4 pb-4 md:px-5">
<div className={`${formPanelClassName} mt-6`}> <div className={`${formPanelClassName} mt-6`}>
{safetyQuizStatus.isLoading || personalityHistoryStatus.isLoading ? ( {safetyQuizStatus.isLoading || personalityHistoryStatus.isLoading || zoneCheckinStatus.isLoading ? (
<p className="text-sm text-slate-300">Loading quiz results...</p> <p className="text-sm text-slate-300">Loading quiz results...</p>
) : ( ) : (
<div className="overflow-hidden rounded-lg border border-slate-700/70"> <div className="overflow-hidden rounded-lg border border-slate-700/70">

View File

@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { import {
checkInZone, checkInZone,
clearTodayZoneCheckin, clearTodayZoneCheckin,
getZoneCheckinCompletion,
getTodayZoneCheckin, getTodayZoneCheckin,
listZoneCheckinHistory, listZoneCheckinHistory,
} from '@/shared/api/zoneCheckins'; } from '@/shared/api/zoneCheckins';
@ -77,4 +78,35 @@ describe('zoneCheckins API', () => {
expect(apiRequestMock).toHaveBeenCalledWith('/zone_checkins'); 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');
});
}); });

View File

@ -3,6 +3,7 @@ import type { ApiListResponse } from '@/shared/types/api';
import type { ZoneColor } from '@/shared/types/app'; import type { ZoneColor } from '@/shared/types/app';
import type { import type {
ZoneCheckinHistoryEntryDto, ZoneCheckinHistoryEntryDto,
ZoneCheckinCompletionDto,
ZoneCheckinTodayDto, ZoneCheckinTodayDto,
} from '@/shared/types/zoneCheckins'; } from '@/shared/types/zoneCheckins';
@ -32,3 +33,7 @@ export function listZoneCheckinHistory(): Promise<
ZONE_CHECKINS_PATH, ZONE_CHECKINS_PATH,
); );
} }
export function getZoneCheckinCompletion(): Promise<ZoneCheckinCompletionDto> {
return apiRequest<ZoneCheckinCompletionDto>(`${ZONE_CHECKINS_PATH}/completion`);
}

View File

@ -33,7 +33,7 @@ export const MODULE_PERMISSIONS = [
'MANAGE_FRAME', 'MANAGE_WALKTHROUGH', 'MANAGE_INTERNAL_COMM', 'MANAGE_FRAME', 'MANAGE_WALKTHROUGH', 'MANAGE_INTERNAL_COMM',
'MANAGE_CONTENT_CATALOG', 'READ_STAFF_ATTENDANCE_REPORTS', 'MANAGE_CONTENT_CATALOG', 'READ_STAFF_ATTENDANCE_REPORTS',
'READ_SAFETY_QUIZ_REPORTS', 'READ_PERSONALITY_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', 'READ_AUDIO_FILES', 'MANAGE_AUDIO_FILES',
] as const; ] as const;

View File

@ -150,17 +150,8 @@ export interface ZonesSafetyConnection {
readonly description: string; readonly description: string;
} }
export interface ZonesDeEscalationStep {
readonly step: string;
readonly label: string;
readonly description: string;
readonly colorClass: string;
}
export interface ZonesOfRegulationPageContent { export interface ZonesOfRegulationPageContent {
readonly safetyConnections: readonly ZonesSafetyConnection[]; readonly safetyConnections: readonly ZonesSafetyConnection[];
readonly quickDeEscalationFlowTitle: string;
readonly quickDeEscalationFlow: readonly ZonesDeEscalationStep[];
} }
export type ModuleId = export type ModuleId =

View File

@ -15,3 +15,29 @@ export interface ZoneCheckinHistoryEntryDto {
readonly date: string; readonly date: string;
readonly zone: ZoneColor; 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[];
}