improved leaders dashboards UI/UX
This commit is contained in:
parent
0fbf6c0387
commit
d12d2b0a10
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
19
backend/src/db/umzug.test.ts
Normal file
19
backend/src/db/umzug.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
26
frontend/src/business/director-dashboard/hooks.test.ts
Normal file
26
frontend/src/business/director-dashboard/hooks.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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',
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user