added behaviour management CRUD
This commit is contained in:
parent
40e60165d4
commit
d79a618d4f
@ -28,6 +28,7 @@ DTO fields: `id`, `content_type`, `payload`, `updatedAt`.
|
|||||||
- `GET /api/content-catalog/read/:contentType` requires an authenticated user and returns only records where `active = true`.
|
- `GET /api/content-catalog/read/:contentType` requires an authenticated user and returns only records where `active = true`.
|
||||||
- All `/api/content-catalog` management endpoints require `MANAGE_CONTENT_CATALOG`. `custom_permissions` can grant it and `custom_permissions_filter` can remove it for non-global users. Tenant/content-type scoping still constrains which row is edited.
|
- All `/api/content-catalog` management endpoints require `MANAGE_CONTENT_CATALOG`. `custom_permissions` can grant it and `custom_permissions_filter` can remove it for non-global users. Tenant/content-type scoping still constrains which row is edited.
|
||||||
- `classroom-strategies` is an organization-scoped catalog. Management requires `MANAGE_CONTENT_CATALOG` and an effective organization scope; school, campus, and class drill-down scopes can read it but cannot update the organization strategy library.
|
- `classroom-strategies` is an organization-scoped catalog. Management requires `MANAGE_CONTENT_CATALOG` and an effective organization scope; school, campus, and class drill-down scopes can read it but cannot update the organization strategy library.
|
||||||
|
- `safety-qbs-quiz` is also organization-scoped. Organization users with `MANAGE_CONTENT_CATALOG` manage the weekly quiz and key reminders once for the organization; school, campus, and class users only read and complete that organization-owned quiz.
|
||||||
|
|
||||||
## Tenant Scope
|
## Tenant Scope
|
||||||
Content records can be tenant-scoped through nullable `organizationId`, `schoolId`, `campusId`, and `classId` columns:
|
Content records can be tenant-scoped through nullable `organizationId`, `schoolId`, `campusId`, and `classId` columns:
|
||||||
@ -35,6 +36,7 @@ Content records can be tenant-scoped through nullable `organizationId`, `schoolI
|
|||||||
- Per-tenant types use the caller's own tenant (`getOwnTenant`) and exact owner matching. Dashboard content (`dashboard-encouraging-quotes`, `dashboard-compliance-items`, `dashboard-sign-of-week`, and related dashboard assets) is seeded at organization, school, and campus scope so leadership dashboards work at every supported drill-down level.
|
- Per-tenant types use the caller's own tenant (`getOwnTenant`) and exact owner matching. Dashboard content (`dashboard-encouraging-quotes`, `dashboard-compliance-items`, `dashboard-sign-of-week`, and related dashboard assets) is seeded at organization, school, and campus scope so leadership dashboards work at every supported drill-down level.
|
||||||
- School-scoped types read the caller's resolved school row.
|
- School-scoped types read the caller's resolved school row.
|
||||||
- Org-scoped types read the caller's organization row. `classroom-strategies` is org-scoped: every organization gets one preset editable strategy library, while school, campus, and classroom views read that organization library instead of owning duplicate rows.
|
- Org-scoped types read the caller's organization row. `classroom-strategies` is org-scoped: every organization gets one preset editable strategy library, while school, campus, and classroom views read that organization library instead of owning duplicate rows.
|
||||||
|
- `safety-qbs-quiz` is org-scoped for the same reason: there is one weekly QBS quiz payload per organization, and descendant scopes read the organization payload.
|
||||||
- Shared/global types use all-null tenant ids.
|
- Shared/global types use all-null tenant ids.
|
||||||
|
|
||||||
Management list excludes tenant-scoped content because those records are edited through dedicated per-type editors.
|
Management list excludes tenant-scoped content because those records are edited through dedicated per-type editors.
|
||||||
@ -49,7 +51,7 @@ Management list excludes tenant-scoped content because those records are edited
|
|||||||
- `create` looks up any existing row by `content_type` plus its exact tenant owner with `paranoid: false`. If a non-deleted row exists it throws `ValidationError`. If a soft-deleted row exists it is restored and updated inside `withTransaction`; otherwise a new row is created.
|
- `create` looks up any existing row by `content_type` plus its exact tenant owner with `paranoid: false`. If a non-deleted row exists it throws `ValidationError`. If a soft-deleted row exists it is restored and updated inside `withTransaction`; otherwise a new row is created.
|
||||||
- `update` and `delete` run inside `withTransaction` and throw `ValidationError('contentCatalogNotFound')` when the row is missing. `delete` sets `active = false` then soft-deletes (`destroy`).
|
- `update` and `delete` run inside `withTransaction` and throw `ValidationError('contentCatalogNotFound')` when the row is missing. `delete` sets `active = false` then soft-deletes (`destroy`).
|
||||||
- `findManagedByType` is the authenticated variant of `findByType`: it enforces manage access and then returns the active record.
|
- `findManagedByType` is the authenticated variant of `findByType`: it enforces manage access and then returns the active record.
|
||||||
- For `classroom-strategies`, `findManagedByType`, `create`, `update`, and `delete` also require organization effective scope so organization leaders manage the shared strategy library and descendant scopes remain read-only.
|
- For `classroom-strategies` and `safety-qbs-quiz`, `findManagedByType`, `create`, `update`, and `delete` also require organization effective scope so organization leaders manage shared content and descendant scopes remain read-only.
|
||||||
- Managed `classroom-strategies` images are uploaded through the file subsystem first. The content payload stores the returned private URL; production uploads use the configured GCloud bucket path from `file.md`.
|
- Managed `classroom-strategies` images are uploaded through the file subsystem first. The content payload stores the returned private URL; production uploads use the configured GCloud bucket path from `file.md`.
|
||||||
- Missing/inactive content types fail explicitly with `ValidationError` rather than returning empty payloads.
|
- Missing/inactive content types fail explicitly with `ValidationError` rather than returning empty payloads.
|
||||||
|
|
||||||
@ -58,7 +60,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 `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.
|
||||||
|
|
||||||
New tenant creation uses `ContentCatalogSeedService.seedDefaultContentForTenant`: org creation presets org-scoped content such as `classroom-strategies`, school creation presets school-scoped content, and campus creation presets only per-tenant campus content. This keeps the Classroom Support library shared at the organization level.
|
New tenant creation uses `ContentCatalogSeedService.seedDefaultContentForTenant`: org creation presets org-scoped content such as `classroom-strategies` and `safety-qbs-quiz`, school creation presets school-scoped content, and campus creation presets only per-tenant campus content. This keeps shared libraries and the weekly QBS quiz owned at the organization level.
|
||||||
|
|
||||||
### Content authoring rules
|
### Content authoring rules
|
||||||
- Add editable or tenant-scoped production content records to backend seed payloads, not frontend constants.
|
- Add editable or tenant-scoped production content records to backend seed payloads, not frontend constants.
|
||||||
@ -66,7 +68,7 @@ New tenant creation uses `ContentCatalogSeedService.seedDefaultContentForTenant`
|
|||||||
- If a catalog needs complex workflow state, approvals, or per-campus variants, replace the generic catalog entry with typed, tenant-scoped backend tables and CRUD APIs.
|
- If a catalog needs complex workflow state, approvals, or per-campus variants, replace the generic catalog entry with typed, tenant-scoped backend tables and CRUD APIs.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
Coverage lives in `src/services/content_catalog.test.ts` and `src/services/content_catalog_seed.test.ts`. It covers tenant read scoping, organization-only management for `classroom-strategies`, and organization-only seeding for the preset Classroom Support library.
|
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 `classroom-strategies` and `safety-qbs-quiz`, and organization-only seeding for the preset Classroom Support library and QBS quiz.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
- Frontend: `frontend/docs/content-catalog-integration.md`.
|
- Frontend: `frontend/docs/content-catalog-integration.md`.
|
||||||
|
|||||||
@ -8,7 +8,8 @@ role snapshot, and persistence. Each submission is an append (create) — there
|
|||||||
|
|
||||||
## Slice Files (by layer)
|
## Slice Files (by layer)
|
||||||
|
|
||||||
- Route: `src/routes/safety_quiz_results.ts` (thin wiring; `GET /`, `POST /`).
|
- Route: `src/routes/safety_quiz_results.ts` (thin wiring; `GET /`, `GET /me`,
|
||||||
|
`GET /completion`, `POST /`).
|
||||||
- Controller: `src/api/controllers/safety_quiz_results.controller.ts` (custom — not the CRUD
|
- Controller: `src/api/controllers/safety_quiz_results.controller.ts` (custom — not the CRUD
|
||||||
factory).
|
factory).
|
||||||
- Service (BLL): `src/services/safety_quiz_results.ts`.
|
- Service (BLL): `src/services/safety_quiz_results.ts`.
|
||||||
@ -27,6 +28,12 @@ All routes require JWT authentication. Base path mounted at `/api/safety_quiz_re
|
|||||||
- `GET /api/safety_quiz_results` -> `200` `{ rows, count }`. Optional query `week_of`, plus
|
- `GET /api/safety_quiz_results` -> `200` `{ rows, count }`. Optional query `week_of`, plus
|
||||||
`limit` / `page` (paginated via `resolvePagination`). Returns results visible to the current user
|
`limit` / `page` (paginated via `resolvePagination`). Returns results visible to the current user
|
||||||
(see Access Rules), ordered by `completed_at` desc.
|
(see Access Rules), ordered by `completed_at` desc.
|
||||||
|
- `GET /api/safety_quiz_results/me` -> `200` `{ completed, result }`. Optional query `week_of`.
|
||||||
|
Always reads only the authenticated user's own saved quiz result and is used for profile status
|
||||||
|
and weekly notification state.
|
||||||
|
- `GET /api/safety_quiz_results/completion` -> `200` `{ summary, rows }`. Optional query
|
||||||
|
`week_of`. Requires report access and returns every staff user in the current scope with
|
||||||
|
`complete` or `pending` status based on saved `safety_quiz_results` rows.
|
||||||
- `POST /api/safety_quiz_results` -> `201`. Request body wrapped as `{ data: <SafetyQuizInput> }`.
|
- `POST /api/safety_quiz_results` -> `201`. Request body wrapped as `{ data: <SafetyQuizInput> }`.
|
||||||
Returns the created result DTO. If the caller is a parent-scope user acting through a drilled
|
Returns the created result DTO. If the caller is a parent-scope user acting through a drilled
|
||||||
child scope, the request is accepted as a no-op and returns `null`.
|
child scope, the request is accepted as a no-op and returns `null`.
|
||||||
@ -41,6 +48,9 @@ All routes require JWT authentication. Base path mounted at `/api/safety_quiz_re
|
|||||||
reportable quiz rows for that child scope.
|
reportable quiz rows for that child scope.
|
||||||
- `list`: users with `READ_SAFETY_QUIZ_REPORTS` see scope-filtered results;
|
- `list`: users with `READ_SAFETY_QUIZ_REPORTS` see scope-filtered results;
|
||||||
everyone else sees only their own rows (filtered by `userId`).
|
everyone else sees only their own rows (filtered by `userId`).
|
||||||
|
- `me`: ignores report access and only checks the authenticated user's own saved result rows.
|
||||||
|
- `completion`: requires `READ_SAFETY_QUIZ_REPORTS` and joins staff users in the current scope with
|
||||||
|
saved result rows. Pending rows are derived from missing database results, not frontend state.
|
||||||
Role-seeded permissions are only the baseline grants. `custom_permissions` can
|
Role-seeded permissions are only the baseline grants. `custom_permissions` can
|
||||||
grant the report permission and
|
grant the report permission and
|
||||||
`custom_permissions_filter` can remove it for non-global users.
|
`custom_permissions_filter` can remove it for non-global users.
|
||||||
@ -79,13 +89,17 @@ All routes require JWT authentication. Base path mounted at `/api/safety_quiz_re
|
|||||||
persistence and returns `null`. Otherwise it runs inside `withTransaction`; trimmed string fields
|
persistence and returns `null`. Otherwise it runs inside `withTransaction`; trimmed string fields
|
||||||
are persisted.
|
are persisted.
|
||||||
- `list` is paginated with shared defaults (`resolvePagination`).
|
- `list` is paginated with shared defaults (`resolvePagination`).
|
||||||
|
- `completion` reports organization, school, campus, and class scope staff according to the active
|
||||||
|
scope. Student, guardian, guest, and system roles are not completion subjects.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
- `src/services/personal_scope_results.test.ts` verifies that parent users drilled into child
|
- `src/services/personal_scope_results.test.ts` verifies that parent users drilled into child
|
||||||
scopes do not create safety quiz result rows.
|
scopes do not create safety quiz result rows, that personal status reads from saved rows, and that
|
||||||
|
completion reports include both completed and pending staff.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- Frontend: `frontend/docs/safety-quiz-integration.md`.
|
- Frontend: `frontend/docs/safety-quiz-integration.md`.
|
||||||
|
- Quiz content management: `backend/docs/content-catalog.md` (`safety-qbs-quiz` is organization-scoped).
|
||||||
- Related slices: `personality-quiz-results.md`, `walkthrough-checkins.md`, `user-progress.md`.
|
- Related slices: `personality-quiz-results.md`, `walkthrough-checkins.md`, `user-progress.md`.
|
||||||
|
|||||||
@ -9,6 +9,22 @@ export async function list(req: Request, res: Response): Promise<void> {
|
|||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function me(req: Request, res: Response): Promise<void> {
|
||||||
|
const payload = await SafetyQuizResultsService.me(
|
||||||
|
req.query,
|
||||||
|
req.currentUser,
|
||||||
|
);
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completion(req: Request, res: Response): Promise<void> {
|
||||||
|
const payload = await SafetyQuizResultsService.completion(
|
||||||
|
req.query,
|
||||||
|
req.currentUser,
|
||||||
|
);
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}
|
||||||
|
|
||||||
export async function create(req: Request, res: Response): Promise<void> {
|
export async function create(req: Request, res: Response): Promise<void> {
|
||||||
const payload = await SafetyQuizResultsService.create(
|
const payload = await SafetyQuizResultsService.create(
|
||||||
req.body.data,
|
req.body.data,
|
||||||
|
|||||||
@ -0,0 +1,73 @@
|
|||||||
|
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 {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const row = value.find(isIdRow);
|
||||||
|
return row?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.TAKE_QUIZ } },
|
||||||
|
);
|
||||||
|
let permissionId = firstId(permissionRows);
|
||||||
|
|
||||||
|
if (!permissionId) {
|
||||||
|
permissionId = uuid();
|
||||||
|
await queryInterface.bulkInsert('permissions', [{
|
||||||
|
id: permissionId,
|
||||||
|
name: FEATURE_PERMISSIONS.TAKE_QUIZ,
|
||||||
|
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 (Array.isArray(existingRows) && existingRows.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryInterface.bulkInsert('rolesPermissionsPermissions', [{
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
roles_permissionsId: roleId,
|
||||||
|
permissionId,
|
||||||
|
}]);
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async () => {
|
||||||
|
// Keep permission grants on rollback; permissions may be assigned manually.
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
import type { QueryInterface } from 'sequelize';
|
||||||
|
import { SAFETY_QUIZ_CONTENT_TYPE } from '@/shared/constants/content-catalog';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
up: async (queryInterface: QueryInterface) => {
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
`
|
||||||
|
UPDATE content_catalog
|
||||||
|
SET active = false, "deletedAt" = NOW(), "updatedAt" = NOW()
|
||||||
|
WHERE content_type = :contentType
|
||||||
|
AND "deletedAt" IS NULL
|
||||||
|
AND ("schoolId" IS NOT NULL OR "campusId" IS NOT NULL OR "classId" IS NOT NULL)
|
||||||
|
`,
|
||||||
|
{ replacements: { contentType: SAFETY_QUIZ_CONTENT_TYPE } },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async () => {
|
||||||
|
// Do not recreate formerly duplicated school/campus quiz rows on rollback.
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -87,8 +87,8 @@ export const EXTERNAL_ROLES: readonly RoleName[] = [
|
|||||||
* `student` gets external pages; `guardian` gets external pages plus parent comms.
|
* `student` gets external pages; `guardian` gets external pages plus parent comms.
|
||||||
*/
|
*/
|
||||||
export const MODULE_PERMISSIONS_BY_ROLE: Partial<Record<RoleName, readonly string[]>> = {
|
export const MODULE_PERMISSIONS_BY_ROLE: Partial<Record<RoleName, readonly string[]>> = {
|
||||||
// Registrar: read every product surface across the school for audit, but no
|
// Registrar: read every product surface across the school for audit, and
|
||||||
// action permissions (no fill-attendance/quiz/ack/zone, no audio manage).
|
// complete required staff safety training, but no operational write actions.
|
||||||
[ROLE_NAMES.REGISTRAR]: [
|
[ROLE_NAMES.REGISTRAR]: [
|
||||||
...MODULE_READ_ALL_STAFF,
|
...MODULE_READ_ALL_STAFF,
|
||||||
...MODULE_READ_INSTRUCTIONAL,
|
...MODULE_READ_INSTRUCTIONAL,
|
||||||
@ -98,6 +98,7 @@ export const MODULE_PERMISSIONS_BY_ROLE: Partial<Record<RoleName, readonly strin
|
|||||||
'READ_SAFETY_QUIZ_REPORTS',
|
'READ_SAFETY_QUIZ_REPORTS',
|
||||||
'READ_PERSONALITY_REPORTS',
|
'READ_PERSONALITY_REPORTS',
|
||||||
'READ_POLICY_ACKNOWLEDGMENT_REPORTS',
|
'READ_POLICY_ACKNOWLEDGMENT_REPORTS',
|
||||||
|
'TAKE_QUIZ',
|
||||||
'READ_AUDIO_FILES',
|
'READ_AUDIO_FILES',
|
||||||
],
|
],
|
||||||
[ROLE_NAMES.OFFICE_MANAGER]: [
|
[ROLE_NAMES.OFFICE_MANAGER]: [
|
||||||
|
|||||||
@ -24,8 +24,9 @@ import { CONTENT_CATALOG_SEED_PAYLOADS } from './content-catalog-data/content-ca
|
|||||||
/**
|
/**
|
||||||
* Owning-tenant ids for a seeded content type, derived from its scope class so
|
* Owning-tenant ids for a seeded content type, derived from its scope class so
|
||||||
* scoped users actually see the demo content: per-tenant types are seeded at
|
* scoped users actually see the demo content: per-tenant types are seeded at
|
||||||
* org, school, and campus levels; school-scoped at schools; org-scoped at orgs;
|
* org, school, and campus levels; school-scoped at schools; org-scoped at orgs
|
||||||
* the rest (truly global) carry no tenant.
|
* (including the organization-owned QBS safety quiz); the rest (truly global)
|
||||||
|
* carry no tenant.
|
||||||
*/
|
*/
|
||||||
function seedTenants(contentType: string): Array<{
|
function seedTenants(contentType: string): Array<{
|
||||||
organizationId: string | null;
|
organizationId: string | null;
|
||||||
|
|||||||
@ -29,7 +29,24 @@ const router = express.Router();
|
|||||||
* responses:
|
* responses:
|
||||||
* 200: { description: Submitted. }
|
* 200: { description: Submitted. }
|
||||||
* 403: { $ref: '#/components/responses/ForbiddenError' }
|
* 403: { $ref: '#/components/responses/ForbiddenError' }
|
||||||
|
* /api/safety_quiz_results/me:
|
||||||
|
* get:
|
||||||
|
* tags: [Quizzes]
|
||||||
|
* summary: Get current user's weekly safety quiz completion status
|
||||||
|
* responses:
|
||||||
|
* 200: { description: Personal completion status. }
|
||||||
|
* 401: { $ref: '#/components/responses/UnauthorizedError' }
|
||||||
|
* /api/safety_quiz_results/completion:
|
||||||
|
* get:
|
||||||
|
* tags: [Quizzes]
|
||||||
|
* summary: Staff safety quiz completion report
|
||||||
|
* description: Requires the `READ_SAFETY_QUIZ_REPORTS` permission.
|
||||||
|
* responses:
|
||||||
|
* 200: { description: Completion rows and summary. }
|
||||||
|
* 403: { $ref: '#/components/responses/ForbiddenError' }
|
||||||
*/
|
*/
|
||||||
|
router.get('/me', wrapAsync(safety_quiz_results.me));
|
||||||
|
router.get('/completion', wrapAsync(safety_quiz_results.completion));
|
||||||
router.get('/', wrapAsync(safety_quiz_results.list));
|
router.get('/', wrapAsync(safety_quiz_results.list));
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
|
|||||||
@ -172,4 +172,28 @@ describe('ContentCatalogService tenant scoping', () => {
|
|||||||
{ name: 'ForbiddenError' },
|
{ name: 'ForbiddenError' },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rejects QBS quiz management outside organization scope', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() => ContentCatalogService.findManagedByType(
|
||||||
|
'safety-qbs-quiz',
|
||||||
|
createGlobalAccessUser({
|
||||||
|
app_role: {
|
||||||
|
name: ROLE_NAMES.SUPER_ADMIN,
|
||||||
|
scope: ROLE_SCOPES.SYSTEM,
|
||||||
|
globalAccess: true,
|
||||||
|
permissions: [{ name: FEATURE_PERMISSIONS.MANAGE_CONTENT_CATALOG }],
|
||||||
|
},
|
||||||
|
activeScope: {
|
||||||
|
level: ROLE_SCOPES.CAMPUS,
|
||||||
|
organizationId: 'org-1',
|
||||||
|
schoolId: 'school-1',
|
||||||
|
campusId: 'campus-1',
|
||||||
|
classId: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
{ name: 'ForbiddenError' },
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
} from '@/services/shared/access';
|
} from '@/services/shared/access';
|
||||||
import {
|
import {
|
||||||
CLASSROOM_SUPPORT_CONTENT_TYPE,
|
CLASSROOM_SUPPORT_CONTENT_TYPE,
|
||||||
|
SAFETY_QUIZ_CONTENT_TYPE,
|
||||||
PER_TENANT_CONTENT_TYPES,
|
PER_TENANT_CONTENT_TYPES,
|
||||||
SCHOOL_SCOPED_CONTENT_TYPES,
|
SCHOOL_SCOPED_CONTENT_TYPES,
|
||||||
ORG_SCOPED_CONTENT_TYPES,
|
ORG_SCOPED_CONTENT_TYPES,
|
||||||
@ -114,7 +115,10 @@ function assertCanManageType(contentType: string, currentUser?: CurrentUser): vo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
contentType === CLASSROOM_SUPPORT_CONTENT_TYPE
|
(
|
||||||
|
contentType === CLASSROOM_SUPPORT_CONTENT_TYPE
|
||||||
|
|| contentType === SAFETY_QUIZ_CONTENT_TYPE
|
||||||
|
)
|
||||||
&& getRoleScope(currentUser) !== ROLE_SCOPES.ORGANIZATION
|
&& getRoleScope(currentUser) !== ROLE_SCOPES.ORGANIZATION
|
||||||
) {
|
) {
|
||||||
throw new ForbiddenError();
|
throw new ForbiddenError();
|
||||||
|
|||||||
@ -72,4 +72,46 @@ describe('seedDefaultContentForTenant', () => {
|
|||||||
assert.equal(classroomRows[0]?.active, true);
|
assert.equal(classroomRows[0]?.active, true);
|
||||||
assert.ok(Array.isArray(classroomRows[0]?.payload));
|
assert.ok(Array.isArray(classroomRows[0]?.payload));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('seeds safety quiz 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 safetyQuizRows = createdRows.filter((row) =>
|
||||||
|
row.content_type === 'safety-qbs-quiz',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(safetyQuizRows.length, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
safetyQuizRows.map((row) => ({
|
||||||
|
organizationId: row.organizationId,
|
||||||
|
schoolId: row.schoolId,
|
||||||
|
campusId: row.campusId,
|
||||||
|
})),
|
||||||
|
[
|
||||||
|
{ organizationId: 'org-1', schoolId: null, campusId: null },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -25,9 +25,10 @@ interface OwnerStamp {
|
|||||||
/**
|
/**
|
||||||
* The owning-tenant ids a content type takes when the given tenant level is
|
* The owning-tenant ids a content type takes when the given tenant level is
|
||||||
* created — or `null` if this type is not preset at this level. Per-tenant
|
* created — or `null` if this type is not preset at this level. Per-tenant
|
||||||
* safety quiz exists at org/school/campus; dashboard + parent templates only at
|
* org-scoped content such as the safety quiz exists only at organization level;
|
||||||
* org/school/campus; org-scoped only at org; school-scoped only at school; truly
|
* dashboard + parent templates exist at org/school/campus; school-scoped only
|
||||||
* global types are seeded once (with no tenant) when the first org is created.
|
* at school; truly global types are seeded once (with no tenant) when the first
|
||||||
|
* org is created.
|
||||||
*/
|
*/
|
||||||
function stampForLevel(
|
function stampForLevel(
|
||||||
contentType: string,
|
contentType: string,
|
||||||
|
|||||||
@ -85,4 +85,104 @@ describe('personal result persistence while drilled into child scope', () => {
|
|||||||
assert.equal(result, null);
|
assert.equal(result, null);
|
||||||
assert.equal(createCount, 0);
|
assert.equal(createCount, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('reads the current user safety quiz status from saved results', async () => {
|
||||||
|
mock.method(db.safety_quiz_results, 'findOne', (async () => ({
|
||||||
|
get: () => ({
|
||||||
|
id: 'result-1',
|
||||||
|
quiz_id: 'qbs-weekly',
|
||||||
|
quiz_title: 'QBS Weekly',
|
||||||
|
week_of: '2026-06-15',
|
||||||
|
score: 4,
|
||||||
|
total_questions: 4,
|
||||||
|
answers: [0, 1, 2, 3],
|
||||||
|
user_name: 'Emily Johnson',
|
||||||
|
user_role: ROLE_NAMES.TEACHER,
|
||||||
|
completed_at: new Date('2026-06-17T12:00:00Z'),
|
||||||
|
organizationId: 'org-1',
|
||||||
|
campusId: 'campus-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
createdAt: new Date('2026-06-17T12:00:00Z'),
|
||||||
|
updatedAt: new Date('2026-06-17T12:00:00Z'),
|
||||||
|
}),
|
||||||
|
})) as unknown as typeof db.safety_quiz_results.findOne);
|
||||||
|
|
||||||
|
const result = await SafetyQuizResultsService.me(
|
||||||
|
{ week_of: '2026-06-15' },
|
||||||
|
createTestUser({
|
||||||
|
id: 'user-1',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
organizations: { id: 'org-1' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.completed, true);
|
||||||
|
assert.equal(result.result?.id, 'result-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('builds safety quiz completion rows for completed and pending staff', async () => {
|
||||||
|
mock.method(db.users, 'findAll', (async () => [
|
||||||
|
{
|
||||||
|
id: 'user-1',
|
||||||
|
firstName: 'Emily',
|
||||||
|
lastName: 'Johnson',
|
||||||
|
email: 'teacher@flatlogic.com',
|
||||||
|
app_role: { name: ROLE_NAMES.TEACHER },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-2',
|
||||||
|
firstName: 'Marcus',
|
||||||
|
lastName: 'Davis',
|
||||||
|
email: 'support@flatlogic.com',
|
||||||
|
app_role: { name: ROLE_NAMES.SUPPORT_STAFF },
|
||||||
|
},
|
||||||
|
]) as unknown as typeof db.users.findAll);
|
||||||
|
mock.method(db.safety_quiz_results, 'findAll', (async () => [
|
||||||
|
{
|
||||||
|
userId: 'user-1',
|
||||||
|
get: () => ({
|
||||||
|
id: 'result-1',
|
||||||
|
quiz_id: 'qbs-weekly',
|
||||||
|
quiz_title: 'QBS Weekly',
|
||||||
|
week_of: '2026-06-15',
|
||||||
|
score: 4,
|
||||||
|
total_questions: 4,
|
||||||
|
answers: [0, 1, 2, 3],
|
||||||
|
user_name: 'Emily Johnson',
|
||||||
|
user_role: ROLE_NAMES.TEACHER,
|
||||||
|
completed_at: new Date('2026-06-17T12:00:00Z'),
|
||||||
|
organizationId: 'org-1',
|
||||||
|
campusId: 'campus-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
createdAt: new Date('2026-06-17T12:00:00Z'),
|
||||||
|
updatedAt: new Date('2026-06-17T12:00:00Z'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]) as unknown as typeof db.safety_quiz_results.findAll);
|
||||||
|
|
||||||
|
const report = await SafetyQuizResultsService.completion(
|
||||||
|
{ week_of: '2026-06-15' },
|
||||||
|
createTestUser({
|
||||||
|
id: 'director-1',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
organizations: { id: 'org-1' },
|
||||||
|
campusId: 'campus-1',
|
||||||
|
app_role: {
|
||||||
|
name: ROLE_NAMES.DIRECTOR,
|
||||||
|
scope: ROLE_SCOPES.CAMPUS,
|
||||||
|
globalAccess: false,
|
||||||
|
permissions: [permission(FEATURE_PERMISSIONS.READ_SAFETY_QUIZ_REPORTS)],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(report.summary, {
|
||||||
|
totalStaff: 2,
|
||||||
|
completedCount: 1,
|
||||||
|
pendingCount: 1,
|
||||||
|
completionRate: 50,
|
||||||
|
});
|
||||||
|
assert.equal(report.rows[0]?.status, 'complete');
|
||||||
|
assert.equal(report.rows[1]?.status, 'pending');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,19 +1,26 @@
|
|||||||
|
import { Op } from 'sequelize';
|
||||||
import db from '@/db/models';
|
import db from '@/db/models';
|
||||||
import { withTransaction } from '@/db/with-transaction';
|
import { withTransaction } from '@/db/with-transaction';
|
||||||
import { resolvePagination } from '@/shared/constants/pagination';
|
import { resolvePagination } from '@/shared/constants/pagination';
|
||||||
import ValidationError from '@/shared/errors/validation';
|
import ValidationError from '@/shared/errors/validation';
|
||||||
|
import ForbiddenError from '@/shared/errors/forbidden';
|
||||||
import {
|
import {
|
||||||
getOrganizationIdOrGlobal,
|
getOrganizationIdOrGlobal,
|
||||||
getCampusId,
|
getCampusId,
|
||||||
|
getSchoolId,
|
||||||
|
getClassId,
|
||||||
assertAuthenticatedTenantUser,
|
assertAuthenticatedTenantUser,
|
||||||
campusDimensionScope,
|
campusDimensionScope,
|
||||||
hasFeaturePermission,
|
hasFeaturePermission,
|
||||||
getDisplayName,
|
getDisplayName,
|
||||||
isActingInOwnScope,
|
isActingInOwnScope,
|
||||||
|
requireUserId,
|
||||||
|
getRoleScope,
|
||||||
} from '@/services/shared/access';
|
} from '@/services/shared/access';
|
||||||
import { ROLE_NAMES } from '@/shared/constants/roles';
|
import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles';
|
||||||
import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions';
|
import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions';
|
||||||
import type { SafetyQuizResults } from '@/db/models/safety_quiz_results';
|
import type { SafetyQuizResults } from '@/db/models/safety_quiz_results';
|
||||||
|
import type { Users } from '@/db/models/users';
|
||||||
import type { CurrentUser } from '@/db/api/types';
|
import type { CurrentUser } from '@/db/api/types';
|
||||||
|
|
||||||
interface SafetyQuizInput {
|
interface SafetyQuizInput {
|
||||||
@ -32,6 +39,16 @@ interface SafetyQuizFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const REQUIRED_STRINGS = ['quiz_id', 'quiz_title', 'week_of'] as const;
|
const REQUIRED_STRINGS = ['quiz_id', 'quiz_title', 'week_of'] as const;
|
||||||
|
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,
|
||||||
|
]);
|
||||||
|
|
||||||
function getProductRole(currentUser?: CurrentUser): string {
|
function getProductRole(currentUser?: CurrentUser): string {
|
||||||
return currentUser?.app_role?.name ?? ROLE_NAMES.TEACHER;
|
return currentUser?.app_role?.name ?? ROLE_NAMES.TEACHER;
|
||||||
@ -80,6 +97,91 @@ function toDto(record: SafetyQuizResults) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertCanReadCompletion(currentUser?: CurrentUser): void {
|
||||||
|
assertAuthenticatedTenantUser(currentUser);
|
||||||
|
if (
|
||||||
|
hasFeaturePermission(
|
||||||
|
currentUser,
|
||||||
|
FEATURE_PERMISSIONS.READ_SAFETY_QUIZ_REPORTS,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new ForbiddenError();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 latestResultByUser(
|
||||||
|
results: readonly SafetyQuizResults[],
|
||||||
|
): ReadonlyMap<string, SafetyQuizResults> {
|
||||||
|
const byUser = new Map<string, SafetyQuizResults>();
|
||||||
|
for (const result of results) {
|
||||||
|
const userId = result.userId;
|
||||||
|
if (!userId || byUser.has(userId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
byUser.set(userId, result);
|
||||||
|
}
|
||||||
|
return byUser;
|
||||||
|
}
|
||||||
|
|
||||||
class SafetyQuizResultsService {
|
class SafetyQuizResultsService {
|
||||||
static async list(filter: SafetyQuizFilter, currentUser?: CurrentUser) {
|
static async list(filter: SafetyQuizFilter, currentUser?: CurrentUser) {
|
||||||
assertAuthenticatedTenantUser(currentUser);
|
assertAuthenticatedTenantUser(currentUser);
|
||||||
@ -144,6 +246,81 @@ class SafetyQuizResultsService {
|
|||||||
return toDto(created);
|
return toDto(created);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async me(filter: SafetyQuizFilter, currentUser?: CurrentUser) {
|
||||||
|
assertAuthenticatedTenantUser(currentUser);
|
||||||
|
const result = await db.safety_quiz_results.findOne({
|
||||||
|
where: {
|
||||||
|
userId: requireUserId(currentUser),
|
||||||
|
...(filter.week_of ? { week_of: filter.week_of } : {}),
|
||||||
|
},
|
||||||
|
order: [['completed_at', 'desc']],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
completed: Boolean(result),
|
||||||
|
result: result ? toDto(result) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static async completion(filter: SafetyQuizFilter, 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 userIds = staffUsers.map((user) => user.id);
|
||||||
|
const results = userIds.length > 0
|
||||||
|
? await db.safety_quiz_results.findAll({
|
||||||
|
where: {
|
||||||
|
userId: userIds,
|
||||||
|
...(filter.week_of ? { week_of: filter.week_of } : {}),
|
||||||
|
},
|
||||||
|
order: [['completed_at', 'desc']],
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const resultByUser = latestResultByUser(results);
|
||||||
|
const rows = staffUsers.map((user) => {
|
||||||
|
const result = resultByUser.get(user.id);
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
name: displayNameOf(user),
|
||||||
|
email: user.email,
|
||||||
|
role: user.app_role?.name ?? null,
|
||||||
|
status: result ? 'complete' : 'pending',
|
||||||
|
result: result ? toDto(result) : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const completedCount = rows.filter((row) => row.status === 'complete').length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
totalStaff: rows.length,
|
||||||
|
completedCount,
|
||||||
|
pendingCount: Math.max(rows.length - completedCount, 0),
|
||||||
|
completionRate: rows.length > 0
|
||||||
|
? Math.round((completedCount / rows.length) * 100)
|
||||||
|
: 0,
|
||||||
|
},
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SafetyQuizResultsService;
|
export default SafetyQuizResultsService;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
/** Classroom Support — org-scoped and managed from organization scope. */
|
/** Classroom Support — org-scoped and managed from organization scope. */
|
||||||
export const CLASSROOM_SUPPORT_CONTENT_TYPE = 'classroom-strategies';
|
export const CLASSROOM_SUPPORT_CONTENT_TYPE = 'classroom-strategies';
|
||||||
|
|
||||||
/** The safety/QBS quiz content type, dedicated per tenant (org/school/campus). */
|
/** The safety/QBS quiz content type, owned and managed at organization scope. */
|
||||||
export const SAFETY_QUIZ_CONTENT_TYPE = 'safety-qbs-quiz';
|
export const SAFETY_QUIZ_CONTENT_TYPE = 'safety-qbs-quiz';
|
||||||
|
|
||||||
/** ESA funding content — school-scoped (rules depend on the school's locale). */
|
/** ESA funding content — school-scoped (rules depend on the school's locale). */
|
||||||
@ -9,11 +9,10 @@ export const ESA_CONTENT_TYPE = 'esa-funding-content';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* **Per-tenant** content types (read/written at the user's own tenant level via
|
* **Per-tenant** content types (read/written at the user's own tenant level via
|
||||||
* `getOwnTenant`; preset at organization + school + campus levels). The safety
|
* `getOwnTenant`; preset at organization + school + campus levels). Dashboard
|
||||||
* quiz, dashboard, and parent templates span org/school/campus.
|
* and parent templates span org/school/campus.
|
||||||
*/
|
*/
|
||||||
export const PER_TENANT_CONTENT_TYPES: ReadonlySet<string> = new Set([
|
export const PER_TENANT_CONTENT_TYPES: ReadonlySet<string> = new Set([
|
||||||
SAFETY_QUIZ_CONTENT_TYPE,
|
|
||||||
'dashboard-sign-of-week',
|
'dashboard-sign-of-week',
|
||||||
'dashboard-teacher-images',
|
'dashboard-teacher-images',
|
||||||
'dashboard-encouraging-quotes',
|
'dashboard-encouraging-quotes',
|
||||||
@ -27,6 +26,7 @@ export const SCHOOL_SCOPED_CONTENT_TYPES: ReadonlySet<string> = new Set([
|
|||||||
|
|
||||||
/** **Org-scoped** content types (one per organization; preset at org creation). */
|
/** **Org-scoped** content types (one per organization; preset at org creation). */
|
||||||
export const ORG_SCOPED_CONTENT_TYPES: ReadonlySet<string> = new Set([
|
export const ORG_SCOPED_CONTENT_TYPES: ReadonlySet<string> = new Set([
|
||||||
|
SAFETY_QUIZ_CONTENT_TYPE,
|
||||||
CLASSROOM_SUPPORT_CONTENT_TYPE,
|
CLASSROOM_SUPPORT_CONTENT_TYPE,
|
||||||
'regulation-zones',
|
'regulation-zones',
|
||||||
'zones-of-regulation-page-content',
|
'zones-of-regulation-page-content',
|
||||||
|
|||||||
@ -104,6 +104,8 @@ The seeded e2e suite first verifies this minimum set through `GET /api/content-c
|
|||||||
|
|
||||||
QBS quiz title, weekly focus, key reminders, questions, answer choices, correct answers, and explanations are part of the `safety-qbs-quiz` content catalog payload. The frontend renders this payload and does not keep quiz content or reminder copy in shared constants.
|
QBS quiz title, weekly focus, key reminders, questions, answer choices, correct answers, and explanations are part of the `safety-qbs-quiz` content catalog payload. The frontend renders this payload and does not keep quiz content or reminder copy in shared constants.
|
||||||
|
|
||||||
|
`safety-qbs-quiz` is organization-scoped. Organization users with `MANAGE_CONTENT_CATALOG` can manage the quiz through the QBS safety editor; school, campus, and classroom users read the organization-owned payload and only complete the quiz.
|
||||||
|
|
||||||
## Editable Classroom Strategy Content
|
## Editable Classroom Strategy Content
|
||||||
|
|
||||||
Classroom strategy titles, descriptions, images, categories, age groups, regulation zones, and implementation tips are part of the `classroom-strategies` content catalog payload. The frontend may keep filter labels and style tokens, but it must not keep strategy records or implementation copy in shared constants.
|
Classroom strategy titles, descriptions, images, categories, age groups, regulation zones, and implementation tips are part of the `classroom-strategies` content catalog payload. The frontend may keep filter labels and style tokens, but it must not keep strategy records or implementation copy in shared constants.
|
||||||
|
|||||||
@ -40,25 +40,34 @@ Constants:
|
|||||||
## Behavior
|
## Behavior
|
||||||
|
|
||||||
- Quiz submission uses `POST /api/safety_quiz_results`.
|
- Quiz submission uses `POST /api/safety_quiz_results`.
|
||||||
- Behavior Management is available at organization, campus, and class effective
|
- Behavior Management is available at organization, school, campus, and class effective
|
||||||
tiers when the user has `READ_QBS`.
|
tiers when the user has `READ_QBS`.
|
||||||
- Result-saving UI and mutation are enabled only in the user's own scope. A
|
- Result-saving UI and mutation are enabled only in the user's own non-organization scope. A
|
||||||
parent user drilled into a child tenant can complete the quiz for immediate
|
parent user drilled into a child tenant can complete the quiz for immediate feedback, but no
|
||||||
feedback, but no "saved" badge is shown and no reportable child-scope result is
|
"saved" badge is shown and no reportable child-scope result is created.
|
||||||
created.
|
- The notification dropdown uses `GET /api/safety_quiz_results/me` for the current week. School,
|
||||||
- Staff completion and director dashboard rows load from `GET /api/safety_quiz_results`.
|
campus, and class staff with `TAKE_QUIZ` see a reminder until their saved database result exists.
|
||||||
|
- Staff completion and leadership dashboard rows load from `GET /api/safety_quiz_results/completion`;
|
||||||
|
pending status is derived from missing saved rows in the backend response.
|
||||||
- QBS quiz content loads from `GET /api/content-catalog/read/safety-qbs-quiz`.
|
- QBS quiz content loads from `GET /api/content-catalog/read/safety-qbs-quiz`.
|
||||||
- Directors and superintendents can edit the QBS quiz content through the authenticated content catalog endpoint `PUT /api/content-catalog/safety-qbs-quiz`.
|
- Organization-scope content managers can add, update, and delete the QBS quiz and key reminders
|
||||||
- Editable QBS quiz payloads are JSON-validated in the business layer before saving.
|
through a form-based editor backed by the authenticated content catalog endpoints. The editor is
|
||||||
- Compliance views render empty and error states explicitly instead of substituting static staff rows.
|
hidden outside the user's own organization scope, and the backend enforces the same rule.
|
||||||
|
- Editable QBS quiz payloads are typed and validated in the business layer before saving.
|
||||||
|
- Compliance views render completed and pending staff from the backend report instead of
|
||||||
|
substituting static staff rows.
|
||||||
- Result ownership is derived by the backend from the authenticated session.
|
- Result ownership is derived by the backend from the authenticated session.
|
||||||
|
- User profile renders the latest saved QBS result from `GET /api/safety_quiz_results/me`.
|
||||||
- `QBSSafety.tsx` is a thin wrapper that calls `useSafetyQuizPage` and renders focused safety quiz view components.
|
- `QBSSafety.tsx` is a thin wrapper that calls `useSafetyQuizPage` and renders focused safety quiz view components.
|
||||||
- Quiz score, progress, result feedback, and compliance summary are derived in business selectors.
|
- Quiz score, progress, result feedback, and compliance summary are derived in business selectors.
|
||||||
- The "current week" key (`getCurrentSafetyQuizWeek`) uses the shared American (Sunday-start) week util (`shared/business/week.ts`) — consistent with the dashboard hero and F.R.A.M.E.
|
- The "current week" key (`getCurrentSafetyQuizWeek`) uses the shared American (Sunday-start) week util (`shared/business/week.ts`) — consistent with the dashboard hero and F.R.A.M.E.
|
||||||
- Weekly focus and key reminders are backend content payload fields, not frontend constants.
|
- Weekly focus and key reminders are backend content payload fields, not frontend constants.
|
||||||
- Safety quiz view components use shared `Button`, `StatePanel`, and `ModuleHeader` primitives.
|
- Safety quiz view components use shared `Button`, `StatePanel`, and `ModuleHeader` primitives.
|
||||||
- Director dashboard derives QBS completion metrics and risk rows in business selectors.
|
- Leadership dashboards derive QBS completion metrics and risk rows from the backend completion
|
||||||
|
summary.
|
||||||
|
|
||||||
## Remaining Related Work
|
## Preset Content
|
||||||
|
|
||||||
If compliance needs pending/overdue rows for all staff, add a backend summary endpoint that joins staff membership with submitted results. Do not recreate pending staff rows in frontend static data.
|
The default `safety-qbs-quiz` payload is seeded from the backend content catalog defaults for new
|
||||||
|
organizations. School, campus, and classroom scopes read the organization-owned payload. The
|
||||||
|
frontend does not keep fallback quiz questions or reminder copy in constants.
|
||||||
|
|||||||
@ -96,11 +96,11 @@ describe('app-shell selectors', () => {
|
|||||||
expect(getScopedModules(scopedModules, classroomUser, 'class', false).map((m) => m.id)).toEqual(['classroom']);
|
expect(getScopedModules(scopedModules, classroomUser, 'class', false).map((m) => m.id)).toEqual(['classroom']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('makes Behavior Management available at organization, campus, and class scopes', () => {
|
it('makes Behavior Management available from organization through class scopes', () => {
|
||||||
const behaviorUser = user(['READ_QBS']);
|
const behaviorUser = user(['READ_QBS']);
|
||||||
|
|
||||||
expect(getScopedModules(scopedModules, behaviorUser, 'organization', false).map((m) => m.id)).toEqual(['qbs']);
|
expect(getScopedModules(scopedModules, behaviorUser, 'organization', false).map((m) => m.id)).toEqual(['qbs']);
|
||||||
expect(getScopedModules(scopedModules, behaviorUser, 'school', false).map((m) => m.id)).toEqual([]);
|
expect(getScopedModules(scopedModules, behaviorUser, 'school', false).map((m) => m.id)).toEqual(['qbs']);
|
||||||
expect(getScopedModules(scopedModules, behaviorUser, 'campus', false).map((m) => m.id)).toEqual(['qbs']);
|
expect(getScopedModules(scopedModules, behaviorUser, 'campus', false).map((m) => m.id)).toEqual(['qbs']);
|
||||||
expect(getScopedModules(scopedModules, behaviorUser, 'class', false).map((m) => m.id)).toEqual(['qbs']);
|
expect(getScopedModules(scopedModules, behaviorUser, 'class', false).map((m) => m.id)).toEqual(['qbs']);
|
||||||
});
|
});
|
||||||
@ -119,7 +119,7 @@ describe('app-shell selectors', () => {
|
|||||||
expect(canAccessScopedModuleRoute(scopedModules, '/classroom-support', user(['READ_CLASSROOM']), 'organization', false)).toBe(true);
|
expect(canAccessScopedModuleRoute(scopedModules, '/classroom-support', user(['READ_CLASSROOM']), 'organization', false)).toBe(true);
|
||||||
expect(canAccessScopedModuleRoute(scopedModules, '/classroom-support', user(['READ_CLASSROOM']), 'school', false)).toBe(true);
|
expect(canAccessScopedModuleRoute(scopedModules, '/classroom-support', user(['READ_CLASSROOM']), 'school', false)).toBe(true);
|
||||||
expect(canAccessScopedModuleRoute(scopedModules, '/qbs-safety', user(['READ_QBS']), 'organization', false)).toBe(true);
|
expect(canAccessScopedModuleRoute(scopedModules, '/qbs-safety', user(['READ_QBS']), 'organization', false)).toBe(true);
|
||||||
expect(canAccessScopedModuleRoute(scopedModules, '/qbs-safety', user(['READ_QBS']), 'school', false)).toBe(false);
|
expect(canAccessScopedModuleRoute(scopedModules, '/qbs-safety', user(['READ_QBS']), 'school', false)).toBe(true);
|
||||||
expect(canAccessScopedModuleRoute(scopedModules, '/zones-of-regulation', user(['READ_ZONES']), 'class', false)).toBe(true);
|
expect(canAccessScopedModuleRoute(scopedModules, '/zones-of-regulation', user(['READ_ZONES']), 'class', false)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -34,7 +34,7 @@ const MODULE_SCOPE_TIERS: Partial<Record<ModuleId, readonly ScopeTier[]>> = {
|
|||||||
class: ['class'],
|
class: ['class'],
|
||||||
classroom: ['organization', 'school', 'campus', 'class'],
|
classroom: ['organization', 'school', 'campus', 'class'],
|
||||||
timer: ['class'],
|
timer: ['class'],
|
||||||
qbs: ['organization', 'campus', 'class'],
|
qbs: ['organization', 'school', 'campus', 'class'],
|
||||||
// Leadership dashboard: each leader sees it at their own tier (owner/
|
// Leadership dashboard: each leader sees it at their own tier (owner/
|
||||||
// superintendent → organization, principal/registrar → school, director →
|
// superintendent → organization, principal/registrar → school, director →
|
||||||
// campus). Never shown via drill-down (see getScopedModules).
|
// campus). Never shown via drill-down (see getScopedModules).
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useFrameEntries } from '@/business/frame/hooks';
|
import { useFrameEntries } from '@/business/frame/hooks';
|
||||||
import { useSafetyQuizResults } from '@/business/safety-quiz/hooks';
|
import { useSafetyQuizCompliance } from '@/business/safety-quiz/hooks';
|
||||||
import {
|
import {
|
||||||
useStaffAttendanceRecords,
|
useStaffAttendanceRecords,
|
||||||
useStaffAttendanceSummary,
|
useStaffAttendanceSummary,
|
||||||
@ -21,6 +21,7 @@ import { useAuth } from '@/shared/app/useAuth';
|
|||||||
import { getLeadershipDashboardName } from '@/business/app-shell/selectors';
|
import { getLeadershipDashboardName } from '@/business/app-shell/selectors';
|
||||||
import { getActiveTenant } from '@/business/scope/selectors';
|
import { getActiveTenant } from '@/business/scope/selectors';
|
||||||
import { DEFAULT_PRODUCT_ROLE } from '@/shared/constants/roles';
|
import { DEFAULT_PRODUCT_ROLE } from '@/shared/constants/roles';
|
||||||
|
import { getCurrentSafetyQuizWeek } from '@/business/safety-quiz/selectors';
|
||||||
|
|
||||||
export function useDirectorDashboardPage(): DirectorDashboardPage {
|
export function useDirectorDashboardPage(): DirectorDashboardPage {
|
||||||
const { user, profile } = useAuth();
|
const { user, profile } = useAuth();
|
||||||
@ -28,22 +29,28 @@ export function useDirectorDashboardPage(): DirectorDashboardPage {
|
|||||||
const title = getLeadershipDashboardName(role);
|
const title = getLeadershipDashboardName(role);
|
||||||
const scopeLabel = getActiveTenant(user)?.name ?? '';
|
const scopeLabel = getActiveTenant(user)?.name ?? '';
|
||||||
const [timeRange, setTimeRangeState] = useState<DirectorDashboardTimeRange>('month');
|
const [timeRange, setTimeRangeState] = useState<DirectorDashboardTimeRange>('month');
|
||||||
|
const safetyQuizWeek = getCurrentSafetyQuizWeek(new Date());
|
||||||
const frameEntriesQuery = useFrameEntries();
|
const frameEntriesQuery = useFrameEntries();
|
||||||
const quizResultsQuery = useSafetyQuizResults();
|
const quizCompletionQuery = useSafetyQuizCompliance(safetyQuizWeek, true);
|
||||||
const staffAttendanceRecordsQuery = useStaffAttendanceRecords();
|
const staffAttendanceRecordsQuery = useStaffAttendanceRecords();
|
||||||
const staffAttendanceSummaryQuery = useStaffAttendanceSummary();
|
const staffAttendanceSummaryQuery = useStaffAttendanceSummary();
|
||||||
const acknowledgmentReportQuery = usePolicyAcknowledgmentReport();
|
const acknowledgmentReportQuery = usePolicyAcknowledgmentReport();
|
||||||
const frameEntries = frameEntriesQuery.data ?? [];
|
const frameEntries = frameEntriesQuery.data ?? [];
|
||||||
const quizResults = quizResultsQuery.data ?? [];
|
const quizRows = quizCompletionQuery.data?.rows ?? [];
|
||||||
|
const quizSummary = quizCompletionQuery.data?.summary ?? {
|
||||||
|
totalStaff: 0,
|
||||||
|
completedCount: 0,
|
||||||
|
pendingCount: 0,
|
||||||
|
completionRate: 0,
|
||||||
|
};
|
||||||
const attendanceRecords = staffAttendanceRecordsQuery.data ?? [];
|
const attendanceRecords = staffAttendanceRecordsQuery.data ?? [];
|
||||||
const staffCount = staffAttendanceSummaryQuery.data?.staffCount ?? 0;
|
|
||||||
const isLoading = frameEntriesQuery.isLoading
|
const isLoading = frameEntriesQuery.isLoading
|
||||||
|| quizResultsQuery.isLoading
|
|| quizCompletionQuery.isLoading
|
||||||
|| staffAttendanceRecordsQuery.isLoading
|
|| staffAttendanceRecordsQuery.isLoading
|
||||||
|| staffAttendanceSummaryQuery.isLoading
|
|| staffAttendanceSummaryQuery.isLoading
|
||||||
|| acknowledgmentReportQuery.isLoading;
|
|| acknowledgmentReportQuery.isLoading;
|
||||||
const error = frameEntriesQuery.error
|
const error = frameEntriesQuery.error
|
||||||
?? quizResultsQuery.error
|
?? quizCompletionQuery.error
|
||||||
?? staffAttendanceRecordsQuery.error
|
?? staffAttendanceRecordsQuery.error
|
||||||
?? staffAttendanceSummaryQuery.error
|
?? staffAttendanceSummaryQuery.error
|
||||||
?? acknowledgmentReportQuery.error;
|
?? acknowledgmentReportQuery.error;
|
||||||
@ -54,15 +61,14 @@ export function useDirectorDashboardPage(): DirectorDashboardPage {
|
|||||||
timeRange,
|
timeRange,
|
||||||
overviewCards: buildDirectorOverviewCards(
|
overviewCards: buildDirectorOverviewCards(
|
||||||
attendanceRecords,
|
attendanceRecords,
|
||||||
quizResults,
|
quizSummary,
|
||||||
frameEntries,
|
frameEntries,
|
||||||
staffCount,
|
|
||||||
acknowledgmentReportQuery.data?.summary,
|
acknowledgmentReportQuery.data?.summary,
|
||||||
),
|
),
|
||||||
riskAreas: buildDirectorRiskAreas(attendanceRecords, quizResults, staffCount),
|
riskAreas: buildDirectorRiskAreas(attendanceRecords, quizSummary),
|
||||||
framePreviews: buildDirectorFramePreviews(frameEntries),
|
framePreviews: buildDirectorFramePreviews(frameEntries),
|
||||||
quickActions: DIRECTOR_DASHBOARD_QUICK_ACTIONS,
|
quickActions: DIRECTOR_DASHBOARD_QUICK_ACTIONS,
|
||||||
quizResults,
|
quizResults: quizRows,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
setTimeRange: setTimeRangeState,
|
setTimeRange: setTimeRangeState,
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import {
|
|||||||
} from '@/business/director-dashboard/selectors';
|
} from '@/business/director-dashboard/selectors';
|
||||||
import type { FrameEntryViewModel } from '@/business/frame/types';
|
import type { FrameEntryViewModel } from '@/business/frame/types';
|
||||||
import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types';
|
import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types';
|
||||||
import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz';
|
import type { SafetyQuizCompletionSummary } from '@/business/safety-quiz/types';
|
||||||
|
|
||||||
function createAttendanceRecord(
|
function createAttendanceRecord(
|
||||||
overrides: Partial<StaffAttendanceRecordViewModel> = {},
|
overrides: Partial<StaffAttendanceRecordViewModel> = {},
|
||||||
@ -24,23 +24,12 @@ function createAttendanceRecord(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createQuizResult(overrides: Partial<SafetyQuizResultDto> = {}): SafetyQuizResultDto {
|
function createQuizSummary(overrides: Partial<SafetyQuizCompletionSummary> = {}): SafetyQuizCompletionSummary {
|
||||||
return {
|
return {
|
||||||
id: 'quiz-1',
|
completedCount: 1,
|
||||||
quiz_id: 'qbs',
|
pendingCount: 1,
|
||||||
quiz_title: 'QBS Safety',
|
totalStaff: 2,
|
||||||
week_of: '2026-06-01',
|
completionRate: 50,
|
||||||
score: 5,
|
|
||||||
total_questions: 5,
|
|
||||||
answers: [0, 1, 2],
|
|
||||||
user_name: 'Ava Lee',
|
|
||||||
user_role: 'teacher',
|
|
||||||
completed_at: '2026-06-01T09:00:00.000Z',
|
|
||||||
organizationId: 'org-1',
|
|
||||||
campusId: 'campus-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
createdAt: '2026-06-01T09:00:00.000Z',
|
|
||||||
updatedAt: '2026-06-01T09:00:00.000Z',
|
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -64,8 +53,8 @@ function createFrameEntry(overrides: Partial<FrameEntryViewModel> = {}): FrameEn
|
|||||||
|
|
||||||
describe('director dashboard selectors', () => {
|
describe('director dashboard selectors', () => {
|
||||||
it('calculates quiz completion rate with empty staff protection', () => {
|
it('calculates quiz completion rate with empty staff protection', () => {
|
||||||
expect(calculateQuizCompletionRate([createQuizResult()], 0)).toBe(0);
|
expect(calculateQuizCompletionRate(createQuizSummary({ completionRate: 0 }))).toBe(0);
|
||||||
expect(calculateQuizCompletionRate([createQuizResult()], 4)).toBe(25);
|
expect(calculateQuizCompletionRate(createQuizSummary({ completionRate: 25 }))).toBe(25);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('builds overview cards from backend-backed records', () => {
|
it('builds overview cards from backend-backed records', () => {
|
||||||
@ -74,9 +63,8 @@ describe('director dashboard selectors', () => {
|
|||||||
createAttendanceRecord({ id: 'present', status: 'present' }),
|
createAttendanceRecord({ id: 'present', status: 'present' }),
|
||||||
createAttendanceRecord({ id: 'absent', status: 'absent' }),
|
createAttendanceRecord({ id: 'absent', status: 'absent' }),
|
||||||
],
|
],
|
||||||
[createQuizResult()],
|
createQuizSummary(),
|
||||||
[createFrameEntry()],
|
[createFrameEntry()],
|
||||||
2,
|
|
||||||
{
|
{
|
||||||
scope: 'campus',
|
scope: 'campus',
|
||||||
totalDocuments: 2,
|
totalDocuments: 2,
|
||||||
@ -106,8 +94,7 @@ describe('director dashboard selectors', () => {
|
|||||||
createAttendanceRecord({ id: '3', status: 'absent' }),
|
createAttendanceRecord({ id: '3', status: 'absent' }),
|
||||||
createAttendanceRecord({ id: '4', status: 'absent' }),
|
createAttendanceRecord({ id: '4', status: 'absent' }),
|
||||||
],
|
],
|
||||||
[createQuizResult()],
|
createQuizSummary({ completedCount: 1, pendingCount: 5, totalStaff: 6, completionRate: 17 }),
|
||||||
6,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(risks).toEqual([
|
expect(risks).toEqual([
|
||||||
|
|||||||
@ -11,34 +11,28 @@ import {
|
|||||||
} from '@/business/staff-attendance/selectors';
|
} from '@/business/staff-attendance/selectors';
|
||||||
import type { FrameEntryViewModel } from '@/business/frame/types';
|
import type { FrameEntryViewModel } from '@/business/frame/types';
|
||||||
import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types';
|
import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types';
|
||||||
import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz';
|
|
||||||
import type { PolicyAcknowledgmentReportSummaryDto } from '@/shared/types/policyDocuments';
|
import type { PolicyAcknowledgmentReportSummaryDto } from '@/shared/types/policyDocuments';
|
||||||
import type {
|
import type {
|
||||||
DirectorFramePreview,
|
DirectorFramePreview,
|
||||||
DirectorOverviewCard,
|
DirectorOverviewCard,
|
||||||
DirectorRiskArea,
|
DirectorRiskArea,
|
||||||
} from '@/business/director-dashboard/types';
|
} from '@/business/director-dashboard/types';
|
||||||
|
import type { SafetyQuizCompletionSummary } from '@/business/safety-quiz/types';
|
||||||
|
|
||||||
export function calculateQuizCompletionRate(
|
export function calculateQuizCompletionRate(
|
||||||
quizResults: readonly SafetyQuizResultDto[],
|
quizSummary: SafetyQuizCompletionSummary,
|
||||||
staffCount: number,
|
|
||||||
): number {
|
): number {
|
||||||
if (staffCount <= 0) {
|
return quizSummary.completionRate;
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.round((quizResults.length / staffCount) * 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDirectorOverviewCards(
|
export function buildDirectorOverviewCards(
|
||||||
attendanceRecords: readonly StaffAttendanceRecordViewModel[],
|
attendanceRecords: readonly StaffAttendanceRecordViewModel[],
|
||||||
quizResults: readonly SafetyQuizResultDto[],
|
quizSummary: SafetyQuizCompletionSummary,
|
||||||
frameEntries: readonly FrameEntryViewModel[],
|
frameEntries: readonly FrameEntryViewModel[],
|
||||||
staffCount: number,
|
|
||||||
acknowledgmentSummary?: PolicyAcknowledgmentReportSummaryDto | null,
|
acknowledgmentSummary?: PolicyAcknowledgmentReportSummaryDto | null,
|
||||||
): readonly DirectorOverviewCard[] {
|
): readonly DirectorOverviewCard[] {
|
||||||
const attendanceRate = staffAttendanceRate(attendanceRecords);
|
const attendanceRate = staffAttendanceRate(attendanceRecords);
|
||||||
const quizCompletionRate = calculateQuizCompletionRate(quizResults, staffCount);
|
const quizCompletionRate = calculateQuizCompletionRate(quizSummary);
|
||||||
const acknowledgmentRate = acknowledgmentSummary?.completionRate ?? 0;
|
const acknowledgmentRate = acknowledgmentSummary?.completionRate ?? 0;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -53,7 +47,7 @@ export function buildDirectorOverviewCards(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'De-escalation Completion',
|
label: 'De-escalation Completion',
|
||||||
value: `${quizResults.length}/${staffCount}`,
|
value: `${quizSummary.completedCount}/${quizSummary.totalStaff}`,
|
||||||
change: `${quizCompletionRate}%`,
|
change: `${quizCompletionRate}%`,
|
||||||
trend: quizCompletionRate > DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD ? 'up' : 'down',
|
trend: quizCompletionRate > DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD ? 'up' : 'down',
|
||||||
iconId: 'shield',
|
iconId: 'shield',
|
||||||
@ -71,7 +65,7 @@ export function buildDirectorOverviewCards(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Staff Members',
|
label: 'Staff Members',
|
||||||
value: staffCount.toString(),
|
value: quizSummary.totalStaff.toString(),
|
||||||
change: 'Active',
|
change: 'Active',
|
||||||
trend: 'up',
|
trend: 'up',
|
||||||
iconId: 'users',
|
iconId: 'users',
|
||||||
@ -92,10 +86,9 @@ export function buildDirectorOverviewCards(
|
|||||||
|
|
||||||
export function buildDirectorRiskAreas(
|
export function buildDirectorRiskAreas(
|
||||||
attendanceRecords: readonly StaffAttendanceRecordViewModel[],
|
attendanceRecords: readonly StaffAttendanceRecordViewModel[],
|
||||||
quizResults: readonly SafetyQuizResultDto[],
|
quizSummary: SafetyQuizCompletionSummary,
|
||||||
staffCount: number,
|
|
||||||
): readonly DirectorRiskArea[] {
|
): readonly DirectorRiskArea[] {
|
||||||
const incompleteStaffCount = Math.max(staffCount - quizResults.length, 0);
|
const incompleteStaffCount = quizSummary.pendingCount;
|
||||||
const absenceCount = countStaffAttendanceStatus(attendanceRecords, 'absent');
|
const absenceCount = countStaffAttendanceStatus(attendanceRecords, 'absent');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type {
|
|||||||
DirectorQuickActionConfig,
|
DirectorQuickActionConfig,
|
||||||
} from '@/shared/constants/directorDashboard';
|
} from '@/shared/constants/directorDashboard';
|
||||||
import type { ModuleId } from '@/shared/types/app';
|
import type { ModuleId } from '@/shared/types/app';
|
||||||
import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz';
|
import type { SafetyQuizComplianceRow } from '@/business/safety-quiz/types';
|
||||||
|
|
||||||
export type DirectorDashboardTrend = 'up' | 'down';
|
export type DirectorDashboardTrend = 'up' | 'down';
|
||||||
export type DirectorDashboardRiskSeverity = 'high' | 'medium' | 'low';
|
export type DirectorDashboardRiskSeverity = 'high' | 'medium' | 'low';
|
||||||
@ -48,7 +48,7 @@ export interface DirectorDashboardPage {
|
|||||||
readonly riskAreas: readonly DirectorRiskArea[];
|
readonly riskAreas: readonly DirectorRiskArea[];
|
||||||
readonly framePreviews: readonly DirectorFramePreview[];
|
readonly framePreviews: readonly DirectorFramePreview[];
|
||||||
readonly quickActions: readonly DirectorQuickActionConfig[];
|
readonly quickActions: readonly DirectorQuickActionConfig[];
|
||||||
readonly quizResults: readonly SafetyQuizResultDto[];
|
readonly quizResults: readonly SafetyQuizComplianceRow[];
|
||||||
readonly isLoading: boolean;
|
readonly isLoading: boolean;
|
||||||
readonly error: unknown;
|
readonly error: unknown;
|
||||||
readonly setTimeRange: (timeRange: DirectorDashboardTimeRange) => void;
|
readonly setTimeRange: (timeRange: DirectorDashboardTimeRange) => void;
|
||||||
|
|||||||
@ -2,9 +2,13 @@ import { useMemo, useState } from 'react';
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
createSafetyQuizResult,
|
createSafetyQuizResult,
|
||||||
|
getMySafetyQuizStatus,
|
||||||
|
getSafetyQuizCompletionReport,
|
||||||
listSafetyQuizResults,
|
listSafetyQuizResults,
|
||||||
} from '@/shared/api/safetyQuizResults';
|
} from '@/shared/api/safetyQuizResults';
|
||||||
import {
|
import {
|
||||||
|
createManagedContentCatalog,
|
||||||
|
deleteManagedContentCatalog,
|
||||||
getManagedContentCatalog,
|
getManagedContentCatalog,
|
||||||
updateManagedContentCatalog,
|
updateManagedContentCatalog,
|
||||||
} from '@/shared/api/contentCatalog';
|
} from '@/shared/api/contentCatalog';
|
||||||
@ -18,7 +22,7 @@ import {
|
|||||||
CONTENT_CATALOG_TYPES,
|
CONTENT_CATALOG_TYPES,
|
||||||
} from '@/shared/constants/contentCatalog';
|
} from '@/shared/constants/contentCatalog';
|
||||||
import {
|
import {
|
||||||
toSafetyQuizComplianceRow,
|
toSafetyQuizCompletionRow,
|
||||||
toSafetyQuizResultCreateDto,
|
toSafetyQuizResultCreateDto,
|
||||||
} from '@/business/safety-quiz/mappers';
|
} from '@/business/safety-quiz/mappers';
|
||||||
import {
|
import {
|
||||||
@ -30,12 +34,11 @@ import {
|
|||||||
calculateSafetyQuizCompletionSummary,
|
calculateSafetyQuizCompletionSummary,
|
||||||
calculateSafetyQuizScore,
|
calculateSafetyQuizScore,
|
||||||
getCurrentSafetyQuizWeek,
|
getCurrentSafetyQuizWeek,
|
||||||
parseSafetyQuizPayload,
|
validateSafetyQuizPayload,
|
||||||
serializeSafetyQuizPayload,
|
|
||||||
} from '@/business/safety-quiz/selectors';
|
} from '@/business/safety-quiz/selectors';
|
||||||
import { canPersistPersonalScopeResults } from '@/business/scope/selectors';
|
import { canPersistPersonalScopeResults } from '@/business/scope/selectors';
|
||||||
import type { SafetyQuiz } from '@/shared/types/app';
|
import type { QuizQuestion, SafetyQuiz } from '@/shared/types/app';
|
||||||
import { getApiListRows, mapApiListRows } from '@/shared/business/apiListRows';
|
import { getApiListRows } from '@/shared/business/apiListRows';
|
||||||
import { useInvalidatingMutation } from '@/shared/business/queryMutations';
|
import { useInvalidatingMutation } from '@/shared/business/queryMutations';
|
||||||
import { getOptionalErrorMessage } from '@/shared/errors/errorMessages';
|
import { getOptionalErrorMessage } from '@/shared/errors/errorMessages';
|
||||||
import { usePermissions } from '@/shared/app/usePermissions';
|
import { usePermissions } from '@/shared/app/usePermissions';
|
||||||
@ -48,27 +51,60 @@ export function useSafetyQuizResults(weekOf?: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useMySafetyQuizStatus(weekOf?: string, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: weekOf
|
||||||
|
? [...SAFETY_QUIZ_QUERY_KEYS.personalStatus, weekOf]
|
||||||
|
: SAFETY_QUIZ_QUERY_KEYS.personalStatus,
|
||||||
|
enabled,
|
||||||
|
queryFn: () => getMySafetyQuizStatus(weekOf),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useSafetyQuizCompliance(weekOf: string, enabled: boolean) {
|
export function useSafetyQuizCompliance(weekOf: string, enabled: boolean) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: [...SAFETY_QUIZ_QUERY_KEYS.results, SAFETY_QUIZ_COMPLIANCE_QUERY_SEGMENT, weekOf],
|
queryKey: [...SAFETY_QUIZ_QUERY_KEYS.completion, SAFETY_QUIZ_COMPLIANCE_QUERY_SEGMENT, weekOf],
|
||||||
enabled,
|
enabled,
|
||||||
queryFn: () => mapApiListRows(listSafetyQuizResults(weekOf), toSafetyQuizComplianceRow),
|
queryFn: async () => {
|
||||||
|
const report = await getSafetyQuizCompletionReport(weekOf);
|
||||||
|
return {
|
||||||
|
rows: report.rows.map(toSafetyQuizCompletionRow),
|
||||||
|
summary: report.summary,
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSaveSafetyQuizResult() {
|
export function useSaveSafetyQuizResult() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useInvalidatingMutation({
|
return useInvalidatingMutation({
|
||||||
mutationFn: (submission: SafetyQuizSubmission) => createSafetyQuizResult(
|
mutationFn: (submission: SafetyQuizSubmission) => createSafetyQuizResult(
|
||||||
toSafetyQuizResultCreateDto(submission),
|
toSafetyQuizResultCreateDto(submission),
|
||||||
),
|
),
|
||||||
invalidateQueryKey: SAFETY_QUIZ_QUERY_KEYS.results,
|
invalidateQueryKey: SAFETY_QUIZ_QUERY_KEYS.results,
|
||||||
|
onSuccess: async (_data, submission) => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: SAFETY_QUIZ_QUERY_KEYS.results }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: SAFETY_QUIZ_QUERY_KEYS.personalStatus }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: SAFETY_QUIZ_QUERY_KEYS.completion }),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [...SAFETY_QUIZ_QUERY_KEYS.personalStatus, submission.weekOf],
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [...SAFETY_QUIZ_QUERY_KEYS.completion, SAFETY_QUIZ_COMPLIANCE_QUERY_SEGMENT, submission.weekOf],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSafetyQuizPage(): SafetyQuizPage {
|
export function useSafetyQuizPage(): SafetyQuizPage {
|
||||||
const permissions = usePermissions();
|
const permissions = usePermissions();
|
||||||
const { ownTenant, selectedTenant } = useScopeContext();
|
const { ownTenant, selectedTenant } = useScopeContext();
|
||||||
const canPersistResult = canPersistPersonalScopeResults(ownTenant, selectedTenant);
|
const activeTenant = selectedTenant ?? ownTenant;
|
||||||
|
const canPersistResult = canPersistPersonalScopeResults(ownTenant, selectedTenant)
|
||||||
|
&& activeTenant?.level !== 'organization';
|
||||||
const quizQuery = useContentCatalogPayload<SafetyQuiz | null>(
|
const quizQuery = useContentCatalogPayload<SafetyQuiz | null>(
|
||||||
CONTENT_CATALOG_TYPES.safetyQbsQuiz,
|
CONTENT_CATALOG_TYPES.safetyQbsQuiz,
|
||||||
null,
|
null,
|
||||||
@ -83,10 +119,13 @@ export function useSafetyQuizPage(): SafetyQuizPage {
|
|||||||
const quiz = quizQuery.payload;
|
const quiz = quizQuery.payload;
|
||||||
const weekOf = getCurrentSafetyQuizWeek(new Date());
|
const weekOf = getCurrentSafetyQuizWeek(new Date());
|
||||||
const canViewCompliance = permissions.has('READ_SAFETY_QUIZ_REPORTS');
|
const canViewCompliance = permissions.has('READ_SAFETY_QUIZ_REPORTS');
|
||||||
const canManageQuizContent = permissions.has('MANAGE_CONTENT_CATALOG');
|
const canManageQuizContent = permissions.has('MANAGE_CONTENT_CATALOG')
|
||||||
|
&& ownTenant?.level === 'organization'
|
||||||
|
&& activeTenant?.level === 'organization';
|
||||||
const complianceQuery = useSafetyQuizCompliance(weekOf, canViewCompliance);
|
const complianceQuery = useSafetyQuizCompliance(weekOf, canViewCompliance);
|
||||||
const saveResultMutation = useSaveSafetyQuizResult();
|
const saveResultMutation = useSaveSafetyQuizResult();
|
||||||
const complianceRows = complianceQuery.data ?? [];
|
const complianceRows = complianceQuery.data?.rows ?? [];
|
||||||
|
const completionSummary = complianceQuery.data?.summary ?? calculateSafetyQuizCompletionSummary(complianceRows);
|
||||||
|
|
||||||
function startQuiz() {
|
function startQuiz() {
|
||||||
setQuizStarted(true);
|
setQuizStarted(true);
|
||||||
@ -158,7 +197,7 @@ export function useSafetyQuizPage(): SafetyQuizPage {
|
|||||||
score,
|
score,
|
||||||
answers,
|
answers,
|
||||||
complianceRows,
|
complianceRows,
|
||||||
completionSummary: calculateSafetyQuizCompletionSummary(complianceRows),
|
completionSummary,
|
||||||
canViewCompliance,
|
canViewCompliance,
|
||||||
canManageQuizContent,
|
canManageQuizContent,
|
||||||
canPersistResult,
|
canPersistResult,
|
||||||
@ -177,7 +216,8 @@ export function useSafetyQuizPage(): SafetyQuizPage {
|
|||||||
|
|
||||||
export function useSafetyQuizContentEditor(): SafetyQuizContentEditor {
|
export function useSafetyQuizContentEditor(): SafetyQuizContentEditor {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [draftOverride, setDraftOverride] = useState<string | null>(null);
|
const [draftOverride, setDraftOverride] = useState<SafetyQuiz | null>(null);
|
||||||
|
const [hasDraftOverride, setHasDraftOverride] = useState(false);
|
||||||
const [validationError, setValidationError] = useState<string | null>(null);
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
const [savedMessage, setSavedMessage] = useState<string | null>(null);
|
const [savedMessage, setSavedMessage] = useState<string | null>(null);
|
||||||
const contentType = CONTENT_CATALOG_TYPES.safetyQbsQuiz;
|
const contentType = CONTENT_CATALOG_TYPES.safetyQbsQuiz;
|
||||||
@ -186,10 +226,14 @@ export function useSafetyQuizContentEditor(): SafetyQuizContentEditor {
|
|||||||
queryFn: () => getManagedContentCatalog<SafetyQuiz>(contentType),
|
queryFn: () => getManagedContentCatalog<SafetyQuiz>(contentType),
|
||||||
});
|
});
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (payload: SafetyQuiz) => updateManagedContentCatalog(contentType, { payload }),
|
mutationFn: (payload: SafetyQuiz) => (
|
||||||
|
managedQuery.data
|
||||||
|
? updateManagedContentCatalog(contentType, { payload })
|
||||||
|
: createManagedContentCatalog({ content_type: contentType, payload })
|
||||||
|
),
|
||||||
onSuccess: async (response) => {
|
onSuccess: async (response) => {
|
||||||
const serializedPayload = serializeSafetyQuizPayload(response.payload);
|
setDraftOverride(response.payload);
|
||||||
setDraftOverride(serializedPayload);
|
setHasDraftOverride(true);
|
||||||
setValidationError(null);
|
setValidationError(null);
|
||||||
setSavedMessage('Safety quiz content saved.');
|
setSavedMessage('Safety quiz content saved.');
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@ -198,48 +242,188 @@ export function useSafetyQuizContentEditor(): SafetyQuizContentEditor {
|
|||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => deleteManagedContentCatalog(contentType),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setDraftOverride(null);
|
||||||
|
setHasDraftOverride(true);
|
||||||
|
setValidationError(null);
|
||||||
|
setSavedMessage('Safety quiz content deleted.');
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, contentType] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, 'managed', contentType] }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
const serverDraft = useMemo(
|
const serverDraft = useMemo(
|
||||||
() => (managedQuery.data?.payload ? serializeSafetyQuizPayload(managedQuery.data.payload) : ''),
|
() => managedQuery.data?.payload ?? null,
|
||||||
[managedQuery.data],
|
[managedQuery.data],
|
||||||
);
|
);
|
||||||
const draft = draftOverride ?? serverDraft;
|
const draft = hasDraftOverride ? draftOverride : serverDraft;
|
||||||
|
|
||||||
const canSave = useMemo(
|
const canSave = useMemo(
|
||||||
() => Boolean(draft.trim()) && !managedQuery.isLoading && !saveMutation.isPending,
|
() => Boolean(draft) && !managedQuery.isLoading && !saveMutation.isPending && !deleteMutation.isPending,
|
||||||
[draft, managedQuery.isLoading, saveMutation.isPending],
|
[draft, managedQuery.isLoading, saveMutation.isPending, deleteMutation.isPending],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function updateDraft(updater: (current: SafetyQuiz) => SafetyQuiz): void {
|
||||||
|
setDraftOverride((currentOverride) => {
|
||||||
|
const current = hasDraftOverride ? currentOverride : serverDraft;
|
||||||
|
return current ? updater(current) : current;
|
||||||
|
});
|
||||||
|
setHasDraftOverride(true);
|
||||||
|
setValidationError(null);
|
||||||
|
setSavedMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
draft,
|
draft,
|
||||||
isLoading: managedQuery.isLoading,
|
isLoading: managedQuery.isLoading,
|
||||||
isSaving: saveMutation.isPending,
|
isSaving: saveMutation.isPending,
|
||||||
|
isDeleting: deleteMutation.isPending,
|
||||||
errorMessage: getOptionalErrorMessage(
|
errorMessage: getOptionalErrorMessage(
|
||||||
managedQuery.error || saveMutation.error,
|
managedQuery.error || saveMutation.error || deleteMutation.error,
|
||||||
'Safety quiz content could not be loaded or saved.',
|
'Safety quiz content could not be loaded or saved.',
|
||||||
),
|
),
|
||||||
validationError,
|
validationError,
|
||||||
savedMessage,
|
savedMessage,
|
||||||
canSave,
|
canSave,
|
||||||
setDraft: (nextDraft) => {
|
updateQuiz: (patch) => updateDraft((current) => ({ ...current, ...patch })),
|
||||||
setDraftOverride(nextDraft);
|
updateWeeklyFocus: (patch) => updateDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
weeklyFocus: { ...current.weeklyFocus, ...patch },
|
||||||
|
})),
|
||||||
|
updateReminder: (index, value) => updateDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
keyReminders: current.keyReminders.map((reminder, reminderIndex) =>
|
||||||
|
reminderIndex === index ? value : reminder,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
addReminder: () => updateDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
keyReminders: [...current.keyReminders, ''],
|
||||||
|
})),
|
||||||
|
removeReminder: (index) => updateDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
keyReminders: current.keyReminders.filter((_reminder, reminderIndex) => reminderIndex !== index),
|
||||||
|
})),
|
||||||
|
updateQuestion: (index, patch) => updateDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
questions: current.questions.map((question, questionIndex) =>
|
||||||
|
questionIndex === index ? normalizeQuestion({ ...question, ...patch }) : question,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
updateQuestionOption: (questionIndex, optionIndex, value) => updateDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
questions: current.questions.map((question, currentQuestionIndex) => {
|
||||||
|
if (currentQuestionIndex !== questionIndex) {
|
||||||
|
return question;
|
||||||
|
}
|
||||||
|
return normalizeQuestion({
|
||||||
|
...question,
|
||||||
|
options: question.options.map((option, currentOptionIndex) =>
|
||||||
|
currentOptionIndex === optionIndex ? value : option,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
addQuestion: () => updateDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
questions: [...current.questions, createDefaultQuestion(current.questions.length + 1)],
|
||||||
|
})),
|
||||||
|
removeQuestion: (index) => updateDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
questions: current.questions.filter((_question, questionIndex) => questionIndex !== index),
|
||||||
|
})),
|
||||||
|
addQuestionOption: (questionIndex) => updateDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
questions: current.questions.map((question, currentQuestionIndex) =>
|
||||||
|
currentQuestionIndex === questionIndex
|
||||||
|
? normalizeQuestion({ ...question, options: [...question.options, ''] })
|
||||||
|
: question,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
removeQuestionOption: (questionIndex, optionIndex) => updateDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
questions: current.questions.map((question, currentQuestionIndex) =>
|
||||||
|
currentQuestionIndex === questionIndex
|
||||||
|
? normalizeQuestion({
|
||||||
|
...question,
|
||||||
|
options: question.options.filter((_option, currentOptionIndex) => currentOptionIndex !== optionIndex),
|
||||||
|
})
|
||||||
|
: question,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
addDefaultQuiz: () => {
|
||||||
|
setDraftOverride(createDefaultSafetyQuiz());
|
||||||
|
setHasDraftOverride(true);
|
||||||
setValidationError(null);
|
setValidationError(null);
|
||||||
setSavedMessage(null);
|
setSavedMessage(null);
|
||||||
},
|
},
|
||||||
reset: () => {
|
reset: () => {
|
||||||
setDraftOverride(null);
|
setDraftOverride(null);
|
||||||
|
setHasDraftOverride(false);
|
||||||
setValidationError(null);
|
setValidationError(null);
|
||||||
setSavedMessage(null);
|
setSavedMessage(null);
|
||||||
},
|
},
|
||||||
save: async () => {
|
save: async () => {
|
||||||
const result = parseSafetyQuizPayload(draft);
|
if (!draft) {
|
||||||
|
setValidationError('Add a quiz before saving.');
|
||||||
if (typeof result === 'string') {
|
|
||||||
setValidationError(result);
|
|
||||||
setSavedMessage(null);
|
setSavedMessage(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveMutation.mutateAsync(result);
|
const error = validateSafetyQuizPayload(draft);
|
||||||
|
if (error) {
|
||||||
|
setValidationError(error);
|
||||||
|
setSavedMessage(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveMutation.mutateAsync(draft);
|
||||||
|
},
|
||||||
|
deleteQuiz: async () => {
|
||||||
|
await deleteMutation.mutateAsync();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeQuestion(question: QuizQuestion): QuizQuestion {
|
||||||
|
const correctIndex = question.options.length === 0
|
||||||
|
? 0
|
||||||
|
: Math.min(question.correctIndex, question.options.length - 1);
|
||||||
|
|
||||||
|
return { ...question, correctIndex };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultQuestion(sequence: number): QuizQuestion {
|
||||||
|
return {
|
||||||
|
id: `question-${Date.now()}-${sequence}`,
|
||||||
|
question: '',
|
||||||
|
options: ['Correct answer', 'Incorrect answer'],
|
||||||
|
correctIndex: 0,
|
||||||
|
explanation: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultSafetyQuiz(): SafetyQuiz {
|
||||||
|
return {
|
||||||
|
id: `qbs-${Date.now()}`,
|
||||||
|
title: 'QBS Safety Quiz',
|
||||||
|
focus: 'de-escalation',
|
||||||
|
weeklyFocus: {
|
||||||
|
title: 'Weekly Safety Focus',
|
||||||
|
description: 'Add the weekly safety focus for staff.',
|
||||||
|
},
|
||||||
|
keyReminders: ['Add a key reminder.'],
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: 'question-1',
|
||||||
|
question: 'Add the first QBS safety question.',
|
||||||
|
options: ['Correct answer', 'Incorrect answer'],
|
||||||
|
correctIndex: 0,
|
||||||
|
explanation: 'Explain why the correct answer is safest.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -37,8 +37,9 @@ function createResult(overrides: Partial<SafetyQuizResultDto> = {}): SafetyQuizR
|
|||||||
describe('safety quiz mappers', () => {
|
describe('safety quiz mappers', () => {
|
||||||
it('maps result DTOs to compliance rows with user-facing labels', () => {
|
it('maps result DTOs to compliance rows with user-facing labels', () => {
|
||||||
expect(toSafetyQuizComplianceRow(createResult())).toEqual({
|
expect(toSafetyQuizComplianceRow(createResult())).toEqual({
|
||||||
|
userId: 'user-1',
|
||||||
name: 'Ava Lee',
|
name: 'Ava Lee',
|
||||||
role: 'Para',
|
role: 'Support Staff',
|
||||||
status: 'complete',
|
status: 'complete',
|
||||||
score: '4/5',
|
score: '4/5',
|
||||||
date: 'Jun 8',
|
date: 'Jun 8',
|
||||||
@ -110,12 +111,13 @@ describe('safety quiz selectors', () => {
|
|||||||
it('summarizes compliance rows', () => {
|
it('summarizes compliance rows', () => {
|
||||||
expect(
|
expect(
|
||||||
calculateSafetyQuizCompletionSummary([
|
calculateSafetyQuizCompletionSummary([
|
||||||
{ name: 'Ava', role: 'Teacher', status: 'complete', score: '5/5', date: 'Jun 8' },
|
{ userId: 'user-1', name: 'Ava', role: 'Teacher', status: 'complete', score: '5/5', date: 'Jun 8' },
|
||||||
{ name: 'Ben', role: 'Para', status: 'complete', score: '4/5', date: 'Jun 8' },
|
{ userId: 'user-2', name: 'Ben', role: 'Para', status: 'complete', score: '4/5', date: 'Jun 8' },
|
||||||
]),
|
]),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
completedCount: 2,
|
completedCount: 2,
|
||||||
totalStaff: 2,
|
totalStaff: 2,
|
||||||
|
pendingCount: 0,
|
||||||
completionRate: 100,
|
completionRate: 100,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,9 +1,25 @@
|
|||||||
import { SafetyQuizResultCreateDto, SafetyQuizResultDto } from '@/shared/types/safetyQuiz';
|
import {
|
||||||
|
SafetyQuizCompletionRowDto,
|
||||||
|
SafetyQuizResultCreateDto,
|
||||||
|
SafetyQuizResultDto,
|
||||||
|
} from '@/shared/types/safetyQuiz';
|
||||||
import { SafetyQuizComplianceRow, SafetyQuizSubmission } from '@/business/safety-quiz/types';
|
import { SafetyQuizComplianceRow, SafetyQuizSubmission } from '@/business/safety-quiz/types';
|
||||||
|
|
||||||
function toRoleLabel(role: string): string {
|
function toRoleLabel(role: string): string {
|
||||||
|
if (role === 'owner') {
|
||||||
|
return 'Owner';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'principal') {
|
||||||
|
return 'Principal';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'registrar') {
|
||||||
|
return 'Registrar';
|
||||||
|
}
|
||||||
|
|
||||||
if (role === 'support_staff') {
|
if (role === 'support_staff') {
|
||||||
return 'Para';
|
return 'Support Staff';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === 'director') {
|
if (role === 'director') {
|
||||||
@ -23,6 +39,7 @@ function toRoleLabel(role: string): string {
|
|||||||
|
|
||||||
export function toSafetyQuizComplianceRow(dto: SafetyQuizResultDto): SafetyQuizComplianceRow {
|
export function toSafetyQuizComplianceRow(dto: SafetyQuizResultDto): SafetyQuizComplianceRow {
|
||||||
return {
|
return {
|
||||||
|
userId: dto.userId,
|
||||||
name: dto.user_name,
|
name: dto.user_name,
|
||||||
role: toRoleLabel(dto.user_role),
|
role: toRoleLabel(dto.user_role),
|
||||||
status: 'complete',
|
status: 'complete',
|
||||||
@ -34,6 +51,22 @@ export function toSafetyQuizComplianceRow(dto: SafetyQuizResultDto): SafetyQuizC
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toSafetyQuizCompletionRow(dto: SafetyQuizCompletionRowDto): SafetyQuizComplianceRow {
|
||||||
|
return {
|
||||||
|
userId: dto.userId,
|
||||||
|
name: dto.name,
|
||||||
|
role: dto.role ? toRoleLabel(dto.role) : 'Staff',
|
||||||
|
status: dto.status,
|
||||||
|
score: dto.result ? `${dto.result.score}/${dto.result.total_questions}` : 'Pending',
|
||||||
|
date: dto.result
|
||||||
|
? new Date(dto.result.completed_at).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
: 'Not completed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function toSafetyQuizResultCreateDto(submission: SafetyQuizSubmission): SafetyQuizResultCreateDto {
|
export function toSafetyQuizResultCreateDto(submission: SafetyQuizSubmission): SafetyQuizResultCreateDto {
|
||||||
return {
|
return {
|
||||||
quiz_id: submission.quizId,
|
quiz_id: submission.quizId,
|
||||||
|
|||||||
@ -53,6 +53,7 @@ export function calculateSafetyQuizCompletionSummary(
|
|||||||
return {
|
return {
|
||||||
completedCount,
|
completedCount,
|
||||||
totalStaff,
|
totalStaff,
|
||||||
|
pendingCount: Math.max(totalStaff - completedCount, 0),
|
||||||
completionRate: totalStaff > 0 ? Math.round((completedCount / totalStaff) * 100) : 0,
|
completionRate: totalStaff > 0 ? Math.round((completedCount / totalStaff) * 100) : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -64,13 +65,18 @@ export function serializeSafetyQuizPayload(payload: SafetyQuiz): string {
|
|||||||
export function parseSafetyQuizPayload(draft: string): SafetyQuiz | string {
|
export function parseSafetyQuizPayload(draft: string): SafetyQuiz | string {
|
||||||
try {
|
try {
|
||||||
const parsed: unknown = JSON.parse(draft);
|
const parsed: unknown = JSON.parse(draft);
|
||||||
return validateSafetyQuizPayload(parsed);
|
return getSafetyQuizPayloadValidationResult(parsed);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return error instanceof Error && error.message ? error.message : 'Safety quiz JSON is invalid.';
|
return error instanceof Error && error.message ? error.message : 'Safety quiz JSON is invalid.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateSafetyQuizPayload(value: unknown): SafetyQuiz | string {
|
export function validateSafetyQuizPayload(payload: SafetyQuiz): string | null {
|
||||||
|
const result = getSafetyQuizPayloadValidationResult(payload);
|
||||||
|
return typeof result === 'string' ? result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSafetyQuizPayloadValidationResult(value: unknown): SafetyQuiz | string {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value)) {
|
||||||
return 'Safety quiz payload must be a JSON object.';
|
return 'Safety quiz payload must be a JSON object.';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
export interface SafetyQuizComplianceRow {
|
export interface SafetyQuizComplianceRow {
|
||||||
|
readonly userId: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly role: string;
|
readonly role: string;
|
||||||
readonly status: 'complete';
|
readonly status: 'complete' | 'pending';
|
||||||
readonly score: string;
|
readonly score: string;
|
||||||
readonly date: string;
|
readonly date: string;
|
||||||
}
|
}
|
||||||
@ -18,6 +19,7 @@ export interface SafetyQuizSubmission {
|
|||||||
export interface SafetyQuizCompletionSummary {
|
export interface SafetyQuizCompletionSummary {
|
||||||
readonly completedCount: number;
|
readonly completedCount: number;
|
||||||
readonly totalStaff: number;
|
readonly totalStaff: number;
|
||||||
|
readonly pendingCount: number;
|
||||||
readonly completionRate: number;
|
readonly completionRate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,14 +52,30 @@ export interface SafetyQuizPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SafetyQuizContentEditor {
|
export interface SafetyQuizContentEditor {
|
||||||
readonly draft: string;
|
readonly draft: import('@/shared/types/app').SafetyQuiz | null;
|
||||||
readonly isLoading: boolean;
|
readonly isLoading: boolean;
|
||||||
readonly isSaving: boolean;
|
readonly isSaving: boolean;
|
||||||
|
readonly isDeleting: boolean;
|
||||||
readonly errorMessage: string | null;
|
readonly errorMessage: string | null;
|
||||||
readonly validationError: string | null;
|
readonly validationError: string | null;
|
||||||
readonly savedMessage: string | null;
|
readonly savedMessage: string | null;
|
||||||
readonly canSave: boolean;
|
readonly canSave: boolean;
|
||||||
readonly setDraft: (draft: string) => void;
|
readonly updateQuiz: (patch: Partial<import('@/shared/types/app').SafetyQuiz>) => void;
|
||||||
|
readonly updateWeeklyFocus: (patch: Partial<import('@/shared/types/app').SafetyQuiz['weeklyFocus']>) => void;
|
||||||
|
readonly updateReminder: (index: number, value: string) => void;
|
||||||
|
readonly addReminder: () => void;
|
||||||
|
readonly removeReminder: (index: number) => void;
|
||||||
|
readonly updateQuestion: (
|
||||||
|
index: number,
|
||||||
|
patch: Partial<import('@/shared/types/app').QuizQuestion>,
|
||||||
|
) => void;
|
||||||
|
readonly updateQuestionOption: (questionIndex: number, optionIndex: number, value: string) => void;
|
||||||
|
readonly addQuestion: () => void;
|
||||||
|
readonly removeQuestion: (index: number) => void;
|
||||||
|
readonly addQuestionOption: (questionIndex: number) => void;
|
||||||
|
readonly removeQuestionOption: (questionIndex: number, optionIndex: number) => void;
|
||||||
|
readonly addDefaultQuiz: () => void;
|
||||||
readonly reset: () => void;
|
readonly reset: () => void;
|
||||||
readonly save: () => Promise<void>;
|
readonly save: () => Promise<void>;
|
||||||
|
readonly deleteQuiz: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
import { getScopedModules } from '@/business/app-shell/selectors';
|
import { getScopedModules } from '@/business/app-shell/selectors';
|
||||||
import { useContentCatalogPayload } from '@/business/content-catalog/hooks';
|
import { useContentCatalogPayload } from '@/business/content-catalog/hooks';
|
||||||
import { usePolicies, usePolicyAcknowledgments } from '@/business/policies/hooks';
|
import { usePolicies, usePolicyAcknowledgments } from '@/business/policies/hooks';
|
||||||
|
import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks';
|
||||||
import { canPersistPersonalScopeResults } from '@/business/scope/selectors';
|
import { canPersistPersonalScopeResults } from '@/business/scope/selectors';
|
||||||
import { useSafetyProtocols } from '@/business/safety-protocols/hooks';
|
import { useSafetyProtocols } from '@/business/safety-protocols/hooks';
|
||||||
import { hasPermission } from '@/business/auth/permissions';
|
import { hasPermission } from '@/business/auth/permissions';
|
||||||
@ -36,6 +37,7 @@ import type {
|
|||||||
import { useTodayZoneCheckIn } from '@/business/zone-checkin/hooks';
|
import { useTodayZoneCheckIn } from '@/business/zone-checkin/hooks';
|
||||||
import { canZoneCheckIn, shouldNudgeZoneCheckIn } from '@/business/zone-checkin/selectors';
|
import { canZoneCheckIn, shouldNudgeZoneCheckIn } from '@/business/zone-checkin/selectors';
|
||||||
import { useScopeContext } from '@/shared/app/scope-context';
|
import { useScopeContext } from '@/shared/app/scope-context';
|
||||||
|
import { getCurrentSafetyQuizWeek } from '@/business/safety-quiz/selectors';
|
||||||
|
|
||||||
const EMPTY_STRATEGIES: readonly Strategy[] = [];
|
const EMPTY_STRATEGIES: readonly Strategy[] = [];
|
||||||
const EMPTY_SIGNS: readonly SignItem[] = [];
|
const EMPTY_SIGNS: readonly SignItem[] = [];
|
||||||
@ -74,6 +76,18 @@ export function useTopBarPage({
|
|||||||
zoneCheckIn.isLoading,
|
zoneCheckIn.isLoading,
|
||||||
zoneCheckIn.isCheckedInToday,
|
zoneCheckIn.isCheckedInToday,
|
||||||
);
|
);
|
||||||
|
const canReceiveSafetyQuizNotification = canPersistPersonalResults
|
||||||
|
&& hasPermission(user, 'TAKE_QUIZ')
|
||||||
|
&& accessibleModuleIds.has('qbs')
|
||||||
|
&& (effectiveTier === 'school' || effectiveTier === 'campus' || effectiveTier === 'class');
|
||||||
|
const safetyQuizWeek = getCurrentSafetyQuizWeek(new Date());
|
||||||
|
const safetyQuizStatus = useMySafetyQuizStatus(
|
||||||
|
safetyQuizWeek,
|
||||||
|
canReceiveSafetyQuizNotification,
|
||||||
|
);
|
||||||
|
const needsSafetyQuiz = canReceiveSafetyQuizNotification
|
||||||
|
&& !safetyQuizStatus.isLoading
|
||||||
|
&& safetyQuizStatus.data?.completed !== true;
|
||||||
const communicationEvents = useCommunicationEvents();
|
const communicationEvents = useCommunicationEvents();
|
||||||
const acknowledgedCommunicationEventIds = useMemo(() => new Set<string>(), []);
|
const acknowledgedCommunicationEventIds = useMemo(() => new Set<string>(), []);
|
||||||
const canReceivePolicyNotifications = canPersistPersonalResults && hasPermission(user, 'ACK_POLICY');
|
const canReceivePolicyNotifications = canPersistPersonalResults && hasPermission(user, 'ACK_POLICY');
|
||||||
@ -86,6 +100,7 @@ export function useTopBarPage({
|
|||||||
const safetyProtocols = useSafetyProtocols(canReadSafetyProtocols);
|
const safetyProtocols = useSafetyProtocols(canReadSafetyProtocols);
|
||||||
const notifications = buildTopBarNotifications({
|
const notifications = buildTopBarNotifications({
|
||||||
needsZoneCheckIn,
|
needsZoneCheckIn,
|
||||||
|
needsSafetyQuiz,
|
||||||
communicationEvents: communicationEvents.data ?? [],
|
communicationEvents: communicationEvents.data ?? [],
|
||||||
acknowledgedCommunicationEventIds,
|
acknowledgedCommunicationEventIds,
|
||||||
handbookPolicies: handbookPolicies.data ?? [],
|
handbookPolicies: handbookPolicies.data ?? [],
|
||||||
|
|||||||
@ -59,6 +59,26 @@ describe('top bar selectors', () => {
|
|||||||
expect(countUnreadTopBarNotifications(withNudge)).toBe(1);
|
expect(countUnreadTopBarNotifications(withNudge)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('surfaces an unread QBS quiz reminder when the weekly quiz is incomplete', () => {
|
||||||
|
expect(buildTopBarNotifications({
|
||||||
|
needsZoneCheckIn: false,
|
||||||
|
needsSafetyQuiz: false,
|
||||||
|
})).toEqual([]);
|
||||||
|
|
||||||
|
const withReminder = buildTopBarNotifications({
|
||||||
|
needsZoneCheckIn: false,
|
||||||
|
needsSafetyQuiz: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(withReminder).toEqual([{
|
||||||
|
id: 'safety-quiz-weekly',
|
||||||
|
text: "You haven't completed this week's QBS safety quiz",
|
||||||
|
time: 'This week',
|
||||||
|
unread: true,
|
||||||
|
href: APP_ROUTE_PATHS.qbs,
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
it('builds initials from display names', () => {
|
it('builds initials from display names', () => {
|
||||||
expect(getTopBarInitials('Guest')).toBe('G');
|
expect(getTopBarInitials('Guest')).toBe('G');
|
||||||
expect(getTopBarInitials('Ada Lovelace')).toBe('AL');
|
expect(getTopBarInitials('Ada Lovelace')).toBe('AL');
|
||||||
|
|||||||
@ -38,6 +38,7 @@ export function countUnreadTopBarNotifications(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ZONE_CHECKIN_NOTIFICATION_ID = 'zone-checkin-today';
|
const ZONE_CHECKIN_NOTIFICATION_ID = 'zone-checkin-today';
|
||||||
|
const SAFETY_QUIZ_NOTIFICATION_ID = 'safety-quiz-weekly';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the top-bar notification list from derived app state (there is no
|
* Builds the top-bar notification list from derived app state (there is no
|
||||||
@ -46,6 +47,7 @@ const ZONE_CHECKIN_NOTIFICATION_ID = 'zone-checkin-today';
|
|||||||
*/
|
*/
|
||||||
export function buildTopBarNotifications(input: {
|
export function buildTopBarNotifications(input: {
|
||||||
readonly needsZoneCheckIn: boolean;
|
readonly needsZoneCheckIn: boolean;
|
||||||
|
readonly needsSafetyQuiz?: boolean;
|
||||||
readonly communicationEvents?: readonly CommunicationEventDto[];
|
readonly communicationEvents?: readonly CommunicationEventDto[];
|
||||||
readonly acknowledgedCommunicationEventIds?: ReadonlySet<string>;
|
readonly acknowledgedCommunicationEventIds?: ReadonlySet<string>;
|
||||||
readonly handbookPolicies?: readonly PolicyViewModel[];
|
readonly handbookPolicies?: readonly PolicyViewModel[];
|
||||||
@ -64,6 +66,16 @@ export function buildTopBarNotifications(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.needsSafetyQuiz) {
|
||||||
|
notifications.push({
|
||||||
|
id: SAFETY_QUIZ_NOTIFICATION_ID,
|
||||||
|
text: "You haven't completed this week's QBS safety quiz",
|
||||||
|
time: 'This week',
|
||||||
|
unread: true,
|
||||||
|
href: APP_ROUTE_PATHS.qbs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const event of input.communicationEvents ?? []) {
|
for (const event of input.communicationEvents ?? []) {
|
||||||
if (input.acknowledgedCommunicationEventIds?.has(event.id)) {
|
if (input.acknowledgedCommunicationEventIds?.has(event.id)) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -9,10 +9,10 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz';
|
import type { SafetyQuizComplianceRow } from '@/business/safety-quiz/types';
|
||||||
|
|
||||||
interface DirectorQuizResultsPanelProps {
|
interface DirectorQuizResultsPanelProps {
|
||||||
readonly results: readonly SafetyQuizResultDto[];
|
readonly results: readonly SafetyQuizComplianceRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelProps) {
|
export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelProps) {
|
||||||
@ -34,16 +34,20 @@ export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelPr
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{results.map((result) => (
|
{results.map((result) => (
|
||||||
<TableRow key={result.id} className="border-t border-gray-50">
|
<TableRow key={result.userId} className="border-t border-gray-50">
|
||||||
<TableCell className="p-3 font-medium text-gray-700">{result.user_name}</TableCell>
|
<TableCell className="p-3 font-medium text-gray-700">{result.name}</TableCell>
|
||||||
<TableCell className="p-3 text-center text-xs text-gray-500 capitalize">{result.user_role}</TableCell>
|
<TableCell className="p-3 text-center text-xs text-gray-500 capitalize">{result.role}</TableCell>
|
||||||
<TableCell className="p-3 text-center">
|
<TableCell className="p-3 text-center">
|
||||||
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${result.score === result.total_questions ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'}`}>
|
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${
|
||||||
{result.score}/{result.total_questions}
|
result.status === 'complete'
|
||||||
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
|
: 'bg-amber-100 text-amber-700'
|
||||||
|
}`}>
|
||||||
|
{result.score}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="p-3 text-center text-xs text-gray-400">
|
<TableCell className="p-3 text-center text-xs text-gray-400">
|
||||||
{new Date(result.completed_at).toLocaleDateString()}
|
{result.date}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
@ -51,7 +55,7 @@ export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelPr
|
|||||||
</Table>
|
</Table>
|
||||||
) : (
|
) : (
|
||||||
<StatePanel className="border-0 bg-transparent" alignment="center">
|
<StatePanel className="border-0 bg-transparent" alignment="center">
|
||||||
No quiz results yet. Staff will appear here after completing quizzes.
|
No staff are in this completion scope yet.
|
||||||
</StatePanel>
|
</StatePanel>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -40,12 +40,16 @@ export function SafetyQuizCompliancePanel({
|
|||||||
{rows.length > 0 && (
|
{rows.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{rows.map((staff) => (
|
{rows.map((staff) => (
|
||||||
<div key={`${staff.name}-${staff.date}`} className="flex items-center justify-between p-2 rounded-lg bg-slate-700/30 border border-slate-700/30">
|
<div key={staff.userId} className="flex items-center justify-between p-2 rounded-lg bg-slate-700/30 border border-slate-700/30">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-slate-200">{staff.name}</p>
|
<p className="text-xs font-medium text-slate-200">{staff.name}</p>
|
||||||
<p className="text-[10px] text-slate-500">{staff.role}</p>
|
<p className="text-[10px] text-slate-500">{staff.role} · {staff.date}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-lg border bg-emerald-500/15 text-emerald-400 border-emerald-500/20">
|
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-lg border ${
|
||||||
|
staff.status === 'complete'
|
||||||
|
? 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20'
|
||||||
|
: 'bg-amber-500/15 text-amber-300 border-amber-500/20'
|
||||||
|
}`}>
|
||||||
{staff.score}
|
{staff.score}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,69 +1,381 @@
|
|||||||
import { Settings } from 'lucide-react';
|
import { ChevronDown, Plus, Settings, Trash2 } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { useSafetyQuizContentEditor } from '@/business/safety-quiz/hooks';
|
import { useSafetyQuizContentEditor } from '@/business/safety-quiz/hooks';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ConfirmationDialog } from '@/components/common/ConfirmationDialog';
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { NativeSelect } from '@/components/ui/native-select';
|
||||||
import { StatePanel } from '@/components/ui/state-panel';
|
import { StatePanel } from '@/components/ui/state-panel';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import type { SafetyQuiz } from '@/shared/types/app';
|
||||||
|
|
||||||
|
const focusOptions: readonly { value: SafetyQuiz['focus']; label: string }[] = [
|
||||||
|
{ value: 'de-escalation', label: 'De-escalation' },
|
||||||
|
{ value: 'physical-management', label: 'Physical management' },
|
||||||
|
{ value: 'safety-reminders', label: 'Safety reminders' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const inputClassName = 'border-slate-700/60 bg-slate-950/70 text-slate-100 placeholder:text-slate-500';
|
||||||
|
const labelClassName = 'text-xs font-semibold uppercase tracking-wide text-slate-400';
|
||||||
|
const sectionClassName = 'rounded-xl border border-slate-700/50 bg-slate-950/30 p-4 space-y-4';
|
||||||
|
const iconButtonClassName = 'h-9 w-9 border border-slate-700/60 text-slate-300 hover:bg-slate-700/40';
|
||||||
|
|
||||||
export function SafetyQuizContentEditorPanel() {
|
export function SafetyQuizContentEditorPanel() {
|
||||||
const editor = useSafetyQuizContentEditor();
|
const editor = useSafetyQuizContentEditor();
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const disabled = editor.isLoading || editor.isSaving || editor.isDeleting;
|
||||||
|
const quiz = editor.draft;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-slate-700/40 bg-slate-800/40 p-4 space-y-3">
|
<Collapsible
|
||||||
<div className="flex items-center gap-2">
|
open={open}
|
||||||
<Settings size={18} className="text-blue-400" />
|
onOpenChange={setOpen}
|
||||||
<h3 className="text-sm font-semibold text-white">Quiz Content Editor</h3>
|
className="rounded-2xl border border-slate-700/40 bg-slate-800/40"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3 border-b border-slate-700/40 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-3 rounded-xl text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
|
||||||
|
aria-label={open ? 'Collapse quiz editor' : 'Expand quiz editor'}
|
||||||
|
>
|
||||||
|
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-blue-500/15 text-blue-300">
|
||||||
|
<Settings size={18} />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-semibold text-white">Quiz Content Editor</span>
|
||||||
|
<span className="block text-xs text-slate-400">Edit the weekly QBS quiz and key reminders.</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={18}
|
||||||
|
className={`ml-auto text-slate-400 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<div className="flex shrink-0 flex-wrap gap-2 sm:justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
leadingIcon={<Plus size={16} />}
|
||||||
|
onClick={() => {
|
||||||
|
editor.addDefaultQuiz();
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
className="border border-emerald-500/30 bg-emerald-500/10 text-emerald-200 hover:bg-emerald-500/20"
|
||||||
|
>
|
||||||
|
{quiz ? 'Replace with Default' : 'Add Quiz'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
leadingIcon={<Trash2 size={16} />}
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteOpen(true);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
disabled={disabled || !quiz}
|
||||||
|
className="border border-red-500/30 bg-red-500/10 text-red-200 hover:bg-red-500/20"
|
||||||
|
>
|
||||||
|
Delete Quiz
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editor.errorMessage && (
|
<CollapsibleContent className="space-y-4 p-4">
|
||||||
<StatePanel tone="red" role="alert" size="inline">
|
{editor.errorMessage && (
|
||||||
{editor.errorMessage}
|
<StatePanel tone="red" role="alert" size="inline">
|
||||||
</StatePanel>
|
{editor.errorMessage}
|
||||||
)}
|
</StatePanel>
|
||||||
|
)}
|
||||||
|
|
||||||
{editor.validationError && (
|
{editor.validationError && (
|
||||||
<StatePanel tone="red" role="alert" size="inline">
|
<StatePanel tone="red" role="alert" size="inline">
|
||||||
{editor.validationError}
|
{editor.validationError}
|
||||||
</StatePanel>
|
</StatePanel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editor.savedMessage && (
|
{editor.savedMessage && (
|
||||||
<StatePanel tone="cyan" size="inline">
|
<StatePanel tone="cyan" size="inline">
|
||||||
{editor.savedMessage}
|
{editor.savedMessage}
|
||||||
</StatePanel>
|
</StatePanel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Textarea
|
{!quiz ? (
|
||||||
value={editor.draft}
|
<div className="rounded-xl border border-dashed border-slate-700/70 bg-slate-950/30 px-4 py-8 text-center">
|
||||||
onChange={(event) => editor.setDraft(event.target.value)}
|
<p className="text-sm font-semibold text-slate-100">No QBS quiz is configured.</p>
|
||||||
disabled={editor.isLoading || editor.isSaving}
|
<p className="mt-1 text-sm text-slate-400">Add a quiz to publish questions and reminders for this scope.</p>
|
||||||
aria-label="Safety quiz content JSON"
|
</div>
|
||||||
className="min-h-80 border-slate-700/60 bg-slate-950/70 font-mono text-xs text-slate-200"
|
) : (
|
||||||
placeholder="Loading safety quiz content..."
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void editor.save();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={sectionClassName}>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Field label="Quiz title">
|
||||||
|
<Input
|
||||||
|
value={quiz.title}
|
||||||
|
onChange={(event) => editor.updateQuiz({ title: event.target.value })}
|
||||||
|
disabled={disabled}
|
||||||
|
className={inputClassName}
|
||||||
|
placeholder="QBS Safety Quiz"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Focus">
|
||||||
|
<NativeSelect
|
||||||
|
value={quiz.focus}
|
||||||
|
onChange={(event) => {
|
||||||
|
const focus = focusOptions.find((option) => option.value === event.target.value)?.value;
|
||||||
|
if (focus) {
|
||||||
|
editor.updateQuiz({ focus });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
className={inputClassName}
|
||||||
|
>
|
||||||
|
{focusOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Field label="Weekly focus title">
|
||||||
|
<Input
|
||||||
|
value={quiz.weeklyFocus.title}
|
||||||
|
onChange={(event) => editor.updateWeeklyFocus({ title: event.target.value })}
|
||||||
|
disabled={disabled}
|
||||||
|
className={inputClassName}
|
||||||
|
placeholder="This week's focus"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Weekly focus description">
|
||||||
|
<Textarea
|
||||||
|
value={quiz.weeklyFocus.description}
|
||||||
|
onChange={(event) => editor.updateWeeklyFocus({ description: event.target.value })}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`${inputClassName} min-h-24`}
|
||||||
|
placeholder="Describe what staff should focus on this week."
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={sectionClassName}>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h4 className="text-sm font-semibold text-white">Key Reminders</h4>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
leadingIcon={<Plus size={14} />}
|
||||||
|
onClick={editor.addReminder}
|
||||||
|
disabled={disabled}
|
||||||
|
className="border border-slate-700/60 text-slate-200 hover:bg-slate-700/40"
|
||||||
|
>
|
||||||
|
Add Reminder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{quiz.keyReminders.map((reminder, index) => (
|
||||||
|
<div key={`${index}-${reminder}`} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={reminder}
|
||||||
|
onChange={(event) => editor.updateReminder(index, event.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={inputClassName}
|
||||||
|
aria-label={`Key reminder ${index + 1}`}
|
||||||
|
placeholder="Add a key reminder."
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`Remove reminder ${index + 1}`}
|
||||||
|
onClick={() => editor.removeReminder(index)}
|
||||||
|
disabled={disabled || quiz.keyReminders.length <= 1}
|
||||||
|
className={iconButtonClassName}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={sectionClassName}>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h4 className="text-sm font-semibold text-white">Questions</h4>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
leadingIcon={<Plus size={14} />}
|
||||||
|
onClick={editor.addQuestion}
|
||||||
|
disabled={disabled}
|
||||||
|
className="border border-slate-700/60 text-slate-200 hover:bg-slate-700/40"
|
||||||
|
>
|
||||||
|
Add Question
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{quiz.questions.map((question, questionIndex) => (
|
||||||
|
<div
|
||||||
|
key={question.id}
|
||||||
|
className="rounded-xl border border-slate-700/50 bg-slate-900/50 p-4 space-y-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h5 className="text-sm font-semibold text-slate-100">Question {questionIndex + 1}</h5>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`Remove question ${questionIndex + 1}`}
|
||||||
|
onClick={() => editor.removeQuestion(questionIndex)}
|
||||||
|
disabled={disabled || quiz.questions.length <= 1}
|
||||||
|
className={iconButtonClassName}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Question text">
|
||||||
|
<Textarea
|
||||||
|
value={question.question}
|
||||||
|
onChange={(event) => editor.updateQuestion(questionIndex, { question: event.target.value })}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`${inputClassName} min-h-20`}
|
||||||
|
placeholder="Write the question staff should answer."
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className={labelClassName}>Answers</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
leadingIcon={<Plus size={14} />}
|
||||||
|
onClick={() => editor.addQuestionOption(questionIndex)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="border border-slate-700/60 text-slate-200 hover:bg-slate-700/40"
|
||||||
|
>
|
||||||
|
Add Answer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{question.options.map((option, optionIndex) => (
|
||||||
|
<div key={`${question.id}-${optionIndex}`} className="grid gap-2 md:grid-cols-[1fr_160px_40px]">
|
||||||
|
<Input
|
||||||
|
value={option}
|
||||||
|
onChange={(event) =>
|
||||||
|
editor.updateQuestionOption(questionIndex, optionIndex, event.target.value)
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
className={inputClassName}
|
||||||
|
aria-label={`Question ${questionIndex + 1} answer ${optionIndex + 1}`}
|
||||||
|
placeholder={`Answer ${optionIndex + 1}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={question.correctIndex === optionIndex ? 'default' : 'ghost'}
|
||||||
|
onClick={() => editor.updateQuestion(questionIndex, { correctIndex: optionIndex })}
|
||||||
|
disabled={disabled}
|
||||||
|
className={question.correctIndex === optionIndex
|
||||||
|
? 'bg-emerald-500/20 text-emerald-200 border border-emerald-500/30 hover:bg-emerald-500/30'
|
||||||
|
: 'border border-slate-700/60 text-slate-300 hover:bg-slate-700/40'}
|
||||||
|
>
|
||||||
|
Correct
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`Remove answer ${optionIndex + 1}`}
|
||||||
|
onClick={() => editor.removeQuestionOption(questionIndex, optionIndex)}
|
||||||
|
disabled={disabled || question.options.length <= 2}
|
||||||
|
className={iconButtonClassName}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Explanation">
|
||||||
|
<Textarea
|
||||||
|
value={question.explanation}
|
||||||
|
onChange={(event) => editor.updateQuestion(questionIndex, { explanation: event.target.value })}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`${inputClassName} min-h-20`}
|
||||||
|
placeholder="Explain why the correct answer is safest."
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={editor.isSaving}
|
||||||
|
disabled={!editor.canSave}
|
||||||
|
className="bg-blue-500/20 text-blue-300 border border-blue-500/30 hover:bg-blue-500/30"
|
||||||
|
>
|
||||||
|
Save Quiz
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={editor.reset}
|
||||||
|
disabled={disabled}
|
||||||
|
className="border border-slate-700/60 text-slate-300 hover:bg-slate-700/40"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CollapsibleContent>
|
||||||
|
|
||||||
|
<ConfirmationDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
title="Delete QBS safety quiz?"
|
||||||
|
description="This will remove the current quiz and key reminders from this tenant's Behavior Management page."
|
||||||
|
confirmLabel="Delete quiz"
|
||||||
|
loading={editor.isDeleting}
|
||||||
|
tone="danger"
|
||||||
|
onCancel={() => setDeleteOpen(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
void editor.deleteQuiz().then(() => setDeleteOpen(false));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
</Collapsible>
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
);
|
||||||
<Button
|
}
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
function Field({
|
||||||
void editor.save();
|
label,
|
||||||
}}
|
children,
|
||||||
loading={editor.isSaving}
|
}: {
|
||||||
disabled={!editor.canSave}
|
readonly label: string;
|
||||||
className="bg-blue-500/20 text-blue-300 border border-blue-500/30 hover:bg-blue-500/30"
|
readonly children: ReactNode;
|
||||||
>
|
}) {
|
||||||
Save Content
|
return (
|
||||||
</Button>
|
<label className="space-y-2">
|
||||||
<Button
|
<span className={labelClassName}>{label}</span>
|
||||||
type="button"
|
{children}
|
||||||
variant="ghost"
|
</label>
|
||||||
onClick={editor.reset}
|
|
||||||
disabled={editor.isLoading || editor.isSaving}
|
|
||||||
className="border border-slate-700/60 text-slate-300 hover:bg-slate-700/40"
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,7 @@ export function SafetyQuizView({ page }: SafetyQuizViewProps) {
|
|||||||
{!quizConfigurationError && page.quiz ? (
|
{!quizConfigurationError && page.quiz ? (
|
||||||
<SafetyQuizFocusPanel weeklyFocus={page.quiz.weeklyFocus} />
|
<SafetyQuizFocusPanel weeklyFocus={page.quiz.weeklyFocus} />
|
||||||
) : null}
|
) : null}
|
||||||
|
{page.canManageQuizContent && <SafetyQuizContentEditorPanel />}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<SafetyQuizMainPanel page={page} />
|
<SafetyQuizMainPanel page={page} />
|
||||||
@ -52,7 +53,6 @@ export function SafetyQuizView({ page }: SafetyQuizViewProps) {
|
|||||||
error={page.complianceError}
|
error={page.complianceError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{page.canManageQuizContent && <SafetyQuizContentEditorPanel />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { KeyRound, Loader2, UserCircle } from 'lucide-react';
|
import { KeyRound, Loader2, ShieldCheck, UserCircle } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@ -15,6 +15,7 @@ import { changePassword, updateOwnProfile, updateOrganization } from '@/business
|
|||||||
import { getErrorMessage } from '@/shared/errors/errorMessages';
|
import { getErrorMessage } from '@/shared/errors/errorMessages';
|
||||||
import { USER_NAME_PREFIX_OPTIONS } from '@/shared/constants/users';
|
import { USER_NAME_PREFIX_OPTIONS } from '@/shared/constants/users';
|
||||||
import { ImageUpload } from '@/components/common/ImageUpload';
|
import { ImageUpload } from '@/components/common/ImageUpload';
|
||||||
|
import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks';
|
||||||
|
|
||||||
interface StatusMessage {
|
interface StatusMessage {
|
||||||
readonly type: 'success' | 'error';
|
readonly type: 'success' | 'error';
|
||||||
@ -57,6 +58,7 @@ function ReadOnlyField({ label, value }: { label: string; value: string }) {
|
|||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { user, profile, refreshUser } = useAuth();
|
const { user, profile, refreshUser } = useAuth();
|
||||||
const capabilitiesQuery = useIamCapabilities();
|
const capabilitiesQuery = useIamCapabilities();
|
||||||
|
const safetyQuizStatus = useMySafetyQuizStatus(undefined, Boolean(user));
|
||||||
|
|
||||||
const [namePrefix, setNamePrefix] = useState<string>(user?.name_prefix ?? '');
|
const [namePrefix, setNamePrefix] = useState<string>(user?.name_prefix ?? '');
|
||||||
const [firstName, setFirstName] = useState<string>(user?.firstName ?? '');
|
const [firstName, setFirstName] = useState<string>(user?.firstName ?? '');
|
||||||
@ -342,6 +344,39 @@ export default function ProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card className={profileCardClassName}>
|
||||||
|
<CardHeader className="border-b border-slate-700/70 px-4 py-4 md:px-5">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base text-slate-50">
|
||||||
|
<ShieldCheck size={16} />
|
||||||
|
QBS safety quiz
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4 md:px-5">
|
||||||
|
<div className={`${formPanelClassName} mt-6`}>
|
||||||
|
{safetyQuizStatus.isLoading ? (
|
||||||
|
<p className="text-sm text-slate-300">Loading quiz status...</p>
|
||||||
|
) : safetyQuizStatus.data?.result ? (
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<ReadOnlyField
|
||||||
|
label="Latest quiz"
|
||||||
|
value={safetyQuizStatus.data.result.quiz_title}
|
||||||
|
/>
|
||||||
|
<ReadOnlyField
|
||||||
|
label="Score"
|
||||||
|
value={`${safetyQuizStatus.data.result.score}/${safetyQuizStatus.data.result.total_questions}`}
|
||||||
|
/>
|
||||||
|
<ReadOnlyField
|
||||||
|
label="Completed"
|
||||||
|
value={new Date(safetyQuizStatus.data.result.completed_at).toLocaleDateString()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-300">No QBS safety quiz result is saved yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className={profileCardClassName}>
|
<Card className={profileCardClassName}>
|
||||||
<CardHeader className="border-b border-slate-700/70 px-4 py-4 md:px-5">
|
<CardHeader className="border-b border-slate-700/70 px-4 py-4 md:px-5">
|
||||||
<CardTitle className="flex items-center gap-2 text-base text-slate-50">
|
<CardTitle className="flex items-center gap-2 text-base text-slate-50">
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
createSafetyQuizResult,
|
createSafetyQuizResult,
|
||||||
|
getMySafetyQuizStatus,
|
||||||
|
getSafetyQuizCompletionReport,
|
||||||
listSafetyQuizResults,
|
listSafetyQuizResults,
|
||||||
} from '@/shared/api/safetyQuizResults';
|
} from '@/shared/api/safetyQuizResults';
|
||||||
import { apiRequest } from '@/shared/api/httpClient';
|
import { apiRequest } from '@/shared/api/httpClient';
|
||||||
@ -29,6 +31,18 @@ describe('safety quiz results API', () => {
|
|||||||
expect(apiRequestMock).toHaveBeenCalledWith('/safety_quiz_results?week_of=2026-06-08');
|
expect(apiRequestMock).toHaveBeenCalledWith('/safety_quiz_results?week_of=2026-06-08');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('gets current user quiz status filtered by week', () => {
|
||||||
|
void getMySafetyQuizStatus('2026-06-08');
|
||||||
|
|
||||||
|
expect(apiRequestMock).toHaveBeenCalledWith('/safety_quiz_results/me?week_of=2026-06-08');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets quiz completion report filtered by week', () => {
|
||||||
|
void getSafetyQuizCompletionReport('2026-06-08');
|
||||||
|
|
||||||
|
expect(apiRequestMock).toHaveBeenCalledWith('/safety_quiz_results/completion?week_of=2026-06-08');
|
||||||
|
});
|
||||||
|
|
||||||
it('creates safety quiz results with POST body wrapped in data', () => {
|
it('creates safety quiz results with POST body wrapped in data', () => {
|
||||||
const request: SafetyQuizResultCreateDto = {
|
const request: SafetyQuizResultCreateDto = {
|
||||||
quiz_id: 'quiz-1',
|
quiz_id: 'quiz-1',
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { apiRequest } from '@/shared/api/httpClient';
|
import { apiRequest } from '@/shared/api/httpClient';
|
||||||
import { ApiListResponse } from '@/shared/types/api';
|
import { ApiListResponse } from '@/shared/types/api';
|
||||||
import {
|
import {
|
||||||
|
SafetyQuizCompletionReportDto,
|
||||||
|
SafetyQuizPersonalStatusDto,
|
||||||
SafetyQuizResultCreateDto,
|
SafetyQuizResultCreateDto,
|
||||||
SafetyQuizResultDto,
|
SafetyQuizResultDto,
|
||||||
} from '@/shared/types/safetyQuiz';
|
} from '@/shared/types/safetyQuiz';
|
||||||
@ -19,6 +21,16 @@ export function listSafetyQuizResults(weekOf?: string): Promise<ApiListResponse<
|
|||||||
return apiRequest<ApiListResponse<SafetyQuizResultDto>>(createSafetyQuizResultsPath(weekOf));
|
return apiRequest<ApiListResponse<SafetyQuizResultDto>>(createSafetyQuizResultsPath(weekOf));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMySafetyQuizStatus(weekOf?: string): Promise<SafetyQuizPersonalStatusDto> {
|
||||||
|
const suffix = weekOf ? `?${new URLSearchParams({ week_of: weekOf }).toString()}` : '';
|
||||||
|
return apiRequest<SafetyQuizPersonalStatusDto>(`${SAFETY_QUIZ_RESULTS_PATH}/me${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSafetyQuizCompletionReport(weekOf?: string): Promise<SafetyQuizCompletionReportDto> {
|
||||||
|
const suffix = weekOf ? `?${new URLSearchParams({ week_of: weekOf }).toString()}` : '';
|
||||||
|
return apiRequest<SafetyQuizCompletionReportDto>(`${SAFETY_QUIZ_RESULTS_PATH}/completion${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function createSafetyQuizResult(request: SafetyQuizResultCreateDto): Promise<SafetyQuizResultDto | null> {
|
export function createSafetyQuizResult(request: SafetyQuizResultCreateDto): Promise<SafetyQuizResultDto | null> {
|
||||||
return apiRequest<SafetyQuizResultDto | null>(SAFETY_QUIZ_RESULTS_PATH, {
|
return apiRequest<SafetyQuizResultDto | null>(SAFETY_QUIZ_RESULTS_PATH, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
export const SAFETY_QUIZ_QUERY_KEYS = {
|
export const SAFETY_QUIZ_QUERY_KEYS = {
|
||||||
results: ['safetyQuizResults'],
|
results: ['safetyQuizResults'],
|
||||||
|
personalStatus: ['safetyQuizPersonalStatus'],
|
||||||
|
completion: ['safetyQuizCompletion'],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const SAFETY_QUIZ_COMPLIANCE_QUERY_SEGMENT = 'compliance';
|
export const SAFETY_QUIZ_COMPLIANCE_QUERY_SEGMENT = 'compliance';
|
||||||
|
|||||||
@ -26,3 +26,29 @@ export interface SafetyQuizResultCreateDto {
|
|||||||
readonly total_questions: number;
|
readonly total_questions: number;
|
||||||
readonly answers: readonly number[];
|
readonly answers: readonly number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SafetyQuizPersonalStatusDto {
|
||||||
|
readonly completed: boolean;
|
||||||
|
readonly result: SafetyQuizResultDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SafetyQuizCompletionRowDto {
|
||||||
|
readonly userId: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly email: string;
|
||||||
|
readonly role: UserRole | null;
|
||||||
|
readonly status: 'complete' | 'pending';
|
||||||
|
readonly result: SafetyQuizResultDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SafetyQuizCompletionSummaryDto {
|
||||||
|
readonly totalStaff: number;
|
||||||
|
readonly completedCount: number;
|
||||||
|
readonly pendingCount: number;
|
||||||
|
readonly completionRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SafetyQuizCompletionReportDto {
|
||||||
|
readonly summary: SafetyQuizCompletionSummaryDto;
|
||||||
|
readonly rows: readonly SafetyQuizCompletionRowDto[];
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user