improved leaders dashboards UI/UX

This commit is contained in:
Dmitri 2026-06-19 16:43:41 +02:00
parent 0fbf6c0387
commit d12d2b0a10
37 changed files with 933 additions and 363 deletions

View File

@ -23,7 +23,9 @@ source of truth for persisted FRAME data; the frontend never substitutes static
All routes require JWT authentication. All routes require JWT authentication.
- `GET /api/frame_entries` -> `200` `{ rows, count }` for the current user's organization - `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. - `POST /api/frame_entries` -> `201` the created entry DTO.
- `PUT /api/frame_entries/:id` -> `200` the updated entry DTO (scoped to the org). - `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 ## Behavior / Notes
- Create/update run inside `withTransaction`. - 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 - The same Sunday-start canonicalization is used on the frontend
(`shared/business/week.ts`) for the dashboard hero, the safety-quiz week, and the (`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. F.R.A.M.E. week picker, so the week is consistent everywhere.

View File

@ -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 `20260611070000-campuses-timezone.ts` (the required `campuses.timezone` IANA column — added
nullable, backfilled, then set NOT NULL), and `20260612000000-frame-entries-week-label.ts` 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). (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 tenant's per-role users, and the secondary tenant's per-role users from
`shared/constants/seed-fixtures.ts`), `shared/constants/seed-fixtures.ts`),
`user-roles` (the first-class roles, the permission catalog incl. product-feature `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` - `migrator` globs `migrations/*.{ts,js}`; history is tracked in the default `SequelizeMeta`
table via `SequelizeStorage`. 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 - 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 `(queryInterface, Sequelize)`. The runner accepts either a `default` export or top-level
`up`/`down`. `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`). 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 - The `glob` accepts both `.ts` and `.js`, so already-applied entries are not re-run after a
build. 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 ## 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 ## Authoring A New Migration / Seeder
1. Add `src/db/migrations/<timestamp>-<name>.ts` (or `seeders/...`) exporting 1. Add `src/db/migrations/<timestamp>-<name>.ts` (or `seeders/<timestamp>-<name>.ts`) exporting
`export default { up, down }` with typed `(queryInterface, Sequelize)` signatures. `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`. 2. For seeders, use stable natural keys (`id`, `name`, `importHash`, or a
3. Regenerate `database-schema.md` after any schema change (it is generated from the models). 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 ## Tests
- `src/shared/constants/seed-fixtures.test.ts` covers primary/secondary tenant - `src/shared/constants/seed-fixtures.test.ts` covers primary/secondary tenant
user topology and credential uniqueness. 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 - `src/db/seeders/user-roles.test.ts` covers the seeded product-permission
contract for parent communication and registrar report/audit grants. contract for parent communication and registrar report/audit grants.

View File

@ -1,5 +1,5 @@
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import type { CreationAttributes, QueryInterface } from 'sequelize'; import { QueryTypes, type CreationAttributes, type QueryInterface } from 'sequelize';
import { import {
ROLE_DEFINITIONS, ROLE_DEFINITIONS,
ROLE_NAMES, ROLE_NAMES,
@ -141,6 +141,10 @@ export function buildEntityPermissionNames(): readonly string[] {
return names; return names;
} }
function uniquePermissionNames(names: readonly string[]): readonly string[] {
return [...new Set(names)];
}
export function buildSeededPermissionNamesForRole(role: RoleName): readonly string[] { export function buildSeededPermissionNamesForRole(role: RoleName): readonly string[] {
const entityPermissionNames = buildEntityPermissionNames(); const entityPermissionNames = buildEntityPermissionNames();
const allPermissionNames = [ const allPermissionNames = [
@ -150,7 +154,7 @@ export function buildSeededPermissionNamesForRole(role: RoleName): readonly stri
]; ];
if (role === ROLE_NAMES.SYSTEM_ADMIN) { if (role === ROLE_NAMES.SYSTEM_ADMIN) {
return allPermissionNames; return uniquePermissionNames(allPermissionNames);
} }
if (FULL_ACCESS_ROLES.includes(role)) { if (FULL_ACCESS_ROLES.includes(role)) {
@ -160,17 +164,17 @@ export function buildSeededPermissionNamesForRole(role: RoleName): readonly stri
excluded.add(name); excluded.add(name);
} }
} }
return allPermissionNames.filter((name) => !excluded.has(name)); return uniquePermissionNames(allPermissionNames.filter((name) => !excluded.has(name)));
} }
if (READ_ONLY_ROLES.includes(role)) { if (READ_ONLY_ROLES.includes(role)) {
return [ return uniquePermissionNames([
...entityPermissionNames.filter((name) => name.startsWith('READ_')), ...entityPermissionNames.filter((name) => name.startsWith('READ_')),
...(MODULE_PERMISSIONS_BY_ROLE[role] ?? []), ...(MODULE_PERMISSIONS_BY_ROLE[role] ?? []),
]; ]);
} }
return MODULE_PERMISSIONS_BY_ROLE[role] ?? []; return uniquePermissionNames(MODULE_PERMISSIONS_BY_ROLE[role] ?? []);
} }
export default { export default {
@ -182,9 +186,23 @@ export default {
const permId = new Map<string, string>(); const permId = new Map<string, string>();
// 1. Roles. // 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<Roles>[] = ROLE_DEFINITIONS.map( const roleRows: CreationAttributes<Roles>[] = ROLE_DEFINITIONS.map(
(role) => { (role) => {
const id = uuid(); const id = roleId.get(role.name) ?? uuid();
roleId.set(role.name, id); roleId.set(role.name, id);
return { return {
id, id,
@ -195,25 +213,45 @@ export default {
updatedAt, updatedAt,
}; };
}, },
); ).filter((role) => !existingRoles.some((existing) => existing.id === role.id));
await queryInterface.bulkInsert('roles', roleRows); if (roleRows.length > 0) {
await queryInterface.bulkInsert('roles', roleRows);
}
// 2. Permissions (entity CRUD + extras + product module/page perms). // 2. Permissions (entity CRUD + extras + product module/page perms).
const entityPermissionNames = [...buildEntityPermissionNames()]; const entityPermissionNames = [...buildEntityPermissionNames()];
const allPermissionNames = [ const allPermissionNames = uniquePermissionNames([
...entityPermissionNames, ...entityPermissionNames,
...EXTRA_PERMISSIONS, ...EXTRA_PERMISSIONS,
...MODULE_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<Permissions>[] = const permissionRows: CreationAttributes<Permissions>[] =
allPermissionNames.map((name) => { allPermissionNames.map((name) => {
const id = uuid(); const id = permId.get(name) ?? uuid();
permId.set(name, id); permId.set(name, id);
return { id, name, createdAt, updatedAt }; return { id, name, createdAt, updatedAt };
}); }).filter(
await queryInterface.bulkInsert('permissions', permissionRows); (permission) =>
!existingPermissions.some((existing) => existing.id === permission.id),
);
if (permissionRows.length > 0) {
await queryInterface.bulkInsert('permissions', permissionRows);
}
// 3. Role → permission matrix. // 3. Role → permission matrix.
const links: Array<{ const links: Array<{
@ -256,7 +294,39 @@ export default {
'DELETE_POLICY_DOCUMENTS', '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<string>();
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 // 4. Assign roles to the seeded fixture users (by id — robust to the
// configured super-admin email). // configured super-admin email).

View File

@ -1,5 +1,6 @@
import { import {
Op, Op,
QueryTypes,
type CreationAttributes, type CreationAttributes,
type QueryInterface, type QueryInterface,
} from 'sequelize'; } from 'sequelize';
@ -14,8 +15,19 @@ export default {
const rows: CreationAttributes<Campuses>[] = PRODUCT_CAMPUS_SEED_ROWS.map( const rows: CreationAttributes<Campuses>[] = PRODUCT_CAMPUS_SEED_ROWS.map(
(campus) => ({ ...campus, createdAt, updatedAt }), (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) => { down: async (queryInterface: QueryInterface) => {

View File

@ -1,6 +1,7 @@
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { import {
Op, Op,
QueryTypes,
type CreationAttributes, type CreationAttributes,
type QueryInterface, type QueryInterface,
} from 'sequelize'; } from 'sequelize';
@ -109,13 +110,6 @@ const CONTENT_CATALOG_SEED_ROWS = Object.freeze([
export default { export default {
up: async (queryInterface: QueryInterface) => { up: async (queryInterface: QueryInterface) => {
const now = new Date(); 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<ContentCatalog>[] = const rows: CreationAttributes<ContentCatalog>[] =
CONTENT_CATALOG_SEED_ROWS.flatMap((row) => seedTenants(row.content_type).map((stamp) => ({ CONTENT_CATALOG_SEED_ROWS.flatMap((row) => seedTenants(row.content_type).map((stamp) => ({
@ -131,8 +125,21 @@ export default {
createdAt: now, createdAt: now,
updatedAt: 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) => { down: async (queryInterface: QueryInterface) => {

View File

@ -1,5 +1,5 @@
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { Op, type QueryInterface } from 'sequelize'; import { Op, QueryTypes, type QueryInterface } from 'sequelize';
import { import {
SEED_ORGANIZATION_ID, SEED_ORGANIZATION_ID,
SEED_ORGANIZATION_2_ID, SEED_ORGANIZATION_2_ID,
@ -163,15 +163,10 @@ function getScopeDirector(scope: SeedScope) {
export default { export default {
up: async (queryInterface: QueryInterface) => { up: async (queryInterface: QueryInterface) => {
const now = new Date(); const now = new Date();
const legacyImportHashes = ALL_ROWS.map((row) => row.importHash);
const importHashes = SEED_SCOPES.flatMap((scope) => const importHashes = SEED_SCOPES.flatMap((scope) =>
ALL_ROWS.map((row) => scopedImportHash(scope, row)), ALL_ROWS.map((row) => scopedImportHash(scope, row)),
); );
await queryInterface.bulkDelete('policy_documents', {
importHash: { [Op.in]: [...legacyImportHashes, ...importHashes] },
});
const rows = SEED_SCOPES.flatMap((scope) => { const rows = SEED_SCOPES.flatMap((scope) => {
const director = getScopeDirector(scope); const director = getScopeDirector(scope);
const author = director const author = director
@ -204,8 +199,21 @@ export default {
updatedAt: now, 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) => { down: async (queryInterface: QueryInterface) => {

View File

@ -128,4 +128,15 @@ describe('user-role seed permission contract', () => {
ROLE_NAMES.TEACHER, 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`,
);
}
});
}); });

View File

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

View File

@ -18,6 +18,13 @@ interface MigrationModule {
const sequelize = db.sequelize; const sequelize = db.sequelize;
const queryInterface = sequelize.getQueryInterface(); 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( async function loadMigration(
filepath: string, filepath: string,
@ -57,17 +64,17 @@ export const migrator = new Umzug({
resolve: resolveMigration, resolve: resolveMigration,
}, },
context: queryInterface, context: queryInterface,
storage: new SequelizeStorage({ sequelize }), storage: migrationStorage,
logger: console, logger: console,
}); });
export const seeder = new Umzug({ export const seeder = new Umzug({
migrations: { migrations: {
glob: ['seeders/*.{ts,js}', { cwd: import.meta.dirname }], glob: [seederGlob, { cwd: import.meta.dirname }],
resolve: resolveMigration, resolve: resolveMigration,
}, },
context: queryInterface, context: queryInterface,
storage: new SequelizeStorage({ sequelize, tableName: 'SequelizeData' }), storage: seedStorage,
logger: console, logger: console,
}); });

View File

@ -12,7 +12,14 @@ const router = express.Router();
* get: * get:
* tags: [FRAME] * tags: [FRAME]
* summary: List FRAME weekly entries (tenant/campus-scoped) * 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: * responses:
* 200: * 200:
* description: List of FRAME entries. * description: List of FRAME entries.

View File

@ -29,6 +29,7 @@ interface AudioFileInput {
interface AudioFilesFilter { interface AudioFilesFilter {
limit?: number | string; limit?: number | string;
page?: number | string; page?: number | string;
title?: string;
} }
interface ResolvedAudioContent { interface ResolvedAudioContent {
@ -98,8 +99,12 @@ class AudioFilesService {
static async list(filter: AudioFilesFilter, currentUser?: CurrentUser) { static async list(filter: AudioFilesFilter, currentUser?: CurrentUser) {
assertAuthenticatedTenantUser(currentUser); assertAuthenticatedTenantUser(currentUser);
const { limit, offset } = resolvePagination(filter.limit, filter.page); 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]: [ [Op.or]: [
@ -110,6 +115,11 @@ class AudioFilesService {
}, },
], ],
}; };
const where = title
? {
[Op.and]: [visibilityWhere, { title }],
}
: visibilityWhere;
const result = await db.audio_files.findAndCountAll({ const result = await db.audio_files.findAndCountAll({
where, where,

View File

@ -1,8 +1,10 @@
import db from '@/db/models'; import db from '@/db/models';
import { Op } from 'sequelize';
import { withTransaction } from '@/db/with-transaction'; import { withTransaction } from '@/db/with-transaction';
import { resolvePagination } from '@/shared/constants/pagination'; import { resolvePagination } from '@/shared/constants/pagination';
import ForbiddenError from '@/shared/errors/forbidden'; import ForbiddenError from '@/shared/errors/forbidden';
import ValidationError from '@/shared/errors/validation'; import ValidationError from '@/shared/errors/validation';
import { optionalIsoDate } from '@/services/shared/validate';
import { import {
getOwnTenant, getOwnTenant,
tenantExactWhere, tenantExactWhere,
@ -27,6 +29,13 @@ interface FrameEntryInput {
campusId?: string | null; 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. */ /** Normalizes the input week to its Sunday-start ISO date, or throws. */
function requireWeekStart(weekOf: string): string { function requireWeekStart(weekOf: string): string {
const weekStart = toWeekStartIso(weekOf); 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) { function toDto(entry: FrameEntries) {
const plain = entry.get({ plain: true }); const plain = entry.get({ plain: true });
@ -105,14 +130,17 @@ function toDto(entry: FrameEntries) {
class FrameEntriesService { class FrameEntriesService {
static async list( static async list(
filter: { limit?: number | string; page?: number | string } = {}, filter: FrameEntryFilter = {},
currentUser?: CurrentUser, currentUser?: CurrentUser,
) { ) {
const { limit, offset } = resolvePagination(filter.limit, filter.page); const { limit, offset } = resolvePagination(filter.limit, filter.page);
// Per-tenant content: a user sees the FRAME entries dedicated to their own // Per-tenant content: a user sees the FRAME entries dedicated to their own
// tenant level (org/school/campus/class), not an aggregate of children. // 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({ const result = await db.frame_entries.findAndCountAll({
where, where,

View File

@ -37,31 +37,46 @@ Constants:
## Behavior ## Behavior
- The framework component calls `useDirectorDashboardPage` and renders `DirectorDashboardView`. - 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`. - QBS safety quiz completion loads through `useSafetyQuizResults`.
- Emotional Intelligence and Personality Type completion loads through `usePersonalityCompletion`. - Emotional Intelligence and Personality Type completion loads through `usePersonalityCompletion`.
- Daily Zone Check-In completion loads through `useZoneCheckInCompletion`. - Daily Zone Check-In completion loads through `useZoneCheckInCompletion`.
- Staff attendance records and summary load through staff attendance business hooks. - Staff attendance records and summary load through staff attendance business
hooks with the selected period range.
- Policy acknowledgment report loads through `usePolicyAcknowledgmentReport`. - Policy acknowledgment report loads through `usePolicyAcknowledgmentReport`.
- Overview metrics, risk areas, unified quiz result rows, and FRAME previews are derived in business selectors. - Overview metrics, risk areas, unified quiz result rows, and FRAME previews are derived in business selectors.
- Document acknowledgment tracking opens in a modal from the Acknowledgments - Document acknowledgment tracking renders as a collapsible section in the main
overview card or the acknowledgment risk card. It renders current-version dashboard flow. The Acknowledgments overview card and acknowledgment risk card
Safety Protocol and Handbook & Policies documents grouped by category. scroll to that section. When expanded, it renders current-version Safety
Collapsed rows show title, version, and acknowledged/total staff counts for Protocol and Handbook & Policies documents grouped by category. Collapsed
the leader's scope; expanded rows list staff who have not acknowledged that document rows show title, version, and acknowledged/total staff counts for the
version. leader's scope; expanded document rows list staff who have not acknowledged
- The dashboard quiz results table combines Behavior Management, EI Self-Assessment, that version.
Personality Type Quiz, and Daily Zone Check-In rows. EI self-assessment rows - The dashboard quiz results table groups Behavior Management, EI Self-Assessment,
reflect the current Sunday-start week; personality type rows reflect each user's Personality Type Quiz, and Daily Zone Check-In by staff member in a
latest saved type; zone check-in rows reflect today's backend-computed date for collapsible section. When expanded, collapsed staff rows show staff name,
each staff member. 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 - Risk areas include high/medium/low QBS safety quiz completion, low-risk EI
self-assessment pending counts, low-risk Personality Type pending counts, self-assessment pending counts, low-risk Personality Type pending counts,
medium-risk non-green Daily Zone Check-In counts, missing current-version medium-risk non-green Daily Zone Check-In counts, missing current-version
document acknowledgments, and attendance risk. 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. - Loading, empty, and error states are explicit.
- Risk areas, unified quiz results, zone completion, and current-version
## Remaining Related Work acknowledgment tracking intentionally remain current-state views. They are not
filtered by the time range tabs.
Time range tabs currently control UI state only. Add backend-supported date filtering before wiring these tabs to query filters.

View File

@ -41,7 +41,10 @@ Constants:
## Behavior ## 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. - 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 <formatWeekOf(weekStart)>` + 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. - **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 <formatWeekOf(weekStart)>` + 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. - `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. - Static FRAME sample entries are not used as runtime persisted-data substitutes.
- Empty and error states are rendered explicitly. - Empty and error states are rendered explicitly.
- Dynamic F/R/A/M/E field access is typed through `FrameSectionKey`. - 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`. - Home dashboard renders the latest FRAME entry through `useDashboardPage` and `DashboardFramePreview`.
## Remaining Related Work ## Remaining Related Work

View File

@ -27,8 +27,7 @@ Safety Protocols:
Acknowledgment tracking: Acknowledgment tracking:
- Dashboard component: - Dashboard component:
`frontend/src/components/director-dashboard/DirectorAcknowledgmentTrackingModal.tsx` `frontend/src/components/director-dashboard/DirectorAcknowledgmentTrackingPanel.tsx`
and `DirectorAcknowledgmentTrackingPanel.tsx`
- Business/API: `buildDirectorAcknowledgmentDocuments` + - Business/API: `buildDirectorAcknowledgmentDocuments` +
`usePolicyAcknowledgmentReport` in `usePolicyAcknowledgmentReport` in
`frontend/src/business/director-dashboard` and `frontend/src/business/director-dashboard` and
@ -82,9 +81,10 @@ change), `active`, tenant `organizationId` + nullable `campusId`.
- **Acknowledgment report** is available to owner, superintendent, principal, - **Acknowledgment report** is available to owner, superintendent, principal,
registrar, and director. Super/system admins can read it only while drilled registrar, and director. Super/system admins can read it only while drilled
into a tenant. The report counts active director/office_manager/teacher/ into a tenant. The report counts active director/office_manager/teacher/
support_staff accounts in the current scope. The leadership dashboard opens support_staff accounts in the current scope. The leadership dashboard renders
acknowledgment tracking in a modal from the Acknowledgments overview metric or acknowledgment tracking as a collapsible section in the main dashboard flow
the aggregated acknowledgment risk card. The modal groups current-version 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 documents by category, shows collapsed acknowledged/total counts, expands to
missing staff names, and the risk area aggregates missing acknowledgments into missing staff names, and the risk area aggregates missing acknowledgments into
one concise card. one concise card.

View File

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

View File

@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { format, startOfMonth, startOfQuarter } from 'date-fns';
import { useFrameEntries } from '@/business/frame/hooks'; import { useFrameEntries } from '@/business/frame/hooks';
import { useSafetyQuizCompliance } from '@/business/safety-quiz/hooks'; import { useSafetyQuizCompliance } from '@/business/safety-quiz/hooks';
@ -19,6 +20,8 @@ import {
import type { DirectorDashboardPage } from '@/business/director-dashboard/types'; import type { DirectorDashboardPage } from '@/business/director-dashboard/types';
import { import {
DIRECTOR_DASHBOARD_QUICK_ACTIONS, DIRECTOR_DASHBOARD_QUICK_ACTIONS,
DIRECTOR_DASHBOARD_TIME_RANGE_STORAGE_KEY,
DIRECTOR_DASHBOARD_TIME_RANGES,
type DirectorDashboardTimeRange, type DirectorDashboardTimeRange,
} from '@/shared/constants/directorDashboard'; } from '@/shared/constants/directorDashboard';
import { useAuth } from '@/shared/app/useAuth'; import { useAuth } from '@/shared/app/useAuth';
@ -27,6 +30,45 @@ import { getActiveTenant } from '@/business/scope/selectors';
import { useScopeContext } from '@/shared/app/scope-context'; import { useScopeContext } from '@/shared/app/scope-context';
import { DEFAULT_PRODUCT_ROLE } from '@/shared/constants/roles'; import { DEFAULT_PRODUCT_ROLE } from '@/shared/constants/roles';
import { getCurrentSafetyQuizWeek } from '@/business/safety-quiz/selectors'; 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 { export function useDirectorDashboardPage(): DirectorDashboardPage {
const { user, profile } = useAuth(); const { user, profile } = useAuth();
@ -36,14 +78,15 @@ export function useDirectorDashboardPage(): DirectorDashboardPage {
const activeTenant = effectiveTenant ?? getActiveTenant(user); const activeTenant = effectiveTenant ?? getActiveTenant(user);
const scopeLabel = activeTenant?.name ?? ''; const scopeLabel = activeTenant?.name ?? '';
const scopeKey = activeTenant ? `${activeTenant.level}:${activeTenant.id}` : null; const scopeKey = activeTenant ? `${activeTenant.level}:${activeTenant.id}` : null;
const [timeRange, setTimeRangeState] = useState<DirectorDashboardTimeRange>('month'); const [timeRange, setTimeRangeState] = useState<DirectorDashboardTimeRange>(readStoredTimeRange);
const periodFilter = getDirectorDashboardDateRange(timeRange);
const safetyQuizWeek = getCurrentSafetyQuizWeek(new Date()); const safetyQuizWeek = getCurrentSafetyQuizWeek(new Date());
const frameEntriesQuery = useFrameEntries(); const frameEntriesQuery = useFrameEntries(periodFilter);
const quizCompletionQuery = useSafetyQuizCompliance(safetyQuizWeek, true); const quizCompletionQuery = useSafetyQuizCompliance(safetyQuizWeek, true);
const emotionalIntelligenceCompletionQuery = usePersonalityCompletion(true); const emotionalIntelligenceCompletionQuery = usePersonalityCompletion(true);
const zoneCheckinCompletionQuery = useZoneCheckInCompletion(true, scopeKey); const zoneCheckinCompletionQuery = useZoneCheckInCompletion(true, scopeKey);
const staffAttendanceRecordsQuery = useStaffAttendanceRecords(); const staffAttendanceRecordsQuery = useStaffAttendanceRecords(periodFilter);
const staffAttendanceSummaryQuery = useStaffAttendanceSummary(); const staffAttendanceSummaryQuery = useStaffAttendanceSummary(periodFilter);
const acknowledgmentReportQuery = usePolicyAcknowledgmentReport(); const acknowledgmentReportQuery = usePolicyAcknowledgmentReport();
const frameEntries = frameEntriesQuery.data ?? []; const frameEntries = frameEntriesQuery.data ?? [];
const quizRows = quizCompletionQuery.data?.rows ?? []; const quizRows = quizCompletionQuery.data?.rows ?? [];
@ -91,10 +134,24 @@ export function useDirectorDashboardPage(): DirectorDashboardPage {
), ),
framePreviews: buildDirectorFramePreviews(frameEntries), framePreviews: buildDirectorFramePreviews(frameEntries),
quickActions: DIRECTOR_DASHBOARD_QUICK_ACTIONS, quickActions: DIRECTOR_DASHBOARD_QUICK_ACTIONS,
quizResults: buildDirectorQuizResults(quizRows, emotionalIntelligenceCompletion, zoneCheckinCompletion), quizResults: buildDirectorQuizResults(
quizRows,
emotionalIntelligenceCompletion,
zoneCheckinCompletion,
scopeLabel,
),
acknowledgmentDocuments, acknowledgmentDocuments,
isLoading, isLoading,
error, 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.
}
}
},
}; };
} }

View File

@ -265,9 +265,10 @@ describe('director dashboard selectors', () => {
'attendance', 'attendance',
'qbs', 'qbs',
'frame', 'frame',
'attendance', 'user-admin',
'director', 'director',
]); ]);
expect(cards[1]?.action).toBe('openQuizResults');
expect(cards[cards.length - 1]?.action).toBe('openAcknowledgments'); 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", issue: "5 staff haven't completed de-escalation quiz",
severity: 'high', severity: 'high',
module: 'qbs', module: 'qbs',
action: 'openQuizResults',
}, },
{ {
issue: "1 staff haven't completed daily zone check-in", issue: "1 staff haven't completed daily zone check-in",
severity: 'medium', severity: 'medium',
module: 'zones', module: 'zones',
action: 'openQuizResults',
}, },
{ {
issue: 'Non-green regulation zones: Ava Lee (Yellow Zone)', 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( const rows = buildDirectorQuizResults(
[ [
{ {
@ -456,15 +459,25 @@ describe('director dashboard selectors', () => {
], ],
createPersonalityCompletion(), createPersonalityCompletion(),
createZoneCheckinCompletion(), 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', 'Behavior Management',
'EI Self-Assessment', 'EI Self-Assessment',
'Personality Type Quiz', 'Personality Type Quiz',
'Daily Zone Check-In', 'Daily Zone Check-In',
]); ]);
expect(rows.map((row) => row.result)).toEqual([ expect(rows[0].details.map((row) => row.result)).toEqual([
'3/5', '3/5',
'Developing Awareness (14/32)', 'Developing Awareness (14/32)',
'ENFP', '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', () => { it('limits and truncates FRAME previews', () => {
const longText = 'A'.repeat(70); const longText = 'A'.repeat(70);
const previews = buildDirectorFramePreviews([ const previews = buildDirectorFramePreviews([

View File

@ -19,6 +19,7 @@ import type {
DirectorAcknowledgmentDocumentRow, DirectorAcknowledgmentDocumentRow,
DirectorFramePreview, DirectorFramePreview,
DirectorOverviewCard, DirectorOverviewCard,
DirectorQuizResultDetail,
DirectorQuizResultRow, DirectorQuizResultRow,
DirectorRiskArea, DirectorRiskArea,
} from '@/business/director-dashboard/types'; } from '@/business/director-dashboard/types';
@ -59,13 +60,14 @@ export function buildDirectorOverviewCards(
module: 'attendance', module: 'attendance',
}, },
{ {
label: 'De-escalation Completion', label: 'Behavior Management Quiz Completion',
value: `${quizSummary.completedCount}/${quizSummary.totalStaff}`, value: `${quizSummary.completedCount}/${quizSummary.totalStaff}`,
change: `${quizCompletionRate}%`, change: `${quizCompletionRate}%`,
trend: quizCompletionRate > DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD ? 'up' : 'down', trend: quizCompletionRate > DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD ? 'up' : 'down',
iconId: 'shield', iconId: 'shield',
tone: 'blue', tone: 'blue',
module: 'qbs', module: 'qbs',
action: 'openQuizResults',
}, },
{ {
label: 'F.R.A.M.E. Entries', label: 'F.R.A.M.E. Entries',
@ -83,7 +85,7 @@ export function buildDirectorOverviewCards(
trend: 'up', trend: 'up',
iconId: 'users', iconId: 'users',
tone: 'purple', tone: 'purple',
module: 'attendance', module: 'user-admin',
}, },
{ {
label: 'Acknowledgments', label: 'Acknowledgments',
@ -125,6 +127,7 @@ export function buildDirectorRiskAreas(
issue: `${incompleteStaffCount} staff haven't completed de-escalation quiz`, issue: `${incompleteStaffCount} staff haven't completed de-escalation quiz`,
severity: incompleteStaffCount > DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD ? 'high' : 'medium', severity: incompleteStaffCount > DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD ? 'high' : 'medium',
module: 'qbs', module: 'qbs',
action: 'openQuizResults',
}); });
} }
@ -133,6 +136,7 @@ export function buildDirectorRiskAreas(
issue: `${selfAssessmentPendingCount} staff haven't completed EI self-assessment`, issue: `${selfAssessmentPendingCount} staff haven't completed EI self-assessment`,
severity: 'low', severity: 'low',
module: 'ei', module: 'ei',
action: 'openQuizResults',
}); });
} }
@ -141,6 +145,7 @@ export function buildDirectorRiskAreas(
issue: `${personalityPendingCount} staff haven't completed personality type quiz`, issue: `${personalityPendingCount} staff haven't completed personality type quiz`,
severity: 'low', severity: 'low',
module: 'ei', module: 'ei',
action: 'openQuizResults',
}); });
} }
@ -149,6 +154,7 @@ export function buildDirectorRiskAreas(
issue: `${zonePendingCount} staff haven't completed daily zone check-in`, issue: `${zonePendingCount} staff haven't completed daily zone check-in`,
severity: 'medium', severity: 'medium',
module: 'zones', module: 'zones',
action: 'openQuizResults',
}); });
} }
@ -293,50 +299,83 @@ export function buildDirectorQuizResults(
safetyRows: readonly SafetyQuizComplianceRow[], safetyRows: readonly SafetyQuizComplianceRow[],
emotionalIntelligenceCompletion?: PersonalityCompletionDto | null, emotionalIntelligenceCompletion?: PersonalityCompletionDto | null,
zoneCheckinCompletion?: ZoneCheckinCompletionDto | null, zoneCheckinCompletion?: ZoneCheckinCompletionDto | null,
tenantLabel = 'Current scope',
): readonly DirectorQuizResultRow[] { ): readonly DirectorQuizResultRow[] {
const behaviorRows = safetyRows.map((row): DirectorQuizResultRow => ({ const rowsByUserId = new Map<string, {
id: `${row.userId}-behavior-management`, staffName: string;
staffName: row.name, role: string;
details: DirectorQuizResultDetail[];
}>();
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, role: row.role,
quiz: 'Behavior Management', completedCount: row.details.filter((detail) => detail.status === 'complete').length,
result: row.score, totalCount: row.details.length,
date: row.date, details: row.details,
status: row.status,
})); }));
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( export function buildDirectorFramePreviews(

View File

@ -6,7 +6,7 @@ import type { ModuleId } from '@/shared/types/app';
export type DirectorDashboardTrend = 'up' | 'down'; export type DirectorDashboardTrend = 'up' | 'down';
export type DirectorDashboardRiskSeverity = 'high' | 'medium' | 'low'; export type DirectorDashboardRiskSeverity = 'high' | 'medium' | 'low';
export type DirectorDashboardAction = 'openAcknowledgments'; export type DirectorDashboardAction = 'openAcknowledgments' | 'openQuizResults';
export type DirectorOverviewIconId = 'clock' | 'shield' | 'eye' | 'users' | 'clipboard'; export type DirectorOverviewIconId = 'clock' | 'shield' | 'eye' | 'users' | 'clipboard';
export type DirectorOverviewTone = 'orange' | 'blue' | 'amber' | 'purple' | 'emerald'; export type DirectorOverviewTone = 'orange' | 'blue' | 'amber' | 'purple' | 'emerald';
export type DirectorFrameSectionLetter = 'F' | 'R' | 'A' | 'M' | 'E'; export type DirectorFrameSectionLetter = 'F' | 'R' | 'A' | 'M' | 'E';
@ -41,16 +41,24 @@ export interface DirectorFramePreview {
readonly sections: readonly DirectorFrameSectionPreview[]; readonly sections: readonly DirectorFrameSectionPreview[];
} }
export interface DirectorQuizResultRow { export interface DirectorQuizResultDetail {
readonly id: string; readonly id: string;
readonly staffName: string;
readonly role: string;
readonly quiz: string; readonly quiz: string;
readonly result: string; readonly result: string;
readonly date: string; readonly date: string;
readonly status: DirectorQuizResultStatus; 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 { export interface DirectorAcknowledgmentMissingStaff {
readonly userId: string; readonly userId: string;
readonly name: string; readonly name: string;

View File

@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { import {
createFrameEntry, createFrameEntry,
deleteFrameEntry, deleteFrameEntry,
type FrameEntryFilter,
listFrameEntries, listFrameEntries,
updateFrameEntry, updateFrameEntry,
} from '@/shared/api/frame'; } from '@/shared/api/frame';
@ -56,10 +57,10 @@ function isValidDraft(entry: EditableFrameEntry): boolean {
); );
} }
export function useFrameEntries() { export function useFrameEntries(filter?: FrameEntryFilter) {
return useQuery({ return useQuery({
queryKey: FRAME_QUERY_KEYS.entries, queryKey: [...FRAME_QUERY_KEYS.entries, filter],
queryFn: () => mapApiListRows(listFrameEntries(), toFrameEntryViewModel), queryFn: () => mapApiListRows(listFrameEntries(filter), toFrameEntryViewModel),
}); });
} }

View File

@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[86vh] overflow-y-auto border-slate-700/60 bg-slate-950 p-4 text-white shadow-2xl shadow-black/40 sm:max-w-5xl">
<DialogHeader className="pr-8">
<DialogTitle className="flex items-center gap-2 text-white">
<ClipboardCheck size={20} className="text-emerald-400" />
Document Acknowledgments
</DialogTitle>
<DialogDescription className="text-slate-400">
Current safety protocol and handbook versions for this scope.
</DialogDescription>
</DialogHeader>
<DirectorAcknowledgmentTrackingPanel
documents={documents}
showHeader={false}
/>
</DialogContent>
</Dialog>
);
}

View File

@ -2,20 +2,24 @@ import { useMemo, useState } from 'react';
import { ChevronDown, ClipboardCheck } from 'lucide-react'; import { ChevronDown, ClipboardCheck } from 'lucide-react';
import type { DirectorAcknowledgmentDocumentRow } from '@/business/director-dashboard/types'; import type { DirectorAcknowledgmentDocumentRow } from '@/business/director-dashboard/types';
import { cn } from '@/lib/utils';
interface DirectorAcknowledgmentTrackingPanelProps { interface DirectorAcknowledgmentTrackingPanelProps {
readonly documents: readonly DirectorAcknowledgmentDocumentRow[]; readonly documents: readonly DirectorAcknowledgmentDocumentRow[];
readonly showHeader?: boolean;
} }
export function DirectorAcknowledgmentTrackingPanel({ export function DirectorAcknowledgmentTrackingPanel({
documents, documents,
showHeader = true,
}: DirectorAcknowledgmentTrackingPanelProps) { }: DirectorAcknowledgmentTrackingPanelProps) {
const [isPanelExpanded, setIsPanelExpanded] = useState(false);
const [expandedDocumentIds, setExpandedDocumentIds] = useState<ReadonlySet<string>>( const [expandedDocumentIds, setExpandedDocumentIds] = useState<ReadonlySet<string>>(
() => new Set(), () => new Set(),
); );
const groupedDocuments = useMemo(() => groupDocumentsByCategory(documents), [documents]); const groupedDocuments = useMemo(() => groupDocumentsByCategory(documents), [documents]);
const missingAcknowledgmentCount = documents.reduce(
(total, document) => total + document.missingCount,
0,
);
function toggleDocument(documentId: string) { function toggleDocument(documentId: string) {
setExpandedDocumentIds((current) => { setExpandedDocumentIds((current) => {
@ -31,104 +35,117 @@ export function DirectorAcknowledgmentTrackingPanel({
return ( return (
<section className="rounded-2xl border border-violet-100 bg-white p-5 text-gray-900 shadow-sm"> <section className="rounded-2xl border border-violet-100 bg-white p-5 text-gray-900 shadow-sm">
{showHeader && ( <button
<div className="mb-4 flex flex-wrap items-start justify-between gap-3"> type="button"
<div> onClick={() => setIsPanelExpanded((current) => !current)}
<h3 className="flex items-center gap-2 font-semibold text-gray-800"> className="mb-4 flex w-full items-center justify-between gap-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2"
<ClipboardCheck size={18} className="text-emerald-600" /> aria-expanded={isPanelExpanded}
Document Acknowledgments aria-controls="director-acknowledgment-results-panel"
</h3> >
<p className="mt-1 text-sm text-gray-500"> <span>
Current safety protocol and handbook versions for this scope. <span className="flex items-center gap-2 font-semibold text-gray-800">
</p> <ClipboardCheck size={18} className="text-emerald-600" />
</div> Document Acknowledgments
</div> </span>
)} <span className="mt-1 block text-sm text-gray-500">
{documents.length} documents · {missingAcknowledgmentCount} missing acknowledgments
</span>
</span>
<ChevronDown
size={20}
className={cn('shrink-0 text-gray-400 transition-transform', isPanelExpanded && 'rotate-180')}
aria-hidden="true"
/>
</button>
{groupedDocuments.length === 0 ? ( <div id="director-acknowledgment-results-panel">
<p className="rounded-xl bg-gray-50 px-4 py-3 text-sm text-gray-500"> {isPanelExpanded && groupedDocuments.length === 0 ? (
No active documents are assigned to this scope yet. <p className="rounded-xl bg-gray-50 px-4 py-3 text-sm text-gray-500">
</p> No active documents are assigned to this scope yet.
) : ( </p>
<div className="space-y-5"> ) : isPanelExpanded ? (
{groupedDocuments.map((group) => ( <div className="space-y-5">
<div key={group.category} className="space-y-2"> {groupedDocuments.map((group) => (
<p className="text-xs font-bold uppercase text-gray-500"> <div key={group.category} className="space-y-2">
{group.label} <p className="text-xs font-bold uppercase text-gray-500">
</p> {group.label}
<div className="overflow-hidden rounded-xl border border-gray-100"> </p>
{group.documents.map((document, index) => { <div className="overflow-hidden rounded-xl border border-gray-100">
const isExpanded = expandedDocumentIds.has(document.id); {group.documents.map((document, index) => {
return ( const isExpanded = expandedDocumentIds.has(document.id);
<div return (
key={document.id} <div
className={index > 0 ? 'border-t border-gray-100' : undefined} 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"> <button
<span className="block truncate text-sm font-semibold text-gray-800"> type="button"
{document.title} onClick={() => toggleDocument(document.id)}
</span> 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"
<span className="mt-1 block text-xs text-gray-500"> aria-expanded={isExpanded}
Version {document.version} aria-controls={`acknowledgment-${document.id}`}
</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 ? ( <span className="min-w-0">
<p className="text-sm text-emerald-700"> <span className="block truncate text-sm font-semibold text-gray-800">
Every staff member in scope has acknowledged this version. {document.title}
</p> </span>
) : ( <span className="mt-1 block text-xs text-gray-500">
<div className="space-y-2"> Version {document.version}
<p className="text-xs font-semibold uppercase text-gray-500"> </span>
Not acknowledged </span>
<span className="flex shrink-0 items-center gap-3">
<span className={getStatusClassName(document.missingCount)}>
{document.acknowledgedCount}/{document.totalStaff}
</span>
<ChevronDown
size={18}
className={cn('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> </p>
{document.missingStaff.map((staff) => ( ) : (
<div <div className="space-y-2">
key={`${document.id}-${staff.userId}`} <p className="text-xs font-semibold uppercase text-gray-500">
className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-white px-3 py-2 text-sm" Not acknowledged
> </p>
<span className="font-medium text-gray-800">{staff.name}</span> {document.missingStaff.map((staff) => (
<span className="text-xs capitalize text-gray-500"> <div
{staff.role} key={`${document.id}-${staff.userId}`}
</span> className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-white px-3 py-2 text-sm"
</div> >
))} <span className="font-medium text-gray-800">{staff.name}</span>
</div> <span className="text-xs capitalize text-gray-500">
)} {staff.role}
</div> </span>
)} </div>
</div> ))}
); </div>
})} )}
</div>
)}
</div>
);
})}
</div>
</div> </div>
</div> ))}
))} </div>
</div> ) : (
)} <p className="rounded-xl bg-gray-50 px-4 py-3 text-sm text-gray-500">
Expand to review document acknowledgment status by category.
</p>
)}
</div>
</section> </section>
); );
} }

View File

@ -23,8 +23,8 @@ export function DirectorDashboardHeader({
title={title} title={title}
icon={BarChart3} icon={BarChart3}
iconClassName="bg-gradient-to-br from-purple-500 to-purple-700" iconClassName="bg-gradient-to-br from-purple-500 to-purple-700"
titleClassName="text-gray-800" iconShadowClassName="shadow-purple-500/30"
descriptionClassName="text-gray-500 flex items-center gap-2" descriptionClassName="flex items-center gap-2"
description={( description={(
<> <>
{scopeLabel {scopeLabel

View File

@ -1,9 +1,9 @@
import { useState } from 'react'; import { useRef } from 'react';
import type { ModuleId } from '@/shared/types/app'; import type { ModuleId } from '@/shared/types/app';
import type { DirectorDashboardPage } from '@/business/director-dashboard/types'; import type { DirectorDashboardPage } from '@/business/director-dashboard/types';
import { DirectorDashboardHeader } from '@/components/director-dashboard/DirectorDashboardHeader'; import { DirectorDashboardHeader } from '@/components/director-dashboard/DirectorDashboardHeader';
import { DirectorAcknowledgmentTrackingModal } from '@/components/director-dashboard/DirectorAcknowledgmentTrackingModal'; import { DirectorAcknowledgmentTrackingPanel } from '@/components/director-dashboard/DirectorAcknowledgmentTrackingPanel';
import { DirectorOverviewCards } from '@/components/director-dashboard/DirectorOverviewCards'; import { DirectorOverviewCards } from '@/components/director-dashboard/DirectorOverviewCards';
import { DirectorQuickActions } from '@/components/director-dashboard/DirectorQuickActions'; import { DirectorQuickActions } from '@/components/director-dashboard/DirectorQuickActions';
import { DirectorQuizResultsPanel } from '@/components/director-dashboard/DirectorQuizResultsPanel'; import { DirectorQuizResultsPanel } from '@/components/director-dashboard/DirectorQuizResultsPanel';
@ -22,9 +22,24 @@ export function DirectorDashboardView({
page, page,
onOpenModule, onOpenModule,
}: DirectorDashboardViewProps) { }: DirectorDashboardViewProps) {
const [isAcknowledgmentModalOpen, setIsAcknowledgmentModalOpen] = useState(false); const quizResultsRef = useRef<HTMLDivElement>(null);
const acknowledgmentResultsRef = useRef<HTMLDivElement>(null);
const errorMessage = getOptionalErrorMessage(page.error); 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) { if (page.isLoading) {
return <PageSkeleton />; return <PageSkeleton />;
} }
@ -48,16 +63,23 @@ export function DirectorDashboardView({
<DirectorOverviewCards <DirectorOverviewCards
cards={page.overviewCards} cards={page.overviewCards}
onOpenModule={onOpenModule} onOpenModule={onOpenModule}
onOpenAcknowledgments={() => setIsAcknowledgmentModalOpen(true)} onOpenAcknowledgments={scrollToAcknowledgments}
onOpenQuizResults={scrollToQuizResults}
/> />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
<DirectorRiskList <DirectorRiskList
risks={page.riskAreas} risks={page.riskAreas}
onOpenModule={onOpenModule} onOpenModule={onOpenModule}
onOpenAcknowledgments={() => setIsAcknowledgmentModalOpen(true)} onOpenAcknowledgments={scrollToAcknowledgments}
onOpenQuizResults={scrollToQuizResults}
/> />
<DirectorQuizResultsPanel results={page.quizResults} /> <div ref={quizResultsRef}>
<DirectorQuizResultsPanel results={page.quizResults} />
</div>
<div ref={acknowledgmentResultsRef}>
<DirectorAcknowledgmentTrackingPanel documents={page.acknowledgmentDocuments} />
</div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<DirectorRecentFramePanel <DirectorRecentFramePanel
@ -70,11 +92,6 @@ export function DirectorDashboardView({
/> />
</div> </div>
</div> </div>
<DirectorAcknowledgmentTrackingModal
documents={page.acknowledgmentDocuments}
open={isAcknowledgmentModalOpen}
onOpenChange={setIsAcknowledgmentModalOpen}
/>
</div> </div>
); );
} }

View File

@ -1,6 +1,6 @@
import type { ModuleId } from '@/shared/types/app'; import type { ModuleId } from '@/shared/types/app';
import type { DirectorOverviewCard } from '@/business/director-dashboard/types'; import type { DirectorOverviewCard } from '@/business/director-dashboard/types';
import { Button } from '@/components/ui/button'; import { DirectorScaleButton } from '@/components/director-dashboard/DirectorScaleButton';
import { import {
directorOverviewIcons, directorOverviewIcons,
directorOverviewToneClasses, directorOverviewToneClasses,
@ -12,12 +12,14 @@ interface DirectorOverviewCardsProps {
readonly cards: readonly DirectorOverviewCard[]; readonly cards: readonly DirectorOverviewCard[];
readonly onOpenModule: (module: ModuleId) => void; readonly onOpenModule: (module: ModuleId) => void;
readonly onOpenAcknowledgments?: () => void; readonly onOpenAcknowledgments?: () => void;
readonly onOpenQuizResults?: () => void;
} }
export function DirectorOverviewCards({ export function DirectorOverviewCards({
cards, cards,
onOpenModule, onOpenModule,
onOpenAcknowledgments, onOpenAcknowledgments,
onOpenQuizResults,
}: DirectorOverviewCardsProps) { }: DirectorOverviewCardsProps) {
function handleCardClick(card: DirectorOverviewCard) { function handleCardClick(card: DirectorOverviewCard) {
if (card.action === 'openAcknowledgments' && onOpenAcknowledgments) { if (card.action === 'openAcknowledgments' && onOpenAcknowledgments) {
@ -25,6 +27,11 @@ export function DirectorOverviewCards({
return; return;
} }
if (card.action === 'openQuizResults' && onOpenQuizResults) {
onOpenQuizResults();
return;
}
onOpenModule(card.module); onOpenModule(card.module);
} }
@ -35,11 +42,11 @@ export function DirectorOverviewCards({
const TrendIcon = directorTrendIcons[card.trend]; const TrendIcon = directorTrendIcons[card.trend];
return ( return (
<Button <DirectorScaleButton
key={card.label} key={card.label}
type="button" type="button"
onClick={() => handleCardClick(card)} onClick={() => handleCardClick(card)}
className="h-auto bg-white rounded-2xl border border-violet-100 shadow-sm p-5 text-left hover:shadow-md transition-all hover:-translate-y-0.5 group justify-start" className="h-auto rounded-2xl border border-violet-100 bg-white p-5 text-left shadow-sm"
> >
<div className="w-full"> <div className="w-full">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
@ -54,7 +61,7 @@ export function DirectorOverviewCards({
<p className="text-2xl font-bold text-gray-800">{card.value}</p> <p className="text-2xl font-bold text-gray-800">{card.value}</p>
<p className="text-xs text-gray-500 mt-0.5">{card.label}</p> <p className="text-xs text-gray-500 mt-0.5">{card.label}</p>
</div> </div>
</Button> </DirectorScaleButton>
); );
})} })}
</div> </div>

View File

@ -1,6 +1,6 @@
import type { ModuleId } from '@/shared/types/app'; import type { ModuleId } from '@/shared/types/app';
import type { DirectorQuickActionConfig } from '@/shared/constants/directorDashboard'; import type { DirectorQuickActionConfig } from '@/shared/constants/directorDashboard';
import { Button } from '@/components/ui/button'; import { DirectorScaleButton } from '@/components/director-dashboard/DirectorScaleButton';
import { import {
directorQuickActionIcons, directorQuickActionIcons,
directorQuickActionToneClasses, directorQuickActionToneClasses,
@ -23,15 +23,15 @@ export function DirectorQuickActions({
const ActionIcon = action.iconId ? directorQuickActionIcons[action.iconId] : undefined; const ActionIcon = action.iconId ? directorQuickActionIcons[action.iconId] : undefined;
return ( return (
<Button <DirectorScaleButton
key={action.label} key={action.label}
type="button" type="button"
onClick={() => onOpenModule(action.module)} onClick={() => onOpenModule(action.module)}
className={`w-full h-auto text-left px-4 py-2.5 rounded-xl text-sm font-medium transition-all flex items-center gap-2 justify-start ${directorQuickActionToneClasses[action.tone]}`} className={`flex h-auto w-full items-center justify-start gap-2 rounded-xl px-4 py-2.5 text-left text-sm font-medium ${directorQuickActionToneClasses[action.tone]}`}
> >
{ActionIcon && <ActionIcon size={14} />} {ActionIcon && <ActionIcon size={14} />}
{action.label} {action.label}
</Button> </DirectorScaleButton>
); );
})} })}
</div> </div>

View File

@ -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 { 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 type { DirectorQuizResultRow } from '@/business/director-dashboard/types';
import { cn } from '@/lib/utils';
interface DirectorQuizResultsPanelProps { interface DirectorQuizResultsPanelProps {
readonly results: readonly DirectorQuizResultRow[]; readonly results: readonly DirectorQuizResultRow[];
} }
export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelProps) { export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelProps) {
const [isPanelExpanded, setIsPanelExpanded] = useState(false);
const [expandedUserIds, setExpandedUserIds] = useState<ReadonlySet<string>>(() => 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 ( return (
<div className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5"> <div className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5">
<h3 className="font-semibold text-gray-800 mb-4 flex items-center gap-2"> <button
<Users size={18} className="text-violet-500" /> type="button"
Quiz Results from Database onClick={() => setIsPanelExpanded((current) => !current)}
</h3> className="mb-4 flex w-full items-center justify-between gap-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2"
{results.length > 0 ? ( aria-expanded={isPanelExpanded}
<Table> aria-controls="director-quiz-results-panel"
<TableHeader> >
<TableRow className="bg-gray-50 hover:bg-gray-50"> <span>
<TableHead className="h-auto p-3 text-left text-gray-500">Staff</TableHead> <span className="font-semibold text-gray-800 flex items-center gap-2">
<TableHead className="h-auto p-3 text-left text-gray-500">Quiz</TableHead> <Users size={18} className="text-violet-500" />
<TableHead className="h-auto p-3 text-center text-gray-500">Role</TableHead> Quiz Results
<TableHead className="h-auto p-3 text-center text-gray-500">Result</TableHead> </span>
<TableHead className="h-auto p-3 text-center text-gray-500">Date</TableHead> <span className="mt-1 block text-sm text-gray-500">
</TableRow> {results.length} staff · {completedQuizCount}/{totalQuizCount} quizzes complete
</TableHeader> </span>
<TableBody> </span>
{results.map((result) => ( <ChevronDown
<TableRow key={result.id} className="border-t border-gray-50"> size={20}
<TableCell className="p-3 font-medium text-gray-700">{result.staffName}</TableCell> className={cn('shrink-0 text-gray-400 transition-transform', isPanelExpanded && 'rotate-180')}
<TableCell className="p-3 text-sm text-gray-600">{result.quiz}</TableCell> aria-hidden="true"
<TableCell className="p-3 text-center text-xs text-gray-500 capitalize">{result.role}</TableCell> />
<TableCell className="p-3 text-center"> </button>
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${
result.status === 'complete' <div id="director-quiz-results-panel">
? 'bg-emerald-100 text-emerald-700' {isPanelExpanded && results.length > 0 ? (
: 'bg-amber-100 text-amber-700' <div className="overflow-hidden rounded-xl border border-gray-100">
}`}> <div className="grid grid-cols-[minmax(0,1.5fr)_minmax(0,1.2fr)_minmax(8rem,0.8fr)_minmax(7rem,0.7fr)_2rem] gap-3 bg-gray-50 px-4 py-3 text-xs font-semibold uppercase text-gray-500">
{result.result} <span>Staff</span>
</span> <span>Tenant</span>
</TableCell> <span className="text-center">Role</span>
<TableCell className="p-3 text-center text-xs text-gray-400"> <span className="text-center">Quizzes</span>
{result.date} <span className="sr-only">Toggle</span>
</TableCell> </div>
</TableRow> <div className="divide-y divide-gray-100">
))} {results.map((result) => {
</TableBody> const isExpanded = expandedUserIds.has(result.id);
</Table> return (
) : ( <div key={result.id}>
<StatePanel className="border-0 bg-transparent" alignment="center"> <button
No staff are in this completion scope yet. type="button"
</StatePanel> onClick={() => toggleUser(result.id)}
)} className="grid w-full grid-cols-[minmax(0,1.5fr)_minmax(0,1.2fr)_minmax(8rem,0.8fr)_minmax(7rem,0.7fr)_2rem] items-center gap-3 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={`quiz-results-${result.id}`}
>
<span className="truncate font-medium text-gray-700">{result.staffName}</span>
<span className="truncate text-sm text-gray-500">{result.tenant}</span>
<span className="text-center text-xs capitalize text-gray-500">{result.role}</span>
<span className="text-center">
<span className={cn(
'rounded-lg px-2.5 py-1 text-xs font-semibold',
result.completedCount === result.totalCount
? 'bg-emerald-100 text-emerald-700'
: 'bg-amber-100 text-amber-700',
)}>
{result.completedCount}/{result.totalCount}
</span>
</span>
<ChevronDown
size={18}
className={cn('text-gray-400 transition-transform', isExpanded && 'rotate-180')}
aria-hidden="true"
/>
</button>
{isExpanded && (
<div id={`quiz-results-${result.id}`} className="bg-gray-50 px-4 py-3">
<div className="overflow-hidden rounded-lg border border-gray-100 bg-white">
<div className="grid grid-cols-[minmax(0,1.3fr)_8rem_minmax(0,1fr)] gap-3 border-b border-gray-100 px-3 py-2 text-xs font-semibold uppercase text-gray-500">
<span>Quiz</span>
<span>Date</span>
<span>Result</span>
</div>
<div className="divide-y divide-gray-100">
{result.details.map((detail) => (
<div
key={`${result.id}-${detail.id}`}
className="grid grid-cols-[minmax(0,1.3fr)_8rem_minmax(0,1fr)] items-center gap-3 px-3 py-2 text-sm"
>
<span className="font-medium text-gray-700">{detail.quiz}</span>
<span className="text-xs text-gray-500">{detail.date}</span>
<span>
<span className={cn(
'rounded-lg px-2 py-0.5 text-xs font-semibold',
detail.status === 'complete'
? 'bg-emerald-100 text-emerald-700'
: 'bg-amber-100 text-amber-700',
)}>
{detail.result}
</span>
</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
) : isPanelExpanded ? (
<StatePanel className="border-0 bg-transparent" alignment="center">
No staff are in this completion scope yet.
</StatePanel>
) : (
<div className="rounded-xl bg-gray-50 px-4 py-3 text-sm text-gray-500">
Expand to review each staff member's quiz completion details.
</div>
)}
</div>
</div> </div>
); );
} }

View File

@ -2,7 +2,7 @@ import { Eye } from 'lucide-react';
import type { ModuleId } from '@/shared/types/app'; import type { ModuleId } from '@/shared/types/app';
import type { DirectorFramePreview } from '@/business/director-dashboard/types'; 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 { StatePanel } from '@/components/ui/state-panel';
import { import {
directorFrameSectionClasses, directorFrameSectionClasses,
@ -47,14 +47,14 @@ export function DirectorRecentFramePanel({
No F.R.A.M.E. entries are available yet. No F.R.A.M.E. entries are available yet.
</StatePanel> </StatePanel>
)} )}
<Button <DirectorScaleButton
type="button" type="button"
onClick={() => onOpenModule('frame')} onClick={() => onOpenModule('frame')}
className="w-full mt-3 bg-transparent hover:bg-transparent text-sm text-amber-600 hover:text-amber-800 font-medium flex items-center justify-center gap-1" className="mt-3 flex w-full items-center justify-center gap-1 bg-transparent text-sm font-medium text-amber-600"
> >
View All Entries View All Entries
<NavigateIcon size={14} /> <NavigateIcon size={14} />
</Button> </DirectorScaleButton>
</div> </div>
); );
} }

View File

@ -1,6 +1,6 @@
import type { ModuleId } from '@/shared/types/app'; import type { ModuleId } from '@/shared/types/app';
import type { DirectorRiskArea } from '@/business/director-dashboard/types'; import type { DirectorRiskArea } from '@/business/director-dashboard/types';
import { Button } from '@/components/ui/button'; import { DirectorScaleButton } from '@/components/director-dashboard/DirectorScaleButton';
import { import {
directorNavigateIcon, directorNavigateIcon,
directorRiskIcon, directorRiskIcon,
@ -11,12 +11,14 @@ interface DirectorRiskListProps {
readonly risks: readonly DirectorRiskArea[]; readonly risks: readonly DirectorRiskArea[];
readonly onOpenModule: (module: ModuleId) => void; readonly onOpenModule: (module: ModuleId) => void;
readonly onOpenAcknowledgments?: () => void; readonly onOpenAcknowledgments?: () => void;
readonly onOpenQuizResults?: () => void;
} }
export function DirectorRiskList({ export function DirectorRiskList({
risks, risks,
onOpenModule, onOpenModule,
onOpenAcknowledgments, onOpenAcknowledgments,
onOpenQuizResults,
}: DirectorRiskListProps) { }: DirectorRiskListProps) {
const RiskIcon = directorRiskIcon; const RiskIcon = directorRiskIcon;
const NavigateIcon = directorNavigateIcon; const NavigateIcon = directorNavigateIcon;
@ -27,6 +29,11 @@ export function DirectorRiskList({
return; return;
} }
if (risk.action === 'openQuizResults' && onOpenQuizResults) {
onOpenQuizResults();
return;
}
onOpenModule(risk.module); onOpenModule(risk.module);
} }
@ -38,11 +45,11 @@ export function DirectorRiskList({
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
{risks.map((risk) => ( {risks.map((risk) => (
<Button <DirectorScaleButton
key={`${risk.module}-${risk.issue}`} key={`${risk.module}-${risk.issue}`}
type="button" type="button"
onClick={() => handleRiskClick(risk)} onClick={() => handleRiskClick(risk)}
className={`w-full h-auto text-left p-4 rounded-xl border ${directorRiskSeverityClasses[risk.severity]} flex items-center justify-between gap-3 hover:shadow-sm transition-all`} className={`flex h-auto w-full items-center justify-between gap-3 rounded-xl border p-4 text-left ${directorRiskSeverityClasses[risk.severity]}`}
> >
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<span className={`px-2 py-1 rounded-lg text-[10px] font-bold uppercase ${directorRiskSeverityClasses[risk.severity]}`}> <span className={`px-2 py-1 rounded-lg text-[10px] font-bold uppercase ${directorRiskSeverityClasses[risk.severity]}`}>
@ -51,7 +58,7 @@ export function DirectorRiskList({
<p className="min-w-0 break-words text-sm font-medium text-gray-700">{risk.issue}</p> <p className="min-w-0 break-words text-sm font-medium text-gray-700">{risk.issue}</p>
</div> </div>
<NavigateIcon size={14} className="shrink-0 text-gray-400" /> <NavigateIcon size={14} className="shrink-0 text-gray-400" />
</Button> </DirectorScaleButton>
))} ))}
</div> </div>
</div> </div>

View File

@ -0,0 +1,29 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface DirectorScaleButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
readonly children: ReactNode;
}
export function DirectorScaleButton({
children,
className,
type = 'button',
disabled,
...props
}: DirectorScaleButtonProps) {
return (
<button
type={type}
disabled={disabled}
className={cn(
'transition-transform hover:scale-[1.01] active:scale-[0.99] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
className,
)}
{...props}
>
{children}
</button>
);
}

View File

@ -1,4 +1,4 @@
import { Button } from '@/components/ui/button'; import { DirectorScaleButton } from '@/components/director-dashboard/DirectorScaleButton';
import { import {
DIRECTOR_DASHBOARD_TIME_RANGES, DIRECTOR_DASHBOARD_TIME_RANGES,
type DirectorDashboardTimeRange, type DirectorDashboardTimeRange,
@ -16,18 +16,18 @@ export function DirectorTimeRangeTabs({
return ( return (
<div className="flex gap-2 bg-white rounded-xl border border-violet-100 p-1"> <div className="flex gap-2 bg-white rounded-xl border border-violet-100 p-1">
{DIRECTOR_DASHBOARD_TIME_RANGES.map((range) => ( {DIRECTOR_DASHBOARD_TIME_RANGES.map((range) => (
<Button <DirectorScaleButton
key={range} key={range}
type="button" type="button"
onClick={() => onTimeRangeChange(range)} onClick={() => onTimeRangeChange(range)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${ className={`rounded-lg px-4 py-2 text-sm font-medium ${
timeRange === range timeRange === range
? 'bg-purple-500 text-white shadow-sm' ? 'bg-purple-500 text-white shadow-sm'
: 'bg-transparent text-gray-500 hover:text-gray-700 hover:bg-transparent' : 'bg-transparent text-gray-500'
}`} }`}
> >
{range.charAt(0).toUpperCase() + range.slice(1)} {range.charAt(0).toUpperCase() + range.slice(1)}
</Button> </DirectorScaleButton>
))} ))}
</div> </div>
); );

View File

@ -71,10 +71,10 @@ export const directorFrameSectionClasses: Record<DirectorFrameSectionLetter, str
}; };
export const directorQuickActionToneClasses: Record<DirectorQuickActionTone, string> = { export const directorQuickActionToneClasses: Record<DirectorQuickActionTone, string> = {
indigo: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200', indigo: 'bg-indigo-100 text-indigo-700',
amber: 'bg-amber-100 text-amber-700 hover:bg-amber-200', amber: 'bg-amber-100 text-amber-700',
blue: 'bg-blue-100 text-blue-700 hover:bg-blue-200', blue: 'bg-blue-100 text-blue-700',
orange: 'bg-orange-100 text-orange-700 hover:bg-orange-200', orange: 'bg-orange-100 text-orange-700',
rose: 'bg-rose-100 text-rose-700 hover:bg-rose-200', rose: 'bg-rose-100 text-rose-700',
red: 'bg-red-100 text-red-700 hover:bg-red-200', red: 'bg-red-100 text-red-700',
}; };

View File

@ -35,6 +35,17 @@ describe('frame API', () => {
expect(apiRequestMock).toHaveBeenCalledWith('/frame_entries'); 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', () => { it('creates FRAME entries with POST body wrapped in data', () => {
void createFrameEntry(frameRequest); void createFrameEntry(frameRequest);

View File

@ -4,8 +4,32 @@ import { FrameEntryDto, FrameEntryMutationDto } from '@/shared/types/frame';
const FRAME_ENTRIES_PATH = '/frame_entries'; const FRAME_ENTRIES_PATH = '/frame_entries';
export function listFrameEntries(): Promise<ApiListResponse<FrameEntryDto>> { export interface FrameEntryFilter {
return apiRequest<ApiListResponse<FrameEntryDto>>(FRAME_ENTRIES_PATH); 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<ApiListResponse<FrameEntryDto>> {
return apiRequest<ApiListResponse<FrameEntryDto>>(`${FRAME_ENTRIES_PATH}${toSearchParams(filter)}`);
} }
export function createFrameEntry(request: FrameEntryMutationDto): Promise<FrameEntryDto> { export function createFrameEntry(request: FrameEntryMutationDto): Promise<FrameEntryDto> {

View File

@ -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 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_FRAME_PREVIEW_LIMIT = 3;
export const DIRECTOR_DASHBOARD_TEXT_PREVIEW_LENGTH = 60; export const DIRECTOR_DASHBOARD_TEXT_PREVIEW_LENGTH = 60;
export const DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD = 50; export const DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD = 50;

View File

@ -52,10 +52,11 @@ async function login(page: Page, email: string): Promise<void> {
} }
async function findAudio(page: Page, title: string): Promise<AudioRow | undefined> { async function findAudio(page: Page, title: string): Promise<AudioRow | undefined> {
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); expect(res.status()).toBe(200);
const body = (await res.json()) as { rows?: AudioRow[] }; const body = (await res.json()) as { rows?: AudioRow[] };
return (body.rows ?? []).find((row) => row.title === title); return body.rows?.[0];
} }
test.describe('Audio library', () => { test.describe('Audio library', () => {