improved zones of regulation functionality
This commit is contained in:
parent
c397e97b9f
commit
2b496033cf
@ -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`.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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.
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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.
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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]: [
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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).
|
||||||
|
|
||||||
|
|||||||
@ -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/`.
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
{zones.map((zone) => (
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<ZoneOverviewCard
|
{zones.map((zone) => (
|
||||||
key={zone.color}
|
<ZoneOverviewCard
|
||||||
zone={zone}
|
key={zone.color}
|
||||||
expandedZone={expandedZone}
|
zone={zone}
|
||||||
onToggle={onToggleZone}
|
expandedZone={expandedZone}
|
||||||
/>
|
checkedInZone={checkedInZone}
|
||||||
))}
|
isSaving={isSaving}
|
||||||
|
onSelect={onSelectZone}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{errorMessage && <p className="text-xs text-red-400">{errorMessage}</p>}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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`);
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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 =
|
||||||
|
|||||||
@ -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[];
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user