improved users acknowledgement functionality
This commit is contained in:
parent
2b496033cf
commit
0fbf6c0387
@ -37,9 +37,17 @@ entity it replaced has been removed):
|
||||
`SafetyDynamicListEditor`; gated by effective policy-document permissions).
|
||||
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
|
||||
|
||||
@ -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)),
|
||||
),
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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<ModuleId, ReactNode> = {
|
||||
'internal-comm': <InternalAlertsPage />,
|
||||
safety: <SafetyProtocolsPage />,
|
||||
handbook: <HandbookPoliciesPage />,
|
||||
acknowledgments: <AcknowledgmentsPage />,
|
||||
community: <CommunityPartnershipsPage />,
|
||||
vocational: <VocationalOpportunitiesPage />,
|
||||
esa: <EsaFundingPage />,
|
||||
|
||||
@ -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'));
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<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', () => {
|
||||
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(
|
||||
[
|
||||
|
||||
@ -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<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(
|
||||
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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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> = {}): 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', () => {
|
||||
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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
import { ClipboardCheck } from 'lucide-react';
|
||||
|
||||
import type { DirectorAcknowledgmentDocumentRow } from '@/business/director-dashboard/types';
|
||||
import { DirectorAcknowledgmentTrackingPanel } from '@/components/director-dashboard/DirectorAcknowledgmentTrackingPanel';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface DirectorAcknowledgmentTrackingModalProps {
|
||||
readonly documents: readonly DirectorAcknowledgmentDocumentRow[];
|
||||
readonly open: boolean;
|
||||
readonly onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function DirectorAcknowledgmentTrackingModal({
|
||||
documents,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DirectorAcknowledgmentTrackingModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[86vh] overflow-y-auto border-slate-700/60 bg-slate-950 p-4 text-white shadow-2xl shadow-black/40 sm:max-w-5xl">
|
||||
<DialogHeader className="pr-8">
|
||||
<DialogTitle className="flex items-center gap-2 text-white">
|
||||
<ClipboardCheck size={20} className="text-emerald-400" />
|
||||
Document Acknowledgments
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Current safety protocol and handbook versions for this scope.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DirectorAcknowledgmentTrackingPanel
|
||||
documents={documents}
|
||||
showHeader={false}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,162 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronDown, ClipboardCheck } from 'lucide-react';
|
||||
|
||||
import type { DirectorAcknowledgmentDocumentRow } from '@/business/director-dashboard/types';
|
||||
|
||||
interface DirectorAcknowledgmentTrackingPanelProps {
|
||||
readonly documents: readonly DirectorAcknowledgmentDocumentRow[];
|
||||
readonly showHeader?: boolean;
|
||||
}
|
||||
|
||||
export function DirectorAcknowledgmentTrackingPanel({
|
||||
documents,
|
||||
showHeader = true,
|
||||
}: DirectorAcknowledgmentTrackingPanelProps) {
|
||||
const [expandedDocumentIds, setExpandedDocumentIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const groupedDocuments = useMemo(() => groupDocumentsByCategory(documents), [documents]);
|
||||
|
||||
function toggleDocument(documentId: string) {
|
||||
setExpandedDocumentIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(documentId)) {
|
||||
next.delete(documentId);
|
||||
} else {
|
||||
next.add(documentId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-violet-100 bg-white p-5 text-gray-900 shadow-sm">
|
||||
{showHeader && (
|
||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 font-semibold text-gray-800">
|
||||
<ClipboardCheck size={18} className="text-emerald-600" />
|
||||
Document Acknowledgments
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Current safety protocol and handbook versions for this scope.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupedDocuments.length === 0 ? (
|
||||
<p className="rounded-xl bg-gray-50 px-4 py-3 text-sm text-gray-500">
|
||||
No active documents are assigned to this scope yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{groupedDocuments.map((group) => (
|
||||
<div key={group.category} className="space-y-2">
|
||||
<p className="text-xs font-bold uppercase text-gray-500">
|
||||
{group.label}
|
||||
</p>
|
||||
<div className="overflow-hidden rounded-xl border border-gray-100">
|
||||
{group.documents.map((document, index) => {
|
||||
const isExpanded = expandedDocumentIds.has(document.id);
|
||||
return (
|
||||
<div
|
||||
key={document.id}
|
||||
className={index > 0 ? 'border-t border-gray-100' : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDocument(document.id)}
|
||||
className="flex w-full items-center justify-between gap-4 bg-white px-4 py-3 text-left transition-transform hover:scale-[1.005] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2"
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={`acknowledgment-${document.id}`}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-semibold text-gray-800">
|
||||
{document.title}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-gray-500">
|
||||
Version {document.version}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-3">
|
||||
<span className={getStatusClassName(document.missingCount)}>
|
||||
{document.acknowledgedCount}/{document.totalStaff}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={18}
|
||||
className={`text-gray-400 transition-transform ${
|
||||
isExpanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div
|
||||
id={`acknowledgment-${document.id}`}
|
||||
className="bg-gray-50 px-4 py-3"
|
||||
>
|
||||
{document.missingStaff.length === 0 ? (
|
||||
<p className="text-sm text-emerald-700">
|
||||
Every staff member in scope has acknowledged this version.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase text-gray-500">
|
||||
Not acknowledged
|
||||
</p>
|
||||
{document.missingStaff.map((staff) => (
|
||||
<div
|
||||
key={`${document.id}-${staff.userId}`}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-medium text-gray-800">{staff.name}</span>
|
||||
<span className="text-xs capitalize text-gray-500">
|
||||
{staff.role}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function groupDocumentsByCategory(
|
||||
documents: readonly DirectorAcknowledgmentDocumentRow[],
|
||||
): readonly {
|
||||
readonly category: string;
|
||||
readonly label: string;
|
||||
readonly documents: readonly DirectorAcknowledgmentDocumentRow[];
|
||||
}[] {
|
||||
const groups = new Map<string, DirectorAcknowledgmentDocumentRow[]>();
|
||||
const labels = new Map<string, string>();
|
||||
for (const document of documents) {
|
||||
const rows = groups.get(document.category) ?? [];
|
||||
rows.push(document);
|
||||
groups.set(document.category, rows);
|
||||
labels.set(document.category, document.categoryLabel);
|
||||
}
|
||||
return [...groups.entries()].map(([category, rows]) => ({
|
||||
category,
|
||||
label: labels.get(category) ?? category,
|
||||
documents: rows,
|
||||
}));
|
||||
}
|
||||
|
||||
function getStatusClassName(missingCount: number): string {
|
||||
return missingCount > 0
|
||||
? 'rounded-lg bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-700'
|
||||
: 'rounded-lg bg-emerald-100 px-2.5 py-1 text-xs font-semibold text-emerald-700';
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { ModuleId } from '@/shared/types/app';
|
||||
import type { 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({
|
||||
<DirectorOverviewCards
|
||||
cards={page.overviewCards}
|
||||
onOpenModule={onOpenModule}
|
||||
onOpenAcknowledgments={() => setIsAcknowledgmentModalOpen(true)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<DirectorRiskList
|
||||
risks={page.riskAreas}
|
||||
onOpenModule={onOpenModule}
|
||||
onOpenAcknowledgments={() => setIsAcknowledgmentModalOpen(true)}
|
||||
/>
|
||||
<DirectorQuizResultsPanel results={page.quizResults} />
|
||||
</div>
|
||||
@ -64,6 +70,11 @@ export function DirectorDashboardView({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DirectorAcknowledgmentTrackingModal
|
||||
documents={page.acknowledgmentDocuments}
|
||||
open={isAcknowledgmentModalOpen}
|
||||
onOpenChange={setIsAcknowledgmentModalOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-4">
|
||||
{cards.map((card) => {
|
||||
@ -27,7 +38,7 @@ export function DirectorOverviewCards({
|
||||
<Button
|
||||
key={card.label}
|
||||
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"
|
||||
>
|
||||
<div className="w-full">
|
||||
|
||||
@ -10,15 +10,26 @@ import {
|
||||
interface DirectorRiskListProps {
|
||||
readonly risks: readonly DirectorRiskArea[];
|
||||
readonly onOpenModule: (module: ModuleId) => void;
|
||||
readonly onOpenAcknowledgments?: () => void;
|
||||
}
|
||||
|
||||
export function DirectorRiskList({
|
||||
risks,
|
||||
onOpenModule,
|
||||
onOpenAcknowledgments,
|
||||
}: DirectorRiskListProps) {
|
||||
const RiskIcon = directorRiskIcon;
|
||||
const NavigateIcon = directorNavigateIcon;
|
||||
|
||||
function handleRiskClick(risk: DirectorRiskArea) {
|
||||
if (risk.action === 'openAcknowledgments' && onOpenAcknowledgments) {
|
||||
onOpenAcknowledgments();
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenModule(risk.module);
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
@ -30,7 +41,7 @@ export function DirectorRiskList({
|
||||
<Button
|
||||
key={`${risk.module}-${risk.issue}`}
|
||||
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`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { FormEvent } 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@ -19,8 +19,12 @@ import {
|
||||
import { useAuth } from '@/contexts/useAuth';
|
||||
import { useIamCapabilities } from '@/business/iam-capabilities/hooks';
|
||||
import { getAuthRoleLabel } from '@/business/auth/selectors';
|
||||
import { hasPermission } from '@/business/auth/permissions';
|
||||
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 { USER_NAME_PREFIX_OPTIONS } from '@/shared/constants/users';
|
||||
import { ImageUpload } from '@/components/common/ImageUpload';
|
||||
@ -28,6 +32,8 @@ import { useCurrentPersonalityResultHistory } from '@/business/personality/query
|
||||
import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks';
|
||||
import { useTodayZoneCheckIn } from '@/business/zone-checkin/hooks';
|
||||
import { canZoneCheckIn } from '@/business/zone-checkin/selectors';
|
||||
import { usePolicies, usePolicyAcknowledgments } from '@/business/policies/hooks';
|
||||
import { useSafetyProtocols } from '@/business/safety-protocols/hooks';
|
||||
|
||||
interface StatusMessage {
|
||||
readonly type: 'success' | 'error';
|
||||
@ -74,6 +80,10 @@ export default function ProfilePage() {
|
||||
const personalityHistoryStatus = useCurrentPersonalityResultHistory(Boolean(user));
|
||||
const canUseZoneCheckin = canZoneCheckIn(user);
|
||||
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 [firstName, setFirstName] = useState<string>(user?.firstName ?? '');
|
||||
@ -134,6 +144,18 @@ export default function ProfilePage() {
|
||||
canUseZoneCheckin,
|
||||
],
|
||||
);
|
||||
const documentAcknowledgmentRows = useMemo(
|
||||
() => buildProfileDocumentAcknowledgmentRows(
|
||||
handbookPoliciesStatus.data ?? [],
|
||||
safetyProtocolsStatus.data ?? [],
|
||||
policyAcknowledgmentsStatus.data ?? [],
|
||||
),
|
||||
[
|
||||
handbookPoliciesStatus.data,
|
||||
policyAcknowledgmentsStatus.data,
|
||||
safetyProtocolsStatus.data,
|
||||
],
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
@ -430,6 +452,75 @@ export default function ProfilePage() {
|
||||
</CardContent>
|
||||
</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}>
|
||||
<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">
|
||||
|
||||
@ -1,173 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ClipboardCheck } from 'lucide-react';
|
||||
|
||||
import { usePolicyAcknowledgmentReport } from '@/business/policies/hooks';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ModuleHeader } from '@/components/ui/module-header';
|
||||
import { PageSkeleton } from '@/components/ui/page-skeleton';
|
||||
import { StatePanel } from '@/components/ui/state-panel';
|
||||
import { getErrorMessage } from '@/shared/errors/errorMessages';
|
||||
import type { PolicyAcknowledgmentReportStaffDto } from '@/shared/types/policyDocuments';
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return 'Missing';
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function roleLabel(value: string | null): string {
|
||||
return value ? value.replace(/_/g, ' ') : 'staff';
|
||||
}
|
||||
|
||||
export default function AcknowledgmentsPage() {
|
||||
const reportQuery = usePolicyAcknowledgmentReport();
|
||||
const [missingOnly, setMissingOnly] = useState(false);
|
||||
|
||||
const staffRows = useMemo<readonly PolicyAcknowledgmentReportStaffDto[]>(() => {
|
||||
const rows = reportQuery.data?.staff ?? [];
|
||||
return missingOnly ? rows.filter((row) => row.missingCount > 0) : rows;
|
||||
}, [missingOnly, reportQuery.data]);
|
||||
|
||||
if (reportQuery.isLoading) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
if (reportQuery.error) {
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<StatePanel tone="red" role="alert">
|
||||
{getErrorMessage(reportQuery.error, 'Could not load acknowledgments report')}
|
||||
</StatePanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const report = reportQuery.data;
|
||||
if (!report) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4 md:p-6">
|
||||
<ModuleHeader
|
||||
title="Acknowledgments"
|
||||
description="Policy and safety acknowledgment status for the current scope."
|
||||
icon={ClipboardCheck}
|
||||
iconClassName="bg-gradient-to-br from-emerald-500 to-emerald-700"
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-slate-400">Completion</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-white">{report.summary.completionRate}%</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-slate-400">Staff</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-white">{report.summary.totalStaff}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-slate-400">Documents</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-white">{report.summary.totalDocuments}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-slate-400">Missing</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-white">{report.summary.missingCount}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Documents</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="text-left text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th className="py-2 pr-4">Document</th>
|
||||
<th className="py-2 pr-4">Category</th>
|
||||
<th className="py-2 pr-4">Version</th>
|
||||
<th className="py-2 pr-4">Acknowledged</th>
|
||||
<th className="py-2 pr-4">Missing</th>
|
||||
<th className="py-2 pr-4">Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700/50 text-slate-200">
|
||||
{report.documents.map((document) => (
|
||||
<tr key={document.id}>
|
||||
<td className="py-3 pr-4 font-medium">{document.title}</td>
|
||||
<td className="py-3 pr-4">{document.category.replace('_', ' ')}</td>
|
||||
<td className="py-3 pr-4">{document.version}</td>
|
||||
<td className="py-3 pr-4">{document.acknowledgedCount}/{document.totalStaff}</td>
|
||||
<td className="py-3 pr-4">{document.missingCount}</td>
|
||||
<td className="py-3 pr-4">{document.completionRate}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<CardTitle className="text-base">Staff Detail</CardTitle>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={missingOnly}
|
||||
onChange={(event) => setMissingOnly(event.target.checked)}
|
||||
/>
|
||||
Missing only
|
||||
</label>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{staffRows.length === 0 ? (
|
||||
<p className="text-sm text-slate-400">No staff rows match the current filter.</p>
|
||||
) : (
|
||||
staffRows.map((staff) => (
|
||||
<div key={staff.userId} className="rounded-lg border border-slate-700/50 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-white">{staff.name}</p>
|
||||
<p className="text-xs text-slate-400">{staff.email} · {roleLabel(staff.role)}</p>
|
||||
</div>
|
||||
<Badge variant={staff.missingCount > 0 ? 'secondary' : 'default'}>
|
||||
{staff.acknowledgedCount}/{staff.documents.length} acknowledged
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
{staff.documents
|
||||
.filter((document) => !missingOnly || document.missing)
|
||||
.map((document) => (
|
||||
<div
|
||||
key={`${staff.userId}-${document.policyDocumentId}`}
|
||||
className="flex items-center justify-between gap-3 rounded-md bg-slate-900/60 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="min-w-0 truncate text-slate-200">{document.title}</span>
|
||||
<span className={document.missing ? 'text-amber-300' : 'text-emerald-300'}>
|
||||
{formatDate(document.acknowledgedAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -17,7 +17,6 @@ export const MODULES: Module[] = [
|
||||
{ id: 'internal-comm', name: 'Internal Alerts', icon: 'bell', permissions: ['READ_INTERNAL_COMM'], color: 'bg-rose-500', routePath: APP_ROUTE_PATHS.internalComm },
|
||||
{ id: '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: '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: '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 },
|
||||
|
||||
@ -14,7 +14,6 @@ export const APP_ROUTE_PATHS = {
|
||||
internalComm: '/internal-alerts',
|
||||
safety: '/safety-protocols',
|
||||
handbook: '/handbook-policies',
|
||||
acknowledgments: '/acknowledgments',
|
||||
community: '/community-partnerships',
|
||||
vocational: '/vocational-opportunities',
|
||||
esa: '/esa-funding',
|
||||
|
||||
@ -172,7 +172,6 @@ export type ModuleId =
|
||||
| 'internal-comm'
|
||||
| 'safety'
|
||||
| 'handbook'
|
||||
| 'acknowledgments'
|
||||
| 'director'
|
||||
| 'community'
|
||||
| 'vocational'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user