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