improved users acknowledgement functionality
This commit is contained in:
parent
2b496033cf
commit
0fbf6c0387
@ -37,9 +37,17 @@ entity it replaced has been removed):
|
|||||||
`SafetyDynamicListEditor`; gated by effective policy-document permissions).
|
`SafetyDynamicListEditor`; gated by effective policy-document permissions).
|
||||||
Title/body/steps/considerations changes bump `version` and
|
Title/body/steps/considerations changes bump `version` and
|
||||||
require re-acknowledgment.
|
require re-acknowledgment.
|
||||||
- **Acknowledgments** (`business/policies`, `pages/modules/AcknowledgmentsPage`)
|
- **Leadership dashboard acknowledgments** (`business/director-dashboard`,
|
||||||
renders the manager report from `GET /api/policy_acknowledgments/report`.
|
`components/director-dashboard/DirectorAcknowledgmentTrackingPanel`) render the
|
||||||
The Director Dashboard also shows the report summary as an overview card.
|
manager report from `GET /api/policy_acknowledgments/report`. Documents are
|
||||||
|
grouped by category, each current-version row can expand to show staff who
|
||||||
|
have not acknowledged it, and unresolved rows also feed dashboard risk areas.
|
||||||
|
- **Profile document checklist** (`business/profile`, `pages/ProfilePage`)
|
||||||
|
renders the current user's visible current-version safety protocols and
|
||||||
|
handbook policies with acknowledged / not acknowledged status from
|
||||||
|
`GET /api/policy_acknowledgments`. Missing acknowledgments are sorted first,
|
||||||
|
the status appears as a badge beside the document title, and the
|
||||||
|
`Acknowledged on` column is reserved for the timestamp only.
|
||||||
|
|
||||||
## Entities
|
## Entities
|
||||||
|
|
||||||
@ -104,7 +112,9 @@ super_admin/system_admin can read it only while drilled into a tenant.
|
|||||||
`custom_permissions` can grant the report permission to tenant users and
|
`custom_permissions` can grant the report permission to tenant users and
|
||||||
`custom_permissions_filter` can remove it. The report population is active
|
`custom_permissions_filter` can remove it. The report population is active
|
||||||
staff accounts in the current scope holding one of
|
staff accounts in the current scope holding one of
|
||||||
director/office_manager/teacher/support_staff roles.
|
director/office_manager/teacher/support_staff roles. Header notifications use
|
||||||
|
the same current-version acknowledgment rows, so a user is reminded until each
|
||||||
|
visible active document version is acknowledged.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@ -116,8 +126,12 @@ director/office_manager/teacher/support_staff roles.
|
|||||||
acknowledgment listing plus the drilled-child no-op rule for parent users.
|
acknowledgment listing plus the drilled-child no-op rule for parent users.
|
||||||
- **Frontend unit** (`vitest`): `business/policies/mappers.test.ts` (handbook;
|
- **Frontend unit** (`vitest`): `business/policies/mappers.test.ts` (handbook;
|
||||||
tag↔category, author), `business/safety-protocols/mappers.test.ts` (steps +
|
tag↔category, author), `business/safety-protocols/mappers.test.ts` (steps +
|
||||||
autism considerations) and `business/safety-protocols/selectors.test.ts`
|
autism considerations), `business/safety-protocols/selectors.test.ts`
|
||||||
(management grant + draft validation for the authoring form).
|
(management grant + draft validation for the authoring form),
|
||||||
|
`business/director-dashboard/selectors.test.ts` (dashboard acknowledgment rows
|
||||||
|
and risk areas), and `business/profile/selectors.test.ts` (current-version
|
||||||
|
profile checklist rows, missing-first sorting, and old-version acknowledgment
|
||||||
|
exclusion).
|
||||||
- **Seeded e2e** (`frontend/tests/e2e/policy-acknowledgments.seeded.e2e.ts`,
|
- **Seeded e2e** (`frontend/tests/e2e/policy-acknowledgments.seeded.e2e.ts`,
|
||||||
`npm run test:e2e:content`): document create/persist, manage-vs-read RBAC
|
`npm run test:e2e:content`): document create/persist, manage-vs-read RBAC
|
||||||
(director/office_manager manage; teacher reads but cannot create), idempotent
|
(director/office_manager manage; teacher reads but cannot create), idempotent
|
||||||
|
|||||||
@ -2,9 +2,16 @@ import { v4 as uuid } from 'uuid';
|
|||||||
import { Op, type QueryInterface } from 'sequelize';
|
import { Op, type QueryInterface } from 'sequelize';
|
||||||
import {
|
import {
|
||||||
SEED_ORGANIZATION_ID,
|
SEED_ORGANIZATION_ID,
|
||||||
SEED_CAMPUS_ID,
|
SEED_ORGANIZATION_2_ID,
|
||||||
|
SEED_SECONDARY_CAMPUS_ID,
|
||||||
|
SEED_SECONDARY_SCHOOL_ID,
|
||||||
SEED_FIXTURE_USERS,
|
SEED_FIXTURE_USERS,
|
||||||
|
SEED_SECONDARY_USERS,
|
||||||
|
SEED_SCHOOL_CAMPUS_IDS,
|
||||||
|
SEED_SCHOOL_ID,
|
||||||
|
SEED_SCHOOL_2_ID,
|
||||||
} from '@/shared/constants/seed-fixtures';
|
} from '@/shared/constants/seed-fixtures';
|
||||||
|
import { ROLE_NAMES } from '@/shared/constants/roles';
|
||||||
import {
|
import {
|
||||||
POLICY_DOCUMENT_CATEGORIES,
|
POLICY_DOCUMENT_CATEGORIES,
|
||||||
type PolicyDocumentCategory,
|
type PolicyDocumentCategory,
|
||||||
@ -16,15 +23,11 @@ import { CONTENT_CATALOG_SEED_PAYLOADS } from './content-catalog-data/content-ca
|
|||||||
* Seeds the unified `policy_documents` for the Safety Protocols and Handbook &
|
* Seeds the unified `policy_documents` for the Safety Protocols and Handbook &
|
||||||
* Policies pages (Workstream 11). Safety protocols reuse the existing
|
* Policies pages (Workstream 11). Safety protocols reuse the existing
|
||||||
* content-catalog `safetyProtocols` payload (steps + autism considerations);
|
* content-catalog `safetyProtocols` payload (steps + autism considerations);
|
||||||
* a few handbook policies give the handbook page demo content. Authored by the
|
* a few handbook policies give the handbook page demo content. The same current
|
||||||
* seeded director on the primary org/campus. Idempotent by `importHash`.
|
* document set is preset at organization, school, and campus content scopes for
|
||||||
|
* the seeded tenants so every leadership dashboard has documents to track.
|
||||||
|
* Idempotent by scope-aware `importHash`.
|
||||||
*/
|
*/
|
||||||
const DIRECTOR = SEED_FIXTURE_USERS.find((u) => u.role === 'director');
|
|
||||||
const AUTHOR = DIRECTOR
|
|
||||||
? formatPersonName(DIRECTOR.namePrefix, DIRECTOR.firstName, DIRECTOR.lastName)
|
|
||||||
: null;
|
|
||||||
const AUTHOR_ID = DIRECTOR?.id ?? null;
|
|
||||||
|
|
||||||
interface SeedRow {
|
interface SeedRow {
|
||||||
readonly importHash: string;
|
readonly importHash: string;
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
@ -88,44 +91,130 @@ const HANDBOOK_ROWS: SeedRow[] = [
|
|||||||
|
|
||||||
const ALL_ROWS = [...SAFETY_ROWS, ...HANDBOOK_ROWS];
|
const ALL_ROWS = [...SAFETY_ROWS, ...HANDBOOK_ROWS];
|
||||||
|
|
||||||
|
interface SeedScope {
|
||||||
|
readonly key: string;
|
||||||
|
readonly organizationId: string;
|
||||||
|
readonly schoolId: string | null;
|
||||||
|
readonly campusId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIMARY_CAMPUS_IDS = [
|
||||||
|
...SEED_SCHOOL_CAMPUS_IDS[SEED_SCHOOL_ID],
|
||||||
|
...SEED_SCHOOL_CAMPUS_IDS[SEED_SCHOOL_2_ID],
|
||||||
|
];
|
||||||
|
|
||||||
|
const SEED_SCOPES: readonly SeedScope[] = [
|
||||||
|
{
|
||||||
|
key: 'demo-org',
|
||||||
|
organizationId: SEED_ORGANIZATION_ID,
|
||||||
|
schoolId: null,
|
||||||
|
campusId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'demo-school-north',
|
||||||
|
organizationId: SEED_ORGANIZATION_ID,
|
||||||
|
schoolId: SEED_SCHOOL_ID,
|
||||||
|
campusId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'demo-school-south',
|
||||||
|
organizationId: SEED_ORGANIZATION_ID,
|
||||||
|
schoolId: SEED_SCHOOL_2_ID,
|
||||||
|
campusId: null,
|
||||||
|
},
|
||||||
|
...PRIMARY_CAMPUS_IDS.map((campusId) => ({
|
||||||
|
key: `demo-campus-${campusId}`,
|
||||||
|
organizationId: SEED_ORGANIZATION_ID,
|
||||||
|
schoolId: null,
|
||||||
|
campusId,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
key: 'rival-org',
|
||||||
|
organizationId: SEED_ORGANIZATION_2_ID,
|
||||||
|
schoolId: null,
|
||||||
|
campusId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rival-school',
|
||||||
|
organizationId: SEED_ORGANIZATION_2_ID,
|
||||||
|
schoolId: SEED_SECONDARY_SCHOOL_ID,
|
||||||
|
campusId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rival-campus',
|
||||||
|
organizationId: SEED_ORGANIZATION_2_ID,
|
||||||
|
schoolId: null,
|
||||||
|
campusId: SEED_SECONDARY_CAMPUS_ID,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function scopedImportHash(scope: SeedScope, row: SeedRow): string {
|
||||||
|
return `${row.importHash}-${scope.key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScopeDirector(scope: SeedScope) {
|
||||||
|
const fixtureUsers = scope.organizationId === SEED_ORGANIZATION_2_ID
|
||||||
|
? SEED_SECONDARY_USERS
|
||||||
|
: SEED_FIXTURE_USERS;
|
||||||
|
|
||||||
|
return fixtureUsers.find((user) => user.role === ROLE_NAMES.DIRECTOR) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
up: async (queryInterface: QueryInterface) => {
|
up: async (queryInterface: QueryInterface) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const importHashes = ALL_ROWS.map((row) => row.importHash);
|
const legacyImportHashes = ALL_ROWS.map((row) => row.importHash);
|
||||||
|
const importHashes = SEED_SCOPES.flatMap((scope) =>
|
||||||
|
ALL_ROWS.map((row) => scopedImportHash(scope, row)),
|
||||||
|
);
|
||||||
|
|
||||||
await queryInterface.bulkDelete('policy_documents', {
|
await queryInterface.bulkDelete('policy_documents', {
|
||||||
importHash: { [Op.in]: importHashes },
|
importHash: { [Op.in]: [...legacyImportHashes, ...importHashes] },
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = ALL_ROWS.map((row) => ({
|
const rows = SEED_SCOPES.flatMap((scope) => {
|
||||||
id: uuid(),
|
const director = getScopeDirector(scope);
|
||||||
title: row.title,
|
const author = director
|
||||||
body: row.body,
|
? formatPersonName(director.namePrefix, director.firstName, director.lastName)
|
||||||
category: row.category,
|
: null;
|
||||||
tag: row.tag,
|
const authorId = director?.id ?? null;
|
||||||
author: AUTHOR,
|
|
||||||
// JSONB columns: serialize for bulkInsert (Postgres casts JSON → jsonb).
|
return ALL_ROWS.map((row) => ({
|
||||||
steps: row.steps ? JSON.stringify(row.steps) : null,
|
id: uuid(),
|
||||||
autism_considerations: row.autismConsiderations
|
title: row.title,
|
||||||
? JSON.stringify(row.autismConsiderations)
|
body: row.body,
|
||||||
: null,
|
category: row.category,
|
||||||
version: 1,
|
tag: row.tag,
|
||||||
active: true,
|
author,
|
||||||
importHash: row.importHash,
|
// JSONB columns: serialize for bulkInsert (Postgres casts JSON → jsonb).
|
||||||
organizationId: SEED_ORGANIZATION_ID,
|
steps: row.steps ? JSON.stringify(row.steps) : null,
|
||||||
campusId: SEED_CAMPUS_ID,
|
autism_considerations: row.autismConsiderations
|
||||||
createdById: AUTHOR_ID,
|
? JSON.stringify(row.autismConsiderations)
|
||||||
updatedById: AUTHOR_ID,
|
: null,
|
||||||
createdAt: now,
|
version: 1,
|
||||||
updatedAt: now,
|
active: true,
|
||||||
}));
|
importHash: scopedImportHash(scope, row),
|
||||||
|
organizationId: scope.organizationId,
|
||||||
|
schoolId: scope.schoolId,
|
||||||
|
campusId: scope.campusId,
|
||||||
|
classId: null,
|
||||||
|
createdById: authorId,
|
||||||
|
updatedById: authorId,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
await queryInterface.bulkInsert('policy_documents', rows);
|
await queryInterface.bulkInsert('policy_documents', rows);
|
||||||
},
|
},
|
||||||
|
|
||||||
down: async (queryInterface: QueryInterface) => {
|
down: async (queryInterface: QueryInterface) => {
|
||||||
await queryInterface.bulkDelete('policy_documents', {
|
await queryInterface.bulkDelete('policy_documents', {
|
||||||
importHash: { [Op.in]: ALL_ROWS.map((row) => row.importHash) },
|
importHash: {
|
||||||
|
[Op.in]: SEED_SCOPES.flatMap((scope) =>
|
||||||
|
ALL_ROWS.map((row) => scopedImportHash(scope, row)),
|
||||||
|
),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -95,4 +95,90 @@ describe('PolicyAcknowledgmentsService role eligibility', () => {
|
|||||||
assert.equal(documentLookupCount, 0);
|
assert.equal(documentLookupCount, 0);
|
||||||
assert.equal(acknowledgmentCreateCount, 0);
|
assert.equal(acknowledgmentCreateCount, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('builds current-version report rows with missing staff in scope', async () => {
|
||||||
|
mock.method(db.policy_documents, 'findAll', (async () => [
|
||||||
|
{
|
||||||
|
id: 'document-1',
|
||||||
|
title: 'Fire Drill Procedures',
|
||||||
|
category: 'safety_protocol',
|
||||||
|
version: 2,
|
||||||
|
},
|
||||||
|
]) as unknown as typeof db.policy_documents.findAll);
|
||||||
|
|
||||||
|
mock.method(db.users, 'findAll', (async () => [
|
||||||
|
{
|
||||||
|
id: 'user-1',
|
||||||
|
firstName: 'Ava',
|
||||||
|
lastName: 'Lee',
|
||||||
|
email: 'ava@example.test',
|
||||||
|
campusId: 'campus-1',
|
||||||
|
schoolId: 'school-1',
|
||||||
|
classId: 'class-1',
|
||||||
|
app_role: { name: ROLE_NAMES.TEACHER },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-2',
|
||||||
|
firstName: 'Ben',
|
||||||
|
lastName: 'Cruz',
|
||||||
|
email: 'ben@example.test',
|
||||||
|
campusId: 'campus-1',
|
||||||
|
schoolId: 'school-1',
|
||||||
|
classId: 'class-1',
|
||||||
|
app_role: { name: ROLE_NAMES.SUPPORT_STAFF },
|
||||||
|
},
|
||||||
|
]) as unknown as typeof db.users.findAll);
|
||||||
|
|
||||||
|
mock.method(db.policy_acknowledgments, 'findAll', (async () => [
|
||||||
|
{
|
||||||
|
userId: 'user-1',
|
||||||
|
policyDocumentId: 'document-1',
|
||||||
|
version: 2,
|
||||||
|
acknowledgedAt: new Date('2026-06-18T10:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: 'user-2',
|
||||||
|
policyDocumentId: 'document-1',
|
||||||
|
version: 1,
|
||||||
|
acknowledgedAt: new Date('2026-06-17T10:00:00.000Z'),
|
||||||
|
},
|
||||||
|
]) as unknown as typeof db.policy_acknowledgments.findAll);
|
||||||
|
|
||||||
|
const report = await PolicyAcknowledgmentsService.report(createTestUser({
|
||||||
|
organizationId: 'org-1',
|
||||||
|
organizations: { id: 'org-1' },
|
||||||
|
schoolId: 'school-1',
|
||||||
|
campusId: 'campus-1',
|
||||||
|
app_role: {
|
||||||
|
name: ROLE_NAMES.DIRECTOR,
|
||||||
|
scope: ROLE_SCOPES.CAMPUS,
|
||||||
|
globalAccess: false,
|
||||||
|
permissions: [permission(FEATURE_PERMISSIONS.READ_POLICY_ACKNOWLEDGMENT_REPORTS)],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.deepEqual(report.summary, {
|
||||||
|
scope: 'campus',
|
||||||
|
totalDocuments: 1,
|
||||||
|
totalStaff: 2,
|
||||||
|
totalRequired: 2,
|
||||||
|
acknowledgedCount: 1,
|
||||||
|
missingCount: 1,
|
||||||
|
completionRate: 50,
|
||||||
|
});
|
||||||
|
assert.deepEqual(report.documents, [
|
||||||
|
{
|
||||||
|
id: 'document-1',
|
||||||
|
title: 'Fire Drill Procedures',
|
||||||
|
category: 'safety_protocol',
|
||||||
|
version: 2,
|
||||||
|
totalStaff: 2,
|
||||||
|
acknowledgedCount: 1,
|
||||||
|
missingCount: 1,
|
||||||
|
completionRate: 50,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.equal(report.staff[1]?.name, 'Ben Cruz');
|
||||||
|
assert.equal(report.staff[1]?.documents[0]?.missing, true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -42,8 +42,14 @@ Constants:
|
|||||||
- Emotional Intelligence and Personality Type completion loads through `usePersonalityCompletion`.
|
- Emotional Intelligence and Personality Type completion loads through `usePersonalityCompletion`.
|
||||||
- Daily Zone Check-In completion loads through `useZoneCheckInCompletion`.
|
- Daily Zone Check-In completion loads through `useZoneCheckInCompletion`.
|
||||||
- Staff attendance records and summary load through staff attendance business hooks.
|
- Staff attendance records and summary load through staff attendance business hooks.
|
||||||
- Policy acknowledgment summary loads through `usePolicyAcknowledgmentReport`.
|
- Policy acknowledgment report loads through `usePolicyAcknowledgmentReport`.
|
||||||
- Overview metrics, risk areas, unified quiz result rows, and FRAME previews are derived in business selectors.
|
- Overview metrics, risk areas, unified quiz result rows, and FRAME previews are derived in business selectors.
|
||||||
|
- Document acknowledgment tracking opens in a modal from the Acknowledgments
|
||||||
|
overview card or the acknowledgment risk card. It renders current-version
|
||||||
|
Safety Protocol and Handbook & Policies documents grouped by category.
|
||||||
|
Collapsed rows show title, version, and acknowledged/total staff counts for
|
||||||
|
the leader's scope; expanded rows list staff who have not acknowledged that
|
||||||
|
version.
|
||||||
- The dashboard quiz results table combines Behavior Management, EI Self-Assessment,
|
- The dashboard quiz results table combines Behavior Management, EI Self-Assessment,
|
||||||
Personality Type Quiz, and Daily Zone Check-In rows. EI self-assessment rows
|
Personality Type Quiz, and Daily Zone Check-In rows. EI self-assessment rows
|
||||||
reflect the current Sunday-start week; personality type rows reflect each user's
|
reflect the current Sunday-start week; personality type rows reflect each user's
|
||||||
@ -51,7 +57,8 @@ Constants:
|
|||||||
each staff member.
|
each staff member.
|
||||||
- Risk areas include high/medium/low QBS safety quiz completion, low-risk EI
|
- Risk areas include high/medium/low QBS safety quiz completion, low-risk EI
|
||||||
self-assessment pending counts, low-risk Personality Type pending counts,
|
self-assessment pending counts, low-risk Personality Type pending counts,
|
||||||
medium-risk non-green Daily Zone Check-In counts, and attendance risk.
|
medium-risk non-green Daily Zone Check-In counts, missing current-version
|
||||||
|
document acknowledgments, and attendance risk.
|
||||||
- View components use shared `Button`, `Table`, `StatePanel`, and `ModuleHeader` primitives.
|
- View components use shared `Button`, `Table`, `StatePanel`, and `ModuleHeader` primitives.
|
||||||
- Loading, empty, and error states are explicit.
|
- Loading, empty, and error states are explicit.
|
||||||
|
|
||||||
|
|||||||
@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Three pages — **Handbook & Policies**, **Safety Protocols**, and
|
Two document pages — **Handbook & Policies** and **Safety Protocols** — are backed by one
|
||||||
**Acknowledgments** — are backed by one
|
|
||||||
unified store, `policy_documents` (it replaced the former generic `documents`
|
unified store, `policy_documents` (it replaced the former generic `documents`
|
||||||
API, which has been removed). `category` selects the page (`handbook_policy` vs
|
API, which has been removed). `category` selects the page (`handbook_policy` vs
|
||||||
`safety_protocol`); `tag` carries the finer sub-category (the handbook's
|
`safety_protocol`); `tag` carries the finer sub-category (the handbook's
|
||||||
Operations/Behavior/… and the safety card icon). Staff acknowledgment is
|
Operations/Behavior/… and the safety card icon). Staff acknowledgment is
|
||||||
**persisted per document version** via `policy_acknowledgments`.
|
**persisted per document version** via `policy_acknowledgments`, reported in
|
||||||
|
leadership dashboards, and listed for each user in Profile.
|
||||||
|
|
||||||
## Frontend Structure
|
## Frontend Structure
|
||||||
|
|
||||||
@ -24,13 +24,21 @@ Safety Protocols:
|
|||||||
(+ `SafetyProtocolForm.tsx`, `SafetyDynamicListEditor.tsx`)
|
(+ `SafetyProtocolForm.tsx`, `SafetyDynamicListEditor.tsx`)
|
||||||
- Business: `frontend/src/business/safety-protocols/{hooks,mappers,selectors,types}.ts`
|
- Business: `frontend/src/business/safety-protocols/{hooks,mappers,selectors,types}.ts`
|
||||||
|
|
||||||
Acknowledgments:
|
Acknowledgment tracking:
|
||||||
|
|
||||||
- Page: `frontend/src/pages/modules/AcknowledgmentsPage.tsx`
|
- Dashboard component:
|
||||||
- Business/API: `usePolicyAcknowledgmentReport` in
|
`frontend/src/components/director-dashboard/DirectorAcknowledgmentTrackingModal.tsx`
|
||||||
|
and `DirectorAcknowledgmentTrackingPanel.tsx`
|
||||||
|
- Business/API: `buildDirectorAcknowledgmentDocuments` +
|
||||||
|
`usePolicyAcknowledgmentReport` in
|
||||||
|
`frontend/src/business/director-dashboard` and
|
||||||
`frontend/src/business/policies/hooks.ts`, backed by
|
`frontend/src/business/policies/hooks.ts`, backed by
|
||||||
`frontend/src/shared/api/policyAcknowledgments.ts`
|
`frontend/src/shared/api/policyAcknowledgments.ts`
|
||||||
- Dashboard: `DirectorDashboard` includes the report summary as an overview card.
|
- Profile: `buildProfileDocumentAcknowledgmentRows` feeds the profile's
|
||||||
|
current document checklist. The checklist includes every visible active
|
||||||
|
current-version Safety Protocol and Handbook & Policies document, shows a
|
||||||
|
status badge beside the title, and uses the `Acknowledged on` column only for
|
||||||
|
the acknowledgment date.
|
||||||
|
|
||||||
Shared:
|
Shared:
|
||||||
|
|
||||||
@ -74,15 +82,30 @@ change), `active`, tenant `organizationId` + nullable `campusId`.
|
|||||||
- **Acknowledgment report** is available to owner, superintendent, principal,
|
- **Acknowledgment report** is available to owner, superintendent, principal,
|
||||||
registrar, and director. Super/system admins can read it only while drilled
|
registrar, and director. Super/system admins can read it only while drilled
|
||||||
into a tenant. The report counts active director/office_manager/teacher/
|
into a tenant. The report counts active director/office_manager/teacher/
|
||||||
support_staff accounts in the current scope.
|
support_staff accounts in the current scope. The leadership dashboard opens
|
||||||
|
acknowledgment tracking in a modal from the Acknowledgments overview metric or
|
||||||
|
the aggregated acknowledgment risk card. The modal groups current-version
|
||||||
|
documents by category, shows collapsed acknowledged/total counts, expands to
|
||||||
|
missing staff names, and the risk area aggregates missing acknowledgments into
|
||||||
|
one concise card.
|
||||||
|
- **User notifications** remain driven by current-version missing acknowledgments
|
||||||
|
for visible active Safety Protocol and Handbook & Policies documents.
|
||||||
|
- **Profile document checklist** lists active current-version documents visible
|
||||||
|
to the user, sorts missing acknowledgments first, marks each row as
|
||||||
|
acknowledged or not acknowledged with a title badge, and shows the
|
||||||
|
acknowledgment date separately when present.
|
||||||
- Management is gated by effective policy-document permissions, mirroring the backend grant.
|
- Management is gated by effective policy-document permissions, mirroring the backend grant.
|
||||||
- Both pages are seeded from `20260611050000-policy-documents-seed.ts`.
|
- Both pages are seeded from `20260611050000-policy-documents-seed.ts`; fixture
|
||||||
|
seed data presets the same document set at organization, school, and campus
|
||||||
|
scopes for both demo tenants.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
- `business/policies/mappers.test.ts`, `business/policies/selectors.test.ts`
|
- `business/policies/mappers.test.ts`, `business/policies/selectors.test.ts`
|
||||||
- `business/safety-protocols/mappers.test.ts`,
|
- `business/safety-protocols/mappers.test.ts`,
|
||||||
`business/safety-protocols/selectors.test.ts`
|
`business/safety-protocols/selectors.test.ts`
|
||||||
|
- `business/director-dashboard/selectors.test.ts`
|
||||||
|
- `business/profile/selectors.test.ts`
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
|
|||||||
@ -135,7 +135,9 @@ The seeded suite verifies:
|
|||||||
- **Tenant isolation**: Users from one organization cannot read, list, update, or delete records from another organization
|
- **Tenant isolation**: Users from one organization cannot read, list, update, or delete records from another organization
|
||||||
- **Scoped provisioning**: Creating an owner auto-creates and links a new company
|
- **Scoped provisioning**: Creating an owner auto-creates and links a new company
|
||||||
- **Product workflows**: Director FRAME entries and staff progress tracking persist correctly (incl. server-side Sunday normalization of the FRAME `week_of`)
|
- **Product workflows**: Director FRAME entries and staff progress tracking persist correctly (incl. server-side Sunday normalization of the FRAME `week_of`)
|
||||||
- **Policy acknowledgments**: document create/persist, manage-vs-read RBAC, per-version (idempotent) acknowledgment, and external-role lockout
|
- **Policy acknowledgments**: document create/persist, manage-vs-read RBAC,
|
||||||
|
per-version (idempotent) acknowledgment, current-version re-acknowledgment,
|
||||||
|
profile checklist state, leadership tracking, and external-role lockout
|
||||||
- **Audio library**: `file`/`url`/`recipe` create/persist, same-campus read, kind/content validation, `support_staff` read-only, and external-role lockout
|
- **Audio library**: `file`/`url`/`recipe` create/persist, same-campus read, kind/content validation, `support_staff` read-only, and external-role lockout
|
||||||
- **Daily Zone check-in**: explicit-permission record/read-back/clear of today's zone (campus-timezone date), invalid-zone rejection, and external-role lockout
|
- **Daily Zone check-in**: explicit-permission record/read-back/clear of today's zone (campus-timezone date), invalid-zone rejection, and external-role lockout
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { APP_ROUTE_PATHS } from '@/shared/constants/routes';
|
|||||||
import { MODULES } from '@/shared/constants/appData';
|
import { MODULES } from '@/shared/constants/appData';
|
||||||
import type { ModuleId } from '@/shared/types/app';
|
import type { ModuleId } from '@/shared/types/app';
|
||||||
import {
|
import {
|
||||||
AcknowledgmentsPage,
|
|
||||||
CampusAttendancePage,
|
CampusAttendancePage,
|
||||||
CampusAttendanceDetailsPage,
|
CampusAttendanceDetailsPage,
|
||||||
ClassroomSupportPage,
|
ClassroomSupportPage,
|
||||||
@ -58,7 +57,6 @@ const MODULE_ELEMENTS: Record<ModuleId, ReactNode> = {
|
|||||||
'internal-comm': <InternalAlertsPage />,
|
'internal-comm': <InternalAlertsPage />,
|
||||||
safety: <SafetyProtocolsPage />,
|
safety: <SafetyProtocolsPage />,
|
||||||
handbook: <HandbookPoliciesPage />,
|
handbook: <HandbookPoliciesPage />,
|
||||||
acknowledgments: <AcknowledgmentsPage />,
|
|
||||||
community: <CommunityPartnershipsPage />,
|
community: <CommunityPartnershipsPage />,
|
||||||
vocational: <VocationalOpportunitiesPage />,
|
vocational: <VocationalOpportunitiesPage />,
|
||||||
esa: <EsaFundingPage />,
|
esa: <EsaFundingPage />,
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
import { lazy } from 'react';
|
import { lazy } from 'react';
|
||||||
|
|
||||||
export const AcknowledgmentsPage = lazy(() => import('@/pages/modules/AcknowledgmentsPage'));
|
|
||||||
export const DashboardPage = lazy(() => import('@/pages/modules/DashboardPage'));
|
export const DashboardPage = lazy(() => import('@/pages/modules/DashboardPage'));
|
||||||
export const FramePage = lazy(() => import('@/pages/modules/FramePage'));
|
export const FramePage = lazy(() => import('@/pages/modules/FramePage'));
|
||||||
export const ClassroomSupportPage = lazy(() => import('@/pages/modules/ClassroomSupportPage'));
|
export const ClassroomSupportPage = lazy(() => import('@/pages/modules/ClassroomSupportPage'));
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
} from '@/business/staff-attendance/hooks';
|
} from '@/business/staff-attendance/hooks';
|
||||||
import { usePolicyAcknowledgmentReport } from '@/business/policies/hooks';
|
import { usePolicyAcknowledgmentReport } from '@/business/policies/hooks';
|
||||||
import {
|
import {
|
||||||
|
buildDirectorAcknowledgmentDocuments,
|
||||||
buildDirectorFramePreviews,
|
buildDirectorFramePreviews,
|
||||||
buildDirectorOverviewCards,
|
buildDirectorOverviewCards,
|
||||||
buildDirectorQuizResults,
|
buildDirectorQuizResults,
|
||||||
@ -48,6 +49,7 @@ export function useDirectorDashboardPage(): DirectorDashboardPage {
|
|||||||
const quizRows = quizCompletionQuery.data?.rows ?? [];
|
const quizRows = quizCompletionQuery.data?.rows ?? [];
|
||||||
const emotionalIntelligenceCompletion = emotionalIntelligenceCompletionQuery.data ?? null;
|
const emotionalIntelligenceCompletion = emotionalIntelligenceCompletionQuery.data ?? null;
|
||||||
const zoneCheckinCompletion = zoneCheckinCompletionQuery.data ?? null;
|
const zoneCheckinCompletion = zoneCheckinCompletionQuery.data ?? null;
|
||||||
|
const acknowledgmentDocuments = buildDirectorAcknowledgmentDocuments(acknowledgmentReportQuery.data);
|
||||||
const quizSummary = quizCompletionQuery.data?.summary ?? {
|
const quizSummary = quizCompletionQuery.data?.summary ?? {
|
||||||
totalStaff: 0,
|
totalStaff: 0,
|
||||||
completedCount: 0,
|
completedCount: 0,
|
||||||
@ -85,10 +87,12 @@ export function useDirectorDashboardPage(): DirectorDashboardPage {
|
|||||||
quizSummary,
|
quizSummary,
|
||||||
emotionalIntelligenceCompletion,
|
emotionalIntelligenceCompletion,
|
||||||
zoneCheckinCompletion,
|
zoneCheckinCompletion,
|
||||||
|
acknowledgmentDocuments,
|
||||||
),
|
),
|
||||||
framePreviews: buildDirectorFramePreviews(frameEntries),
|
framePreviews: buildDirectorFramePreviews(frameEntries),
|
||||||
quickActions: DIRECTOR_DASHBOARD_QUICK_ACTIONS,
|
quickActions: DIRECTOR_DASHBOARD_QUICK_ACTIONS,
|
||||||
quizResults: buildDirectorQuizResults(quizRows, emotionalIntelligenceCompletion, zoneCheckinCompletion),
|
quizResults: buildDirectorQuizResults(quizRows, emotionalIntelligenceCompletion, zoneCheckinCompletion),
|
||||||
|
acknowledgmentDocuments,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
setTimeRange: setTimeRangeState,
|
setTimeRange: setTimeRangeState,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildDirectorAcknowledgmentDocuments,
|
||||||
buildDirectorFramePreviews,
|
buildDirectorFramePreviews,
|
||||||
buildDirectorOverviewCards,
|
buildDirectorOverviewCards,
|
||||||
buildDirectorQuizResults,
|
buildDirectorQuizResults,
|
||||||
@ -15,6 +16,7 @@ import type {
|
|||||||
} from '@/business/safety-quiz/types';
|
} from '@/business/safety-quiz/types';
|
||||||
import type { PersonalityCompletionDto } from '@/shared/types/personality';
|
import type { PersonalityCompletionDto } from '@/shared/types/personality';
|
||||||
import type { ZoneCheckinCompletionDto } from '@/shared/types/zoneCheckins';
|
import type { ZoneCheckinCompletionDto } from '@/shared/types/zoneCheckins';
|
||||||
|
import type { PolicyAcknowledgmentReportDto } from '@/shared/types/policyDocuments';
|
||||||
|
|
||||||
function createAttendanceRecord(
|
function createAttendanceRecord(
|
||||||
overrides: Partial<StaffAttendanceRecordViewModel> = {},
|
overrides: Partial<StaffAttendanceRecordViewModel> = {},
|
||||||
@ -158,6 +160,81 @@ function createZoneCheckinCompletion(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createAcknowledgmentReport(
|
||||||
|
overrides: Partial<PolicyAcknowledgmentReportDto> = {},
|
||||||
|
): PolicyAcknowledgmentReportDto {
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
scope: 'campus',
|
||||||
|
totalDocuments: 1,
|
||||||
|
totalStaff: 2,
|
||||||
|
totalRequired: 2,
|
||||||
|
acknowledgedCount: 1,
|
||||||
|
missingCount: 1,
|
||||||
|
completionRate: 50,
|
||||||
|
},
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
id: 'policy-1',
|
||||||
|
title: 'Fire Drill Procedures',
|
||||||
|
category: 'safety_protocol',
|
||||||
|
version: 3,
|
||||||
|
totalStaff: 2,
|
||||||
|
acknowledgedCount: 1,
|
||||||
|
missingCount: 1,
|
||||||
|
completionRate: 50,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
staff: [
|
||||||
|
{
|
||||||
|
userId: 'user-1',
|
||||||
|
name: 'Ava Lee',
|
||||||
|
email: 'ava@example.test',
|
||||||
|
role: 'teacher',
|
||||||
|
campusId: 'campus-1',
|
||||||
|
schoolId: 'school-1',
|
||||||
|
classId: 'class-1',
|
||||||
|
acknowledgedCount: 1,
|
||||||
|
missingCount: 0,
|
||||||
|
completionRate: 100,
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
policyDocumentId: 'policy-1',
|
||||||
|
title: 'Fire Drill Procedures',
|
||||||
|
category: 'safety_protocol',
|
||||||
|
version: 3,
|
||||||
|
acknowledgedAt: '2026-06-18T10:00:00.000Z',
|
||||||
|
missing: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: 'user-2',
|
||||||
|
name: 'Ben Cruz',
|
||||||
|
email: 'ben@example.test',
|
||||||
|
role: 'support_staff',
|
||||||
|
campusId: 'campus-1',
|
||||||
|
schoolId: 'school-1',
|
||||||
|
classId: 'class-1',
|
||||||
|
acknowledgedCount: 0,
|
||||||
|
missingCount: 1,
|
||||||
|
completionRate: 0,
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
policyDocumentId: 'policy-1',
|
||||||
|
title: 'Fire Drill Procedures',
|
||||||
|
category: 'safety_protocol',
|
||||||
|
version: 3,
|
||||||
|
acknowledgedAt: null,
|
||||||
|
missing: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('director dashboard selectors', () => {
|
describe('director dashboard selectors', () => {
|
||||||
it('calculates quiz completion rate with empty staff protection', () => {
|
it('calculates quiz completion rate with empty staff protection', () => {
|
||||||
expect(calculateQuizCompletionRate(createQuizSummary({ completionRate: 0 }))).toBe(0);
|
expect(calculateQuizCompletionRate(createQuizSummary({ completionRate: 0 }))).toBe(0);
|
||||||
@ -189,8 +266,9 @@ describe('director dashboard selectors', () => {
|
|||||||
'qbs',
|
'qbs',
|
||||||
'frame',
|
'frame',
|
||||||
'attendance',
|
'attendance',
|
||||||
'acknowledgments',
|
'director',
|
||||||
]);
|
]);
|
||||||
|
expect(cards[cards.length - 1]?.action).toBe('openAcknowledgments');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('flags dashboard risk levels from quiz completion and absences', () => {
|
it('flags dashboard risk levels from quiz completion and absences', () => {
|
||||||
@ -204,6 +282,7 @@ describe('director dashboard selectors', () => {
|
|||||||
createQuizSummary({ completedCount: 1, pendingCount: 5, totalStaff: 6, completionRate: 17 }),
|
createQuizSummary({ completedCount: 1, pendingCount: 5, totalStaff: 6, completionRate: 17 }),
|
||||||
null,
|
null,
|
||||||
createZoneCheckinCompletion(),
|
createZoneCheckinCompletion(),
|
||||||
|
buildDirectorAcknowledgmentDocuments(createAcknowledgmentReport()),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(risks).toEqual([
|
expect(risks).toEqual([
|
||||||
@ -222,6 +301,12 @@ describe('director dashboard selectors', () => {
|
|||||||
severity: 'medium',
|
severity: 'medium',
|
||||||
module: 'zones',
|
module: 'zones',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
issue: "1 staff haven't acknowledged 1 document",
|
||||||
|
severity: 'medium',
|
||||||
|
module: 'director',
|
||||||
|
action: 'openAcknowledgments',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
issue: '4 absences recorded this period',
|
issue: '4 absences recorded this period',
|
||||||
severity: 'high',
|
severity: 'high',
|
||||||
@ -267,11 +352,96 @@ describe('director dashboard selectors', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
buildDirectorAcknowledgmentDocuments(createAcknowledgmentReport({
|
||||||
|
summary: {
|
||||||
|
scope: 'campus',
|
||||||
|
totalDocuments: 1,
|
||||||
|
totalStaff: 2,
|
||||||
|
totalRequired: 2,
|
||||||
|
acknowledgedCount: 2,
|
||||||
|
missingCount: 0,
|
||||||
|
completionRate: 100,
|
||||||
|
},
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
id: 'policy-1',
|
||||||
|
title: 'Fire Drill Procedures',
|
||||||
|
category: 'safety_protocol',
|
||||||
|
version: 3,
|
||||||
|
totalStaff: 2,
|
||||||
|
acknowledgedCount: 2,
|
||||||
|
missingCount: 0,
|
||||||
|
completionRate: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
staff: [],
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(risks).toEqual([]);
|
expect(risks).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('aggregates document acknowledgment risks into one card', () => {
|
||||||
|
const risks = buildDirectorRiskAreas(
|
||||||
|
[],
|
||||||
|
createQuizSummary({ completedCount: 4, pendingCount: 0, totalStaff: 4, completionRate: 100 }),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
Array.from({ length: 10 }, (_, index) => ({
|
||||||
|
id: `policy-${index}`,
|
||||||
|
title: `Policy ${index}`,
|
||||||
|
category: 'handbook_policy',
|
||||||
|
categoryLabel: 'Handbook & Policies',
|
||||||
|
version: 1,
|
||||||
|
totalStaff: 4,
|
||||||
|
acknowledgedCount: 0,
|
||||||
|
missingCount: 4,
|
||||||
|
completionRate: 0,
|
||||||
|
missingStaff: [
|
||||||
|
{ userId: 'user-1', name: 'Ava Lee', role: 'teacher', email: 'ava@example.test' },
|
||||||
|
{ userId: 'user-2', name: 'Ben Cruz', role: 'support staff', email: 'ben@example.test' },
|
||||||
|
{ userId: 'user-3', name: 'Cara Fox', role: 'office manager', email: 'cara@example.test' },
|
||||||
|
{ userId: 'user-4', name: 'Drew Kim', role: 'director', email: 'drew@example.test' },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(risks).toEqual([
|
||||||
|
{
|
||||||
|
issue: "4 staff haven't acknowledged 10 documents",
|
||||||
|
severity: 'high',
|
||||||
|
module: 'director',
|
||||||
|
action: 'openAcknowledgments',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds current-version acknowledgment rows with missing staff', () => {
|
||||||
|
const rows = buildDirectorAcknowledgmentDocuments(createAcknowledgmentReport());
|
||||||
|
|
||||||
|
expect(rows).toEqual([
|
||||||
|
{
|
||||||
|
id: 'policy-1',
|
||||||
|
title: 'Fire Drill Procedures',
|
||||||
|
category: 'safety_protocol',
|
||||||
|
categoryLabel: 'Safety Protocols',
|
||||||
|
version: 3,
|
||||||
|
totalStaff: 2,
|
||||||
|
acknowledgedCount: 1,
|
||||||
|
missingCount: 1,
|
||||||
|
completionRate: 50,
|
||||||
|
missingStaff: [
|
||||||
|
{
|
||||||
|
userId: 'user-2',
|
||||||
|
name: 'Ben Cruz',
|
||||||
|
role: 'support staff',
|
||||||
|
email: 'ben@example.test',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('combines safety, EI assessment, personality, and zone check-in results in one list', () => {
|
it('combines safety, EI assessment, personality, and zone check-in results in one list', () => {
|
||||||
const rows = buildDirectorQuizResults(
|
const rows = buildDirectorQuizResults(
|
||||||
[
|
[
|
||||||
|
|||||||
@ -11,8 +11,12 @@ 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 { PolicyAcknowledgmentReportSummaryDto } from '@/shared/types/policyDocuments';
|
|
||||||
import type {
|
import type {
|
||||||
|
PolicyAcknowledgmentReportDto,
|
||||||
|
PolicyAcknowledgmentReportSummaryDto,
|
||||||
|
} from '@/shared/types/policyDocuments';
|
||||||
|
import type {
|
||||||
|
DirectorAcknowledgmentDocumentRow,
|
||||||
DirectorFramePreview,
|
DirectorFramePreview,
|
||||||
DirectorOverviewCard,
|
DirectorOverviewCard,
|
||||||
DirectorQuizResultRow,
|
DirectorQuizResultRow,
|
||||||
@ -88,7 +92,8 @@ export function buildDirectorOverviewCards(
|
|||||||
trend: acknowledgmentRate > DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD ? 'up' : 'down',
|
trend: acknowledgmentRate > DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD ? 'up' : 'down',
|
||||||
iconId: 'clipboard',
|
iconId: 'clipboard',
|
||||||
tone: 'emerald',
|
tone: 'emerald',
|
||||||
module: 'acknowledgments',
|
module: 'director',
|
||||||
|
action: 'openAcknowledgments',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -98,6 +103,7 @@ export function buildDirectorRiskAreas(
|
|||||||
quizSummary: SafetyQuizCompletionSummary,
|
quizSummary: SafetyQuizCompletionSummary,
|
||||||
emotionalIntelligenceCompletion?: PersonalityCompletionDto | null,
|
emotionalIntelligenceCompletion?: PersonalityCompletionDto | null,
|
||||||
zoneCheckinCompletion?: ZoneCheckinCompletionDto | null,
|
zoneCheckinCompletion?: ZoneCheckinCompletionDto | null,
|
||||||
|
acknowledgmentDocuments: readonly DirectorAcknowledgmentDocumentRow[] = [],
|
||||||
): readonly DirectorRiskArea[] {
|
): readonly DirectorRiskArea[] {
|
||||||
const incompleteStaffCount = quizSummary.pendingCount;
|
const incompleteStaffCount = quizSummary.pendingCount;
|
||||||
const absenceCount = countStaffAttendanceStatus(attendanceRecords, 'absent');
|
const absenceCount = countStaffAttendanceStatus(attendanceRecords, 'absent');
|
||||||
@ -154,6 +160,16 @@ export function buildDirectorRiskAreas(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const acknowledgmentRisk = buildAcknowledgmentRisk(acknowledgmentDocuments);
|
||||||
|
if (acknowledgmentRisk) {
|
||||||
|
risks.push({
|
||||||
|
issue: acknowledgmentRisk.issue,
|
||||||
|
severity: acknowledgmentRisk.severity,
|
||||||
|
module: 'director',
|
||||||
|
action: 'openAcknowledgments',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (absenceCount > 0) {
|
if (absenceCount > 0) {
|
||||||
risks.push({
|
risks.push({
|
||||||
issue: `${absenceCount} absences recorded this period`,
|
issue: `${absenceCount} absences recorded this period`,
|
||||||
@ -165,6 +181,75 @@ export function buildDirectorRiskAreas(
|
|||||||
return risks;
|
return risks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAcknowledgmentRisk(
|
||||||
|
acknowledgmentDocuments: readonly DirectorAcknowledgmentDocumentRow[],
|
||||||
|
): Pick<DirectorRiskArea, 'issue' | 'severity'> | null {
|
||||||
|
const unresolvedDocuments = acknowledgmentDocuments.filter((document) =>
|
||||||
|
document.missingCount > 0,
|
||||||
|
);
|
||||||
|
if (unresolvedDocuments.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const staffIds = new Set<string>();
|
||||||
|
for (const document of unresolvedDocuments) {
|
||||||
|
for (const staff of document.missingStaff) {
|
||||||
|
staffIds.add(staff.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const staffCount = staffIds.size || Math.max(
|
||||||
|
...unresolvedDocuments.map((document) => document.missingCount),
|
||||||
|
);
|
||||||
|
const documentCount = unresolvedDocuments.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
issue: `${staffCount} ${pluralize('staff', staffCount)} haven't acknowledged ${documentCount} ${pluralize('document', documentCount)}`,
|
||||||
|
severity: staffCount > DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD
|
||||||
|
|| documentCount > DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD
|
||||||
|
? 'high'
|
||||||
|
: 'medium',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDirectorAcknowledgmentDocuments(
|
||||||
|
report?: PolicyAcknowledgmentReportDto | null,
|
||||||
|
): readonly DirectorAcknowledgmentDocumentRow[] {
|
||||||
|
if (!report) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return report.documents.map((document) => {
|
||||||
|
const missingStaff = report.staff
|
||||||
|
.filter((staff) =>
|
||||||
|
staff.documents.some((status) =>
|
||||||
|
status.policyDocumentId === document.id
|
||||||
|
&& status.version === document.version
|
||||||
|
&& status.missing,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((staff) => ({
|
||||||
|
userId: staff.userId,
|
||||||
|
name: staff.name,
|
||||||
|
role: formatRoleLabel(staff.role),
|
||||||
|
email: staff.email,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: document.id,
|
||||||
|
title: document.title,
|
||||||
|
category: document.category,
|
||||||
|
categoryLabel: getPolicyCategoryLabel(document.category),
|
||||||
|
version: document.version,
|
||||||
|
totalStaff: document.totalStaff,
|
||||||
|
acknowledgedCount: document.acknowledgedCount,
|
||||||
|
missingCount: document.missingCount,
|
||||||
|
completionRate: document.completionRate,
|
||||||
|
missingStaff,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getNonGreenZoneNote(
|
function getNonGreenZoneNote(
|
||||||
zoneCheckinCompletion?: ZoneCheckinCompletionDto | null,
|
zoneCheckinCompletion?: ZoneCheckinCompletionDto | null,
|
||||||
): string | null {
|
): string | null {
|
||||||
@ -183,6 +268,27 @@ function getNonGreenZoneNote(
|
|||||||
return `Non-green regulation zones: ${staffNotes}`;
|
return `Non-green regulation zones: ${staffNotes}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPolicyCategoryLabel(category: string): string {
|
||||||
|
if (category === 'safety_protocol') {
|
||||||
|
return 'Safety Protocols';
|
||||||
|
}
|
||||||
|
if (category === 'handbook_policy') {
|
||||||
|
return 'Handbook & Policies';
|
||||||
|
}
|
||||||
|
return category.replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRoleLabel(role: string | null): string {
|
||||||
|
return role ? role.replace(/_/g, ' ') : 'Staff';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluralize(noun: string, count: number): string {
|
||||||
|
if (noun === 'staff') {
|
||||||
|
return 'staff';
|
||||||
|
}
|
||||||
|
return count === 1 ? noun : `${noun}s`;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildDirectorQuizResults(
|
export function buildDirectorQuizResults(
|
||||||
safetyRows: readonly SafetyQuizComplianceRow[],
|
safetyRows: readonly SafetyQuizComplianceRow[],
|
||||||
emotionalIntelligenceCompletion?: PersonalityCompletionDto | null,
|
emotionalIntelligenceCompletion?: PersonalityCompletionDto | null,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type { ModuleId } from '@/shared/types/app';
|
|||||||
|
|
||||||
export type DirectorDashboardTrend = 'up' | 'down';
|
export type DirectorDashboardTrend = 'up' | 'down';
|
||||||
export type DirectorDashboardRiskSeverity = 'high' | 'medium' | 'low';
|
export type DirectorDashboardRiskSeverity = 'high' | 'medium' | 'low';
|
||||||
|
export type DirectorDashboardAction = 'openAcknowledgments';
|
||||||
export type DirectorOverviewIconId = 'clock' | 'shield' | 'eye' | 'users' | 'clipboard';
|
export type DirectorOverviewIconId = 'clock' | 'shield' | 'eye' | 'users' | 'clipboard';
|
||||||
export type DirectorOverviewTone = 'orange' | 'blue' | 'amber' | 'purple' | 'emerald';
|
export type DirectorOverviewTone = 'orange' | 'blue' | 'amber' | 'purple' | 'emerald';
|
||||||
export type DirectorFrameSectionLetter = 'F' | 'R' | 'A' | 'M' | 'E';
|
export type DirectorFrameSectionLetter = 'F' | 'R' | 'A' | 'M' | 'E';
|
||||||
@ -19,12 +20,14 @@ export interface DirectorOverviewCard {
|
|||||||
readonly iconId: DirectorOverviewIconId;
|
readonly iconId: DirectorOverviewIconId;
|
||||||
readonly tone: DirectorOverviewTone;
|
readonly tone: DirectorOverviewTone;
|
||||||
readonly module: ModuleId;
|
readonly module: ModuleId;
|
||||||
|
readonly action?: DirectorDashboardAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DirectorRiskArea {
|
export interface DirectorRiskArea {
|
||||||
readonly issue: string;
|
readonly issue: string;
|
||||||
readonly severity: DirectorDashboardRiskSeverity;
|
readonly severity: DirectorDashboardRiskSeverity;
|
||||||
readonly module: ModuleId;
|
readonly module: ModuleId;
|
||||||
|
readonly action?: DirectorDashboardAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DirectorFrameSectionPreview {
|
export interface DirectorFrameSectionPreview {
|
||||||
@ -48,6 +51,26 @@ export interface DirectorQuizResultRow {
|
|||||||
readonly status: DirectorQuizResultStatus;
|
readonly status: DirectorQuizResultStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DirectorAcknowledgmentMissingStaff {
|
||||||
|
readonly userId: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly role: string;
|
||||||
|
readonly email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectorAcknowledgmentDocumentRow {
|
||||||
|
readonly id: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly category: string;
|
||||||
|
readonly categoryLabel: string;
|
||||||
|
readonly version: number;
|
||||||
|
readonly totalStaff: number;
|
||||||
|
readonly acknowledgedCount: number;
|
||||||
|
readonly missingCount: number;
|
||||||
|
readonly completionRate: number;
|
||||||
|
readonly missingStaff: readonly DirectorAcknowledgmentMissingStaff[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DirectorDashboardPage {
|
export interface DirectorDashboardPage {
|
||||||
/** Role-specific dashboard title (e.g. "Owner Dashboard"). */
|
/** Role-specific dashboard title (e.g. "Owner Dashboard"). */
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
@ -59,6 +82,7 @@ export interface DirectorDashboardPage {
|
|||||||
readonly framePreviews: readonly DirectorFramePreview[];
|
readonly framePreviews: readonly DirectorFramePreview[];
|
||||||
readonly quickActions: readonly DirectorQuickActionConfig[];
|
readonly quickActions: readonly DirectorQuickActionConfig[];
|
||||||
readonly quizResults: readonly DirectorQuizResultRow[];
|
readonly quizResults: readonly DirectorQuizResultRow[];
|
||||||
|
readonly acknowledgmentDocuments: readonly DirectorAcknowledgmentDocumentRow[];
|
||||||
readonly isLoading: boolean;
|
readonly isLoading: boolean;
|
||||||
readonly error: unknown;
|
readonly error: unknown;
|
||||||
readonly setTimeRange: (timeRange: DirectorDashboardTimeRange) => void;
|
readonly setTimeRange: (timeRange: DirectorDashboardTimeRange) => void;
|
||||||
|
|||||||
@ -41,6 +41,7 @@ export function useCreatePolicy() {
|
|||||||
mutationFn: (input: PolicyFormInput) =>
|
mutationFn: (input: PolicyFormInput) =>
|
||||||
createPolicyDocument(toPolicyDocumentMutationDto(input)),
|
createPolicyDocument(toPolicyDocumentMutationDto(input)),
|
||||||
invalidateQueryKey: POLICY_QUERY_KEYS.documents,
|
invalidateQueryKey: POLICY_QUERY_KEYS.documents,
|
||||||
|
invalidateQueryKeys: [POLICY_QUERY_KEYS.acknowledgmentReport],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,6 +50,10 @@ export function useUpdatePolicy() {
|
|||||||
mutationFn: (input: PolicyUpdateInput) =>
|
mutationFn: (input: PolicyUpdateInput) =>
|
||||||
updatePolicyDocument(input.id, toPolicyDocumentMutationDto(input.policy)),
|
updatePolicyDocument(input.id, toPolicyDocumentMutationDto(input.policy)),
|
||||||
invalidateQueryKey: POLICY_QUERY_KEYS.documents,
|
invalidateQueryKey: POLICY_QUERY_KEYS.documents,
|
||||||
|
invalidateQueryKeys: [
|
||||||
|
POLICY_QUERY_KEYS.acknowledgments,
|
||||||
|
POLICY_QUERY_KEYS.acknowledgmentReport,
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,6 +61,7 @@ export function useDeletePolicy() {
|
|||||||
return useInvalidatingMutation({
|
return useInvalidatingMutation({
|
||||||
mutationFn: (id: string) => deletePolicyDocument(id),
|
mutationFn: (id: string) => deletePolicyDocument(id),
|
||||||
invalidateQueryKey: POLICY_QUERY_KEYS.documents,
|
invalidateQueryKey: POLICY_QUERY_KEYS.documents,
|
||||||
|
invalidateQueryKeys: [POLICY_QUERY_KEYS.acknowledgmentReport],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,6 +79,7 @@ export function useAcknowledgePolicy() {
|
|||||||
mutationFn: (policyDocumentId: string) =>
|
mutationFn: (policyDocumentId: string) =>
|
||||||
acknowledgePolicyDocument({ policyDocumentId }),
|
acknowledgePolicyDocument({ policyDocumentId }),
|
||||||
invalidateQueryKey: POLICY_QUERY_KEYS.acknowledgments,
|
invalidateQueryKey: POLICY_QUERY_KEYS.acknowledgments,
|
||||||
|
invalidateQueryKeys: [POLICY_QUERY_KEYS.acknowledgmentReport],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,13 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { buildProfileQuizResultRows } from '@/business/profile/selectors';
|
import {
|
||||||
|
buildProfileDocumentAcknowledgmentRows,
|
||||||
|
buildProfileQuizResultRows,
|
||||||
|
} from '@/business/profile/selectors';
|
||||||
import type { PersonalityQuizResultViewModel } from '@/business/personality/types';
|
import type { PersonalityQuizResultViewModel } from '@/business/personality/types';
|
||||||
|
import type { PolicyViewModel } from '@/business/policies/types';
|
||||||
|
import type { SafetyProtocolViewModel } from '@/business/safety-protocols/types';
|
||||||
|
import type { PolicyAcknowledgmentDto } from '@/shared/types/policyDocuments';
|
||||||
import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz';
|
import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz';
|
||||||
|
|
||||||
function createSafetyResult(): SafetyQuizResultDto {
|
function createSafetyResult(): SafetyQuizResultDto {
|
||||||
@ -44,6 +50,52 @@ function createPersonalityResult(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createPolicy(overrides: Partial<PolicyViewModel> = {}): PolicyViewModel {
|
||||||
|
return {
|
||||||
|
id: 'policy-1',
|
||||||
|
title: 'Staff Handbook',
|
||||||
|
category: 'Operations',
|
||||||
|
content: 'Handbook content',
|
||||||
|
version: 2,
|
||||||
|
lastUpdated: '2026-06-18T10:00:00.000Z',
|
||||||
|
updatedBy: 'Owner',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSafetyProtocol(
|
||||||
|
overrides: Partial<SafetyProtocolViewModel> = {},
|
||||||
|
): SafetyProtocolViewModel {
|
||||||
|
return {
|
||||||
|
id: 'safety-1',
|
||||||
|
title: 'Fire Drill Procedures',
|
||||||
|
tag: 'emergency',
|
||||||
|
steps: ['Line up'],
|
||||||
|
autismConsiderations: ['Use calm voice'],
|
||||||
|
version: 3,
|
||||||
|
lastUpdated: '2026-06-18T10:00:00.000Z',
|
||||||
|
author: 'Director',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAcknowledgment(
|
||||||
|
overrides: Partial<PolicyAcknowledgmentDto> = {},
|
||||||
|
): PolicyAcknowledgmentDto {
|
||||||
|
return {
|
||||||
|
id: 'ack-1',
|
||||||
|
policyDocumentId: 'policy-1',
|
||||||
|
version: 2,
|
||||||
|
userId: 'user-1',
|
||||||
|
acknowledgedAt: '2026-06-18T10:00:00.000Z',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
campusId: 'campus-1',
|
||||||
|
createdAt: '2026-06-18T10:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-18T10:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('profile selectors', () => {
|
describe('profile selectors', () => {
|
||||||
it('combines safety, EI assessment, and personality quiz results', () => {
|
it('combines safety, EI assessment, and personality quiz results', () => {
|
||||||
const rows = buildProfileQuizResultRows(createSafetyResult(), [
|
const rows = buildProfileQuizResultRows(createSafetyResult(), [
|
||||||
@ -98,4 +150,52 @@ describe('profile selectors', () => {
|
|||||||
status: 'complete',
|
status: 'complete',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows all current documents with acknowledgment status', () => {
|
||||||
|
const rows = buildProfileDocumentAcknowledgmentRows(
|
||||||
|
[createPolicy(), createPolicy({ id: 'old-policy', title: 'Old Handbook', version: 4 })],
|
||||||
|
[createSafetyProtocol()],
|
||||||
|
[
|
||||||
|
createAcknowledgment(),
|
||||||
|
createAcknowledgment({
|
||||||
|
id: 'ack-2',
|
||||||
|
policyDocumentId: 'safety-1',
|
||||||
|
version: 3,
|
||||||
|
acknowledgedAt: '2026-06-19T10:00:00.000Z',
|
||||||
|
}),
|
||||||
|
createAcknowledgment({
|
||||||
|
id: 'ack-old-version',
|
||||||
|
policyDocumentId: 'old-policy',
|
||||||
|
version: 3,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows).toEqual([
|
||||||
|
{
|
||||||
|
id: 'handbook-old-policy',
|
||||||
|
title: 'Old Handbook',
|
||||||
|
category: 'Handbook & Policies · Operations',
|
||||||
|
version: 4,
|
||||||
|
acknowledgedAt: null,
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'handbook-policy-1',
|
||||||
|
title: 'Staff Handbook',
|
||||||
|
category: 'Handbook & Policies · Operations',
|
||||||
|
version: 2,
|
||||||
|
acknowledgedAt: '2026-06-18T10:00:00.000Z',
|
||||||
|
status: 'acknowledged',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'safety-safety-1',
|
||||||
|
title: 'Fire Drill Procedures',
|
||||||
|
category: 'Safety Protocols',
|
||||||
|
version: 3,
|
||||||
|
acknowledgedAt: '2026-06-19T10:00:00.000Z',
|
||||||
|
status: 'acknowledged',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { PERSONALITY_QUIZ_KINDS } from '@/shared/constants/personality';
|
import { PERSONALITY_QUIZ_KINDS } from '@/shared/constants/personality';
|
||||||
import type { PersonalityQuizResultViewModel } from '@/business/personality/types';
|
import type { PersonalityQuizResultViewModel } from '@/business/personality/types';
|
||||||
|
import type { PolicyViewModel } from '@/business/policies/types';
|
||||||
|
import type { SafetyProtocolViewModel } from '@/business/safety-protocols/types';
|
||||||
|
import type { PolicyAcknowledgmentDto } from '@/shared/types/policyDocuments';
|
||||||
import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz';
|
import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz';
|
||||||
import type { ZoneCheckinTodayDto } from '@/shared/types/zoneCheckins';
|
import type { ZoneCheckinTodayDto } from '@/shared/types/zoneCheckins';
|
||||||
|
|
||||||
@ -12,6 +15,15 @@ export interface ProfileQuizResultRow {
|
|||||||
readonly status: 'complete' | 'pending';
|
readonly status: 'complete' | 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProfileDocumentAcknowledgmentRow {
|
||||||
|
readonly id: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly category: string;
|
||||||
|
readonly version: number;
|
||||||
|
readonly acknowledgedAt: string | null;
|
||||||
|
readonly status: 'acknowledged' | 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
export function buildProfileQuizResultRows(
|
export function buildProfileQuizResultRows(
|
||||||
safetyQuizResult: SafetyQuizResultDto | null,
|
safetyQuizResult: SafetyQuizResultDto | null,
|
||||||
personalityResults: readonly PersonalityQuizResultViewModel[],
|
personalityResults: readonly PersonalityQuizResultViewModel[],
|
||||||
@ -38,6 +50,63 @@ export function buildProfileQuizResultRows(
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildProfileDocumentAcknowledgmentRows(
|
||||||
|
handbookPolicies: readonly PolicyViewModel[],
|
||||||
|
safetyProtocols: readonly SafetyProtocolViewModel[],
|
||||||
|
acknowledgments: readonly PolicyAcknowledgmentDto[],
|
||||||
|
): readonly ProfileDocumentAcknowledgmentRow[] {
|
||||||
|
const rows: ProfileDocumentAcknowledgmentRow[] = [];
|
||||||
|
const acknowledgmentByDocumentVersion = new Map(
|
||||||
|
acknowledgments.map((acknowledgment) => [
|
||||||
|
getDocumentVersionKey(acknowledgment.policyDocumentId, acknowledgment.version),
|
||||||
|
acknowledgment,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const policy of handbookPolicies) {
|
||||||
|
const acknowledgment = acknowledgmentByDocumentVersion.get(
|
||||||
|
getDocumentVersionKey(policy.id, policy.version),
|
||||||
|
);
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
id: `handbook-${policy.id}`,
|
||||||
|
title: policy.title,
|
||||||
|
category: `Handbook & Policies · ${policy.category}`,
|
||||||
|
version: policy.version,
|
||||||
|
acknowledgedAt: acknowledgment?.acknowledgedAt ?? null,
|
||||||
|
status: acknowledgment ? 'acknowledged' : 'pending',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const protocol of safetyProtocols) {
|
||||||
|
const acknowledgment = acknowledgmentByDocumentVersion.get(
|
||||||
|
getDocumentVersionKey(protocol.id, protocol.version),
|
||||||
|
);
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
id: `safety-${protocol.id}`,
|
||||||
|
title: protocol.title,
|
||||||
|
category: 'Safety Protocols',
|
||||||
|
version: protocol.version,
|
||||||
|
acknowledgedAt: acknowledgment?.acknowledgedAt ?? null,
|
||||||
|
status: acknowledgment ? 'acknowledged' : 'pending',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.sort((left, right) => {
|
||||||
|
if (left.status !== right.status) {
|
||||||
|
return left.status === 'pending' ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.category.localeCompare(right.category)
|
||||||
|
|| left.title.localeCompare(right.title);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDocumentVersionKey(documentId: string, version: number): string {
|
||||||
|
return `${documentId}:${version}`;
|
||||||
|
}
|
||||||
|
|
||||||
function hasPersonalityResult(
|
function hasPersonalityResult(
|
||||||
results: readonly PersonalityQuizResultViewModel[],
|
results: readonly PersonalityQuizResultViewModel[],
|
||||||
quizKind: PersonalityQuizResultViewModel['quizKind'],
|
quizKind: PersonalityQuizResultViewModel['quizKind'],
|
||||||
|
|||||||
@ -98,6 +98,7 @@ export function useSafetyProtocolsModule() {
|
|||||||
mutationFn: (input: SafetyProtocolDraft) =>
|
mutationFn: (input: SafetyProtocolDraft) =>
|
||||||
createPolicyDocument(toSafetyProtocolMutationDto(input)),
|
createPolicyDocument(toSafetyProtocolMutationDto(input)),
|
||||||
invalidateQueryKey: POLICY_QUERY_KEYS.safetyDocuments,
|
invalidateQueryKey: POLICY_QUERY_KEYS.safetyDocuments,
|
||||||
|
invalidateQueryKeys: [POLICY_QUERY_KEYS.acknowledgmentReport],
|
||||||
onSuccess: resetForm,
|
onSuccess: resetForm,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -105,12 +106,17 @@ export function useSafetyProtocolsModule() {
|
|||||||
mutationFn: (input: SafetyUpdateInput) =>
|
mutationFn: (input: SafetyUpdateInput) =>
|
||||||
updatePolicyDocument(input.id, toSafetyProtocolMutationDto(input.draft)),
|
updatePolicyDocument(input.id, toSafetyProtocolMutationDto(input.draft)),
|
||||||
invalidateQueryKey: POLICY_QUERY_KEYS.safetyDocuments,
|
invalidateQueryKey: POLICY_QUERY_KEYS.safetyDocuments,
|
||||||
|
invalidateQueryKeys: [
|
||||||
|
POLICY_QUERY_KEYS.acknowledgments,
|
||||||
|
POLICY_QUERY_KEYS.acknowledgmentReport,
|
||||||
|
],
|
||||||
onSuccess: resetForm,
|
onSuccess: resetForm,
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useInvalidatingMutation({
|
const deleteMutation = useInvalidatingMutation({
|
||||||
mutationFn: (id: string) => deletePolicyDocument(id),
|
mutationFn: (id: string) => deletePolicyDocument(id),
|
||||||
invalidateQueryKey: POLICY_QUERY_KEYS.safetyDocuments,
|
invalidateQueryKey: POLICY_QUERY_KEYS.safetyDocuments,
|
||||||
|
invalidateQueryKeys: [POLICY_QUERY_KEYS.acknowledgmentReport],
|
||||||
onSuccess: resetForm,
|
onSuccess: resetForm,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,43 @@
|
|||||||
|
import { ClipboardCheck } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { DirectorAcknowledgmentDocumentRow } from '@/business/director-dashboard/types';
|
||||||
|
import { DirectorAcknowledgmentTrackingPanel } from '@/components/director-dashboard/DirectorAcknowledgmentTrackingPanel';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
interface DirectorAcknowledgmentTrackingModalProps {
|
||||||
|
readonly documents: readonly DirectorAcknowledgmentDocumentRow[];
|
||||||
|
readonly open: boolean;
|
||||||
|
readonly onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DirectorAcknowledgmentTrackingModal({
|
||||||
|
documents,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: DirectorAcknowledgmentTrackingModalProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-h-[86vh] overflow-y-auto border-slate-700/60 bg-slate-950 p-4 text-white shadow-2xl shadow-black/40 sm:max-w-5xl">
|
||||||
|
<DialogHeader className="pr-8">
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-white">
|
||||||
|
<ClipboardCheck size={20} className="text-emerald-400" />
|
||||||
|
Document Acknowledgments
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-400">
|
||||||
|
Current safety protocol and handbook versions for this scope.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DirectorAcknowledgmentTrackingPanel
|
||||||
|
documents={documents}
|
||||||
|
showHeader={false}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,162 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { ChevronDown, ClipboardCheck } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { DirectorAcknowledgmentDocumentRow } from '@/business/director-dashboard/types';
|
||||||
|
|
||||||
|
interface DirectorAcknowledgmentTrackingPanelProps {
|
||||||
|
readonly documents: readonly DirectorAcknowledgmentDocumentRow[];
|
||||||
|
readonly showHeader?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DirectorAcknowledgmentTrackingPanel({
|
||||||
|
documents,
|
||||||
|
showHeader = true,
|
||||||
|
}: DirectorAcknowledgmentTrackingPanelProps) {
|
||||||
|
const [expandedDocumentIds, setExpandedDocumentIds] = useState<ReadonlySet<string>>(
|
||||||
|
() => new Set(),
|
||||||
|
);
|
||||||
|
const groupedDocuments = useMemo(() => groupDocumentsByCategory(documents), [documents]);
|
||||||
|
|
||||||
|
function toggleDocument(documentId: string) {
|
||||||
|
setExpandedDocumentIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(documentId)) {
|
||||||
|
next.delete(documentId);
|
||||||
|
} else {
|
||||||
|
next.add(documentId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-2xl border border-violet-100 bg-white p-5 text-gray-900 shadow-sm">
|
||||||
|
{showHeader && (
|
||||||
|
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="flex items-center gap-2 font-semibold text-gray-800">
|
||||||
|
<ClipboardCheck size={18} className="text-emerald-600" />
|
||||||
|
Document Acknowledgments
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
Current safety protocol and handbook versions for this scope.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groupedDocuments.length === 0 ? (
|
||||||
|
<p className="rounded-xl bg-gray-50 px-4 py-3 text-sm text-gray-500">
|
||||||
|
No active documents are assigned to this scope yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{groupedDocuments.map((group) => (
|
||||||
|
<div key={group.category} className="space-y-2">
|
||||||
|
<p className="text-xs font-bold uppercase text-gray-500">
|
||||||
|
{group.label}
|
||||||
|
</p>
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-100">
|
||||||
|
{group.documents.map((document, index) => {
|
||||||
|
const isExpanded = expandedDocumentIds.has(document.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={document.id}
|
||||||
|
className={index > 0 ? 'border-t border-gray-100' : undefined}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleDocument(document.id)}
|
||||||
|
className="flex w-full items-center justify-between gap-4 bg-white px-4 py-3 text-left transition-transform hover:scale-[1.005] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2"
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
aria-controls={`acknowledgment-${document.id}`}
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-semibold text-gray-800">
|
||||||
|
{document.title}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-xs text-gray-500">
|
||||||
|
Version {document.version}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-3">
|
||||||
|
<span className={getStatusClassName(document.missingCount)}>
|
||||||
|
{document.acknowledgedCount}/{document.totalStaff}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={18}
|
||||||
|
className={`text-gray-400 transition-transform ${
|
||||||
|
isExpanded ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{isExpanded && (
|
||||||
|
<div
|
||||||
|
id={`acknowledgment-${document.id}`}
|
||||||
|
className="bg-gray-50 px-4 py-3"
|
||||||
|
>
|
||||||
|
{document.missingStaff.length === 0 ? (
|
||||||
|
<p className="text-sm text-emerald-700">
|
||||||
|
Every staff member in scope has acknowledged this version.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold uppercase text-gray-500">
|
||||||
|
Not acknowledged
|
||||||
|
</p>
|
||||||
|
{document.missingStaff.map((staff) => (
|
||||||
|
<div
|
||||||
|
key={`${document.id}-${staff.userId}`}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-white px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-gray-800">{staff.name}</span>
|
||||||
|
<span className="text-xs capitalize text-gray-500">
|
||||||
|
{staff.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupDocumentsByCategory(
|
||||||
|
documents: readonly DirectorAcknowledgmentDocumentRow[],
|
||||||
|
): readonly {
|
||||||
|
readonly category: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly documents: readonly DirectorAcknowledgmentDocumentRow[];
|
||||||
|
}[] {
|
||||||
|
const groups = new Map<string, DirectorAcknowledgmentDocumentRow[]>();
|
||||||
|
const labels = new Map<string, string>();
|
||||||
|
for (const document of documents) {
|
||||||
|
const rows = groups.get(document.category) ?? [];
|
||||||
|
rows.push(document);
|
||||||
|
groups.set(document.category, rows);
|
||||||
|
labels.set(document.category, document.categoryLabel);
|
||||||
|
}
|
||||||
|
return [...groups.entries()].map(([category, rows]) => ({
|
||||||
|
category,
|
||||||
|
label: labels.get(category) ?? category,
|
||||||
|
documents: rows,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusClassName(missingCount: number): string {
|
||||||
|
return missingCount > 0
|
||||||
|
? 'rounded-lg bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-700'
|
||||||
|
: 'rounded-lg bg-emerald-100 px-2.5 py-1 text-xs font-semibold text-emerald-700';
|
||||||
|
}
|
||||||
@ -1,6 +1,9 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import type { ModuleId } from '@/shared/types/app';
|
import type { ModuleId } from '@/shared/types/app';
|
||||||
import type { DirectorDashboardPage } from '@/business/director-dashboard/types';
|
import type { DirectorDashboardPage } from '@/business/director-dashboard/types';
|
||||||
import { DirectorDashboardHeader } from '@/components/director-dashboard/DirectorDashboardHeader';
|
import { DirectorDashboardHeader } from '@/components/director-dashboard/DirectorDashboardHeader';
|
||||||
|
import { DirectorAcknowledgmentTrackingModal } from '@/components/director-dashboard/DirectorAcknowledgmentTrackingModal';
|
||||||
import { DirectorOverviewCards } from '@/components/director-dashboard/DirectorOverviewCards';
|
import { DirectorOverviewCards } from '@/components/director-dashboard/DirectorOverviewCards';
|
||||||
import { DirectorQuickActions } from '@/components/director-dashboard/DirectorQuickActions';
|
import { DirectorQuickActions } from '@/components/director-dashboard/DirectorQuickActions';
|
||||||
import { DirectorQuizResultsPanel } from '@/components/director-dashboard/DirectorQuizResultsPanel';
|
import { DirectorQuizResultsPanel } from '@/components/director-dashboard/DirectorQuizResultsPanel';
|
||||||
@ -19,6 +22,7 @@ export function DirectorDashboardView({
|
|||||||
page,
|
page,
|
||||||
onOpenModule,
|
onOpenModule,
|
||||||
}: DirectorDashboardViewProps) {
|
}: DirectorDashboardViewProps) {
|
||||||
|
const [isAcknowledgmentModalOpen, setIsAcknowledgmentModalOpen] = useState(false);
|
||||||
const errorMessage = getOptionalErrorMessage(page.error);
|
const errorMessage = getOptionalErrorMessage(page.error);
|
||||||
|
|
||||||
if (page.isLoading) {
|
if (page.isLoading) {
|
||||||
@ -44,12 +48,14 @@ export function DirectorDashboardView({
|
|||||||
<DirectorOverviewCards
|
<DirectorOverviewCards
|
||||||
cards={page.overviewCards}
|
cards={page.overviewCards}
|
||||||
onOpenModule={onOpenModule}
|
onOpenModule={onOpenModule}
|
||||||
|
onOpenAcknowledgments={() => setIsAcknowledgmentModalOpen(true)}
|
||||||
/>
|
/>
|
||||||
<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 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<DirectorRiskList
|
<DirectorRiskList
|
||||||
risks={page.riskAreas}
|
risks={page.riskAreas}
|
||||||
onOpenModule={onOpenModule}
|
onOpenModule={onOpenModule}
|
||||||
|
onOpenAcknowledgments={() => setIsAcknowledgmentModalOpen(true)}
|
||||||
/>
|
/>
|
||||||
<DirectorQuizResultsPanel results={page.quizResults} />
|
<DirectorQuizResultsPanel results={page.quizResults} />
|
||||||
</div>
|
</div>
|
||||||
@ -64,6 +70,11 @@ export function DirectorDashboardView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<DirectorAcknowledgmentTrackingModal
|
||||||
|
documents={page.acknowledgmentDocuments}
|
||||||
|
open={isAcknowledgmentModalOpen}
|
||||||
|
onOpenChange={setIsAcknowledgmentModalOpen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,12 +11,23 @@ import {
|
|||||||
interface DirectorOverviewCardsProps {
|
interface DirectorOverviewCardsProps {
|
||||||
readonly cards: readonly DirectorOverviewCard[];
|
readonly cards: readonly DirectorOverviewCard[];
|
||||||
readonly onOpenModule: (module: ModuleId) => void;
|
readonly onOpenModule: (module: ModuleId) => void;
|
||||||
|
readonly onOpenAcknowledgments?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DirectorOverviewCards({
|
export function DirectorOverviewCards({
|
||||||
cards,
|
cards,
|
||||||
onOpenModule,
|
onOpenModule,
|
||||||
|
onOpenAcknowledgments,
|
||||||
}: DirectorOverviewCardsProps) {
|
}: DirectorOverviewCardsProps) {
|
||||||
|
function handleCardClick(card: DirectorOverviewCard) {
|
||||||
|
if (card.action === 'openAcknowledgments' && onOpenAcknowledgments) {
|
||||||
|
onOpenAcknowledgments();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenModule(card.module);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-4">
|
||||||
{cards.map((card) => {
|
{cards.map((card) => {
|
||||||
@ -27,7 +38,7 @@ export function DirectorOverviewCards({
|
|||||||
<Button
|
<Button
|
||||||
key={card.label}
|
key={card.label}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpenModule(card.module)}
|
onClick={() => handleCardClick(card)}
|
||||||
className="h-auto bg-white rounded-2xl border border-violet-100 shadow-sm p-5 text-left hover:shadow-md transition-all hover:-translate-y-0.5 group justify-start"
|
className="h-auto bg-white rounded-2xl border border-violet-100 shadow-sm p-5 text-left hover:shadow-md transition-all hover:-translate-y-0.5 group justify-start"
|
||||||
>
|
>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
|||||||
@ -10,15 +10,26 @@ import {
|
|||||||
interface DirectorRiskListProps {
|
interface DirectorRiskListProps {
|
||||||
readonly risks: readonly DirectorRiskArea[];
|
readonly risks: readonly DirectorRiskArea[];
|
||||||
readonly onOpenModule: (module: ModuleId) => void;
|
readonly onOpenModule: (module: ModuleId) => void;
|
||||||
|
readonly onOpenAcknowledgments?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DirectorRiskList({
|
export function DirectorRiskList({
|
||||||
risks,
|
risks,
|
||||||
onOpenModule,
|
onOpenModule,
|
||||||
|
onOpenAcknowledgments,
|
||||||
}: DirectorRiskListProps) {
|
}: DirectorRiskListProps) {
|
||||||
const RiskIcon = directorRiskIcon;
|
const RiskIcon = directorRiskIcon;
|
||||||
const NavigateIcon = directorNavigateIcon;
|
const NavigateIcon = directorNavigateIcon;
|
||||||
|
|
||||||
|
function handleRiskClick(risk: DirectorRiskArea) {
|
||||||
|
if (risk.action === 'openAcknowledgments' && onOpenAcknowledgments) {
|
||||||
|
onOpenAcknowledgments();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenModule(risk.module);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5">
|
<div className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5">
|
||||||
<h3 className="font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
<h3 className="font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||||
@ -30,7 +41,7 @@ export function DirectorRiskList({
|
|||||||
<Button
|
<Button
|
||||||
key={`${risk.module}-${risk.issue}`}
|
key={`${risk.module}-${risk.issue}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpenModule(risk.module)}
|
onClick={() => handleRiskClick(risk)}
|
||||||
className={`w-full h-auto text-left p-4 rounded-xl border ${directorRiskSeverityClasses[risk.severity]} flex items-center justify-between gap-3 hover:shadow-sm transition-all`}
|
className={`w-full h-auto text-left p-4 rounded-xl border ${directorRiskSeverityClasses[risk.severity]} flex items-center justify-between gap-3 hover:shadow-sm transition-all`}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
|||||||
@ -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 { ClipboardList, KeyRound, Loader2, UserCircle } from 'lucide-react';
|
import { ClipboardList, FileCheck, KeyRound, Loader2, 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';
|
||||||
@ -19,8 +19,12 @@ import {
|
|||||||
import { useAuth } from '@/contexts/useAuth';
|
import { useAuth } from '@/contexts/useAuth';
|
||||||
import { useIamCapabilities } from '@/business/iam-capabilities/hooks';
|
import { useIamCapabilities } from '@/business/iam-capabilities/hooks';
|
||||||
import { getAuthRoleLabel } from '@/business/auth/selectors';
|
import { getAuthRoleLabel } from '@/business/auth/selectors';
|
||||||
|
import { hasPermission } from '@/business/auth/permissions';
|
||||||
import { changePassword, updateOwnProfile, updateOrganization } from '@/business/profile/api';
|
import { changePassword, updateOwnProfile, updateOrganization } from '@/business/profile/api';
|
||||||
import { buildProfileQuizResultRows } from '@/business/profile/selectors';
|
import {
|
||||||
|
buildProfileDocumentAcknowledgmentRows,
|
||||||
|
buildProfileQuizResultRows,
|
||||||
|
} from '@/business/profile/selectors';
|
||||||
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';
|
||||||
@ -28,6 +32,8 @@ import { useCurrentPersonalityResultHistory } from '@/business/personality/query
|
|||||||
import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks';
|
import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks';
|
||||||
import { useTodayZoneCheckIn } from '@/business/zone-checkin/hooks';
|
import { useTodayZoneCheckIn } from '@/business/zone-checkin/hooks';
|
||||||
import { canZoneCheckIn } from '@/business/zone-checkin/selectors';
|
import { canZoneCheckIn } from '@/business/zone-checkin/selectors';
|
||||||
|
import { usePolicies, usePolicyAcknowledgments } from '@/business/policies/hooks';
|
||||||
|
import { useSafetyProtocols } from '@/business/safety-protocols/hooks';
|
||||||
|
|
||||||
interface StatusMessage {
|
interface StatusMessage {
|
||||||
readonly type: 'success' | 'error';
|
readonly type: 'success' | 'error';
|
||||||
@ -74,6 +80,10 @@ export default function ProfilePage() {
|
|||||||
const personalityHistoryStatus = useCurrentPersonalityResultHistory(Boolean(user));
|
const personalityHistoryStatus = useCurrentPersonalityResultHistory(Boolean(user));
|
||||||
const canUseZoneCheckin = canZoneCheckIn(user);
|
const canUseZoneCheckin = canZoneCheckIn(user);
|
||||||
const zoneCheckinStatus = useTodayZoneCheckIn({ enabled: canUseZoneCheckin });
|
const zoneCheckinStatus = useTodayZoneCheckIn({ enabled: canUseZoneCheckin });
|
||||||
|
const canReadAcknowledgedDocuments = hasPermission(user, 'ACK_POLICY');
|
||||||
|
const policyAcknowledgmentsStatus = usePolicyAcknowledgments(canReadAcknowledgedDocuments);
|
||||||
|
const handbookPoliciesStatus = usePolicies(canReadAcknowledgedDocuments);
|
||||||
|
const safetyProtocolsStatus = useSafetyProtocols(canReadAcknowledgedDocuments);
|
||||||
|
|
||||||
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 ?? '');
|
||||||
@ -134,6 +144,18 @@ export default function ProfilePage() {
|
|||||||
canUseZoneCheckin,
|
canUseZoneCheckin,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
const documentAcknowledgmentRows = useMemo(
|
||||||
|
() => buildProfileDocumentAcknowledgmentRows(
|
||||||
|
handbookPoliciesStatus.data ?? [],
|
||||||
|
safetyProtocolsStatus.data ?? [],
|
||||||
|
policyAcknowledgmentsStatus.data ?? [],
|
||||||
|
),
|
||||||
|
[
|
||||||
|
handbookPoliciesStatus.data,
|
||||||
|
policyAcknowledgmentsStatus.data,
|
||||||
|
safetyProtocolsStatus.data,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return null;
|
return null;
|
||||||
@ -430,6 +452,75 @@ export default function ProfilePage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{canReadAcknowledgedDocuments && (
|
||||||
|
<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">
|
||||||
|
<FileCheck size={16} />
|
||||||
|
Document acknowledgments
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4 md:px-5">
|
||||||
|
<div className={`${formPanelClassName} mt-6`}>
|
||||||
|
{(
|
||||||
|
policyAcknowledgmentsStatus.isLoading
|
||||||
|
|| handbookPoliciesStatus.isLoading
|
||||||
|
|| safetyProtocolsStatus.isLoading
|
||||||
|
) ? (
|
||||||
|
<p className="text-sm text-slate-300">Loading document acknowledgments...</p>
|
||||||
|
) : documentAcknowledgmentRows.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-300">
|
||||||
|
No current documents are assigned yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-lg border border-slate-700/70">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-slate-700/70 bg-slate-950/60 hover:bg-slate-950/60">
|
||||||
|
<TableHead className="h-auto p-3 text-slate-400">Document</TableHead>
|
||||||
|
<TableHead className="h-auto p-3 text-slate-400">Category</TableHead>
|
||||||
|
<TableHead className="h-auto p-3 text-slate-400">Version</TableHead>
|
||||||
|
<TableHead className="h-auto p-3 text-slate-400">Acknowledged on</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{documentAcknowledgmentRows.map((document) => (
|
||||||
|
<TableRow
|
||||||
|
key={document.id}
|
||||||
|
className="border-slate-800/80 hover:bg-slate-800/20"
|
||||||
|
>
|
||||||
|
<TableCell className="p-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="font-semibold text-slate-100">{document.title}</p>
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||||||
|
document.status === 'acknowledged'
|
||||||
|
? 'bg-emerald-500/15 text-emerald-200 ring-1 ring-emerald-400/30'
|
||||||
|
: 'bg-red-500/15 text-red-200 ring-1 ring-red-400/30'
|
||||||
|
}`}>
|
||||||
|
{document.status === 'acknowledged'
|
||||||
|
? 'Acknowledged'
|
||||||
|
: 'Not acknowledged'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-3 text-slate-300">{document.category}</TableCell>
|
||||||
|
<TableCell className="p-3 text-slate-300">{document.version}</TableCell>
|
||||||
|
<TableCell className="p-3 text-slate-300">
|
||||||
|
{document.acknowledgedAt
|
||||||
|
? new Date(document.acknowledgedAt).toLocaleDateString()
|
||||||
|
: '—'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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,173 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react';
|
|
||||||
import { ClipboardCheck } from 'lucide-react';
|
|
||||||
|
|
||||||
import { usePolicyAcknowledgmentReport } from '@/business/policies/hooks';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { ModuleHeader } from '@/components/ui/module-header';
|
|
||||||
import { PageSkeleton } from '@/components/ui/page-skeleton';
|
|
||||||
import { StatePanel } from '@/components/ui/state-panel';
|
|
||||||
import { getErrorMessage } from '@/shared/errors/errorMessages';
|
|
||||||
import type { PolicyAcknowledgmentReportStaffDto } from '@/shared/types/policyDocuments';
|
|
||||||
|
|
||||||
function formatDate(value: string | null): string {
|
|
||||||
if (!value) return 'Missing';
|
|
||||||
return new Intl.DateTimeFormat(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
}).format(new Date(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function roleLabel(value: string | null): string {
|
|
||||||
return value ? value.replace(/_/g, ' ') : 'staff';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AcknowledgmentsPage() {
|
|
||||||
const reportQuery = usePolicyAcknowledgmentReport();
|
|
||||||
const [missingOnly, setMissingOnly] = useState(false);
|
|
||||||
|
|
||||||
const staffRows = useMemo<readonly PolicyAcknowledgmentReportStaffDto[]>(() => {
|
|
||||||
const rows = reportQuery.data?.staff ?? [];
|
|
||||||
return missingOnly ? rows.filter((row) => row.missingCount > 0) : rows;
|
|
||||||
}, [missingOnly, reportQuery.data]);
|
|
||||||
|
|
||||||
if (reportQuery.isLoading) {
|
|
||||||
return <PageSkeleton />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reportQuery.error) {
|
|
||||||
return (
|
|
||||||
<div className="p-4 md:p-6">
|
|
||||||
<StatePanel tone="red" role="alert">
|
|
||||||
{getErrorMessage(reportQuery.error, 'Could not load acknowledgments report')}
|
|
||||||
</StatePanel>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const report = reportQuery.data;
|
|
||||||
if (!report) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 p-4 md:p-6">
|
|
||||||
<ModuleHeader
|
|
||||||
title="Acknowledgments"
|
|
||||||
description="Policy and safety acknowledgment status for the current scope."
|
|
||||||
icon={ClipboardCheck}
|
|
||||||
iconClassName="bg-gradient-to-br from-emerald-500 to-emerald-700"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-xs text-slate-400">Completion</p>
|
|
||||||
<p className="mt-1 text-2xl font-semibold text-white">{report.summary.completionRate}%</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-xs text-slate-400">Staff</p>
|
|
||||||
<p className="mt-1 text-2xl font-semibold text-white">{report.summary.totalStaff}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-xs text-slate-400">Documents</p>
|
|
||||||
<p className="mt-1 text-2xl font-semibold text-white">{report.summary.totalDocuments}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-xs text-slate-400">Missing</p>
|
|
||||||
<p className="mt-1 text-2xl font-semibold text-white">{report.summary.missingCount}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">Documents</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="overflow-x-auto">
|
|
||||||
<table className="min-w-full text-sm">
|
|
||||||
<thead className="text-left text-xs uppercase text-slate-500">
|
|
||||||
<tr>
|
|
||||||
<th className="py-2 pr-4">Document</th>
|
|
||||||
<th className="py-2 pr-4">Category</th>
|
|
||||||
<th className="py-2 pr-4">Version</th>
|
|
||||||
<th className="py-2 pr-4">Acknowledged</th>
|
|
||||||
<th className="py-2 pr-4">Missing</th>
|
|
||||||
<th className="py-2 pr-4">Rate</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-700/50 text-slate-200">
|
|
||||||
{report.documents.map((document) => (
|
|
||||||
<tr key={document.id}>
|
|
||||||
<td className="py-3 pr-4 font-medium">{document.title}</td>
|
|
||||||
<td className="py-3 pr-4">{document.category.replace('_', ' ')}</td>
|
|
||||||
<td className="py-3 pr-4">{document.version}</td>
|
|
||||||
<td className="py-3 pr-4">{document.acknowledgedCount}/{document.totalStaff}</td>
|
|
||||||
<td className="py-3 pr-4">{document.missingCount}</td>
|
|
||||||
<td className="py-3 pr-4">{document.completionRate}%</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<CardTitle className="text-base">Staff Detail</CardTitle>
|
|
||||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={missingOnly}
|
|
||||||
onChange={(event) => setMissingOnly(event.target.checked)}
|
|
||||||
/>
|
|
||||||
Missing only
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{staffRows.length === 0 ? (
|
|
||||||
<p className="text-sm text-slate-400">No staff rows match the current filter.</p>
|
|
||||||
) : (
|
|
||||||
staffRows.map((staff) => (
|
|
||||||
<div key={staff.userId} className="rounded-lg border border-slate-700/50 p-4">
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-white">{staff.name}</p>
|
|
||||||
<p className="text-xs text-slate-400">{staff.email} · {roleLabel(staff.role)}</p>
|
|
||||||
</div>
|
|
||||||
<Badge variant={staff.missingCount > 0 ? 'secondary' : 'default'}>
|
|
||||||
{staff.acknowledgedCount}/{staff.documents.length} acknowledged
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
|
||||||
{staff.documents
|
|
||||||
.filter((document) => !missingOnly || document.missing)
|
|
||||||
.map((document) => (
|
|
||||||
<div
|
|
||||||
key={`${staff.userId}-${document.policyDocumentId}`}
|
|
||||||
className="flex items-center justify-between gap-3 rounded-md bg-slate-900/60 px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<span className="min-w-0 truncate text-slate-200">{document.title}</span>
|
|
||||||
<span className={document.missing ? 'text-amber-300' : 'text-emerald-300'}>
|
|
||||||
{formatDate(document.acknowledgedAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -17,7 +17,6 @@ export const MODULES: Module[] = [
|
|||||||
{ id: 'internal-comm', name: 'Internal Alerts', icon: 'bell', permissions: ['READ_INTERNAL_COMM'], color: 'bg-rose-500', routePath: APP_ROUTE_PATHS.internalComm },
|
{ id: 'internal-comm', name: 'Internal Alerts', icon: 'bell', permissions: ['READ_INTERNAL_COMM'], color: 'bg-rose-500', routePath: APP_ROUTE_PATHS.internalComm },
|
||||||
{ id: 'safety', name: 'Safety Protocols', icon: 'alert', permissions: ['READ_SAFETY'], color: 'bg-red-500', routePath: APP_ROUTE_PATHS.safety },
|
{ id: 'safety', name: 'Safety Protocols', icon: 'alert', permissions: ['READ_SAFETY'], color: 'bg-red-500', routePath: APP_ROUTE_PATHS.safety },
|
||||||
{ id: 'handbook', name: 'Handbook & Policies', icon: 'file', permissions: ['READ_HANDBOOK'], color: 'bg-slate-600', routePath: APP_ROUTE_PATHS.handbook },
|
{ id: 'handbook', name: 'Handbook & Policies', icon: 'file', permissions: ['READ_HANDBOOK'], color: 'bg-slate-600', routePath: APP_ROUTE_PATHS.handbook },
|
||||||
{ id: 'acknowledgments', name: 'Acknowledgments', icon: 'clipboard', permissions: ['READ_POLICY_ACKNOWLEDGMENT_REPORTS'], color: 'bg-emerald-600', routePath: APP_ROUTE_PATHS.acknowledgments },
|
|
||||||
{ id: 'community', name: 'Community & Partnerships', icon: 'globe', permissions: ['READ_COMMUNITY'], color: 'bg-green-600', routePath: APP_ROUTE_PATHS.community },
|
{ id: 'community', name: 'Community & Partnerships', icon: 'globe', permissions: ['READ_COMMUNITY'], color: 'bg-green-600', routePath: APP_ROUTE_PATHS.community },
|
||||||
{ id: 'vocational', name: 'Vocational Opportunities', icon: 'briefcase', permissions: ['READ_VOCATIONAL'], color: 'bg-sky-600', routePath: APP_ROUTE_PATHS.vocational },
|
{ id: 'vocational', name: 'Vocational Opportunities', icon: 'briefcase', permissions: ['READ_VOCATIONAL'], color: 'bg-sky-600', routePath: APP_ROUTE_PATHS.vocational },
|
||||||
{ id: 'esa', name: 'ESA Funding Info', icon: 'wallet', permissions: ['READ_ESA'], color: 'bg-emerald-600', routePath: APP_ROUTE_PATHS.esa },
|
{ id: 'esa', name: 'ESA Funding Info', icon: 'wallet', permissions: ['READ_ESA'], color: 'bg-emerald-600', routePath: APP_ROUTE_PATHS.esa },
|
||||||
|
|||||||
@ -14,7 +14,6 @@ export const APP_ROUTE_PATHS = {
|
|||||||
internalComm: '/internal-alerts',
|
internalComm: '/internal-alerts',
|
||||||
safety: '/safety-protocols',
|
safety: '/safety-protocols',
|
||||||
handbook: '/handbook-policies',
|
handbook: '/handbook-policies',
|
||||||
acknowledgments: '/acknowledgments',
|
|
||||||
community: '/community-partnerships',
|
community: '/community-partnerships',
|
||||||
vocational: '/vocational-opportunities',
|
vocational: '/vocational-opportunities',
|
||||||
esa: '/esa-funding',
|
esa: '/esa-funding',
|
||||||
|
|||||||
@ -172,7 +172,6 @@ export type ModuleId =
|
|||||||
| 'internal-comm'
|
| 'internal-comm'
|
||||||
| 'safety'
|
| 'safety'
|
||||||
| 'handbook'
|
| 'handbook'
|
||||||
| 'acknowledgments'
|
|
||||||
| 'director'
|
| 'director'
|
||||||
| 'community'
|
| 'community'
|
||||||
| 'vocational'
|
| 'vocational'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user