From d12d2b0a10536627a15c081508a9c425a5a71bb6 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Fri, 19 Jun 2026 16:43:41 +0200 Subject: [PATCH] improved leaders dashboards UI/UX --- backend/docs/frame-entries.md | 7 +- backend/docs/migrations-and-seeders.md | 24 +- .../db/seeders/20200430130760-user-roles.ts | 100 +++++++-- .../20260608100000-product-campuses.ts | 14 +- .../seeders/20260608103000-content-catalog.ts | 23 +- .../20260611050000-policy-documents-seed.ts | 22 +- backend/src/db/seeders/user-roles.test.ts | 11 + backend/src/db/umzug.test.ts | 19 ++ backend/src/db/umzug.ts | 13 +- backend/src/routes/frame_entries.ts | 9 +- backend/src/services/audio_files.ts | 12 +- backend/src/services/frame_entries.ts | 32 ++- .../docs/director-dashboard-integration.md | 51 +++-- frontend/docs/frame-integration.md | 7 +- frontend/docs/policies-integration.md | 10 +- .../business/director-dashboard/hooks.test.ts | 26 +++ .../src/business/director-dashboard/hooks.ts | 69 +++++- .../director-dashboard/selectors.test.ts | 76 ++++++- .../business/director-dashboard/selectors.ts | 125 +++++++---- .../src/business/director-dashboard/types.ts | 16 +- frontend/src/business/frame/hooks.ts | 7 +- .../DirectorAcknowledgmentTrackingModal.tsx | 43 ---- .../DirectorAcknowledgmentTrackingPanel.tsx | 207 ++++++++++-------- .../DirectorDashboardHeader.tsx | 4 +- .../DirectorDashboardView.tsx | 39 +++- .../DirectorOverviewCards.tsx | 15 +- .../DirectorQuickActions.tsx | 8 +- .../DirectorQuizResultsPanel.tsx | 188 +++++++++++----- .../DirectorRecentFramePanel.tsx | 8 +- .../director-dashboard/DirectorRiskList.tsx | 15 +- .../DirectorScaleButton.tsx | 29 +++ .../DirectorTimeRangeTabs.tsx | 10 +- .../directorDashboardViewConfig.ts | 12 +- frontend/src/shared/api/frame.test.ts | 11 + frontend/src/shared/api/frame.ts | 28 ++- .../src/shared/constants/directorDashboard.ts | 1 + frontend/tests/e2e/audio-files.seeded.e2e.ts | 5 +- 37 files changed, 933 insertions(+), 363 deletions(-) create mode 100644 backend/src/db/umzug.test.ts create mode 100644 frontend/src/business/director-dashboard/hooks.test.ts delete mode 100644 frontend/src/components/director-dashboard/DirectorAcknowledgmentTrackingModal.tsx create mode 100644 frontend/src/components/director-dashboard/DirectorScaleButton.tsx diff --git a/backend/docs/frame-entries.md b/backend/docs/frame-entries.md index efa0b54..9918e0a 100644 --- a/backend/docs/frame-entries.md +++ b/backend/docs/frame-entries.md @@ -23,7 +23,9 @@ source of truth for persisted FRAME data; the frontend never substitutes static All routes require JWT authentication. - `GET /api/frame_entries` -> `200` `{ rows, count }` for the current user's organization - (paginated via `resolvePagination`). + (paginated via `resolvePagination`). Optional `startDate` and `endDate` + query params filter entries by their canonical Sunday-start `week_of` date, + inclusive. - `POST /api/frame_entries` -> `201` the created entry DTO. - `PUT /api/frame_entries/:id` -> `200` the updated entry DTO (scoped to the org). @@ -58,7 +60,8 @@ free-text note for that week (e.g. "Spring Break week"), stored trimmed or `null ## Behavior / Notes - Create/update run inside `withTransaction`. -- List is paginated with the shared defaults (`resolvePagination`). +- List is paginated with the shared defaults (`resolvePagination`) and can be + range-filtered for leadership dashboards via `startDate` / `endDate`. - The same Sunday-start canonicalization is used on the frontend (`shared/business/week.ts`) for the dashboard hero, the safety-quiz week, and the F.R.A.M.E. week picker, so the week is consistent everywhere. diff --git a/backend/docs/migrations-and-seeders.md b/backend/docs/migrations-and-seeders.md index d5e9a85..b06f590 100644 --- a/backend/docs/migrations-and-seeders.md +++ b/backend/docs/migrations-and-seeders.md @@ -24,7 +24,7 @@ the runner, file conventions, and how to add a migration or seeder. For VM/PM2 o `20260611070000-campuses-timezone.ts` (the required `campuses.timezone` IANA column — added nullable, backfilled, then set NOT NULL), and `20260612000000-frame-entries-week-label.ts` (the optional `frame_entries.week_label`; `week_of` is now the canonical Sunday-start ISO date). -- Seeders: `src/db/seeders/*.ts` — `admin-user` (the system users, the primary +- Seeders: `src/db/seeders/[0-9]*.ts` — `admin-user` (the system users, the primary tenant's per-role users, and the secondary tenant's per-role users from `shared/constants/seed-fixtures.ts`), `user-roles` (the first-class roles, the permission catalog incl. product-feature @@ -39,7 +39,12 @@ the runner, file conventions, and how to add a migration or seeder. For VM/PM2 o - `migrator` globs `migrations/*.{ts,js}`; history is tracked in the default `SequelizeMeta` table via `SequelizeStorage`. -- `seeder` globs `seeders/*.{ts,js}`; history is tracked in a separate `SequelizeData` table. +- `seeder` globs `seeders/[0-9]*.{ts,js}`; history is tracked in a separate + `SequelizeData` model/table. The runner sets both `modelName` and + `tableName` to `SequelizeData`, which prevents Umzug from reusing the default + `SequelizeMeta` model when seeders run after migrations in the same process. + Only timestamped seeder files are loaded; colocated `*.test.ts` files are + intentionally excluded from `db:seed`. - Each file is ESM TypeScript with a default export `{ up, down }`, each taking `(queryInterface, Sequelize)`. The runner accepts either a `default` export or top-level `up`/`down`. @@ -47,6 +52,10 @@ the runner, file conventions, and how to add a migration or seeder. For VM/PM2 o executed via `tsx` (dev, `.ts`) or compiled (`prod`, `dist/.../*.js`). - The `glob` accepts both `.ts` and `.js`, so already-applied entries are not re-run after a build. +- Seeders are append/idempotent: when a seeded row or relationship already + exists, the seeder preserves it and inserts only missing rows/links. This + lets `db:seed` recover from incomplete `SequelizeData` history without + deleting tenant-edited content or failing on duplicate keys. ## CLI And Scripts @@ -61,15 +70,20 @@ the runner, file conventions, and how to add a migration or seeder. For VM/PM2 o ## Authoring A New Migration / Seeder -1. Add `src/db/migrations/-.ts` (or `seeders/...`) exporting +1. Add `src/db/migrations/-.ts` (or `seeders/-.ts`) exporting `export default { up, down }` with typed `(queryInterface, Sequelize)` signatures. -2. Run `npm run db:migrate` (or `db:seed`) in dev; verify with `db:migrate:pending`. -3. Regenerate `database-schema.md` after any schema change (it is generated from the models). +2. For seeders, use stable natural keys (`id`, `name`, `importHash`, or a + junction pair) and insert only the missing rows. Do not delete/reinsert + tenant-editable seed content in `up`. +3. Run `npm run db:migrate` (or `db:seed`) in dev; verify with `db:migrate:pending`. +4. Regenerate `database-schema.md` after any schema change (it is generated from the models). ## Tests - `src/shared/constants/seed-fixtures.test.ts` covers primary/secondary tenant user topology and credential uniqueness. +- `src/db/umzug.test.ts` covers the dedicated `SequelizeData` storage contract + for seeder history and the timestamp-only seeder glob. - `src/db/seeders/user-roles.test.ts` covers the seeded product-permission contract for parent communication and registrar report/audit grants. diff --git a/backend/src/db/seeders/20200430130760-user-roles.ts b/backend/src/db/seeders/20200430130760-user-roles.ts index d82b551..cbb3e2a 100644 --- a/backend/src/db/seeders/20200430130760-user-roles.ts +++ b/backend/src/db/seeders/20200430130760-user-roles.ts @@ -1,5 +1,5 @@ import { v4 as uuid } from 'uuid'; -import type { CreationAttributes, QueryInterface } from 'sequelize'; +import { QueryTypes, type CreationAttributes, type QueryInterface } from 'sequelize'; import { ROLE_DEFINITIONS, ROLE_NAMES, @@ -141,6 +141,10 @@ export function buildEntityPermissionNames(): readonly string[] { return names; } +function uniquePermissionNames(names: readonly string[]): readonly string[] { + return [...new Set(names)]; +} + export function buildSeededPermissionNamesForRole(role: RoleName): readonly string[] { const entityPermissionNames = buildEntityPermissionNames(); const allPermissionNames = [ @@ -150,7 +154,7 @@ export function buildSeededPermissionNamesForRole(role: RoleName): readonly stri ]; if (role === ROLE_NAMES.SYSTEM_ADMIN) { - return allPermissionNames; + return uniquePermissionNames(allPermissionNames); } if (FULL_ACCESS_ROLES.includes(role)) { @@ -160,17 +164,17 @@ export function buildSeededPermissionNamesForRole(role: RoleName): readonly stri excluded.add(name); } } - return allPermissionNames.filter((name) => !excluded.has(name)); + return uniquePermissionNames(allPermissionNames.filter((name) => !excluded.has(name))); } if (READ_ONLY_ROLES.includes(role)) { - return [ + return uniquePermissionNames([ ...entityPermissionNames.filter((name) => name.startsWith('READ_')), ...(MODULE_PERMISSIONS_BY_ROLE[role] ?? []), - ]; + ]); } - return MODULE_PERMISSIONS_BY_ROLE[role] ?? []; + return uniquePermissionNames(MODULE_PERMISSIONS_BY_ROLE[role] ?? []); } export default { @@ -182,9 +186,23 @@ export default { const permId = new Map(); // 1. Roles. + const existingRoles = await queryInterface.sequelize.query<{ + id: string; + name: string; + }>( + `SELECT id, name FROM roles WHERE name IN (:names)`, + { + replacements: { names: ROLE_DEFINITIONS.map((role) => role.name) }, + type: QueryTypes.SELECT, + }, + ); + for (const role of existingRoles) { + roleId.set(role.name, role.id); + } + const roleRows: CreationAttributes[] = ROLE_DEFINITIONS.map( (role) => { - const id = uuid(); + const id = roleId.get(role.name) ?? uuid(); roleId.set(role.name, id); return { id, @@ -195,25 +213,45 @@ export default { updatedAt, }; }, - ); - await queryInterface.bulkInsert('roles', roleRows); + ).filter((role) => !existingRoles.some((existing) => existing.id === role.id)); + if (roleRows.length > 0) { + await queryInterface.bulkInsert('roles', roleRows); + } // 2. Permissions (entity CRUD + extras + product module/page perms). const entityPermissionNames = [...buildEntityPermissionNames()]; - const allPermissionNames = [ + const allPermissionNames = uniquePermissionNames([ ...entityPermissionNames, ...EXTRA_PERMISSIONS, ...MODULE_PERMISSIONS, - ]; + ]); + const existingPermissions = await queryInterface.sequelize.query<{ + id: string; + name: string; + }>( + `SELECT id, name FROM permissions WHERE name IN (:names)`, + { + replacements: { names: allPermissionNames }, + type: QueryTypes.SELECT, + }, + ); + for (const permission of existingPermissions) { + permId.set(permission.name, permission.id); + } const permissionRows: CreationAttributes[] = allPermissionNames.map((name) => { - const id = uuid(); + const id = permId.get(name) ?? uuid(); permId.set(name, id); return { id, name, createdAt, updatedAt }; - }); - await queryInterface.bulkInsert('permissions', permissionRows); + }).filter( + (permission) => + !existingPermissions.some((existing) => existing.id === permission.id), + ); + if (permissionRows.length > 0) { + await queryInterface.bulkInsert('permissions', permissionRows); + } // 3. Role → permission matrix. const links: Array<{ @@ -256,7 +294,39 @@ export default { 'DELETE_POLICY_DOCUMENTS', ]); - await queryInterface.bulkInsert('rolesPermissionsPermissions', links); + const roleIds = [...roleId.values()]; + const permissionIds = [...permId.values()]; + const existingLinks = await queryInterface.sequelize.query<{ + roles_permissionsId: string; + permissionId: string; + }>( + `SELECT "roles_permissionsId", "permissionId" + FROM "rolesPermissionsPermissions" + WHERE "roles_permissionsId" IN (:roleIds) + AND "permissionId" IN (:permissionIds)`, + { + replacements: { roleIds, permissionIds }, + type: QueryTypes.SELECT, + }, + ); + const existingLinkKeys = new Set( + existingLinks.map( + (link) => `${link.roles_permissionsId}:${link.permissionId}`, + ), + ); + const missingLinkKeys = new Set(); + const missingLinks = links.filter((link) => { + const key = `${link.roles_permissionsId}:${link.permissionId}`; + if (existingLinkKeys.has(key) || missingLinkKeys.has(key)) { + return false; + } + missingLinkKeys.add(key); + return true; + }); + + if (missingLinks.length > 0) { + await queryInterface.bulkInsert('rolesPermissionsPermissions', missingLinks); + } // 4. Assign roles to the seeded fixture users (by id — robust to the // configured super-admin email). diff --git a/backend/src/db/seeders/20260608100000-product-campuses.ts b/backend/src/db/seeders/20260608100000-product-campuses.ts index c7e90d2..626d88e 100644 --- a/backend/src/db/seeders/20260608100000-product-campuses.ts +++ b/backend/src/db/seeders/20260608100000-product-campuses.ts @@ -1,5 +1,6 @@ import { Op, + QueryTypes, type CreationAttributes, type QueryInterface, } from 'sequelize'; @@ -14,8 +15,19 @@ export default { const rows: CreationAttributes[] = PRODUCT_CAMPUS_SEED_ROWS.map( (campus) => ({ ...campus, createdAt, updatedAt }), ); + const existing = await queryInterface.sequelize.query<{ id: string }>( + `SELECT id FROM campuses WHERE id IN (:ids)`, + { + replacements: { ids: rows.map((row) => row.id) }, + type: QueryTypes.SELECT, + }, + ); + const existingIds = new Set(existing.map((row) => row.id)); + const missingRows = rows.filter((row) => !existingIds.has(row.id as string)); - await queryInterface.bulkInsert('campuses', rows); + if (missingRows.length > 0) { + await queryInterface.bulkInsert('campuses', missingRows); + } }, down: async (queryInterface: QueryInterface) => { diff --git a/backend/src/db/seeders/20260608103000-content-catalog.ts b/backend/src/db/seeders/20260608103000-content-catalog.ts index ab68b67..edc115f 100644 --- a/backend/src/db/seeders/20260608103000-content-catalog.ts +++ b/backend/src/db/seeders/20260608103000-content-catalog.ts @@ -1,6 +1,7 @@ import { v4 as uuid } from 'uuid'; import { Op, + QueryTypes, type CreationAttributes, type QueryInterface, } from 'sequelize'; @@ -109,13 +110,6 @@ const CONTENT_CATALOG_SEED_ROWS = Object.freeze([ export default { up: async (queryInterface: QueryInterface) => { const now = new Date(); - const contentTypes = CONTENT_CATALOG_SEED_ROWS.map((row) => row.content_type); - - await queryInterface.bulkDelete('content_catalog', { - content_type: { - [Op.in]: contentTypes, - }, - }); const rows: CreationAttributes[] = CONTENT_CATALOG_SEED_ROWS.flatMap((row) => seedTenants(row.content_type).map((stamp) => ({ @@ -131,8 +125,21 @@ export default { createdAt: now, updatedAt: now, }))); + const existing = await queryInterface.sequelize.query<{ importHash: string }>( + `SELECT "importHash" FROM content_catalog WHERE "importHash" IN (:importHashes)`, + { + replacements: { importHashes: rows.map((row) => row.importHash) }, + type: QueryTypes.SELECT, + }, + ); + const existingImportHashes = new Set(existing.map((row) => row.importHash)); + const missingRows = rows.filter( + (row) => !existingImportHashes.has(row.importHash as string), + ); - await queryInterface.bulkInsert('content_catalog', rows); + if (missingRows.length > 0) { + await queryInterface.bulkInsert('content_catalog', missingRows); + } }, down: async (queryInterface: QueryInterface) => { diff --git a/backend/src/db/seeders/20260611050000-policy-documents-seed.ts b/backend/src/db/seeders/20260611050000-policy-documents-seed.ts index ef2671f..aa34b1d 100644 --- a/backend/src/db/seeders/20260611050000-policy-documents-seed.ts +++ b/backend/src/db/seeders/20260611050000-policy-documents-seed.ts @@ -1,5 +1,5 @@ import { v4 as uuid } from 'uuid'; -import { Op, type QueryInterface } from 'sequelize'; +import { Op, QueryTypes, type QueryInterface } from 'sequelize'; import { SEED_ORGANIZATION_ID, SEED_ORGANIZATION_2_ID, @@ -163,15 +163,10 @@ function getScopeDirector(scope: SeedScope) { export default { up: async (queryInterface: QueryInterface) => { const now = new Date(); - 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]: [...legacyImportHashes, ...importHashes] }, - }); - const rows = SEED_SCOPES.flatMap((scope) => { const director = getScopeDirector(scope); const author = director @@ -204,8 +199,21 @@ export default { updatedAt: now, })); }); + const existing = await queryInterface.sequelize.query<{ importHash: string }>( + `SELECT "importHash" FROM policy_documents WHERE "importHash" IN (:importHashes)`, + { + replacements: { importHashes }, + type: QueryTypes.SELECT, + }, + ); + const existingImportHashes = new Set(existing.map((row) => row.importHash)); + const missingRows = rows.filter( + (row) => !existingImportHashes.has(row.importHash), + ); - await queryInterface.bulkInsert('policy_documents', rows); + if (missingRows.length > 0) { + await queryInterface.bulkInsert('policy_documents', missingRows); + } }, down: async (queryInterface: QueryInterface) => { diff --git a/backend/src/db/seeders/user-roles.test.ts b/backend/src/db/seeders/user-roles.test.ts index 23aac47..942ebc5 100644 --- a/backend/src/db/seeders/user-roles.test.ts +++ b/backend/src/db/seeders/user-roles.test.ts @@ -128,4 +128,15 @@ describe('user-role seed permission contract', () => { ROLE_NAMES.TEACHER, ]); }); + + test('seeded permission grants are unique per role', () => { + for (const role of Object.values(ROLE_NAMES)) { + const permissions = granted(role); + assert.equal( + new Set(permissions).size, + permissions.length, + `${role} should not seed duplicate permission links`, + ); + } + }); }); diff --git a/backend/src/db/umzug.test.ts b/backend/src/db/umzug.test.ts new file mode 100644 index 0000000..8d038a2 --- /dev/null +++ b/backend/src/db/umzug.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { seederGlob, seedStorage } from '@/db/umzug'; + +describe('umzug storage', () => { + it('tracks seeders in the dedicated SequelizeData model and table', () => { + const model = seedStorage.getModel(); + + assert.equal(seedStorage.modelName, 'SequelizeData'); + assert.equal(seedStorage.tableName, 'SequelizeData'); + assert.equal(model.tableName, 'SequelizeData'); + }); + + it('loads timestamped seed files only', () => { + assert.equal(seederGlob, 'seeders/[0-9]*.{ts,js}'); + assert.equal(seederGlob.includes('*.test'), false); + }); +}); diff --git a/backend/src/db/umzug.ts b/backend/src/db/umzug.ts index 347b48e..418325d 100644 --- a/backend/src/db/umzug.ts +++ b/backend/src/db/umzug.ts @@ -18,6 +18,13 @@ interface MigrationModule { const sequelize = db.sequelize; const queryInterface = sequelize.getQueryInterface(); +export const seederGlob = 'seeders/[0-9]*.{ts,js}'; +export const migrationStorage = new SequelizeStorage({ sequelize }); +export const seedStorage = new SequelizeStorage({ + sequelize, + modelName: 'SequelizeData', + tableName: 'SequelizeData', +}); async function loadMigration( filepath: string, @@ -57,17 +64,17 @@ export const migrator = new Umzug({ resolve: resolveMigration, }, context: queryInterface, - storage: new SequelizeStorage({ sequelize }), + storage: migrationStorage, logger: console, }); export const seeder = new Umzug({ migrations: { - glob: ['seeders/*.{ts,js}', { cwd: import.meta.dirname }], + glob: [seederGlob, { cwd: import.meta.dirname }], resolve: resolveMigration, }, context: queryInterface, - storage: new SequelizeStorage({ sequelize, tableName: 'SequelizeData' }), + storage: seedStorage, logger: console, }); diff --git a/backend/src/routes/frame_entries.ts b/backend/src/routes/frame_entries.ts index abcd096..168a5c5 100644 --- a/backend/src/routes/frame_entries.ts +++ b/backend/src/routes/frame_entries.ts @@ -12,7 +12,14 @@ const router = express.Router(); * get: * tags: [FRAME] * summary: List FRAME weekly entries (tenant/campus-scoped) - * description: Requires the `READ_FRAME` product-feature permission. + * description: Requires the `READ_FRAME` product-feature permission. Optional `startDate` / `endDate` filter entries by Sunday-start `week_of`. + * parameters: + * - in: query + * name: startDate + * schema: { type: string, format: date } + * - in: query + * name: endDate + * schema: { type: string, format: date } * responses: * 200: * description: List of FRAME entries. diff --git a/backend/src/services/audio_files.ts b/backend/src/services/audio_files.ts index 0226d9f..59db355 100644 --- a/backend/src/services/audio_files.ts +++ b/backend/src/services/audio_files.ts @@ -29,6 +29,7 @@ interface AudioFileInput { interface AudioFilesFilter { limit?: number | string; page?: number | string; + title?: string; } interface ResolvedAudioContent { @@ -98,8 +99,12 @@ class AudioFilesService { static async list(filter: AudioFilesFilter, currentUser?: CurrentUser) { assertAuthenticatedTenantUser(currentUser); const { limit, offset } = resolvePagination(filter.limit, filter.page); + const title = + typeof filter.title === 'string' && filter.title.trim().length > 0 + ? filter.title.trim() + : undefined; - const where = hasGlobalAccess(currentUser) + const visibilityWhere = hasGlobalAccess(currentUser) ? {} : { [Op.or]: [ @@ -110,6 +115,11 @@ class AudioFilesService { }, ], }; + const where = title + ? { + [Op.and]: [visibilityWhere, { title }], + } + : visibilityWhere; const result = await db.audio_files.findAndCountAll({ where, diff --git a/backend/src/services/frame_entries.ts b/backend/src/services/frame_entries.ts index d3acb4b..a0f38c9 100644 --- a/backend/src/services/frame_entries.ts +++ b/backend/src/services/frame_entries.ts @@ -1,8 +1,10 @@ import db from '@/db/models'; +import { Op } from 'sequelize'; import { withTransaction } from '@/db/with-transaction'; import { resolvePagination } from '@/shared/constants/pagination'; import ForbiddenError from '@/shared/errors/forbidden'; import ValidationError from '@/shared/errors/validation'; +import { optionalIsoDate } from '@/services/shared/validate'; import { getOwnTenant, tenantExactWhere, @@ -27,6 +29,13 @@ interface FrameEntryInput { campusId?: string | null; } +interface FrameEntryFilter { + readonly limit?: number | string; + readonly page?: number | string; + readonly startDate?: unknown; + readonly endDate?: unknown; +} + /** Normalizes the input week to its Sunday-start ISO date, or throws. */ function requireWeekStart(weekOf: string): string { const weekStart = toWeekStartIso(weekOf); @@ -80,6 +89,22 @@ function assertValidFrameEntry(data: FrameEntryInput): void { } } +function weekRangeFilter(filter: FrameEntryFilter) { + const startDate = optionalIsoDate(filter.startDate); + const endDate = optionalIsoDate(filter.endDate); + + if (!startDate && !endDate) { + return {}; + } + + return { + week_of: { + ...(startDate ? { [Op.gte]: startDate } : {}), + ...(endDate ? { [Op.lte]: endDate } : {}), + }, + }; +} + function toDto(entry: FrameEntries) { const plain = entry.get({ plain: true }); @@ -105,14 +130,17 @@ function toDto(entry: FrameEntries) { class FrameEntriesService { static async list( - filter: { limit?: number | string; page?: number | string } = {}, + filter: FrameEntryFilter = {}, currentUser?: CurrentUser, ) { const { limit, offset } = resolvePagination(filter.limit, filter.page); // Per-tenant content: a user sees the FRAME entries dedicated to their own // tenant level (org/school/campus/class), not an aggregate of children. - const where = tenantExactWhere(getOwnTenant(currentUser)); + const where = { + ...tenantExactWhere(getOwnTenant(currentUser)), + ...weekRangeFilter(filter), + }; const result = await db.frame_entries.findAndCountAll({ where, diff --git a/frontend/docs/director-dashboard-integration.md b/frontend/docs/director-dashboard-integration.md index 30e11e8..7124bd6 100644 --- a/frontend/docs/director-dashboard-integration.md +++ b/frontend/docs/director-dashboard-integration.md @@ -37,31 +37,46 @@ Constants: ## Behavior - The framework component calls `useDirectorDashboardPage` and renders `DirectorDashboardView`. -- FRAME entries load through `useFrameEntries`. +- The Week / Month / Quarter switcher is persisted in local storage and drives + period-bounded API queries for dashboard overview statistics and the + F.R.A.M.E. Tracker. The selected period is translated into `startDate` and + `endDate` query parameters, anchored to the current week, current month, or + current quarter. +- FRAME entries load through `useFrameEntries` with the selected period range. - QBS safety quiz completion loads through `useSafetyQuizResults`. - 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. +- Staff attendance records and summary load through staff attendance business + hooks with the selected period range. - 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 - latest saved type; zone check-in rows reflect today's backend-computed date for - each staff member. +- Document acknowledgment tracking renders as a collapsible section in the main + dashboard flow. The Acknowledgments overview card and acknowledgment risk card + scroll to that section. When expanded, it renders current-version Safety + Protocol and Handbook & Policies documents grouped by category. Collapsed + document rows show title, version, and acknowledged/total staff counts for the + leader's scope; expanded document rows list staff who have not acknowledged + that version. +- The dashboard quiz results table groups Behavior Management, EI Self-Assessment, + Personality Type Quiz, and Daily Zone Check-In by staff member in a + collapsible section. When expanded, collapsed staff rows show staff name, + current tenant scope, role, and completed/total quiz count; expanded staff rows + list each quiz title, completion date, and result. EI self-assessment details + reflect the current Sunday-start week; personality type details reflect each + user's latest saved type; zone check-in details reflect today's + backend-computed date for 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, missing current-version document acknowledgments, and attendance risk. -- View components use shared `Button`, `Table`, `StatePanel`, and `ModuleHeader` primitives. +- The Behavior Management Quiz Completion overview card and incomplete quiz + risk cards do not navigate to individual quiz pages. They scroll to the top of + the unified Quiz Results list so leaders can review the affected staff and + expand each row in place. +- View components use shared `Table`, `StatePanel`, and `ModuleHeader` + primitives, plus the dashboard-local `DirectorScaleButton` for scale-only + interactive cards and action controls. - Loading, empty, and error states are explicit. - -## Remaining Related Work - -Time range tabs currently control UI state only. Add backend-supported date filtering before wiring these tabs to query filters. +- Risk areas, unified quiz results, zone completion, and current-version + acknowledgment tracking intentionally remain current-state views. They are not + filtered by the time range tabs. diff --git a/frontend/docs/frame-integration.md b/frontend/docs/frame-integration.md index 4eba081..a6d72f0 100644 --- a/frontend/docs/frame-integration.md +++ b/frontend/docs/frame-integration.md @@ -41,7 +41,10 @@ Constants: ## Behavior -- FRAME entries load from `GET /api/frame_entries`. +- FRAME entries load from `GET /api/frame_entries`. The shared API accepts + optional `startDate` / `endDate` params so leadership dashboards can request + only the selected reporting period instead of fetching extra rows and + filtering them locally. - Create/update workflows use typed API calls and React Query mutations. - **Week selection**: the create and edit forms use `FrameWeekPicker` (a `Popover` + `Calendar`) — picking any day snaps to that week's **Sunday** (American week) via the shared `shared/business/week.ts` (`toWeekStartIso`), and an optional free-text **week label** (e.g. "Spring Break week") is captured separately. The entry stores the canonical Sunday-start ISO in `week_of` and the label in `week_label`; cards render `Week of ` + the label badge. The same week util backs the dashboard hero "Week of …" and the safety-quiz week, so the week is consistent across the app. - `FrameModule.tsx` is a thin wrapper that calls `useFrameModule` and renders focused FRAME view components. @@ -49,7 +52,7 @@ Constants: - Static FRAME sample entries are not used as runtime persisted-data substitutes. - Empty and error states are rendered explicitly. - Dynamic F/R/A/M/E field access is typed through `FrameSectionKey`. -- Director dashboard renders recent FRAME previews through director dashboard selectors instead of deriving preview rows in JSX. +- Director dashboard renders recent FRAME previews through director dashboard selectors instead of deriving preview rows in JSX. Its Week / Month / Quarter switcher is persisted in local storage, and the selected period is sent to the FRAME API as the date range for the query. - Home dashboard renders the latest FRAME entry through `useDashboardPage` and `DashboardFramePreview`. ## Remaining Related Work diff --git a/frontend/docs/policies-integration.md b/frontend/docs/policies-integration.md index 514fa70..e0e395c 100644 --- a/frontend/docs/policies-integration.md +++ b/frontend/docs/policies-integration.md @@ -27,8 +27,7 @@ Safety Protocols: Acknowledgment tracking: - Dashboard component: - `frontend/src/components/director-dashboard/DirectorAcknowledgmentTrackingModal.tsx` - and `DirectorAcknowledgmentTrackingPanel.tsx` + `frontend/src/components/director-dashboard/DirectorAcknowledgmentTrackingPanel.tsx` - Business/API: `buildDirectorAcknowledgmentDocuments` + `usePolicyAcknowledgmentReport` in `frontend/src/business/director-dashboard` and @@ -82,9 +81,10 @@ 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. 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 + support_staff accounts in the current scope. The leadership dashboard renders + acknowledgment tracking as a collapsible section in the main dashboard flow + and scrolls to it from the Acknowledgments overview metric or the aggregated + acknowledgment risk card. When expanded, the section 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. diff --git a/frontend/src/business/director-dashboard/hooks.test.ts b/frontend/src/business/director-dashboard/hooks.test.ts new file mode 100644 index 0000000..21cad22 --- /dev/null +++ b/frontend/src/business/director-dashboard/hooks.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { getDirectorDashboardDateRange } from '@/business/director-dashboard/hooks'; + +describe('director dashboard hooks', () => { + it('builds a Sunday-start range for the selected week', () => { + expect(getDirectorDashboardDateRange('week', new Date('2026-06-19T12:00:00.000Z'))).toEqual({ + startDate: '2026-06-14', + endDate: '2026-06-19', + }); + }); + + it('builds a month-to-date range for the selected month', () => { + expect(getDirectorDashboardDateRange('month', new Date('2026-06-19T12:00:00.000Z'))).toEqual({ + startDate: '2026-06-01', + endDate: '2026-06-19', + }); + }); + + it('builds a quarter-to-date range for the selected quarter', () => { + expect(getDirectorDashboardDateRange('quarter', new Date('2026-06-19T12:00:00.000Z'))).toEqual({ + startDate: '2026-04-01', + endDate: '2026-06-19', + }); + }); +}); diff --git a/frontend/src/business/director-dashboard/hooks.ts b/frontend/src/business/director-dashboard/hooks.ts index 49af5e1..1efca6a 100644 --- a/frontend/src/business/director-dashboard/hooks.ts +++ b/frontend/src/business/director-dashboard/hooks.ts @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { format, startOfMonth, startOfQuarter } from 'date-fns'; import { useFrameEntries } from '@/business/frame/hooks'; import { useSafetyQuizCompliance } from '@/business/safety-quiz/hooks'; @@ -19,6 +20,8 @@ import { import type { DirectorDashboardPage } from '@/business/director-dashboard/types'; import { DIRECTOR_DASHBOARD_QUICK_ACTIONS, + DIRECTOR_DASHBOARD_TIME_RANGE_STORAGE_KEY, + DIRECTOR_DASHBOARD_TIME_RANGES, type DirectorDashboardTimeRange, } from '@/shared/constants/directorDashboard'; import { useAuth } from '@/shared/app/useAuth'; @@ -27,6 +30,45 @@ import { getActiveTenant } from '@/business/scope/selectors'; import { useScopeContext } from '@/shared/app/scope-context'; import { DEFAULT_PRODUCT_ROLE } from '@/shared/constants/roles'; import { getCurrentSafetyQuizWeek } from '@/business/safety-quiz/selectors'; +import { getWeekStart } from '@/shared/business/week'; + +interface DirectorDashboardDateRange { + readonly startDate: string; + readonly endDate: string; +} + +function isDirectorDashboardTimeRange(value: string | null): value is DirectorDashboardTimeRange { + return DIRECTOR_DASHBOARD_TIME_RANGES.some((range) => range === value); +} + +function readStoredTimeRange(): DirectorDashboardTimeRange { + if (typeof window === 'undefined') { + return 'month'; + } + + try { + const stored = window.localStorage.getItem(DIRECTOR_DASHBOARD_TIME_RANGE_STORAGE_KEY); + return isDirectorDashboardTimeRange(stored) ? stored : 'month'; + } catch { + return 'month'; + } +} + +export function getDirectorDashboardDateRange( + timeRange: DirectorDashboardTimeRange, + now = new Date(), +): DirectorDashboardDateRange { + const startDate = timeRange === 'week' + ? getWeekStart(now) + : timeRange === 'month' + ? startOfMonth(now) + : startOfQuarter(now); + + return { + startDate: format(startDate, 'yyyy-MM-dd'), + endDate: format(now, 'yyyy-MM-dd'), + }; +} export function useDirectorDashboardPage(): DirectorDashboardPage { const { user, profile } = useAuth(); @@ -36,14 +78,15 @@ export function useDirectorDashboardPage(): DirectorDashboardPage { const activeTenant = effectiveTenant ?? getActiveTenant(user); const scopeLabel = activeTenant?.name ?? ''; const scopeKey = activeTenant ? `${activeTenant.level}:${activeTenant.id}` : null; - const [timeRange, setTimeRangeState] = useState('month'); + const [timeRange, setTimeRangeState] = useState(readStoredTimeRange); + const periodFilter = getDirectorDashboardDateRange(timeRange); const safetyQuizWeek = getCurrentSafetyQuizWeek(new Date()); - const frameEntriesQuery = useFrameEntries(); + const frameEntriesQuery = useFrameEntries(periodFilter); const quizCompletionQuery = useSafetyQuizCompliance(safetyQuizWeek, true); const emotionalIntelligenceCompletionQuery = usePersonalityCompletion(true); const zoneCheckinCompletionQuery = useZoneCheckInCompletion(true, scopeKey); - const staffAttendanceRecordsQuery = useStaffAttendanceRecords(); - const staffAttendanceSummaryQuery = useStaffAttendanceSummary(); + const staffAttendanceRecordsQuery = useStaffAttendanceRecords(periodFilter); + const staffAttendanceSummaryQuery = useStaffAttendanceSummary(periodFilter); const acknowledgmentReportQuery = usePolicyAcknowledgmentReport(); const frameEntries = frameEntriesQuery.data ?? []; const quizRows = quizCompletionQuery.data?.rows ?? []; @@ -91,10 +134,24 @@ export function useDirectorDashboardPage(): DirectorDashboardPage { ), framePreviews: buildDirectorFramePreviews(frameEntries), quickActions: DIRECTOR_DASHBOARD_QUICK_ACTIONS, - quizResults: buildDirectorQuizResults(quizRows, emotionalIntelligenceCompletion, zoneCheckinCompletion), + quizResults: buildDirectorQuizResults( + quizRows, + emotionalIntelligenceCompletion, + zoneCheckinCompletion, + scopeLabel, + ), acknowledgmentDocuments, isLoading, error, - setTimeRange: setTimeRangeState, + setTimeRange: (nextTimeRange) => { + setTimeRangeState(nextTimeRange); + if (typeof window !== 'undefined') { + try { + window.localStorage.setItem(DIRECTOR_DASHBOARD_TIME_RANGE_STORAGE_KEY, nextTimeRange); + } catch { + // The selected range still updates in memory when storage is blocked. + } + } + }, }; } diff --git a/frontend/src/business/director-dashboard/selectors.test.ts b/frontend/src/business/director-dashboard/selectors.test.ts index 8cd3f0d..e5a38d4 100644 --- a/frontend/src/business/director-dashboard/selectors.test.ts +++ b/frontend/src/business/director-dashboard/selectors.test.ts @@ -265,9 +265,10 @@ describe('director dashboard selectors', () => { 'attendance', 'qbs', 'frame', - 'attendance', + 'user-admin', 'director', ]); + expect(cards[1]?.action).toBe('openQuizResults'); expect(cards[cards.length - 1]?.action).toBe('openAcknowledgments'); }); @@ -290,11 +291,13 @@ describe('director dashboard selectors', () => { issue: "5 staff haven't completed de-escalation quiz", severity: 'high', module: 'qbs', + action: 'openQuizResults', }, { issue: "1 staff haven't completed daily zone check-in", severity: 'medium', module: 'zones', + action: 'openQuizResults', }, { issue: 'Non-green regulation zones: Ava Lee (Yellow Zone)', @@ -442,7 +445,7 @@ describe('director dashboard selectors', () => { ]); }); - it('combines safety, EI assessment, personality, and zone check-in results in one list', () => { + it('groups safety, EI assessment, personality, and zone check-in results by staff member', () => { const rows = buildDirectorQuizResults( [ { @@ -456,15 +459,25 @@ describe('director dashboard selectors', () => { ], createPersonalityCompletion(), createZoneCheckinCompletion(), + 'Tigers Campus', ); - expect(rows.map((row) => row.quiz)).toEqual([ + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + id: 'user-1', + staffName: 'Ava Lee', + tenant: 'Tigers Campus', + role: 'Teacher', + completedCount: 4, + totalCount: 4, + }); + expect(rows[0].details.map((row) => row.quiz)).toEqual([ 'Behavior Management', 'EI Self-Assessment', 'Personality Type Quiz', 'Daily Zone Check-In', ]); - expect(rows.map((row) => row.result)).toEqual([ + expect(rows[0].details.map((row) => row.result)).toEqual([ '3/5', 'Developing Awareness (14/32)', 'ENFP', @@ -472,6 +485,61 @@ describe('director dashboard selectors', () => { ]); }); + it('keeps one quiz row per staff member when completion sources contain different users', () => { + const rows = buildDirectorQuizResults( + [ + { + userId: 'user-1', + name: 'Ava Lee', + role: 'Teacher', + status: 'complete', + score: '3/5', + date: 'Jun 18', + } satisfies SafetyQuizComplianceRow, + ], + createPersonalityCompletion({ + rows: [ + { + userId: 'user-2', + name: 'Ben Cruz', + email: 'ben@example.test', + role: 'Support Staff', + status: 'pending', + completedKinds: [], + selfAssessment: null, + personality: null, + }, + ], + }), + createZoneCheckinCompletion({ + rows: [ + { + userId: 'user-1', + name: 'Ava Lee', + email: 'ava@example.test', + role: 'Teacher', + date: '2026-06-18', + status: 'pending', + zone: null, + riskLevel: 'pending', + result: 'Pending', + }, + ], + }), + 'Demo Academy', + ); + + expect(rows.map((row) => row.staffName)).toEqual(['Ava Lee', 'Ben Cruz']); + expect(rows.find((row) => row.id === 'user-1')?.details.map((detail) => detail.quiz)).toEqual([ + 'Behavior Management', + 'Daily Zone Check-In', + ]); + expect(rows.find((row) => row.id === 'user-2')?.details.map((detail) => detail.quiz)).toEqual([ + 'EI Self-Assessment', + 'Personality Type Quiz', + ]); + }); + it('limits and truncates FRAME previews', () => { const longText = 'A'.repeat(70); const previews = buildDirectorFramePreviews([ diff --git a/frontend/src/business/director-dashboard/selectors.ts b/frontend/src/business/director-dashboard/selectors.ts index 8fd0ccf..946ca22 100644 --- a/frontend/src/business/director-dashboard/selectors.ts +++ b/frontend/src/business/director-dashboard/selectors.ts @@ -19,6 +19,7 @@ import type { DirectorAcknowledgmentDocumentRow, DirectorFramePreview, DirectorOverviewCard, + DirectorQuizResultDetail, DirectorQuizResultRow, DirectorRiskArea, } from '@/business/director-dashboard/types'; @@ -59,13 +60,14 @@ export function buildDirectorOverviewCards( module: 'attendance', }, { - label: 'De-escalation Completion', + label: 'Behavior Management Quiz Completion', value: `${quizSummary.completedCount}/${quizSummary.totalStaff}`, change: `${quizCompletionRate}%`, trend: quizCompletionRate > DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD ? 'up' : 'down', iconId: 'shield', tone: 'blue', module: 'qbs', + action: 'openQuizResults', }, { label: 'F.R.A.M.E. Entries', @@ -83,7 +85,7 @@ export function buildDirectorOverviewCards( trend: 'up', iconId: 'users', tone: 'purple', - module: 'attendance', + module: 'user-admin', }, { label: 'Acknowledgments', @@ -125,6 +127,7 @@ export function buildDirectorRiskAreas( issue: `${incompleteStaffCount} staff haven't completed de-escalation quiz`, severity: incompleteStaffCount > DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD ? 'high' : 'medium', module: 'qbs', + action: 'openQuizResults', }); } @@ -133,6 +136,7 @@ export function buildDirectorRiskAreas( issue: `${selfAssessmentPendingCount} staff haven't completed EI self-assessment`, severity: 'low', module: 'ei', + action: 'openQuizResults', }); } @@ -141,6 +145,7 @@ export function buildDirectorRiskAreas( issue: `${personalityPendingCount} staff haven't completed personality type quiz`, severity: 'low', module: 'ei', + action: 'openQuizResults', }); } @@ -149,6 +154,7 @@ export function buildDirectorRiskAreas( issue: `${zonePendingCount} staff haven't completed daily zone check-in`, severity: 'medium', module: 'zones', + action: 'openQuizResults', }); } @@ -293,50 +299,83 @@ export function buildDirectorQuizResults( safetyRows: readonly SafetyQuizComplianceRow[], emotionalIntelligenceCompletion?: PersonalityCompletionDto | null, zoneCheckinCompletion?: ZoneCheckinCompletionDto | null, + tenantLabel = 'Current scope', ): readonly DirectorQuizResultRow[] { - const behaviorRows = safetyRows.map((row): DirectorQuizResultRow => ({ - id: `${row.userId}-behavior-management`, - staffName: row.name, + const rowsByUserId = new Map(); + + const ensureRow = (userId: string, staffName: string, role: string | null): { + staffName: string; + role: string; + details: DirectorQuizResultDetail[]; + } => { + const existing = rowsByUserId.get(userId); + if (existing) { + return existing; + } + const next = { + staffName, + role: role ?? 'Staff', + details: [], + }; + rowsByUserId.set(userId, next); + return next; + }; + + for (const row of safetyRows) { + ensureRow(row.userId, row.name, row.role).details.push({ + id: 'behavior-management', + quiz: 'Behavior Management', + result: row.score, + date: row.date, + status: row.status, + }); + } + + for (const row of emotionalIntelligenceCompletion?.rows ?? []) { + const target = ensureRow(row.userId, row.name, row.role); + target.details.push( + { + id: 'ei-self-assessment', + quiz: 'EI Self-Assessment', + result: formatPersonalityQuizResult(row.selfAssessment, 'Pending'), + date: formatPersonalityQuizDate(row.selfAssessment), + status: row.selfAssessment ? 'complete' : 'pending', + }, + { + id: 'personality-type', + quiz: 'Personality Type Quiz', + result: formatPersonalityQuizResult(row.personality, 'Pending'), + date: formatPersonalityQuizDate(row.personality), + status: row.personality ? 'complete' : 'pending', + }, + ); + } + + for (const row of zoneCheckinCompletion?.rows ?? []) { + ensureRow(row.userId, row.name, row.role).details.push({ + id: 'daily-zone-check-in', + quiz: 'Daily Zone Check-In', + result: row.result, + date: row.status === 'complete' + ? new Date(`${row.date}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + : 'Not completed', + status: row.status, + }); + } + + return [...rowsByUserId.entries()].map(([userId, row]) => ({ + id: userId, + staffName: row.staffName, + tenant: tenantLabel, role: row.role, - quiz: 'Behavior Management', - result: row.score, - date: row.date, - status: row.status, + completedCount: row.details.filter((detail) => detail.status === 'complete').length, + totalCount: row.details.length, + details: row.details, })); - - const emotionalIntelligenceRows = emotionalIntelligenceCompletion?.rows.flatMap((row) => [ - { - id: `${row.userId}-ei-self-assessment`, - staffName: row.name, - role: row.role ?? 'Staff', - quiz: 'EI Self-Assessment', - result: formatPersonalityQuizResult(row.selfAssessment, 'Pending'), - date: formatPersonalityQuizDate(row.selfAssessment), - status: row.selfAssessment ? 'complete' as const : 'pending' as const, - }, - { - id: `${row.userId}-personality-type`, - staffName: row.name, - role: row.role ?? 'Staff', - quiz: 'Personality Type Quiz', - result: formatPersonalityQuizResult(row.personality, 'Pending'), - date: formatPersonalityQuizDate(row.personality), - status: row.personality ? 'complete' as const : 'pending' as const, - }, - ]) ?? []; - const zoneRows = zoneCheckinCompletion?.rows.map((row): DirectorQuizResultRow => ({ - id: `${row.userId}-daily-zone-check-in`, - staffName: row.name, - role: row.role ?? 'Staff', - quiz: 'Daily Zone Check-In', - result: row.result, - date: row.status === 'complete' - ? new Date(`${row.date}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) - : 'Not completed', - status: row.status, - })) ?? []; - - return [...behaviorRows, ...emotionalIntelligenceRows, ...zoneRows]; } export function buildDirectorFramePreviews( diff --git a/frontend/src/business/director-dashboard/types.ts b/frontend/src/business/director-dashboard/types.ts index 3e50d72..5b5d655 100644 --- a/frontend/src/business/director-dashboard/types.ts +++ b/frontend/src/business/director-dashboard/types.ts @@ -6,7 +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 DirectorDashboardAction = 'openAcknowledgments' | 'openQuizResults'; export type DirectorOverviewIconId = 'clock' | 'shield' | 'eye' | 'users' | 'clipboard'; export type DirectorOverviewTone = 'orange' | 'blue' | 'amber' | 'purple' | 'emerald'; export type DirectorFrameSectionLetter = 'F' | 'R' | 'A' | 'M' | 'E'; @@ -41,16 +41,24 @@ export interface DirectorFramePreview { readonly sections: readonly DirectorFrameSectionPreview[]; } -export interface DirectorQuizResultRow { +export interface DirectorQuizResultDetail { readonly id: string; - readonly staffName: string; - readonly role: string; readonly quiz: string; readonly result: string; readonly date: string; readonly status: DirectorQuizResultStatus; } +export interface DirectorQuizResultRow { + readonly id: string; + readonly staffName: string; + readonly tenant: string; + readonly role: string; + readonly completedCount: number; + readonly totalCount: number; + readonly details: readonly DirectorQuizResultDetail[]; +} + export interface DirectorAcknowledgmentMissingStaff { readonly userId: string; readonly name: string; diff --git a/frontend/src/business/frame/hooks.ts b/frontend/src/business/frame/hooks.ts index 97bb9d0..63a52fb 100644 --- a/frontend/src/business/frame/hooks.ts +++ b/frontend/src/business/frame/hooks.ts @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { createFrameEntry, deleteFrameEntry, + type FrameEntryFilter, listFrameEntries, updateFrameEntry, } from '@/shared/api/frame'; @@ -56,10 +57,10 @@ function isValidDraft(entry: EditableFrameEntry): boolean { ); } -export function useFrameEntries() { +export function useFrameEntries(filter?: FrameEntryFilter) { return useQuery({ - queryKey: FRAME_QUERY_KEYS.entries, - queryFn: () => mapApiListRows(listFrameEntries(), toFrameEntryViewModel), + queryKey: [...FRAME_QUERY_KEYS.entries, filter], + queryFn: () => mapApiListRows(listFrameEntries(filter), toFrameEntryViewModel), }); } diff --git a/frontend/src/components/director-dashboard/DirectorAcknowledgmentTrackingModal.tsx b/frontend/src/components/director-dashboard/DirectorAcknowledgmentTrackingModal.tsx deleted file mode 100644 index df8fd2b..0000000 --- a/frontend/src/components/director-dashboard/DirectorAcknowledgmentTrackingModal.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { ClipboardCheck } from 'lucide-react'; - -import type { DirectorAcknowledgmentDocumentRow } from '@/business/director-dashboard/types'; -import { DirectorAcknowledgmentTrackingPanel } from '@/components/director-dashboard/DirectorAcknowledgmentTrackingPanel'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; - -interface DirectorAcknowledgmentTrackingModalProps { - readonly documents: readonly DirectorAcknowledgmentDocumentRow[]; - readonly open: boolean; - readonly onOpenChange: (open: boolean) => void; -} - -export function DirectorAcknowledgmentTrackingModal({ - documents, - open, - onOpenChange, -}: DirectorAcknowledgmentTrackingModalProps) { - return ( - - - - - - Document Acknowledgments - - - Current safety protocol and handbook versions for this scope. - - - - - - ); -} diff --git a/frontend/src/components/director-dashboard/DirectorAcknowledgmentTrackingPanel.tsx b/frontend/src/components/director-dashboard/DirectorAcknowledgmentTrackingPanel.tsx index a32e00c..3b396b7 100644 --- a/frontend/src/components/director-dashboard/DirectorAcknowledgmentTrackingPanel.tsx +++ b/frontend/src/components/director-dashboard/DirectorAcknowledgmentTrackingPanel.tsx @@ -2,20 +2,24 @@ import { useMemo, useState } from 'react'; import { ChevronDown, ClipboardCheck } from 'lucide-react'; import type { DirectorAcknowledgmentDocumentRow } from '@/business/director-dashboard/types'; +import { cn } from '@/lib/utils'; interface DirectorAcknowledgmentTrackingPanelProps { readonly documents: readonly DirectorAcknowledgmentDocumentRow[]; - readonly showHeader?: boolean; } export function DirectorAcknowledgmentTrackingPanel({ documents, - showHeader = true, }: DirectorAcknowledgmentTrackingPanelProps) { + const [isPanelExpanded, setIsPanelExpanded] = useState(false); const [expandedDocumentIds, setExpandedDocumentIds] = useState>( () => new Set(), ); const groupedDocuments = useMemo(() => groupDocumentsByCategory(documents), [documents]); + const missingAcknowledgmentCount = documents.reduce( + (total, document) => total + document.missingCount, + 0, + ); function toggleDocument(documentId: string) { setExpandedDocumentIds((current) => { @@ -31,104 +35,117 @@ export function DirectorAcknowledgmentTrackingPanel({ return (
- {showHeader && ( -
-
-

- - Document Acknowledgments -

-

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

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

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

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

- {group.label} -

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

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

- ) : ( -
-

- Not acknowledged + + + {document.title} + + + Version {document.version} + + + + + {document.acknowledgedCount}/{document.totalStaff} + + + + {isExpanded && ( +

+ {document.missingStaff.length === 0 ? ( +

+ Every staff member in scope has acknowledged this version.

- {document.missingStaff.map((staff) => ( -
- {staff.name} - - {staff.role} - -
- ))} -
- )} -
- )} -
- ); - })} + ) : ( +
+

+ Not acknowledged +

+ {document.missingStaff.map((staff) => ( +
+ {staff.name} + + {staff.role} + +
+ ))} +
+ )} +
+ )} +
+ ); + })} +
- - ))} - - )} + ))} + + ) : ( +

+ Expand to review document acknowledgment status by category. +

+ )} +
); } diff --git a/frontend/src/components/director-dashboard/DirectorDashboardHeader.tsx b/frontend/src/components/director-dashboard/DirectorDashboardHeader.tsx index 0b064fb..c1aeb15 100644 --- a/frontend/src/components/director-dashboard/DirectorDashboardHeader.tsx +++ b/frontend/src/components/director-dashboard/DirectorDashboardHeader.tsx @@ -23,8 +23,8 @@ export function DirectorDashboardHeader({ title={title} icon={BarChart3} iconClassName="bg-gradient-to-br from-purple-500 to-purple-700" - titleClassName="text-gray-800" - descriptionClassName="text-gray-500 flex items-center gap-2" + iconShadowClassName="shadow-purple-500/30" + descriptionClassName="flex items-center gap-2" description={( <> {scopeLabel diff --git a/frontend/src/components/director-dashboard/DirectorDashboardView.tsx b/frontend/src/components/director-dashboard/DirectorDashboardView.tsx index 129ea1d..2271e48 100644 --- a/frontend/src/components/director-dashboard/DirectorDashboardView.tsx +++ b/frontend/src/components/director-dashboard/DirectorDashboardView.tsx @@ -1,9 +1,9 @@ -import { useState } from 'react'; +import { useRef } 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 { DirectorAcknowledgmentTrackingPanel } from '@/components/director-dashboard/DirectorAcknowledgmentTrackingPanel'; import { DirectorOverviewCards } from '@/components/director-dashboard/DirectorOverviewCards'; import { DirectorQuickActions } from '@/components/director-dashboard/DirectorQuickActions'; import { DirectorQuizResultsPanel } from '@/components/director-dashboard/DirectorQuizResultsPanel'; @@ -22,9 +22,24 @@ export function DirectorDashboardView({ page, onOpenModule, }: DirectorDashboardViewProps) { - const [isAcknowledgmentModalOpen, setIsAcknowledgmentModalOpen] = useState(false); + const quizResultsRef = useRef(null); + const acknowledgmentResultsRef = useRef(null); const errorMessage = getOptionalErrorMessage(page.error); + function scrollToQuizResults() { + quizResultsRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + } + + function scrollToAcknowledgments() { + acknowledgmentResultsRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + } + if (page.isLoading) { return ; } @@ -48,16 +63,23 @@ export function DirectorDashboardView({ setIsAcknowledgmentModalOpen(true)} + onOpenAcknowledgments={scrollToAcknowledgments} + onOpenQuizResults={scrollToQuizResults} />
setIsAcknowledgmentModalOpen(true)} + onOpenAcknowledgments={scrollToAcknowledgments} + onOpenQuizResults={scrollToQuizResults} /> - +
+ +
+
+ +
- ); } diff --git a/frontend/src/components/director-dashboard/DirectorOverviewCards.tsx b/frontend/src/components/director-dashboard/DirectorOverviewCards.tsx index 2c99142..8b8fa77 100644 --- a/frontend/src/components/director-dashboard/DirectorOverviewCards.tsx +++ b/frontend/src/components/director-dashboard/DirectorOverviewCards.tsx @@ -1,6 +1,6 @@ import type { ModuleId } from '@/shared/types/app'; import type { DirectorOverviewCard } from '@/business/director-dashboard/types'; -import { Button } from '@/components/ui/button'; +import { DirectorScaleButton } from '@/components/director-dashboard/DirectorScaleButton'; import { directorOverviewIcons, directorOverviewToneClasses, @@ -12,12 +12,14 @@ interface DirectorOverviewCardsProps { readonly cards: readonly DirectorOverviewCard[]; readonly onOpenModule: (module: ModuleId) => void; readonly onOpenAcknowledgments?: () => void; + readonly onOpenQuizResults?: () => void; } export function DirectorOverviewCards({ cards, onOpenModule, onOpenAcknowledgments, + onOpenQuizResults, }: DirectorOverviewCardsProps) { function handleCardClick(card: DirectorOverviewCard) { if (card.action === 'openAcknowledgments' && onOpenAcknowledgments) { @@ -25,6 +27,11 @@ export function DirectorOverviewCards({ return; } + if (card.action === 'openQuizResults' && onOpenQuizResults) { + onOpenQuizResults(); + return; + } + onOpenModule(card.module); } @@ -35,11 +42,11 @@ export function DirectorOverviewCards({ const TrendIcon = directorTrendIcons[card.trend]; return ( - + ); })} diff --git a/frontend/src/components/director-dashboard/DirectorQuickActions.tsx b/frontend/src/components/director-dashboard/DirectorQuickActions.tsx index 6aecf63..1b40847 100644 --- a/frontend/src/components/director-dashboard/DirectorQuickActions.tsx +++ b/frontend/src/components/director-dashboard/DirectorQuickActions.tsx @@ -1,6 +1,6 @@ import type { ModuleId } from '@/shared/types/app'; import type { DirectorQuickActionConfig } from '@/shared/constants/directorDashboard'; -import { Button } from '@/components/ui/button'; +import { DirectorScaleButton } from '@/components/director-dashboard/DirectorScaleButton'; import { directorQuickActionIcons, directorQuickActionToneClasses, @@ -23,15 +23,15 @@ export function DirectorQuickActions({ const ActionIcon = action.iconId ? directorQuickActionIcons[action.iconId] : undefined; return ( - + ); })} diff --git a/frontend/src/components/director-dashboard/DirectorQuizResultsPanel.tsx b/frontend/src/components/director-dashboard/DirectorQuizResultsPanel.tsx index 370fb01..3043d51 100644 --- a/frontend/src/components/director-dashboard/DirectorQuizResultsPanel.tsx +++ b/frontend/src/components/director-dashboard/DirectorQuizResultsPanel.tsx @@ -1,65 +1,151 @@ -import { Users } from 'lucide-react'; +import { useState } from 'react'; +import { ChevronDown, Users } from 'lucide-react'; import { StatePanel } from '@/components/ui/state-panel'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; import type { DirectorQuizResultRow } from '@/business/director-dashboard/types'; +import { cn } from '@/lib/utils'; interface DirectorQuizResultsPanelProps { readonly results: readonly DirectorQuizResultRow[]; } export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelProps) { + const [isPanelExpanded, setIsPanelExpanded] = useState(false); + const [expandedUserIds, setExpandedUserIds] = useState>(() => new Set()); + const completedQuizCount = results.reduce( + (total, result) => total + result.completedCount, + 0, + ); + const totalQuizCount = results.reduce( + (total, result) => total + result.totalCount, + 0, + ); + + function toggleUser(userId: string) { + setExpandedUserIds((current) => { + const next = new Set(current); + if (next.has(userId)) { + next.delete(userId); + } else { + next.add(userId); + } + return next; + }); + } + return (
-

- - Quiz Results from Database -

- {results.length > 0 ? ( - - - - Staff - Quiz - Role - Result - Date - - - - {results.map((result) => ( - - {result.staffName} - {result.quiz} - {result.role} - - - {result.result} - - - - {result.date} - - - ))} - -
- ) : ( - - No staff are in this completion scope yet. - - )} + + +
+ {isPanelExpanded && results.length > 0 ? ( +
+
+ Staff + Tenant + Role + Quizzes + Toggle +
+
+ {results.map((result) => { + const isExpanded = expandedUserIds.has(result.id); + return ( +
+ + {isExpanded && ( +
+
+
+ Quiz + Date + Result +
+
+ {result.details.map((detail) => ( +
+ {detail.quiz} + {detail.date} + + + {detail.result} + + +
+ ))} +
+
+
+ )} +
+ ); + })} +
+
+ ) : isPanelExpanded ? ( + + No staff are in this completion scope yet. + + ) : ( +
+ Expand to review each staff member's quiz completion details. +
+ )} +
); } diff --git a/frontend/src/components/director-dashboard/DirectorRecentFramePanel.tsx b/frontend/src/components/director-dashboard/DirectorRecentFramePanel.tsx index 9330041..c26e59c 100644 --- a/frontend/src/components/director-dashboard/DirectorRecentFramePanel.tsx +++ b/frontend/src/components/director-dashboard/DirectorRecentFramePanel.tsx @@ -2,7 +2,7 @@ import { Eye } from 'lucide-react'; import type { ModuleId } from '@/shared/types/app'; import type { DirectorFramePreview } from '@/business/director-dashboard/types'; -import { Button } from '@/components/ui/button'; +import { DirectorScaleButton } from '@/components/director-dashboard/DirectorScaleButton'; import { StatePanel } from '@/components/ui/state-panel'; import { directorFrameSectionClasses, @@ -47,14 +47,14 @@ export function DirectorRecentFramePanel({ No F.R.A.M.E. entries are available yet. )} - + ); } diff --git a/frontend/src/components/director-dashboard/DirectorRiskList.tsx b/frontend/src/components/director-dashboard/DirectorRiskList.tsx index c43e5bc..f0e5008 100644 --- a/frontend/src/components/director-dashboard/DirectorRiskList.tsx +++ b/frontend/src/components/director-dashboard/DirectorRiskList.tsx @@ -1,6 +1,6 @@ import type { ModuleId } from '@/shared/types/app'; import type { DirectorRiskArea } from '@/business/director-dashboard/types'; -import { Button } from '@/components/ui/button'; +import { DirectorScaleButton } from '@/components/director-dashboard/DirectorScaleButton'; import { directorNavigateIcon, directorRiskIcon, @@ -11,12 +11,14 @@ interface DirectorRiskListProps { readonly risks: readonly DirectorRiskArea[]; readonly onOpenModule: (module: ModuleId) => void; readonly onOpenAcknowledgments?: () => void; + readonly onOpenQuizResults?: () => void; } export function DirectorRiskList({ risks, onOpenModule, onOpenAcknowledgments, + onOpenQuizResults, }: DirectorRiskListProps) { const RiskIcon = directorRiskIcon; const NavigateIcon = directorNavigateIcon; @@ -27,6 +29,11 @@ export function DirectorRiskList({ return; } + if (risk.action === 'openQuizResults' && onOpenQuizResults) { + onOpenQuizResults(); + return; + } + onOpenModule(risk.module); } @@ -38,11 +45,11 @@ export function DirectorRiskList({
{risks.map((risk) => ( - + ))}
diff --git a/frontend/src/components/director-dashboard/DirectorScaleButton.tsx b/frontend/src/components/director-dashboard/DirectorScaleButton.tsx new file mode 100644 index 0000000..5544401 --- /dev/null +++ b/frontend/src/components/director-dashboard/DirectorScaleButton.tsx @@ -0,0 +1,29 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; + +import { cn } from '@/lib/utils'; + +interface DirectorScaleButtonProps extends ButtonHTMLAttributes { + readonly children: ReactNode; +} + +export function DirectorScaleButton({ + children, + className, + type = 'button', + disabled, + ...props +}: DirectorScaleButtonProps) { + return ( + + ); +} diff --git a/frontend/src/components/director-dashboard/DirectorTimeRangeTabs.tsx b/frontend/src/components/director-dashboard/DirectorTimeRangeTabs.tsx index 24546c2..97e7d94 100644 --- a/frontend/src/components/director-dashboard/DirectorTimeRangeTabs.tsx +++ b/frontend/src/components/director-dashboard/DirectorTimeRangeTabs.tsx @@ -1,4 +1,4 @@ -import { Button } from '@/components/ui/button'; +import { DirectorScaleButton } from '@/components/director-dashboard/DirectorScaleButton'; import { DIRECTOR_DASHBOARD_TIME_RANGES, type DirectorDashboardTimeRange, @@ -16,18 +16,18 @@ export function DirectorTimeRangeTabs({ return (
{DIRECTOR_DASHBOARD_TIME_RANGES.map((range) => ( - + ))}
); diff --git a/frontend/src/components/director-dashboard/directorDashboardViewConfig.ts b/frontend/src/components/director-dashboard/directorDashboardViewConfig.ts index 88ab617..8a3ef9a 100644 --- a/frontend/src/components/director-dashboard/directorDashboardViewConfig.ts +++ b/frontend/src/components/director-dashboard/directorDashboardViewConfig.ts @@ -71,10 +71,10 @@ export const directorFrameSectionClasses: Record = { - indigo: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200', - amber: 'bg-amber-100 text-amber-700 hover:bg-amber-200', - blue: 'bg-blue-100 text-blue-700 hover:bg-blue-200', - orange: 'bg-orange-100 text-orange-700 hover:bg-orange-200', - rose: 'bg-rose-100 text-rose-700 hover:bg-rose-200', - red: 'bg-red-100 text-red-700 hover:bg-red-200', + indigo: 'bg-indigo-100 text-indigo-700', + amber: 'bg-amber-100 text-amber-700', + blue: 'bg-blue-100 text-blue-700', + orange: 'bg-orange-100 text-orange-700', + rose: 'bg-rose-100 text-rose-700', + red: 'bg-red-100 text-red-700', }; diff --git a/frontend/src/shared/api/frame.test.ts b/frontend/src/shared/api/frame.test.ts index 3602b3e..7e1ce7b 100644 --- a/frontend/src/shared/api/frame.test.ts +++ b/frontend/src/shared/api/frame.test.ts @@ -35,6 +35,17 @@ describe('frame API', () => { expect(apiRequestMock).toHaveBeenCalledWith('/frame_entries'); }); + it('lists FRAME entries with date filters', () => { + void listFrameEntries({ + startDate: '2026-04-01', + endDate: '2026-06-19', + }); + + expect(apiRequestMock).toHaveBeenCalledWith( + '/frame_entries?startDate=2026-04-01&endDate=2026-06-19', + ); + }); + it('creates FRAME entries with POST body wrapped in data', () => { void createFrameEntry(frameRequest); diff --git a/frontend/src/shared/api/frame.ts b/frontend/src/shared/api/frame.ts index f0334a8..7e7a917 100644 --- a/frontend/src/shared/api/frame.ts +++ b/frontend/src/shared/api/frame.ts @@ -4,8 +4,32 @@ import { FrameEntryDto, FrameEntryMutationDto } from '@/shared/types/frame'; const FRAME_ENTRIES_PATH = '/frame_entries'; -export function listFrameEntries(): Promise> { - return apiRequest>(FRAME_ENTRIES_PATH); +export interface FrameEntryFilter { + readonly startDate?: string; + readonly endDate?: string; +} + +function toSearchParams(filter?: FrameEntryFilter): string { + if (!filter) { + return ''; + } + + const params = new URLSearchParams(); + + if (filter.startDate) { + params.set('startDate', filter.startDate); + } + + if (filter.endDate) { + params.set('endDate', filter.endDate); + } + + const query = params.toString(); + return query ? `?${query}` : ''; +} + +export function listFrameEntries(filter?: FrameEntryFilter): Promise> { + return apiRequest>(`${FRAME_ENTRIES_PATH}${toSearchParams(filter)}`); } export function createFrameEntry(request: FrameEntryMutationDto): Promise { diff --git a/frontend/src/shared/constants/directorDashboard.ts b/frontend/src/shared/constants/directorDashboard.ts index afa0e80..1f90192 100644 --- a/frontend/src/shared/constants/directorDashboard.ts +++ b/frontend/src/shared/constants/directorDashboard.ts @@ -4,6 +4,7 @@ export const DIRECTOR_DASHBOARD_TIME_RANGES = ['week', 'month', 'quarter'] as co export type DirectorDashboardTimeRange = typeof DIRECTOR_DASHBOARD_TIME_RANGES[number]; +export const DIRECTOR_DASHBOARD_TIME_RANGE_STORAGE_KEY = 'director-dashboard-time-range'; export const DIRECTOR_DASHBOARD_FRAME_PREVIEW_LIMIT = 3; export const DIRECTOR_DASHBOARD_TEXT_PREVIEW_LENGTH = 60; export const DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD = 50; diff --git a/frontend/tests/e2e/audio-files.seeded.e2e.ts b/frontend/tests/e2e/audio-files.seeded.e2e.ts index 2a8fdfd..c3754e0 100644 --- a/frontend/tests/e2e/audio-files.seeded.e2e.ts +++ b/frontend/tests/e2e/audio-files.seeded.e2e.ts @@ -52,10 +52,11 @@ async function login(page: Page, email: string): Promise { } async function findAudio(page: Page, title: string): Promise { - const res = await page.request.get(AUDIO); + const params = new URLSearchParams({ title, limit: '1' }); + const res = await page.request.get(`${AUDIO}?${params.toString()}`); expect(res.status()).toBe(200); const body = (await res.json()) as { rows?: AudioRow[] }; - return (body.rows ?? []).find((row) => row.title === title); + return body.rows?.[0]; } test.describe('Audio library', () => {