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

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

View File

@ -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<string, string>();
// 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(
(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<Permissions>[] =
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<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
// configured super-admin email).

View File

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

View File

@ -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<ContentCatalog>[] =
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) => {

View File

@ -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) => {

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 { 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<DirectorDashboardTimeRange>('month');
const [timeRange, setTimeRangeState] = useState<DirectorDashboardTimeRange>(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.
}
}
},
};
}

View File

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

View File

@ -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<string, {
staffName: string;
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,
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(

View File

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

View File

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

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 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<ReadonlySet<string>>(
() => 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 (
<section className="rounded-2xl border border-violet-100 bg-white p-5 text-gray-900 shadow-sm">
{showHeader && (
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="flex items-center gap-2 font-semibold text-gray-800">
<ClipboardCheck size={18} className="text-emerald-600" />
Document Acknowledgments
</h3>
<p className="mt-1 text-sm text-gray-500">
Current safety protocol and handbook versions for this scope.
</p>
</div>
</div>
)}
<button
type="button"
onClick={() => setIsPanelExpanded((current) => !current)}
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"
aria-expanded={isPanelExpanded}
aria-controls="director-acknowledgment-results-panel"
>
<span>
<span className="flex items-center gap-2 font-semibold text-gray-800">
<ClipboardCheck size={18} className="text-emerald-600" />
Document Acknowledgments
</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 ? (
<p className="rounded-xl bg-gray-50 px-4 py-3 text-sm text-gray-500">
No active documents are assigned to this scope yet.
</p>
) : (
<div className="space-y-5">
{groupedDocuments.map((group) => (
<div key={group.category} className="space-y-2">
<p className="text-xs font-bold uppercase text-gray-500">
{group.label}
</p>
<div className="overflow-hidden rounded-xl border border-gray-100">
{group.documents.map((document, index) => {
const isExpanded = expandedDocumentIds.has(document.id);
return (
<div
key={document.id}
className={index > 0 ? 'border-t border-gray-100' : undefined}
>
<button
type="button"
onClick={() => toggleDocument(document.id)}
className="flex w-full items-center justify-between gap-4 bg-white px-4 py-3 text-left transition-transform hover:scale-[1.005] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2"
aria-expanded={isExpanded}
aria-controls={`acknowledgment-${document.id}`}
<div id="director-acknowledgment-results-panel">
{isPanelExpanded && groupedDocuments.length === 0 ? (
<p className="rounded-xl bg-gray-50 px-4 py-3 text-sm text-gray-500">
No active documents are assigned to this scope yet.
</p>
) : isPanelExpanded ? (
<div className="space-y-5">
{groupedDocuments.map((group) => (
<div key={group.category} className="space-y-2">
<p className="text-xs font-bold uppercase text-gray-500">
{group.label}
</p>
<div className="overflow-hidden rounded-xl border border-gray-100">
{group.documents.map((document, index) => {
const isExpanded = expandedDocumentIds.has(document.id);
return (
<div
key={document.id}
className={index > 0 ? 'border-t border-gray-100' : undefined}
>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-gray-800">
{document.title}
</span>
<span className="mt-1 block text-xs text-gray-500">
Version {document.version}
</span>
</span>
<span className="flex shrink-0 items-center gap-3">
<span className={getStatusClassName(document.missingCount)}>
{document.acknowledgedCount}/{document.totalStaff}
</span>
<ChevronDown
size={18}
className={`text-gray-400 transition-transform ${
isExpanded ? 'rotate-180' : ''
}`}
aria-hidden="true"
/>
</span>
</button>
{isExpanded && (
<div
id={`acknowledgment-${document.id}`}
className="bg-gray-50 px-4 py-3"
<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}`}
>
{document.missingStaff.length === 0 ? (
<p className="text-sm text-emerald-700">
Every staff member in scope has acknowledged this version.
</p>
) : (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase text-gray-500">
Not acknowledged
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-gray-800">
{document.title}
</span>
<span className="mt-1 block text-xs text-gray-500">
Version {document.version}
</span>
</span>
<span className="flex shrink-0 items-center gap-3">
<span className={getStatusClassName(document.missingCount)}>
{document.acknowledgedCount}/{document.totalStaff}
</span>
<ChevronDown
size={18}
className={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>
{document.missingStaff.map((staff) => (
<div
key={`${document.id}-${staff.userId}`}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-white px-3 py-2 text-sm"
>
<span className="font-medium text-gray-800">{staff.name}</span>
<span className="text-xs capitalize text-gray-500">
{staff.role}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
})}
) : (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase text-gray-500">
Not acknowledged
</p>
{document.missingStaff.map((staff) => (
<div
key={`${document.id}-${staff.userId}`}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-white px-3 py-2 text-sm"
>
<span className="font-medium text-gray-800">{staff.name}</span>
<span className="text-xs capitalize text-gray-500">
{staff.role}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
</div>
))}
</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>
);
}

View File

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

View File

@ -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<HTMLDivElement>(null);
const acknowledgmentResultsRef = useRef<HTMLDivElement>(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 <PageSkeleton />;
}
@ -48,16 +63,23 @@ export function DirectorDashboardView({
<DirectorOverviewCards
cards={page.overviewCards}
onOpenModule={onOpenModule}
onOpenAcknowledgments={() => setIsAcknowledgmentModalOpen(true)}
onOpenAcknowledgments={scrollToAcknowledgments}
onOpenQuizResults={scrollToQuizResults}
/>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<DirectorRiskList
risks={page.riskAreas}
onOpenModule={onOpenModule}
onOpenAcknowledgments={() => setIsAcknowledgmentModalOpen(true)}
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 className="space-y-4">
<DirectorRecentFramePanel
@ -70,11 +92,6 @@ export function DirectorDashboardView({
/>
</div>
</div>
<DirectorAcknowledgmentTrackingModal
documents={page.acknowledgmentDocuments}
open={isAcknowledgmentModalOpen}
onOpenChange={setIsAcknowledgmentModalOpen}
/>
</div>
);
}

View File

@ -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 (
<Button
<DirectorScaleButton
key={card.label}
type="button"
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="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-xs text-gray-500 mt-0.5">{card.label}</p>
</div>
</Button>
</DirectorScaleButton>
);
})}
</div>

View File

@ -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 (
<Button
<DirectorScaleButton
key={action.label}
type="button"
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} />}
{action.label}
</Button>
</DirectorScaleButton>
);
})}
</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 {
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<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 (
<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">
<Users size={18} className="text-violet-500" />
Quiz Results from Database
</h3>
{results.length > 0 ? (
<Table>
<TableHeader>
<TableRow className="bg-gray-50 hover:bg-gray-50">
<TableHead className="h-auto p-3 text-left text-gray-500">Staff</TableHead>
<TableHead className="h-auto p-3 text-left text-gray-500">Quiz</TableHead>
<TableHead className="h-auto p-3 text-center text-gray-500">Role</TableHead>
<TableHead className="h-auto p-3 text-center text-gray-500">Result</TableHead>
<TableHead className="h-auto p-3 text-center text-gray-500">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{results.map((result) => (
<TableRow key={result.id} className="border-t border-gray-50">
<TableCell className="p-3 font-medium text-gray-700">{result.staffName}</TableCell>
<TableCell className="p-3 text-sm text-gray-600">{result.quiz}</TableCell>
<TableCell className="p-3 text-center text-xs text-gray-500 capitalize">{result.role}</TableCell>
<TableCell className="p-3 text-center">
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${
result.status === 'complete'
? 'bg-emerald-100 text-emerald-700'
: 'bg-amber-100 text-amber-700'
}`}>
{result.result}
</span>
</TableCell>
<TableCell className="p-3 text-center text-xs text-gray-400">
{result.date}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<StatePanel className="border-0 bg-transparent" alignment="center">
No staff are in this completion scope yet.
</StatePanel>
)}
<button
type="button"
onClick={() => setIsPanelExpanded((current) => !current)}
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"
aria-expanded={isPanelExpanded}
aria-controls="director-quiz-results-panel"
>
<span>
<span className="font-semibold text-gray-800 flex items-center gap-2">
<Users size={18} className="text-violet-500" />
Quiz Results
</span>
<span className="mt-1 block text-sm text-gray-500">
{results.length} staff · {completedQuizCount}/{totalQuizCount} quizzes complete
</span>
</span>
<ChevronDown
size={20}
className={cn('shrink-0 text-gray-400 transition-transform', isPanelExpanded && 'rotate-180')}
aria-hidden="true"
/>
</button>
<div id="director-quiz-results-panel">
{isPanelExpanded && results.length > 0 ? (
<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">
<span>Staff</span>
<span>Tenant</span>
<span className="text-center">Role</span>
<span className="text-center">Quizzes</span>
<span className="sr-only">Toggle</span>
</div>
<div className="divide-y divide-gray-100">
{results.map((result) => {
const isExpanded = expandedUserIds.has(result.id);
return (
<div key={result.id}>
<button
type="button"
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>
);
}

View File

@ -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.
</StatePanel>
)}
<Button
<DirectorScaleButton
type="button"
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
<NavigateIcon size={14} />
</Button>
</DirectorScaleButton>
</div>
);
}

View File

@ -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({
</h3>
<div className="space-y-3">
{risks.map((risk) => (
<Button
<DirectorScaleButton
key={`${risk.module}-${risk.issue}`}
type="button"
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">
<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>
</div>
<NavigateIcon size={14} className="shrink-0 text-gray-400" />
</Button>
</DirectorScaleButton>
))}
</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 {
DIRECTOR_DASHBOARD_TIME_RANGES,
type DirectorDashboardTimeRange,
@ -16,18 +16,18 @@ export function DirectorTimeRangeTabs({
return (
<div className="flex gap-2 bg-white rounded-xl border border-violet-100 p-1">
{DIRECTOR_DASHBOARD_TIME_RANGES.map((range) => (
<Button
<DirectorScaleButton
key={range}
type="button"
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
? '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)}
</Button>
</DirectorScaleButton>
))}
</div>
);

View File

@ -71,10 +71,10 @@ export const directorFrameSectionClasses: Record<DirectorFrameSectionLetter, str
};
export const directorQuickActionToneClasses: Record<DirectorQuickActionTone, string> = {
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',
};

View File

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

View File

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

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> {
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', () => {