improved users acknowledgement functionality

This commit is contained in:
Dmitri 2026-06-19 12:17:17 +02:00
parent 2b496033cf
commit 0fbf6c0387
26 changed files with 1096 additions and 240 deletions

View File

@ -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

View File

@ -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)),
),
},
}); });
}, },
}; };

View File

@ -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);
});
}); });

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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 />,

View File

@ -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'));

View File

@ -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,

View File

@ -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(
[ [

View File

@ -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,

View File

@ -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;

View File

@ -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],
}); });
} }

View File

@ -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',
},
]);
});
}); });

View File

@ -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'],

View File

@ -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,
}); });

View File

@ -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>
);
}

View File

@ -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';
}

View File

@ -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>
); );
} }

View File

@ -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">

View File

@ -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">

View File

@ -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">

View File

@ -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>
);
}

View File

@ -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 },

View File

@ -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',

View File

@ -172,7 +172,6 @@ export type ModuleId =
| 'internal-comm' | 'internal-comm'
| 'safety' | 'safety'
| 'handbook' | 'handbook'
| 'acknowledgments'
| 'director' | 'director'
| 'community' | 'community'
| 'vocational' | 'vocational'