diff --git a/backend/docs/policy-documents.md b/backend/docs/policy-documents.md index e23b15c..9064681 100644 --- a/backend/docs/policy-documents.md +++ b/backend/docs/policy-documents.md @@ -37,9 +37,17 @@ entity it replaced has been removed): `SafetyDynamicListEditor`; gated by effective policy-document permissions). Title/body/steps/considerations changes bump `version` and require re-acknowledgment. -- **Acknowledgments** (`business/policies`, `pages/modules/AcknowledgmentsPage`) - renders the manager report from `GET /api/policy_acknowledgments/report`. - The Director Dashboard also shows the report summary as an overview card. +- **Leadership dashboard acknowledgments** (`business/director-dashboard`, + `components/director-dashboard/DirectorAcknowledgmentTrackingPanel`) render the + 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 @@ -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_filter` can remove it. The report population is active 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 @@ -116,8 +126,12 @@ director/office_manager/teacher/support_staff roles. acknowledgment listing plus the drilled-child no-op rule for parent users. - **Frontend unit** (`vitest`): `business/policies/mappers.test.ts` (handbook; tag↔category, author), `business/safety-protocols/mappers.test.ts` (steps + - autism considerations) and `business/safety-protocols/selectors.test.ts` - (management grant + draft validation for the authoring form). + autism considerations), `business/safety-protocols/selectors.test.ts` + (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`, `npm run test:e2e:content`): document create/persist, manage-vs-read RBAC (director/office_manager manage; teacher reads but cannot create), idempotent diff --git a/backend/src/db/seeders/20260611050000-policy-documents-seed.ts b/backend/src/db/seeders/20260611050000-policy-documents-seed.ts index 1955088..ef2671f 100644 --- a/backend/src/db/seeders/20260611050000-policy-documents-seed.ts +++ b/backend/src/db/seeders/20260611050000-policy-documents-seed.ts @@ -2,9 +2,16 @@ import { v4 as uuid } from 'uuid'; import { Op, type QueryInterface } from 'sequelize'; import { SEED_ORGANIZATION_ID, - SEED_CAMPUS_ID, + SEED_ORGANIZATION_2_ID, + SEED_SECONDARY_CAMPUS_ID, + SEED_SECONDARY_SCHOOL_ID, SEED_FIXTURE_USERS, + SEED_SECONDARY_USERS, + SEED_SCHOOL_CAMPUS_IDS, + SEED_SCHOOL_ID, + SEED_SCHOOL_2_ID, } from '@/shared/constants/seed-fixtures'; +import { ROLE_NAMES } from '@/shared/constants/roles'; import { POLICY_DOCUMENT_CATEGORIES, 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 & * Policies pages (Workstream 11). Safety protocols reuse the existing * content-catalog `safetyProtocols` payload (steps + autism considerations); - * a few handbook policies give the handbook page demo content. Authored by the - * seeded director on the primary org/campus. Idempotent by `importHash`. + * a few handbook policies give the handbook page demo content. The same current + * 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 { readonly importHash: string; readonly title: string; @@ -88,44 +91,130 @@ const HANDBOOK_ROWS: SeedRow[] = [ 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 { up: async (queryInterface: QueryInterface) => { 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', { - importHash: { [Op.in]: importHashes }, + importHash: { [Op.in]: [...legacyImportHashes, ...importHashes] }, }); - const rows = ALL_ROWS.map((row) => ({ - id: uuid(), - title: row.title, - body: row.body, - category: row.category, - tag: row.tag, - author: AUTHOR, - // JSONB columns: serialize for bulkInsert (Postgres casts JSON → jsonb). - steps: row.steps ? JSON.stringify(row.steps) : null, - autism_considerations: row.autismConsiderations - ? JSON.stringify(row.autismConsiderations) - : null, - version: 1, - active: true, - importHash: row.importHash, - organizationId: SEED_ORGANIZATION_ID, - campusId: SEED_CAMPUS_ID, - createdById: AUTHOR_ID, - updatedById: AUTHOR_ID, - createdAt: now, - updatedAt: now, - })); + const rows = SEED_SCOPES.flatMap((scope) => { + const director = getScopeDirector(scope); + const author = director + ? formatPersonName(director.namePrefix, director.firstName, director.lastName) + : null; + const authorId = director?.id ?? null; + + return ALL_ROWS.map((row) => ({ + id: uuid(), + title: row.title, + body: row.body, + category: row.category, + tag: row.tag, + author, + // JSONB columns: serialize for bulkInsert (Postgres casts JSON → jsonb). + steps: row.steps ? JSON.stringify(row.steps) : null, + autism_considerations: row.autismConsiderations + ? JSON.stringify(row.autismConsiderations) + : null, + version: 1, + 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); }, down: async (queryInterface: QueryInterface) => { 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)), + ), + }, }); }, }; diff --git a/backend/src/services/policy_acknowledgments.test.ts b/backend/src/services/policy_acknowledgments.test.ts index 1f5e946..8179fcb 100644 --- a/backend/src/services/policy_acknowledgments.test.ts +++ b/backend/src/services/policy_acknowledgments.test.ts @@ -95,4 +95,90 @@ describe('PolicyAcknowledgmentsService role eligibility', () => { assert.equal(documentLookupCount, 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); + }); }); diff --git a/frontend/docs/director-dashboard-integration.md b/frontend/docs/director-dashboard-integration.md index 36003cc..30e11e8 100644 --- a/frontend/docs/director-dashboard-integration.md +++ b/frontend/docs/director-dashboard-integration.md @@ -42,8 +42,14 @@ Constants: - Emotional Intelligence and Personality Type completion loads through `usePersonalityCompletion`. - Daily Zone Check-In completion loads through `useZoneCheckInCompletion`. - Staff attendance records and summary load through staff attendance business hooks. -- Policy acknowledgment summary loads through `usePolicyAcknowledgmentReport`. +- Policy acknowledgment report loads through `usePolicyAcknowledgmentReport`. - 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, 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 @@ -51,7 +57,8 @@ Constants: each staff member. - Risk areas include high/medium/low QBS safety quiz completion, low-risk EI self-assessment pending counts, low-risk Personality Type pending counts, - medium-risk non-green Daily Zone Check-In counts, and attendance risk. + 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. - Loading, empty, and error states are explicit. diff --git a/frontend/docs/policies-integration.md b/frontend/docs/policies-integration.md index ee7e4d3..514fa70 100644 --- a/frontend/docs/policies-integration.md +++ b/frontend/docs/policies-integration.md @@ -2,13 +2,13 @@ ## Purpose -Three pages — **Handbook & Policies**, **Safety Protocols**, and -**Acknowledgments** — are backed by one +Two document pages — **Handbook & Policies** and **Safety Protocols** — are backed by one unified store, `policy_documents` (it replaced the former generic `documents` API, which has been removed). `category` selects the page (`handbook_policy` vs `safety_protocol`); `tag` carries the finer sub-category (the handbook's 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 @@ -24,13 +24,21 @@ Safety Protocols: (+ `SafetyProtocolForm.tsx`, `SafetyDynamicListEditor.tsx`) - Business: `frontend/src/business/safety-protocols/{hooks,mappers,selectors,types}.ts` -Acknowledgments: +Acknowledgment tracking: -- Page: `frontend/src/pages/modules/AcknowledgmentsPage.tsx` -- Business/API: `usePolicyAcknowledgmentReport` in +- Dashboard component: + `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/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: @@ -74,15 +82,30 @@ change), `active`, tenant `organizationId` + nullable `campusId`. - **Acknowledgment report** is available to owner, superintendent, principal, registrar, and director. Super/system admins can read it only while drilled 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. -- 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 - `business/policies/mappers.test.ts`, `business/policies/selectors.test.ts` - `business/safety-protocols/mappers.test.ts`, `business/safety-protocols/selectors.test.ts` +- `business/director-dashboard/selectors.test.ts` +- `business/profile/selectors.test.ts` ## Verification diff --git a/frontend/docs/test-coverage.md b/frontend/docs/test-coverage.md index 55a64e0..6a2d951 100644 --- a/frontend/docs/test-coverage.md +++ b/frontend/docs/test-coverage.md @@ -135,7 +135,9 @@ The seeded suite verifies: - **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 - **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 - **Daily Zone check-in**: explicit-permission record/read-back/clear of today's zone (campus-timezone date), invalid-zone rejection, and external-role lockout diff --git a/frontend/src/app/appRoutes.tsx b/frontend/src/app/appRoutes.tsx index 2feda17..a90eac5 100644 --- a/frontend/src/app/appRoutes.tsx +++ b/frontend/src/app/appRoutes.tsx @@ -5,7 +5,6 @@ import { APP_ROUTE_PATHS } from '@/shared/constants/routes'; import { MODULES } from '@/shared/constants/appData'; import type { ModuleId } from '@/shared/types/app'; import { - AcknowledgmentsPage, CampusAttendancePage, CampusAttendanceDetailsPage, ClassroomSupportPage, @@ -58,7 +57,6 @@ const MODULE_ELEMENTS: Record = { 'internal-comm': , safety: , handbook: , - acknowledgments: , community: , vocational: , esa: , diff --git a/frontend/src/app/lazyModulePages.ts b/frontend/src/app/lazyModulePages.ts index e132197..05b880e 100644 --- a/frontend/src/app/lazyModulePages.ts +++ b/frontend/src/app/lazyModulePages.ts @@ -1,6 +1,4 @@ import { lazy } from 'react'; - -export const AcknowledgmentsPage = lazy(() => import('@/pages/modules/AcknowledgmentsPage')); export const DashboardPage = lazy(() => import('@/pages/modules/DashboardPage')); export const FramePage = lazy(() => import('@/pages/modules/FramePage')); export const ClassroomSupportPage = lazy(() => import('@/pages/modules/ClassroomSupportPage')); diff --git a/frontend/src/business/director-dashboard/hooks.ts b/frontend/src/business/director-dashboard/hooks.ts index 779fb52..49af5e1 100644 --- a/frontend/src/business/director-dashboard/hooks.ts +++ b/frontend/src/business/director-dashboard/hooks.ts @@ -10,6 +10,7 @@ import { } from '@/business/staff-attendance/hooks'; import { usePolicyAcknowledgmentReport } from '@/business/policies/hooks'; import { + buildDirectorAcknowledgmentDocuments, buildDirectorFramePreviews, buildDirectorOverviewCards, buildDirectorQuizResults, @@ -48,6 +49,7 @@ export function useDirectorDashboardPage(): DirectorDashboardPage { const quizRows = quizCompletionQuery.data?.rows ?? []; const emotionalIntelligenceCompletion = emotionalIntelligenceCompletionQuery.data ?? null; const zoneCheckinCompletion = zoneCheckinCompletionQuery.data ?? null; + const acknowledgmentDocuments = buildDirectorAcknowledgmentDocuments(acknowledgmentReportQuery.data); const quizSummary = quizCompletionQuery.data?.summary ?? { totalStaff: 0, completedCount: 0, @@ -85,10 +87,12 @@ export function useDirectorDashboardPage(): DirectorDashboardPage { quizSummary, emotionalIntelligenceCompletion, zoneCheckinCompletion, + acknowledgmentDocuments, ), framePreviews: buildDirectorFramePreviews(frameEntries), quickActions: DIRECTOR_DASHBOARD_QUICK_ACTIONS, quizResults: buildDirectorQuizResults(quizRows, emotionalIntelligenceCompletion, zoneCheckinCompletion), + acknowledgmentDocuments, isLoading, error, setTimeRange: setTimeRangeState, diff --git a/frontend/src/business/director-dashboard/selectors.test.ts b/frontend/src/business/director-dashboard/selectors.test.ts index 53f486a..8cd3f0d 100644 --- a/frontend/src/business/director-dashboard/selectors.test.ts +++ b/frontend/src/business/director-dashboard/selectors.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + buildDirectorAcknowledgmentDocuments, buildDirectorFramePreviews, buildDirectorOverviewCards, buildDirectorQuizResults, @@ -15,6 +16,7 @@ import type { } from '@/business/safety-quiz/types'; import type { PersonalityCompletionDto } from '@/shared/types/personality'; import type { ZoneCheckinCompletionDto } from '@/shared/types/zoneCheckins'; +import type { PolicyAcknowledgmentReportDto } from '@/shared/types/policyDocuments'; function createAttendanceRecord( overrides: Partial = {}, @@ -158,6 +160,81 @@ function createZoneCheckinCompletion( }; } +function createAcknowledgmentReport( + overrides: Partial = {}, +): 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', () => { it('calculates quiz completion rate with empty staff protection', () => { expect(calculateQuizCompletionRate(createQuizSummary({ completionRate: 0 }))).toBe(0); @@ -189,8 +266,9 @@ describe('director dashboard selectors', () => { 'qbs', 'frame', 'attendance', - 'acknowledgments', + 'director', ]); + expect(cards[cards.length - 1]?.action).toBe('openAcknowledgments'); }); 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 }), null, createZoneCheckinCompletion(), + buildDirectorAcknowledgmentDocuments(createAcknowledgmentReport()), ); expect(risks).toEqual([ @@ -222,6 +301,12 @@ describe('director dashboard selectors', () => { severity: 'medium', module: 'zones', }, + { + issue: "1 staff haven't acknowledged 1 document", + severity: 'medium', + module: 'director', + action: 'openAcknowledgments', + }, { issue: '4 absences recorded this period', 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([]); }); + 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', () => { const rows = buildDirectorQuizResults( [ diff --git a/frontend/src/business/director-dashboard/selectors.ts b/frontend/src/business/director-dashboard/selectors.ts index a75e7bd..8fd0ccf 100644 --- a/frontend/src/business/director-dashboard/selectors.ts +++ b/frontend/src/business/director-dashboard/selectors.ts @@ -11,8 +11,12 @@ import { } from '@/business/staff-attendance/selectors'; import type { FrameEntryViewModel } from '@/business/frame/types'; import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types'; -import type { PolicyAcknowledgmentReportSummaryDto } from '@/shared/types/policyDocuments'; import type { + PolicyAcknowledgmentReportDto, + PolicyAcknowledgmentReportSummaryDto, +} from '@/shared/types/policyDocuments'; +import type { + DirectorAcknowledgmentDocumentRow, DirectorFramePreview, DirectorOverviewCard, DirectorQuizResultRow, @@ -88,7 +92,8 @@ export function buildDirectorOverviewCards( trend: acknowledgmentRate > DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD ? 'up' : 'down', iconId: 'clipboard', tone: 'emerald', - module: 'acknowledgments', + module: 'director', + action: 'openAcknowledgments', }, ]; } @@ -98,6 +103,7 @@ export function buildDirectorRiskAreas( quizSummary: SafetyQuizCompletionSummary, emotionalIntelligenceCompletion?: PersonalityCompletionDto | null, zoneCheckinCompletion?: ZoneCheckinCompletionDto | null, + acknowledgmentDocuments: readonly DirectorAcknowledgmentDocumentRow[] = [], ): readonly DirectorRiskArea[] { const incompleteStaffCount = quizSummary.pendingCount; 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) { risks.push({ issue: `${absenceCount} absences recorded this period`, @@ -165,6 +181,75 @@ export function buildDirectorRiskAreas( return risks; } +function buildAcknowledgmentRisk( + acknowledgmentDocuments: readonly DirectorAcknowledgmentDocumentRow[], +): Pick | null { + const unresolvedDocuments = acknowledgmentDocuments.filter((document) => + document.missingCount > 0, + ); + if (unresolvedDocuments.length === 0) { + return null; + } + + const staffIds = new Set(); + 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( zoneCheckinCompletion?: ZoneCheckinCompletionDto | null, ): string | null { @@ -183,6 +268,27 @@ function getNonGreenZoneNote( 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( safetyRows: readonly SafetyQuizComplianceRow[], emotionalIntelligenceCompletion?: PersonalityCompletionDto | null, diff --git a/frontend/src/business/director-dashboard/types.ts b/frontend/src/business/director-dashboard/types.ts index c0d5be7..3e50d72 100644 --- a/frontend/src/business/director-dashboard/types.ts +++ b/frontend/src/business/director-dashboard/types.ts @@ -6,6 +6,7 @@ import type { ModuleId } from '@/shared/types/app'; export type DirectorDashboardTrend = 'up' | 'down'; export type DirectorDashboardRiskSeverity = 'high' | 'medium' | 'low'; +export type DirectorDashboardAction = 'openAcknowledgments'; export type DirectorOverviewIconId = 'clock' | 'shield' | 'eye' | 'users' | 'clipboard'; export type DirectorOverviewTone = 'orange' | 'blue' | 'amber' | 'purple' | 'emerald'; export type DirectorFrameSectionLetter = 'F' | 'R' | 'A' | 'M' | 'E'; @@ -19,12 +20,14 @@ export interface DirectorOverviewCard { readonly iconId: DirectorOverviewIconId; readonly tone: DirectorOverviewTone; readonly module: ModuleId; + readonly action?: DirectorDashboardAction; } export interface DirectorRiskArea { readonly issue: string; readonly severity: DirectorDashboardRiskSeverity; readonly module: ModuleId; + readonly action?: DirectorDashboardAction; } export interface DirectorFrameSectionPreview { @@ -48,6 +51,26 @@ export interface DirectorQuizResultRow { 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 { /** Role-specific dashboard title (e.g. "Owner Dashboard"). */ readonly title: string; @@ -59,6 +82,7 @@ export interface DirectorDashboardPage { readonly framePreviews: readonly DirectorFramePreview[]; readonly quickActions: readonly DirectorQuickActionConfig[]; readonly quizResults: readonly DirectorQuizResultRow[]; + readonly acknowledgmentDocuments: readonly DirectorAcknowledgmentDocumentRow[]; readonly isLoading: boolean; readonly error: unknown; readonly setTimeRange: (timeRange: DirectorDashboardTimeRange) => void; diff --git a/frontend/src/business/policies/hooks.ts b/frontend/src/business/policies/hooks.ts index 1aaf10c..d657d5c 100644 --- a/frontend/src/business/policies/hooks.ts +++ b/frontend/src/business/policies/hooks.ts @@ -41,6 +41,7 @@ export function useCreatePolicy() { mutationFn: (input: PolicyFormInput) => createPolicyDocument(toPolicyDocumentMutationDto(input)), invalidateQueryKey: POLICY_QUERY_KEYS.documents, + invalidateQueryKeys: [POLICY_QUERY_KEYS.acknowledgmentReport], }); } @@ -49,6 +50,10 @@ export function useUpdatePolicy() { mutationFn: (input: PolicyUpdateInput) => updatePolicyDocument(input.id, toPolicyDocumentMutationDto(input.policy)), invalidateQueryKey: POLICY_QUERY_KEYS.documents, + invalidateQueryKeys: [ + POLICY_QUERY_KEYS.acknowledgments, + POLICY_QUERY_KEYS.acknowledgmentReport, + ], }); } @@ -56,6 +61,7 @@ export function useDeletePolicy() { return useInvalidatingMutation({ mutationFn: (id: string) => deletePolicyDocument(id), invalidateQueryKey: POLICY_QUERY_KEYS.documents, + invalidateQueryKeys: [POLICY_QUERY_KEYS.acknowledgmentReport], }); } @@ -73,6 +79,7 @@ export function useAcknowledgePolicy() { mutationFn: (policyDocumentId: string) => acknowledgePolicyDocument({ policyDocumentId }), invalidateQueryKey: POLICY_QUERY_KEYS.acknowledgments, + invalidateQueryKeys: [POLICY_QUERY_KEYS.acknowledgmentReport], }); } diff --git a/frontend/src/business/profile/selectors.test.ts b/frontend/src/business/profile/selectors.test.ts index 8389b75..2b20637 100644 --- a/frontend/src/business/profile/selectors.test.ts +++ b/frontend/src/business/profile/selectors.test.ts @@ -1,7 +1,13 @@ 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 { 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'; function createSafetyResult(): SafetyQuizResultDto { @@ -44,6 +50,52 @@ function createPersonalityResult( }; } +function createPolicy(overrides: Partial = {}): 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 { + 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 { + 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', () => { it('combines safety, EI assessment, and personality quiz results', () => { const rows = buildProfileQuizResultRows(createSafetyResult(), [ @@ -98,4 +150,52 @@ describe('profile selectors', () => { 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', + }, + ]); + }); }); diff --git a/frontend/src/business/profile/selectors.ts b/frontend/src/business/profile/selectors.ts index 3eb0989..b131060 100644 --- a/frontend/src/business/profile/selectors.ts +++ b/frontend/src/business/profile/selectors.ts @@ -1,5 +1,8 @@ import { PERSONALITY_QUIZ_KINDS } from '@/shared/constants/personality'; 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 { ZoneCheckinTodayDto } from '@/shared/types/zoneCheckins'; @@ -12,6 +15,15 @@ export interface ProfileQuizResultRow { 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( safetyQuizResult: SafetyQuizResultDto | null, personalityResults: readonly PersonalityQuizResultViewModel[], @@ -38,6 +50,63 @@ export function buildProfileQuizResultRows( 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( results: readonly PersonalityQuizResultViewModel[], quizKind: PersonalityQuizResultViewModel['quizKind'], diff --git a/frontend/src/business/safety-protocols/hooks.ts b/frontend/src/business/safety-protocols/hooks.ts index d1de224..38deaae 100644 --- a/frontend/src/business/safety-protocols/hooks.ts +++ b/frontend/src/business/safety-protocols/hooks.ts @@ -98,6 +98,7 @@ export function useSafetyProtocolsModule() { mutationFn: (input: SafetyProtocolDraft) => createPolicyDocument(toSafetyProtocolMutationDto(input)), invalidateQueryKey: POLICY_QUERY_KEYS.safetyDocuments, + invalidateQueryKeys: [POLICY_QUERY_KEYS.acknowledgmentReport], onSuccess: resetForm, }); @@ -105,12 +106,17 @@ export function useSafetyProtocolsModule() { mutationFn: (input: SafetyUpdateInput) => updatePolicyDocument(input.id, toSafetyProtocolMutationDto(input.draft)), invalidateQueryKey: POLICY_QUERY_KEYS.safetyDocuments, + invalidateQueryKeys: [ + POLICY_QUERY_KEYS.acknowledgments, + POLICY_QUERY_KEYS.acknowledgmentReport, + ], onSuccess: resetForm, }); const deleteMutation = useInvalidatingMutation({ mutationFn: (id: string) => deletePolicyDocument(id), invalidateQueryKey: POLICY_QUERY_KEYS.safetyDocuments, + invalidateQueryKeys: [POLICY_QUERY_KEYS.acknowledgmentReport], onSuccess: resetForm, }); diff --git a/frontend/src/components/director-dashboard/DirectorAcknowledgmentTrackingModal.tsx b/frontend/src/components/director-dashboard/DirectorAcknowledgmentTrackingModal.tsx new file mode 100644 index 0000000..df8fd2b --- /dev/null +++ b/frontend/src/components/director-dashboard/DirectorAcknowledgmentTrackingModal.tsx @@ -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 ( + + + + + + Document Acknowledgments + + + Current safety protocol and handbook versions for this scope. + + + + + + ); +} diff --git a/frontend/src/components/director-dashboard/DirectorAcknowledgmentTrackingPanel.tsx b/frontend/src/components/director-dashboard/DirectorAcknowledgmentTrackingPanel.tsx new file mode 100644 index 0000000..a32e00c --- /dev/null +++ b/frontend/src/components/director-dashboard/DirectorAcknowledgmentTrackingPanel.tsx @@ -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>( + () => 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 ( +
+ {showHeader && ( +
+
+

+ + Document Acknowledgments +

+

+ Current safety protocol and handbook versions for this scope. +

+
+
+ )} + + {groupedDocuments.length === 0 ? ( +

+ No active documents are assigned to this scope yet. +

+ ) : ( +
+ {groupedDocuments.map((group) => ( +
+

+ {group.label} +

+
+ {group.documents.map((document, index) => { + const isExpanded = expandedDocumentIds.has(document.id); + return ( +
0 ? 'border-t border-gray-100' : undefined} + > + + {isExpanded && ( +
+ {document.missingStaff.length === 0 ? ( +

+ Every staff member in scope has acknowledged this version. +

+ ) : ( +
+

+ Not acknowledged +

+ {document.missingStaff.map((staff) => ( +
+ {staff.name} + + {staff.role} + +
+ ))} +
+ )} +
+ )} +
+ ); + })} +
+
+ ))} +
+ )} +
+ ); +} + +function groupDocumentsByCategory( + documents: readonly DirectorAcknowledgmentDocumentRow[], +): readonly { + readonly category: string; + readonly label: string; + readonly documents: readonly DirectorAcknowledgmentDocumentRow[]; +}[] { + const groups = new Map(); + const labels = new Map(); + 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'; +} diff --git a/frontend/src/components/director-dashboard/DirectorDashboardView.tsx b/frontend/src/components/director-dashboard/DirectorDashboardView.tsx index 4392d86..129ea1d 100644 --- a/frontend/src/components/director-dashboard/DirectorDashboardView.tsx +++ b/frontend/src/components/director-dashboard/DirectorDashboardView.tsx @@ -1,6 +1,9 @@ +import { useState } from 'react'; + import type { ModuleId } from '@/shared/types/app'; import type { DirectorDashboardPage } from '@/business/director-dashboard/types'; import { DirectorDashboardHeader } from '@/components/director-dashboard/DirectorDashboardHeader'; +import { DirectorAcknowledgmentTrackingModal } from '@/components/director-dashboard/DirectorAcknowledgmentTrackingModal'; import { DirectorOverviewCards } from '@/components/director-dashboard/DirectorOverviewCards'; import { DirectorQuickActions } from '@/components/director-dashboard/DirectorQuickActions'; import { DirectorQuizResultsPanel } from '@/components/director-dashboard/DirectorQuizResultsPanel'; @@ -19,6 +22,7 @@ export function DirectorDashboardView({ page, onOpenModule, }: DirectorDashboardViewProps) { + const [isAcknowledgmentModalOpen, setIsAcknowledgmentModalOpen] = useState(false); const errorMessage = getOptionalErrorMessage(page.error); if (page.isLoading) { @@ -44,12 +48,14 @@ export function DirectorDashboardView({ setIsAcknowledgmentModalOpen(true)} />
setIsAcknowledgmentModalOpen(true)} />
@@ -64,6 +70,11 @@ export function DirectorDashboardView({ />
+ ); } diff --git a/frontend/src/components/director-dashboard/DirectorOverviewCards.tsx b/frontend/src/components/director-dashboard/DirectorOverviewCards.tsx index 880df2a..2c99142 100644 --- a/frontend/src/components/director-dashboard/DirectorOverviewCards.tsx +++ b/frontend/src/components/director-dashboard/DirectorOverviewCards.tsx @@ -11,12 +11,23 @@ import { interface DirectorOverviewCardsProps { readonly cards: readonly DirectorOverviewCard[]; readonly onOpenModule: (module: ModuleId) => void; + readonly onOpenAcknowledgments?: () => void; } export function DirectorOverviewCards({ cards, onOpenModule, + onOpenAcknowledgments, }: DirectorOverviewCardsProps) { + function handleCardClick(card: DirectorOverviewCard) { + if (card.action === 'openAcknowledgments' && onOpenAcknowledgments) { + onOpenAcknowledgments(); + return; + } + + onOpenModule(card.module); + } + return (
{cards.map((card) => { @@ -27,7 +38,7 @@ export function DirectorOverviewCards({