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.
|
All routes require JWT authentication.
|
||||||
|
|
||||||
- `GET /api/frame_entries` -> `200` `{ rows, count }` for the current user's organization
|
- `GET /api/frame_entries` -> `200` `{ rows, count }` for the current user's organization
|
||||||
(paginated via `resolvePagination`).
|
(paginated via `resolvePagination`). Optional `startDate` and `endDate`
|
||||||
|
query params filter entries by their canonical Sunday-start `week_of` date,
|
||||||
|
inclusive.
|
||||||
- `POST /api/frame_entries` -> `201` the created entry DTO.
|
- `POST /api/frame_entries` -> `201` the created entry DTO.
|
||||||
- `PUT /api/frame_entries/:id` -> `200` the updated entry DTO (scoped to the org).
|
- `PUT /api/frame_entries/:id` -> `200` the updated entry DTO (scoped to the org).
|
||||||
|
|
||||||
@ -58,7 +60,8 @@ free-text note for that week (e.g. "Spring Break week"), stored trimmed or `null
|
|||||||
## Behavior / Notes
|
## Behavior / Notes
|
||||||
|
|
||||||
- Create/update run inside `withTransaction`.
|
- Create/update run inside `withTransaction`.
|
||||||
- List is paginated with the shared defaults (`resolvePagination`).
|
- List is paginated with the shared defaults (`resolvePagination`) and can be
|
||||||
|
range-filtered for leadership dashboards via `startDate` / `endDate`.
|
||||||
- The same Sunday-start canonicalization is used on the frontend
|
- The same Sunday-start canonicalization is used on the frontend
|
||||||
(`shared/business/week.ts`) for the dashboard hero, the safety-quiz week, and the
|
(`shared/business/week.ts`) for the dashboard hero, the safety-quiz week, and the
|
||||||
F.R.A.M.E. week picker, so the week is consistent everywhere.
|
F.R.A.M.E. week picker, so the week is consistent everywhere.
|
||||||
|
|||||||
@ -24,7 +24,7 @@ the runner, file conventions, and how to add a migration or seeder. For VM/PM2 o
|
|||||||
`20260611070000-campuses-timezone.ts` (the required `campuses.timezone` IANA column — added
|
`20260611070000-campuses-timezone.ts` (the required `campuses.timezone` IANA column — added
|
||||||
nullable, backfilled, then set NOT NULL), and `20260612000000-frame-entries-week-label.ts`
|
nullable, backfilled, then set NOT NULL), and `20260612000000-frame-entries-week-label.ts`
|
||||||
(the optional `frame_entries.week_label`; `week_of` is now the canonical Sunday-start ISO date).
|
(the optional `frame_entries.week_label`; `week_of` is now the canonical Sunday-start ISO date).
|
||||||
- Seeders: `src/db/seeders/*.ts` — `admin-user` (the system users, the primary
|
- Seeders: `src/db/seeders/[0-9]*.ts` — `admin-user` (the system users, the primary
|
||||||
tenant's per-role users, and the secondary tenant's per-role users from
|
tenant's per-role users, and the secondary tenant's per-role users from
|
||||||
`shared/constants/seed-fixtures.ts`),
|
`shared/constants/seed-fixtures.ts`),
|
||||||
`user-roles` (the first-class roles, the permission catalog incl. product-feature
|
`user-roles` (the first-class roles, the permission catalog incl. product-feature
|
||||||
@ -39,7 +39,12 @@ the runner, file conventions, and how to add a migration or seeder. For VM/PM2 o
|
|||||||
|
|
||||||
- `migrator` globs `migrations/*.{ts,js}`; history is tracked in the default `SequelizeMeta`
|
- `migrator` globs `migrations/*.{ts,js}`; history is tracked in the default `SequelizeMeta`
|
||||||
table via `SequelizeStorage`.
|
table via `SequelizeStorage`.
|
||||||
- `seeder` globs `seeders/*.{ts,js}`; history is tracked in a separate `SequelizeData` table.
|
- `seeder` globs `seeders/[0-9]*.{ts,js}`; history is tracked in a separate
|
||||||
|
`SequelizeData` model/table. The runner sets both `modelName` and
|
||||||
|
`tableName` to `SequelizeData`, which prevents Umzug from reusing the default
|
||||||
|
`SequelizeMeta` model when seeders run after migrations in the same process.
|
||||||
|
Only timestamped seeder files are loaded; colocated `*.test.ts` files are
|
||||||
|
intentionally excluded from `db:seed`.
|
||||||
- Each file is ESM TypeScript with a default export `{ up, down }`, each taking
|
- Each file is ESM TypeScript with a default export `{ up, down }`, each taking
|
||||||
`(queryInterface, Sequelize)`. The runner accepts either a `default` export or top-level
|
`(queryInterface, Sequelize)`. The runner accepts either a `default` export or top-level
|
||||||
`up`/`down`.
|
`up`/`down`.
|
||||||
@ -47,6 +52,10 @@ the runner, file conventions, and how to add a migration or seeder. For VM/PM2 o
|
|||||||
executed via `tsx` (dev, `.ts`) or compiled (`prod`, `dist/.../*.js`).
|
executed via `tsx` (dev, `.ts`) or compiled (`prod`, `dist/.../*.js`).
|
||||||
- The `glob` accepts both `.ts` and `.js`, so already-applied entries are not re-run after a
|
- The `glob` accepts both `.ts` and `.js`, so already-applied entries are not re-run after a
|
||||||
build.
|
build.
|
||||||
|
- Seeders are append/idempotent: when a seeded row or relationship already
|
||||||
|
exists, the seeder preserves it and inserts only missing rows/links. This
|
||||||
|
lets `db:seed` recover from incomplete `SequelizeData` history without
|
||||||
|
deleting tenant-edited content or failing on duplicate keys.
|
||||||
|
|
||||||
## CLI And Scripts
|
## CLI And Scripts
|
||||||
|
|
||||||
@ -61,15 +70,20 @@ the runner, file conventions, and how to add a migration or seeder. For VM/PM2 o
|
|||||||
|
|
||||||
## Authoring A New Migration / Seeder
|
## Authoring A New Migration / Seeder
|
||||||
|
|
||||||
1. Add `src/db/migrations/<timestamp>-<name>.ts` (or `seeders/...`) exporting
|
1. Add `src/db/migrations/<timestamp>-<name>.ts` (or `seeders/<timestamp>-<name>.ts`) exporting
|
||||||
`export default { up, down }` with typed `(queryInterface, Sequelize)` signatures.
|
`export default { up, down }` with typed `(queryInterface, Sequelize)` signatures.
|
||||||
2. Run `npm run db:migrate` (or `db:seed`) in dev; verify with `db:migrate:pending`.
|
2. For seeders, use stable natural keys (`id`, `name`, `importHash`, or a
|
||||||
3. Regenerate `database-schema.md` after any schema change (it is generated from the models).
|
junction pair) and insert only the missing rows. Do not delete/reinsert
|
||||||
|
tenant-editable seed content in `up`.
|
||||||
|
3. Run `npm run db:migrate` (or `db:seed`) in dev; verify with `db:migrate:pending`.
|
||||||
|
4. Regenerate `database-schema.md` after any schema change (it is generated from the models).
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
- `src/shared/constants/seed-fixtures.test.ts` covers primary/secondary tenant
|
- `src/shared/constants/seed-fixtures.test.ts` covers primary/secondary tenant
|
||||||
user topology and credential uniqueness.
|
user topology and credential uniqueness.
|
||||||
|
- `src/db/umzug.test.ts` covers the dedicated `SequelizeData` storage contract
|
||||||
|
for seeder history and the timestamp-only seeder glob.
|
||||||
- `src/db/seeders/user-roles.test.ts` covers the seeded product-permission
|
- `src/db/seeders/user-roles.test.ts` covers the seeded product-permission
|
||||||
contract for parent communication and registrar report/audit grants.
|
contract for parent communication and registrar report/audit grants.
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import type { CreationAttributes, QueryInterface } from 'sequelize';
|
import { QueryTypes, type CreationAttributes, type QueryInterface } from 'sequelize';
|
||||||
import {
|
import {
|
||||||
ROLE_DEFINITIONS,
|
ROLE_DEFINITIONS,
|
||||||
ROLE_NAMES,
|
ROLE_NAMES,
|
||||||
@ -141,6 +141,10 @@ export function buildEntityPermissionNames(): readonly string[] {
|
|||||||
return names;
|
return names;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uniquePermissionNames(names: readonly string[]): readonly string[] {
|
||||||
|
return [...new Set(names)];
|
||||||
|
}
|
||||||
|
|
||||||
export function buildSeededPermissionNamesForRole(role: RoleName): readonly string[] {
|
export function buildSeededPermissionNamesForRole(role: RoleName): readonly string[] {
|
||||||
const entityPermissionNames = buildEntityPermissionNames();
|
const entityPermissionNames = buildEntityPermissionNames();
|
||||||
const allPermissionNames = [
|
const allPermissionNames = [
|
||||||
@ -150,7 +154,7 @@ export function buildSeededPermissionNamesForRole(role: RoleName): readonly stri
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (role === ROLE_NAMES.SYSTEM_ADMIN) {
|
if (role === ROLE_NAMES.SYSTEM_ADMIN) {
|
||||||
return allPermissionNames;
|
return uniquePermissionNames(allPermissionNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (FULL_ACCESS_ROLES.includes(role)) {
|
if (FULL_ACCESS_ROLES.includes(role)) {
|
||||||
@ -160,17 +164,17 @@ export function buildSeededPermissionNamesForRole(role: RoleName): readonly stri
|
|||||||
excluded.add(name);
|
excluded.add(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return allPermissionNames.filter((name) => !excluded.has(name));
|
return uniquePermissionNames(allPermissionNames.filter((name) => !excluded.has(name)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (READ_ONLY_ROLES.includes(role)) {
|
if (READ_ONLY_ROLES.includes(role)) {
|
||||||
return [
|
return uniquePermissionNames([
|
||||||
...entityPermissionNames.filter((name) => name.startsWith('READ_')),
|
...entityPermissionNames.filter((name) => name.startsWith('READ_')),
|
||||||
...(MODULE_PERMISSIONS_BY_ROLE[role] ?? []),
|
...(MODULE_PERMISSIONS_BY_ROLE[role] ?? []),
|
||||||
];
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return MODULE_PERMISSIONS_BY_ROLE[role] ?? [];
|
return uniquePermissionNames(MODULE_PERMISSIONS_BY_ROLE[role] ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -182,9 +186,23 @@ export default {
|
|||||||
const permId = new Map<string, string>();
|
const permId = new Map<string, string>();
|
||||||
|
|
||||||
// 1. Roles.
|
// 1. Roles.
|
||||||
|
const existingRoles = await queryInterface.sequelize.query<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}>(
|
||||||
|
`SELECT id, name FROM roles WHERE name IN (:names)`,
|
||||||
|
{
|
||||||
|
replacements: { names: ROLE_DEFINITIONS.map((role) => role.name) },
|
||||||
|
type: QueryTypes.SELECT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
for (const role of existingRoles) {
|
||||||
|
roleId.set(role.name, role.id);
|
||||||
|
}
|
||||||
|
|
||||||
const roleRows: CreationAttributes<Roles>[] = ROLE_DEFINITIONS.map(
|
const roleRows: CreationAttributes<Roles>[] = ROLE_DEFINITIONS.map(
|
||||||
(role) => {
|
(role) => {
|
||||||
const id = uuid();
|
const id = roleId.get(role.name) ?? uuid();
|
||||||
roleId.set(role.name, id);
|
roleId.set(role.name, id);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@ -195,25 +213,45 @@ export default {
|
|||||||
updatedAt,
|
updatedAt,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
).filter((role) => !existingRoles.some((existing) => existing.id === role.id));
|
||||||
await queryInterface.bulkInsert('roles', roleRows);
|
if (roleRows.length > 0) {
|
||||||
|
await queryInterface.bulkInsert('roles', roleRows);
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Permissions (entity CRUD + extras + product module/page perms).
|
// 2. Permissions (entity CRUD + extras + product module/page perms).
|
||||||
const entityPermissionNames = [...buildEntityPermissionNames()];
|
const entityPermissionNames = [...buildEntityPermissionNames()];
|
||||||
|
|
||||||
const allPermissionNames = [
|
const allPermissionNames = uniquePermissionNames([
|
||||||
...entityPermissionNames,
|
...entityPermissionNames,
|
||||||
...EXTRA_PERMISSIONS,
|
...EXTRA_PERMISSIONS,
|
||||||
...MODULE_PERMISSIONS,
|
...MODULE_PERMISSIONS,
|
||||||
];
|
]);
|
||||||
|
const existingPermissions = await queryInterface.sequelize.query<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}>(
|
||||||
|
`SELECT id, name FROM permissions WHERE name IN (:names)`,
|
||||||
|
{
|
||||||
|
replacements: { names: allPermissionNames },
|
||||||
|
type: QueryTypes.SELECT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
for (const permission of existingPermissions) {
|
||||||
|
permId.set(permission.name, permission.id);
|
||||||
|
}
|
||||||
|
|
||||||
const permissionRows: CreationAttributes<Permissions>[] =
|
const permissionRows: CreationAttributes<Permissions>[] =
|
||||||
allPermissionNames.map((name) => {
|
allPermissionNames.map((name) => {
|
||||||
const id = uuid();
|
const id = permId.get(name) ?? uuid();
|
||||||
permId.set(name, id);
|
permId.set(name, id);
|
||||||
return { id, name, createdAt, updatedAt };
|
return { id, name, createdAt, updatedAt };
|
||||||
});
|
}).filter(
|
||||||
await queryInterface.bulkInsert('permissions', permissionRows);
|
(permission) =>
|
||||||
|
!existingPermissions.some((existing) => existing.id === permission.id),
|
||||||
|
);
|
||||||
|
if (permissionRows.length > 0) {
|
||||||
|
await queryInterface.bulkInsert('permissions', permissionRows);
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Role → permission matrix.
|
// 3. Role → permission matrix.
|
||||||
const links: Array<{
|
const links: Array<{
|
||||||
@ -256,7 +294,39 @@ export default {
|
|||||||
'DELETE_POLICY_DOCUMENTS',
|
'DELETE_POLICY_DOCUMENTS',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await queryInterface.bulkInsert('rolesPermissionsPermissions', links);
|
const roleIds = [...roleId.values()];
|
||||||
|
const permissionIds = [...permId.values()];
|
||||||
|
const existingLinks = await queryInterface.sequelize.query<{
|
||||||
|
roles_permissionsId: string;
|
||||||
|
permissionId: string;
|
||||||
|
}>(
|
||||||
|
`SELECT "roles_permissionsId", "permissionId"
|
||||||
|
FROM "rolesPermissionsPermissions"
|
||||||
|
WHERE "roles_permissionsId" IN (:roleIds)
|
||||||
|
AND "permissionId" IN (:permissionIds)`,
|
||||||
|
{
|
||||||
|
replacements: { roleIds, permissionIds },
|
||||||
|
type: QueryTypes.SELECT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const existingLinkKeys = new Set(
|
||||||
|
existingLinks.map(
|
||||||
|
(link) => `${link.roles_permissionsId}:${link.permissionId}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const missingLinkKeys = new Set<string>();
|
||||||
|
const missingLinks = links.filter((link) => {
|
||||||
|
const key = `${link.roles_permissionsId}:${link.permissionId}`;
|
||||||
|
if (existingLinkKeys.has(key) || missingLinkKeys.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
missingLinkKeys.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (missingLinks.length > 0) {
|
||||||
|
await queryInterface.bulkInsert('rolesPermissionsPermissions', missingLinks);
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Assign roles to the seeded fixture users (by id — robust to the
|
// 4. Assign roles to the seeded fixture users (by id — robust to the
|
||||||
// configured super-admin email).
|
// configured super-admin email).
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Op,
|
Op,
|
||||||
|
QueryTypes,
|
||||||
type CreationAttributes,
|
type CreationAttributes,
|
||||||
type QueryInterface,
|
type QueryInterface,
|
||||||
} from 'sequelize';
|
} from 'sequelize';
|
||||||
@ -14,8 +15,19 @@ export default {
|
|||||||
const rows: CreationAttributes<Campuses>[] = PRODUCT_CAMPUS_SEED_ROWS.map(
|
const rows: CreationAttributes<Campuses>[] = PRODUCT_CAMPUS_SEED_ROWS.map(
|
||||||
(campus) => ({ ...campus, createdAt, updatedAt }),
|
(campus) => ({ ...campus, createdAt, updatedAt }),
|
||||||
);
|
);
|
||||||
|
const existing = await queryInterface.sequelize.query<{ id: string }>(
|
||||||
|
`SELECT id FROM campuses WHERE id IN (:ids)`,
|
||||||
|
{
|
||||||
|
replacements: { ids: rows.map((row) => row.id) },
|
||||||
|
type: QueryTypes.SELECT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const existingIds = new Set(existing.map((row) => row.id));
|
||||||
|
const missingRows = rows.filter((row) => !existingIds.has(row.id as string));
|
||||||
|
|
||||||
await queryInterface.bulkInsert('campuses', rows);
|
if (missingRows.length > 0) {
|
||||||
|
await queryInterface.bulkInsert('campuses', missingRows);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
down: async (queryInterface: QueryInterface) => {
|
down: async (queryInterface: QueryInterface) => {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import {
|
import {
|
||||||
Op,
|
Op,
|
||||||
|
QueryTypes,
|
||||||
type CreationAttributes,
|
type CreationAttributes,
|
||||||
type QueryInterface,
|
type QueryInterface,
|
||||||
} from 'sequelize';
|
} from 'sequelize';
|
||||||
@ -109,13 +110,6 @@ const CONTENT_CATALOG_SEED_ROWS = Object.freeze([
|
|||||||
export default {
|
export default {
|
||||||
up: async (queryInterface: QueryInterface) => {
|
up: async (queryInterface: QueryInterface) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const contentTypes = CONTENT_CATALOG_SEED_ROWS.map((row) => row.content_type);
|
|
||||||
|
|
||||||
await queryInterface.bulkDelete('content_catalog', {
|
|
||||||
content_type: {
|
|
||||||
[Op.in]: contentTypes,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows: CreationAttributes<ContentCatalog>[] =
|
const rows: CreationAttributes<ContentCatalog>[] =
|
||||||
CONTENT_CATALOG_SEED_ROWS.flatMap((row) => seedTenants(row.content_type).map((stamp) => ({
|
CONTENT_CATALOG_SEED_ROWS.flatMap((row) => seedTenants(row.content_type).map((stamp) => ({
|
||||||
@ -131,8 +125,21 @@ export default {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})));
|
})));
|
||||||
|
const existing = await queryInterface.sequelize.query<{ importHash: string }>(
|
||||||
|
`SELECT "importHash" FROM content_catalog WHERE "importHash" IN (:importHashes)`,
|
||||||
|
{
|
||||||
|
replacements: { importHashes: rows.map((row) => row.importHash) },
|
||||||
|
type: QueryTypes.SELECT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const existingImportHashes = new Set(existing.map((row) => row.importHash));
|
||||||
|
const missingRows = rows.filter(
|
||||||
|
(row) => !existingImportHashes.has(row.importHash as string),
|
||||||
|
);
|
||||||
|
|
||||||
await queryInterface.bulkInsert('content_catalog', rows);
|
if (missingRows.length > 0) {
|
||||||
|
await queryInterface.bulkInsert('content_catalog', missingRows);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
down: async (queryInterface: QueryInterface) => {
|
down: async (queryInterface: QueryInterface) => {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { Op, type QueryInterface } from 'sequelize';
|
import { Op, QueryTypes, type QueryInterface } from 'sequelize';
|
||||||
import {
|
import {
|
||||||
SEED_ORGANIZATION_ID,
|
SEED_ORGANIZATION_ID,
|
||||||
SEED_ORGANIZATION_2_ID,
|
SEED_ORGANIZATION_2_ID,
|
||||||
@ -163,15 +163,10 @@ function getScopeDirector(scope: SeedScope) {
|
|||||||
export default {
|
export default {
|
||||||
up: async (queryInterface: QueryInterface) => {
|
up: async (queryInterface: QueryInterface) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const legacyImportHashes = ALL_ROWS.map((row) => row.importHash);
|
|
||||||
const importHashes = SEED_SCOPES.flatMap((scope) =>
|
const importHashes = SEED_SCOPES.flatMap((scope) =>
|
||||||
ALL_ROWS.map((row) => scopedImportHash(scope, row)),
|
ALL_ROWS.map((row) => scopedImportHash(scope, row)),
|
||||||
);
|
);
|
||||||
|
|
||||||
await queryInterface.bulkDelete('policy_documents', {
|
|
||||||
importHash: { [Op.in]: [...legacyImportHashes, ...importHashes] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows = SEED_SCOPES.flatMap((scope) => {
|
const rows = SEED_SCOPES.flatMap((scope) => {
|
||||||
const director = getScopeDirector(scope);
|
const director = getScopeDirector(scope);
|
||||||
const author = director
|
const author = director
|
||||||
@ -204,8 +199,21 @@ export default {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
const existing = await queryInterface.sequelize.query<{ importHash: string }>(
|
||||||
|
`SELECT "importHash" FROM policy_documents WHERE "importHash" IN (:importHashes)`,
|
||||||
|
{
|
||||||
|
replacements: { importHashes },
|
||||||
|
type: QueryTypes.SELECT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const existingImportHashes = new Set(existing.map((row) => row.importHash));
|
||||||
|
const missingRows = rows.filter(
|
||||||
|
(row) => !existingImportHashes.has(row.importHash),
|
||||||
|
);
|
||||||
|
|
||||||
await queryInterface.bulkInsert('policy_documents', rows);
|
if (missingRows.length > 0) {
|
||||||
|
await queryInterface.bulkInsert('policy_documents', missingRows);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
down: async (queryInterface: QueryInterface) => {
|
down: async (queryInterface: QueryInterface) => {
|
||||||
|
|||||||
@ -128,4 +128,15 @@ describe('user-role seed permission contract', () => {
|
|||||||
ROLE_NAMES.TEACHER,
|
ROLE_NAMES.TEACHER,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('seeded permission grants are unique per role', () => {
|
||||||
|
for (const role of Object.values(ROLE_NAMES)) {
|
||||||
|
const permissions = granted(role);
|
||||||
|
assert.equal(
|
||||||
|
new Set(permissions).size,
|
||||||
|
permissions.length,
|
||||||
|
`${role} should not seed duplicate permission links`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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 sequelize = db.sequelize;
|
||||||
const queryInterface = sequelize.getQueryInterface();
|
const queryInterface = sequelize.getQueryInterface();
|
||||||
|
export const seederGlob = 'seeders/[0-9]*.{ts,js}';
|
||||||
|
export const migrationStorage = new SequelizeStorage({ sequelize });
|
||||||
|
export const seedStorage = new SequelizeStorage({
|
||||||
|
sequelize,
|
||||||
|
modelName: 'SequelizeData',
|
||||||
|
tableName: 'SequelizeData',
|
||||||
|
});
|
||||||
|
|
||||||
async function loadMigration(
|
async function loadMigration(
|
||||||
filepath: string,
|
filepath: string,
|
||||||
@ -57,17 +64,17 @@ export const migrator = new Umzug({
|
|||||||
resolve: resolveMigration,
|
resolve: resolveMigration,
|
||||||
},
|
},
|
||||||
context: queryInterface,
|
context: queryInterface,
|
||||||
storage: new SequelizeStorage({ sequelize }),
|
storage: migrationStorage,
|
||||||
logger: console,
|
logger: console,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const seeder = new Umzug({
|
export const seeder = new Umzug({
|
||||||
migrations: {
|
migrations: {
|
||||||
glob: ['seeders/*.{ts,js}', { cwd: import.meta.dirname }],
|
glob: [seederGlob, { cwd: import.meta.dirname }],
|
||||||
resolve: resolveMigration,
|
resolve: resolveMigration,
|
||||||
},
|
},
|
||||||
context: queryInterface,
|
context: queryInterface,
|
||||||
storage: new SequelizeStorage({ sequelize, tableName: 'SequelizeData' }),
|
storage: seedStorage,
|
||||||
logger: console,
|
logger: console,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,14 @@ const router = express.Router();
|
|||||||
* get:
|
* get:
|
||||||
* tags: [FRAME]
|
* tags: [FRAME]
|
||||||
* summary: List FRAME weekly entries (tenant/campus-scoped)
|
* summary: List FRAME weekly entries (tenant/campus-scoped)
|
||||||
* description: Requires the `READ_FRAME` product-feature permission.
|
* description: Requires the `READ_FRAME` product-feature permission. Optional `startDate` / `endDate` filter entries by Sunday-start `week_of`.
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: startDate
|
||||||
|
* schema: { type: string, format: date }
|
||||||
|
* - in: query
|
||||||
|
* name: endDate
|
||||||
|
* schema: { type: string, format: date }
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: List of FRAME entries.
|
* description: List of FRAME entries.
|
||||||
|
|||||||
@ -29,6 +29,7 @@ interface AudioFileInput {
|
|||||||
interface AudioFilesFilter {
|
interface AudioFilesFilter {
|
||||||
limit?: number | string;
|
limit?: number | string;
|
||||||
page?: number | string;
|
page?: number | string;
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResolvedAudioContent {
|
interface ResolvedAudioContent {
|
||||||
@ -98,8 +99,12 @@ class AudioFilesService {
|
|||||||
static async list(filter: AudioFilesFilter, currentUser?: CurrentUser) {
|
static async list(filter: AudioFilesFilter, currentUser?: CurrentUser) {
|
||||||
assertAuthenticatedTenantUser(currentUser);
|
assertAuthenticatedTenantUser(currentUser);
|
||||||
const { limit, offset } = resolvePagination(filter.limit, filter.page);
|
const { limit, offset } = resolvePagination(filter.limit, filter.page);
|
||||||
|
const title =
|
||||||
|
typeof filter.title === 'string' && filter.title.trim().length > 0
|
||||||
|
? filter.title.trim()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const where = hasGlobalAccess(currentUser)
|
const visibilityWhere = hasGlobalAccess(currentUser)
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
@ -110,6 +115,11 @@ class AudioFilesService {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
const where = title
|
||||||
|
? {
|
||||||
|
[Op.and]: [visibilityWhere, { title }],
|
||||||
|
}
|
||||||
|
: visibilityWhere;
|
||||||
|
|
||||||
const result = await db.audio_files.findAndCountAll({
|
const result = await db.audio_files.findAndCountAll({
|
||||||
where,
|
where,
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import db from '@/db/models';
|
import db from '@/db/models';
|
||||||
|
import { Op } from 'sequelize';
|
||||||
import { withTransaction } from '@/db/with-transaction';
|
import { withTransaction } from '@/db/with-transaction';
|
||||||
import { resolvePagination } from '@/shared/constants/pagination';
|
import { resolvePagination } from '@/shared/constants/pagination';
|
||||||
import ForbiddenError from '@/shared/errors/forbidden';
|
import ForbiddenError from '@/shared/errors/forbidden';
|
||||||
import ValidationError from '@/shared/errors/validation';
|
import ValidationError from '@/shared/errors/validation';
|
||||||
|
import { optionalIsoDate } from '@/services/shared/validate';
|
||||||
import {
|
import {
|
||||||
getOwnTenant,
|
getOwnTenant,
|
||||||
tenantExactWhere,
|
tenantExactWhere,
|
||||||
@ -27,6 +29,13 @@ interface FrameEntryInput {
|
|||||||
campusId?: string | null;
|
campusId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FrameEntryFilter {
|
||||||
|
readonly limit?: number | string;
|
||||||
|
readonly page?: number | string;
|
||||||
|
readonly startDate?: unknown;
|
||||||
|
readonly endDate?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
/** Normalizes the input week to its Sunday-start ISO date, or throws. */
|
/** Normalizes the input week to its Sunday-start ISO date, or throws. */
|
||||||
function requireWeekStart(weekOf: string): string {
|
function requireWeekStart(weekOf: string): string {
|
||||||
const weekStart = toWeekStartIso(weekOf);
|
const weekStart = toWeekStartIso(weekOf);
|
||||||
@ -80,6 +89,22 @@ function assertValidFrameEntry(data: FrameEntryInput): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function weekRangeFilter(filter: FrameEntryFilter) {
|
||||||
|
const startDate = optionalIsoDate(filter.startDate);
|
||||||
|
const endDate = optionalIsoDate(filter.endDate);
|
||||||
|
|
||||||
|
if (!startDate && !endDate) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
week_of: {
|
||||||
|
...(startDate ? { [Op.gte]: startDate } : {}),
|
||||||
|
...(endDate ? { [Op.lte]: endDate } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function toDto(entry: FrameEntries) {
|
function toDto(entry: FrameEntries) {
|
||||||
const plain = entry.get({ plain: true });
|
const plain = entry.get({ plain: true });
|
||||||
|
|
||||||
@ -105,14 +130,17 @@ function toDto(entry: FrameEntries) {
|
|||||||
|
|
||||||
class FrameEntriesService {
|
class FrameEntriesService {
|
||||||
static async list(
|
static async list(
|
||||||
filter: { limit?: number | string; page?: number | string } = {},
|
filter: FrameEntryFilter = {},
|
||||||
currentUser?: CurrentUser,
|
currentUser?: CurrentUser,
|
||||||
) {
|
) {
|
||||||
const { limit, offset } = resolvePagination(filter.limit, filter.page);
|
const { limit, offset } = resolvePagination(filter.limit, filter.page);
|
||||||
|
|
||||||
// Per-tenant content: a user sees the FRAME entries dedicated to their own
|
// Per-tenant content: a user sees the FRAME entries dedicated to their own
|
||||||
// tenant level (org/school/campus/class), not an aggregate of children.
|
// tenant level (org/school/campus/class), not an aggregate of children.
|
||||||
const where = tenantExactWhere(getOwnTenant(currentUser));
|
const where = {
|
||||||
|
...tenantExactWhere(getOwnTenant(currentUser)),
|
||||||
|
...weekRangeFilter(filter),
|
||||||
|
};
|
||||||
|
|
||||||
const result = await db.frame_entries.findAndCountAll({
|
const result = await db.frame_entries.findAndCountAll({
|
||||||
where,
|
where,
|
||||||
|
|||||||
@ -37,31 +37,46 @@ Constants:
|
|||||||
## Behavior
|
## Behavior
|
||||||
|
|
||||||
- The framework component calls `useDirectorDashboardPage` and renders `DirectorDashboardView`.
|
- The framework component calls `useDirectorDashboardPage` and renders `DirectorDashboardView`.
|
||||||
- FRAME entries load through `useFrameEntries`.
|
- The Week / Month / Quarter switcher is persisted in local storage and drives
|
||||||
|
period-bounded API queries for dashboard overview statistics and the
|
||||||
|
F.R.A.M.E. Tracker. The selected period is translated into `startDate` and
|
||||||
|
`endDate` query parameters, anchored to the current week, current month, or
|
||||||
|
current quarter.
|
||||||
|
- FRAME entries load through `useFrameEntries` with the selected period range.
|
||||||
- QBS safety quiz completion loads through `useSafetyQuizResults`.
|
- QBS safety quiz completion loads through `useSafetyQuizResults`.
|
||||||
- Emotional Intelligence and Personality Type completion loads through `usePersonalityCompletion`.
|
- Emotional Intelligence and Personality Type completion loads through `usePersonalityCompletion`.
|
||||||
- Daily Zone Check-In completion loads through `useZoneCheckInCompletion`.
|
- Daily Zone Check-In completion loads through `useZoneCheckInCompletion`.
|
||||||
- Staff attendance records and summary load through staff attendance business hooks.
|
- Staff attendance records and summary load through staff attendance business
|
||||||
|
hooks with the selected period range.
|
||||||
- Policy acknowledgment report loads through `usePolicyAcknowledgmentReport`.
|
- Policy acknowledgment report loads through `usePolicyAcknowledgmentReport`.
|
||||||
- Overview metrics, risk areas, unified quiz result rows, and FRAME previews are derived in business selectors.
|
- Overview metrics, risk areas, unified quiz result rows, and FRAME previews are derived in business selectors.
|
||||||
- Document acknowledgment tracking opens in a modal from the Acknowledgments
|
- Document acknowledgment tracking renders as a collapsible section in the main
|
||||||
overview card or the acknowledgment risk card. It renders current-version
|
dashboard flow. The Acknowledgments overview card and acknowledgment risk card
|
||||||
Safety Protocol and Handbook & Policies documents grouped by category.
|
scroll to that section. When expanded, it renders current-version Safety
|
||||||
Collapsed rows show title, version, and acknowledged/total staff counts for
|
Protocol and Handbook & Policies documents grouped by category. Collapsed
|
||||||
the leader's scope; expanded rows list staff who have not acknowledged that
|
document rows show title, version, and acknowledged/total staff counts for the
|
||||||
version.
|
leader's scope; expanded document rows list staff who have not acknowledged
|
||||||
- The dashboard quiz results table combines Behavior Management, EI Self-Assessment,
|
that version.
|
||||||
Personality Type Quiz, and Daily Zone Check-In rows. EI self-assessment rows
|
- The dashboard quiz results table groups Behavior Management, EI Self-Assessment,
|
||||||
reflect the current Sunday-start week; personality type rows reflect each user's
|
Personality Type Quiz, and Daily Zone Check-In by staff member in a
|
||||||
latest saved type; zone check-in rows reflect today's backend-computed date for
|
collapsible section. When expanded, collapsed staff rows show staff name,
|
||||||
each staff member.
|
current tenant scope, role, and completed/total quiz count; expanded staff rows
|
||||||
|
list each quiz title, completion date, and result. EI self-assessment details
|
||||||
|
reflect the current Sunday-start week; personality type details reflect each
|
||||||
|
user's latest saved type; zone check-in details reflect today's
|
||||||
|
backend-computed date for each staff member.
|
||||||
- Risk areas include high/medium/low QBS safety quiz completion, low-risk EI
|
- Risk areas include high/medium/low QBS safety quiz completion, low-risk EI
|
||||||
self-assessment pending counts, low-risk Personality Type pending counts,
|
self-assessment pending counts, low-risk Personality Type pending counts,
|
||||||
medium-risk non-green Daily Zone Check-In counts, missing current-version
|
medium-risk non-green Daily Zone Check-In counts, missing current-version
|
||||||
document acknowledgments, and attendance risk.
|
document acknowledgments, and attendance risk.
|
||||||
- View components use shared `Button`, `Table`, `StatePanel`, and `ModuleHeader` primitives.
|
- The Behavior Management Quiz Completion overview card and incomplete quiz
|
||||||
|
risk cards do not navigate to individual quiz pages. They scroll to the top of
|
||||||
|
the unified Quiz Results list so leaders can review the affected staff and
|
||||||
|
expand each row in place.
|
||||||
|
- View components use shared `Table`, `StatePanel`, and `ModuleHeader`
|
||||||
|
primitives, plus the dashboard-local `DirectorScaleButton` for scale-only
|
||||||
|
interactive cards and action controls.
|
||||||
- Loading, empty, and error states are explicit.
|
- Loading, empty, and error states are explicit.
|
||||||
|
- Risk areas, unified quiz results, zone completion, and current-version
|
||||||
## Remaining Related Work
|
acknowledgment tracking intentionally remain current-state views. They are not
|
||||||
|
filtered by the time range tabs.
|
||||||
Time range tabs currently control UI state only. Add backend-supported date filtering before wiring these tabs to query filters.
|
|
||||||
|
|||||||
@ -41,7 +41,10 @@ Constants:
|
|||||||
|
|
||||||
## Behavior
|
## Behavior
|
||||||
|
|
||||||
- FRAME entries load from `GET /api/frame_entries`.
|
- FRAME entries load from `GET /api/frame_entries`. The shared API accepts
|
||||||
|
optional `startDate` / `endDate` params so leadership dashboards can request
|
||||||
|
only the selected reporting period instead of fetching extra rows and
|
||||||
|
filtering them locally.
|
||||||
- Create/update workflows use typed API calls and React Query mutations.
|
- Create/update workflows use typed API calls and React Query mutations.
|
||||||
- **Week selection**: the create and edit forms use `FrameWeekPicker` (a `Popover` + `Calendar`) — picking any day snaps to that week's **Sunday** (American week) via the shared `shared/business/week.ts` (`toWeekStartIso`), and an optional free-text **week label** (e.g. "Spring Break week") is captured separately. The entry stores the canonical Sunday-start ISO in `week_of` and the label in `week_label`; cards render `Week of <formatWeekOf(weekStart)>` + the label badge. The same week util backs the dashboard hero "Week of …" and the safety-quiz week, so the week is consistent across the app.
|
- **Week selection**: the create and edit forms use `FrameWeekPicker` (a `Popover` + `Calendar`) — picking any day snaps to that week's **Sunday** (American week) via the shared `shared/business/week.ts` (`toWeekStartIso`), and an optional free-text **week label** (e.g. "Spring Break week") is captured separately. The entry stores the canonical Sunday-start ISO in `week_of` and the label in `week_label`; cards render `Week of <formatWeekOf(weekStart)>` + the label badge. The same week util backs the dashboard hero "Week of …" and the safety-quiz week, so the week is consistent across the app.
|
||||||
- `FrameModule.tsx` is a thin wrapper that calls `useFrameModule` and renders focused FRAME view components.
|
- `FrameModule.tsx` is a thin wrapper that calls `useFrameModule` and renders focused FRAME view components.
|
||||||
@ -49,7 +52,7 @@ Constants:
|
|||||||
- Static FRAME sample entries are not used as runtime persisted-data substitutes.
|
- Static FRAME sample entries are not used as runtime persisted-data substitutes.
|
||||||
- Empty and error states are rendered explicitly.
|
- Empty and error states are rendered explicitly.
|
||||||
- Dynamic F/R/A/M/E field access is typed through `FrameSectionKey`.
|
- Dynamic F/R/A/M/E field access is typed through `FrameSectionKey`.
|
||||||
- Director dashboard renders recent FRAME previews through director dashboard selectors instead of deriving preview rows in JSX.
|
- Director dashboard renders recent FRAME previews through director dashboard selectors instead of deriving preview rows in JSX. Its Week / Month / Quarter switcher is persisted in local storage, and the selected period is sent to the FRAME API as the date range for the query.
|
||||||
- Home dashboard renders the latest FRAME entry through `useDashboardPage` and `DashboardFramePreview`.
|
- Home dashboard renders the latest FRAME entry through `useDashboardPage` and `DashboardFramePreview`.
|
||||||
|
|
||||||
## Remaining Related Work
|
## Remaining Related Work
|
||||||
|
|||||||
@ -27,8 +27,7 @@ Safety Protocols:
|
|||||||
Acknowledgment tracking:
|
Acknowledgment tracking:
|
||||||
|
|
||||||
- Dashboard component:
|
- Dashboard component:
|
||||||
`frontend/src/components/director-dashboard/DirectorAcknowledgmentTrackingModal.tsx`
|
`frontend/src/components/director-dashboard/DirectorAcknowledgmentTrackingPanel.tsx`
|
||||||
and `DirectorAcknowledgmentTrackingPanel.tsx`
|
|
||||||
- Business/API: `buildDirectorAcknowledgmentDocuments` +
|
- Business/API: `buildDirectorAcknowledgmentDocuments` +
|
||||||
`usePolicyAcknowledgmentReport` in
|
`usePolicyAcknowledgmentReport` in
|
||||||
`frontend/src/business/director-dashboard` and
|
`frontend/src/business/director-dashboard` and
|
||||||
@ -82,9 +81,10 @@ change), `active`, tenant `organizationId` + nullable `campusId`.
|
|||||||
- **Acknowledgment report** is available to owner, superintendent, principal,
|
- **Acknowledgment report** is available to owner, superintendent, principal,
|
||||||
registrar, and director. Super/system admins can read it only while drilled
|
registrar, and director. Super/system admins can read it only while drilled
|
||||||
into a tenant. The report counts active director/office_manager/teacher/
|
into a tenant. The report counts active director/office_manager/teacher/
|
||||||
support_staff accounts in the current scope. The leadership dashboard opens
|
support_staff accounts in the current scope. The leadership dashboard renders
|
||||||
acknowledgment tracking in a modal from the Acknowledgments overview metric or
|
acknowledgment tracking as a collapsible section in the main dashboard flow
|
||||||
the aggregated acknowledgment risk card. The modal groups current-version
|
and scrolls to it from the Acknowledgments overview metric or the aggregated
|
||||||
|
acknowledgment risk card. When expanded, the section groups current-version
|
||||||
documents by category, shows collapsed acknowledged/total counts, expands to
|
documents by category, shows collapsed acknowledged/total counts, expands to
|
||||||
missing staff names, and the risk area aggregates missing acknowledgments into
|
missing staff names, and the risk area aggregates missing acknowledgments into
|
||||||
one concise card.
|
one concise card.
|
||||||
|
|||||||
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 { useState } from 'react';
|
||||||
|
import { format, startOfMonth, startOfQuarter } from 'date-fns';
|
||||||
|
|
||||||
import { useFrameEntries } from '@/business/frame/hooks';
|
import { useFrameEntries } from '@/business/frame/hooks';
|
||||||
import { useSafetyQuizCompliance } from '@/business/safety-quiz/hooks';
|
import { useSafetyQuizCompliance } from '@/business/safety-quiz/hooks';
|
||||||
@ -19,6 +20,8 @@ import {
|
|||||||
import type { DirectorDashboardPage } from '@/business/director-dashboard/types';
|
import type { DirectorDashboardPage } from '@/business/director-dashboard/types';
|
||||||
import {
|
import {
|
||||||
DIRECTOR_DASHBOARD_QUICK_ACTIONS,
|
DIRECTOR_DASHBOARD_QUICK_ACTIONS,
|
||||||
|
DIRECTOR_DASHBOARD_TIME_RANGE_STORAGE_KEY,
|
||||||
|
DIRECTOR_DASHBOARD_TIME_RANGES,
|
||||||
type DirectorDashboardTimeRange,
|
type DirectorDashboardTimeRange,
|
||||||
} from '@/shared/constants/directorDashboard';
|
} from '@/shared/constants/directorDashboard';
|
||||||
import { useAuth } from '@/shared/app/useAuth';
|
import { useAuth } from '@/shared/app/useAuth';
|
||||||
@ -27,6 +30,45 @@ import { getActiveTenant } from '@/business/scope/selectors';
|
|||||||
import { useScopeContext } from '@/shared/app/scope-context';
|
import { useScopeContext } from '@/shared/app/scope-context';
|
||||||
import { DEFAULT_PRODUCT_ROLE } from '@/shared/constants/roles';
|
import { DEFAULT_PRODUCT_ROLE } from '@/shared/constants/roles';
|
||||||
import { getCurrentSafetyQuizWeek } from '@/business/safety-quiz/selectors';
|
import { getCurrentSafetyQuizWeek } from '@/business/safety-quiz/selectors';
|
||||||
|
import { getWeekStart } from '@/shared/business/week';
|
||||||
|
|
||||||
|
interface DirectorDashboardDateRange {
|
||||||
|
readonly startDate: string;
|
||||||
|
readonly endDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirectorDashboardTimeRange(value: string | null): value is DirectorDashboardTimeRange {
|
||||||
|
return DIRECTOR_DASHBOARD_TIME_RANGES.some((range) => range === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredTimeRange(): DirectorDashboardTimeRange {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 'month';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(DIRECTOR_DASHBOARD_TIME_RANGE_STORAGE_KEY);
|
||||||
|
return isDirectorDashboardTimeRange(stored) ? stored : 'month';
|
||||||
|
} catch {
|
||||||
|
return 'month';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDirectorDashboardDateRange(
|
||||||
|
timeRange: DirectorDashboardTimeRange,
|
||||||
|
now = new Date(),
|
||||||
|
): DirectorDashboardDateRange {
|
||||||
|
const startDate = timeRange === 'week'
|
||||||
|
? getWeekStart(now)
|
||||||
|
: timeRange === 'month'
|
||||||
|
? startOfMonth(now)
|
||||||
|
: startOfQuarter(now);
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate: format(startDate, 'yyyy-MM-dd'),
|
||||||
|
endDate: format(now, 'yyyy-MM-dd'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useDirectorDashboardPage(): DirectorDashboardPage {
|
export function useDirectorDashboardPage(): DirectorDashboardPage {
|
||||||
const { user, profile } = useAuth();
|
const { user, profile } = useAuth();
|
||||||
@ -36,14 +78,15 @@ export function useDirectorDashboardPage(): DirectorDashboardPage {
|
|||||||
const activeTenant = effectiveTenant ?? getActiveTenant(user);
|
const activeTenant = effectiveTenant ?? getActiveTenant(user);
|
||||||
const scopeLabel = activeTenant?.name ?? '';
|
const scopeLabel = activeTenant?.name ?? '';
|
||||||
const scopeKey = activeTenant ? `${activeTenant.level}:${activeTenant.id}` : null;
|
const scopeKey = activeTenant ? `${activeTenant.level}:${activeTenant.id}` : null;
|
||||||
const [timeRange, setTimeRangeState] = useState<DirectorDashboardTimeRange>('month');
|
const [timeRange, setTimeRangeState] = useState<DirectorDashboardTimeRange>(readStoredTimeRange);
|
||||||
|
const periodFilter = getDirectorDashboardDateRange(timeRange);
|
||||||
const safetyQuizWeek = getCurrentSafetyQuizWeek(new Date());
|
const safetyQuizWeek = getCurrentSafetyQuizWeek(new Date());
|
||||||
const frameEntriesQuery = useFrameEntries();
|
const frameEntriesQuery = useFrameEntries(periodFilter);
|
||||||
const quizCompletionQuery = useSafetyQuizCompliance(safetyQuizWeek, true);
|
const quizCompletionQuery = useSafetyQuizCompliance(safetyQuizWeek, true);
|
||||||
const emotionalIntelligenceCompletionQuery = usePersonalityCompletion(true);
|
const emotionalIntelligenceCompletionQuery = usePersonalityCompletion(true);
|
||||||
const zoneCheckinCompletionQuery = useZoneCheckInCompletion(true, scopeKey);
|
const zoneCheckinCompletionQuery = useZoneCheckInCompletion(true, scopeKey);
|
||||||
const staffAttendanceRecordsQuery = useStaffAttendanceRecords();
|
const staffAttendanceRecordsQuery = useStaffAttendanceRecords(periodFilter);
|
||||||
const staffAttendanceSummaryQuery = useStaffAttendanceSummary();
|
const staffAttendanceSummaryQuery = useStaffAttendanceSummary(periodFilter);
|
||||||
const acknowledgmentReportQuery = usePolicyAcknowledgmentReport();
|
const acknowledgmentReportQuery = usePolicyAcknowledgmentReport();
|
||||||
const frameEntries = frameEntriesQuery.data ?? [];
|
const frameEntries = frameEntriesQuery.data ?? [];
|
||||||
const quizRows = quizCompletionQuery.data?.rows ?? [];
|
const quizRows = quizCompletionQuery.data?.rows ?? [];
|
||||||
@ -91,10 +134,24 @@ export function useDirectorDashboardPage(): DirectorDashboardPage {
|
|||||||
),
|
),
|
||||||
framePreviews: buildDirectorFramePreviews(frameEntries),
|
framePreviews: buildDirectorFramePreviews(frameEntries),
|
||||||
quickActions: DIRECTOR_DASHBOARD_QUICK_ACTIONS,
|
quickActions: DIRECTOR_DASHBOARD_QUICK_ACTIONS,
|
||||||
quizResults: buildDirectorQuizResults(quizRows, emotionalIntelligenceCompletion, zoneCheckinCompletion),
|
quizResults: buildDirectorQuizResults(
|
||||||
|
quizRows,
|
||||||
|
emotionalIntelligenceCompletion,
|
||||||
|
zoneCheckinCompletion,
|
||||||
|
scopeLabel,
|
||||||
|
),
|
||||||
acknowledgmentDocuments,
|
acknowledgmentDocuments,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
setTimeRange: setTimeRangeState,
|
setTimeRange: (nextTimeRange) => {
|
||||||
|
setTimeRangeState(nextTimeRange);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(DIRECTOR_DASHBOARD_TIME_RANGE_STORAGE_KEY, nextTimeRange);
|
||||||
|
} catch {
|
||||||
|
// The selected range still updates in memory when storage is blocked.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -265,9 +265,10 @@ describe('director dashboard selectors', () => {
|
|||||||
'attendance',
|
'attendance',
|
||||||
'qbs',
|
'qbs',
|
||||||
'frame',
|
'frame',
|
||||||
'attendance',
|
'user-admin',
|
||||||
'director',
|
'director',
|
||||||
]);
|
]);
|
||||||
|
expect(cards[1]?.action).toBe('openQuizResults');
|
||||||
expect(cards[cards.length - 1]?.action).toBe('openAcknowledgments');
|
expect(cards[cards.length - 1]?.action).toBe('openAcknowledgments');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -290,11 +291,13 @@ describe('director dashboard selectors', () => {
|
|||||||
issue: "5 staff haven't completed de-escalation quiz",
|
issue: "5 staff haven't completed de-escalation quiz",
|
||||||
severity: 'high',
|
severity: 'high',
|
||||||
module: 'qbs',
|
module: 'qbs',
|
||||||
|
action: 'openQuizResults',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
issue: "1 staff haven't completed daily zone check-in",
|
issue: "1 staff haven't completed daily zone check-in",
|
||||||
severity: 'medium',
|
severity: 'medium',
|
||||||
module: 'zones',
|
module: 'zones',
|
||||||
|
action: 'openQuizResults',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
issue: 'Non-green regulation zones: Ava Lee (Yellow Zone)',
|
issue: 'Non-green regulation zones: Ava Lee (Yellow Zone)',
|
||||||
@ -442,7 +445,7 @@ describe('director dashboard selectors', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('combines safety, EI assessment, personality, and zone check-in results in one list', () => {
|
it('groups safety, EI assessment, personality, and zone check-in results by staff member', () => {
|
||||||
const rows = buildDirectorQuizResults(
|
const rows = buildDirectorQuizResults(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@ -456,15 +459,25 @@ describe('director dashboard selectors', () => {
|
|||||||
],
|
],
|
||||||
createPersonalityCompletion(),
|
createPersonalityCompletion(),
|
||||||
createZoneCheckinCompletion(),
|
createZoneCheckinCompletion(),
|
||||||
|
'Tigers Campus',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(rows.map((row) => row.quiz)).toEqual([
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]).toMatchObject({
|
||||||
|
id: 'user-1',
|
||||||
|
staffName: 'Ava Lee',
|
||||||
|
tenant: 'Tigers Campus',
|
||||||
|
role: 'Teacher',
|
||||||
|
completedCount: 4,
|
||||||
|
totalCount: 4,
|
||||||
|
});
|
||||||
|
expect(rows[0].details.map((row) => row.quiz)).toEqual([
|
||||||
'Behavior Management',
|
'Behavior Management',
|
||||||
'EI Self-Assessment',
|
'EI Self-Assessment',
|
||||||
'Personality Type Quiz',
|
'Personality Type Quiz',
|
||||||
'Daily Zone Check-In',
|
'Daily Zone Check-In',
|
||||||
]);
|
]);
|
||||||
expect(rows.map((row) => row.result)).toEqual([
|
expect(rows[0].details.map((row) => row.result)).toEqual([
|
||||||
'3/5',
|
'3/5',
|
||||||
'Developing Awareness (14/32)',
|
'Developing Awareness (14/32)',
|
||||||
'ENFP',
|
'ENFP',
|
||||||
@ -472,6 +485,61 @@ describe('director dashboard selectors', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps one quiz row per staff member when completion sources contain different users', () => {
|
||||||
|
const rows = buildDirectorQuizResults(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
userId: 'user-1',
|
||||||
|
name: 'Ava Lee',
|
||||||
|
role: 'Teacher',
|
||||||
|
status: 'complete',
|
||||||
|
score: '3/5',
|
||||||
|
date: 'Jun 18',
|
||||||
|
} satisfies SafetyQuizComplianceRow,
|
||||||
|
],
|
||||||
|
createPersonalityCompletion({
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
userId: 'user-2',
|
||||||
|
name: 'Ben Cruz',
|
||||||
|
email: 'ben@example.test',
|
||||||
|
role: 'Support Staff',
|
||||||
|
status: 'pending',
|
||||||
|
completedKinds: [],
|
||||||
|
selfAssessment: null,
|
||||||
|
personality: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
createZoneCheckinCompletion({
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
userId: 'user-1',
|
||||||
|
name: 'Ava Lee',
|
||||||
|
email: 'ava@example.test',
|
||||||
|
role: 'Teacher',
|
||||||
|
date: '2026-06-18',
|
||||||
|
status: 'pending',
|
||||||
|
zone: null,
|
||||||
|
riskLevel: 'pending',
|
||||||
|
result: 'Pending',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
'Demo Academy',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows.map((row) => row.staffName)).toEqual(['Ava Lee', 'Ben Cruz']);
|
||||||
|
expect(rows.find((row) => row.id === 'user-1')?.details.map((detail) => detail.quiz)).toEqual([
|
||||||
|
'Behavior Management',
|
||||||
|
'Daily Zone Check-In',
|
||||||
|
]);
|
||||||
|
expect(rows.find((row) => row.id === 'user-2')?.details.map((detail) => detail.quiz)).toEqual([
|
||||||
|
'EI Self-Assessment',
|
||||||
|
'Personality Type Quiz',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('limits and truncates FRAME previews', () => {
|
it('limits and truncates FRAME previews', () => {
|
||||||
const longText = 'A'.repeat(70);
|
const longText = 'A'.repeat(70);
|
||||||
const previews = buildDirectorFramePreviews([
|
const previews = buildDirectorFramePreviews([
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import type {
|
|||||||
DirectorAcknowledgmentDocumentRow,
|
DirectorAcknowledgmentDocumentRow,
|
||||||
DirectorFramePreview,
|
DirectorFramePreview,
|
||||||
DirectorOverviewCard,
|
DirectorOverviewCard,
|
||||||
|
DirectorQuizResultDetail,
|
||||||
DirectorQuizResultRow,
|
DirectorQuizResultRow,
|
||||||
DirectorRiskArea,
|
DirectorRiskArea,
|
||||||
} from '@/business/director-dashboard/types';
|
} from '@/business/director-dashboard/types';
|
||||||
@ -59,13 +60,14 @@ export function buildDirectorOverviewCards(
|
|||||||
module: 'attendance',
|
module: 'attendance',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'De-escalation Completion',
|
label: 'Behavior Management Quiz Completion',
|
||||||
value: `${quizSummary.completedCount}/${quizSummary.totalStaff}`,
|
value: `${quizSummary.completedCount}/${quizSummary.totalStaff}`,
|
||||||
change: `${quizCompletionRate}%`,
|
change: `${quizCompletionRate}%`,
|
||||||
trend: quizCompletionRate > DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD ? 'up' : 'down',
|
trend: quizCompletionRate > DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD ? 'up' : 'down',
|
||||||
iconId: 'shield',
|
iconId: 'shield',
|
||||||
tone: 'blue',
|
tone: 'blue',
|
||||||
module: 'qbs',
|
module: 'qbs',
|
||||||
|
action: 'openQuizResults',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'F.R.A.M.E. Entries',
|
label: 'F.R.A.M.E. Entries',
|
||||||
@ -83,7 +85,7 @@ export function buildDirectorOverviewCards(
|
|||||||
trend: 'up',
|
trend: 'up',
|
||||||
iconId: 'users',
|
iconId: 'users',
|
||||||
tone: 'purple',
|
tone: 'purple',
|
||||||
module: 'attendance',
|
module: 'user-admin',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Acknowledgments',
|
label: 'Acknowledgments',
|
||||||
@ -125,6 +127,7 @@ export function buildDirectorRiskAreas(
|
|||||||
issue: `${incompleteStaffCount} staff haven't completed de-escalation quiz`,
|
issue: `${incompleteStaffCount} staff haven't completed de-escalation quiz`,
|
||||||
severity: incompleteStaffCount > DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD ? 'high' : 'medium',
|
severity: incompleteStaffCount > DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD ? 'high' : 'medium',
|
||||||
module: 'qbs',
|
module: 'qbs',
|
||||||
|
action: 'openQuizResults',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -133,6 +136,7 @@ export function buildDirectorRiskAreas(
|
|||||||
issue: `${selfAssessmentPendingCount} staff haven't completed EI self-assessment`,
|
issue: `${selfAssessmentPendingCount} staff haven't completed EI self-assessment`,
|
||||||
severity: 'low',
|
severity: 'low',
|
||||||
module: 'ei',
|
module: 'ei',
|
||||||
|
action: 'openQuizResults',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,6 +145,7 @@ export function buildDirectorRiskAreas(
|
|||||||
issue: `${personalityPendingCount} staff haven't completed personality type quiz`,
|
issue: `${personalityPendingCount} staff haven't completed personality type quiz`,
|
||||||
severity: 'low',
|
severity: 'low',
|
||||||
module: 'ei',
|
module: 'ei',
|
||||||
|
action: 'openQuizResults',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -149,6 +154,7 @@ export function buildDirectorRiskAreas(
|
|||||||
issue: `${zonePendingCount} staff haven't completed daily zone check-in`,
|
issue: `${zonePendingCount} staff haven't completed daily zone check-in`,
|
||||||
severity: 'medium',
|
severity: 'medium',
|
||||||
module: 'zones',
|
module: 'zones',
|
||||||
|
action: 'openQuizResults',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -293,50 +299,83 @@ export function buildDirectorQuizResults(
|
|||||||
safetyRows: readonly SafetyQuizComplianceRow[],
|
safetyRows: readonly SafetyQuizComplianceRow[],
|
||||||
emotionalIntelligenceCompletion?: PersonalityCompletionDto | null,
|
emotionalIntelligenceCompletion?: PersonalityCompletionDto | null,
|
||||||
zoneCheckinCompletion?: ZoneCheckinCompletionDto | null,
|
zoneCheckinCompletion?: ZoneCheckinCompletionDto | null,
|
||||||
|
tenantLabel = 'Current scope',
|
||||||
): readonly DirectorQuizResultRow[] {
|
): readonly DirectorQuizResultRow[] {
|
||||||
const behaviorRows = safetyRows.map((row): DirectorQuizResultRow => ({
|
const rowsByUserId = new Map<string, {
|
||||||
id: `${row.userId}-behavior-management`,
|
staffName: string;
|
||||||
staffName: row.name,
|
role: string;
|
||||||
|
details: DirectorQuizResultDetail[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const ensureRow = (userId: string, staffName: string, role: string | null): {
|
||||||
|
staffName: string;
|
||||||
|
role: string;
|
||||||
|
details: DirectorQuizResultDetail[];
|
||||||
|
} => {
|
||||||
|
const existing = rowsByUserId.get(userId);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const next = {
|
||||||
|
staffName,
|
||||||
|
role: role ?? 'Staff',
|
||||||
|
details: [],
|
||||||
|
};
|
||||||
|
rowsByUserId.set(userId, next);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of safetyRows) {
|
||||||
|
ensureRow(row.userId, row.name, row.role).details.push({
|
||||||
|
id: 'behavior-management',
|
||||||
|
quiz: 'Behavior Management',
|
||||||
|
result: row.score,
|
||||||
|
date: row.date,
|
||||||
|
status: row.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of emotionalIntelligenceCompletion?.rows ?? []) {
|
||||||
|
const target = ensureRow(row.userId, row.name, row.role);
|
||||||
|
target.details.push(
|
||||||
|
{
|
||||||
|
id: 'ei-self-assessment',
|
||||||
|
quiz: 'EI Self-Assessment',
|
||||||
|
result: formatPersonalityQuizResult(row.selfAssessment, 'Pending'),
|
||||||
|
date: formatPersonalityQuizDate(row.selfAssessment),
|
||||||
|
status: row.selfAssessment ? 'complete' : 'pending',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'personality-type',
|
||||||
|
quiz: 'Personality Type Quiz',
|
||||||
|
result: formatPersonalityQuizResult(row.personality, 'Pending'),
|
||||||
|
date: formatPersonalityQuizDate(row.personality),
|
||||||
|
status: row.personality ? 'complete' : 'pending',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of zoneCheckinCompletion?.rows ?? []) {
|
||||||
|
ensureRow(row.userId, row.name, row.role).details.push({
|
||||||
|
id: 'daily-zone-check-in',
|
||||||
|
quiz: 'Daily Zone Check-In',
|
||||||
|
result: row.result,
|
||||||
|
date: row.status === 'complete'
|
||||||
|
? new Date(`${row.date}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||||
|
: 'Not completed',
|
||||||
|
status: row.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...rowsByUserId.entries()].map(([userId, row]) => ({
|
||||||
|
id: userId,
|
||||||
|
staffName: row.staffName,
|
||||||
|
tenant: tenantLabel,
|
||||||
role: row.role,
|
role: row.role,
|
||||||
quiz: 'Behavior Management',
|
completedCount: row.details.filter((detail) => detail.status === 'complete').length,
|
||||||
result: row.score,
|
totalCount: row.details.length,
|
||||||
date: row.date,
|
details: row.details,
|
||||||
status: row.status,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const emotionalIntelligenceRows = emotionalIntelligenceCompletion?.rows.flatMap((row) => [
|
|
||||||
{
|
|
||||||
id: `${row.userId}-ei-self-assessment`,
|
|
||||||
staffName: row.name,
|
|
||||||
role: row.role ?? 'Staff',
|
|
||||||
quiz: 'EI Self-Assessment',
|
|
||||||
result: formatPersonalityQuizResult(row.selfAssessment, 'Pending'),
|
|
||||||
date: formatPersonalityQuizDate(row.selfAssessment),
|
|
||||||
status: row.selfAssessment ? 'complete' as const : 'pending' as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: `${row.userId}-personality-type`,
|
|
||||||
staffName: row.name,
|
|
||||||
role: row.role ?? 'Staff',
|
|
||||||
quiz: 'Personality Type Quiz',
|
|
||||||
result: formatPersonalityQuizResult(row.personality, 'Pending'),
|
|
||||||
date: formatPersonalityQuizDate(row.personality),
|
|
||||||
status: row.personality ? 'complete' as const : 'pending' as const,
|
|
||||||
},
|
|
||||||
]) ?? [];
|
|
||||||
const zoneRows = zoneCheckinCompletion?.rows.map((row): DirectorQuizResultRow => ({
|
|
||||||
id: `${row.userId}-daily-zone-check-in`,
|
|
||||||
staffName: row.name,
|
|
||||||
role: row.role ?? 'Staff',
|
|
||||||
quiz: 'Daily Zone Check-In',
|
|
||||||
result: row.result,
|
|
||||||
date: row.status === 'complete'
|
|
||||||
? new Date(`${row.date}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
|
||||||
: 'Not completed',
|
|
||||||
status: row.status,
|
|
||||||
})) ?? [];
|
|
||||||
|
|
||||||
return [...behaviorRows, ...emotionalIntelligenceRows, ...zoneRows];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDirectorFramePreviews(
|
export function buildDirectorFramePreviews(
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import type { ModuleId } from '@/shared/types/app';
|
|||||||
|
|
||||||
export type DirectorDashboardTrend = 'up' | 'down';
|
export type DirectorDashboardTrend = 'up' | 'down';
|
||||||
export type DirectorDashboardRiskSeverity = 'high' | 'medium' | 'low';
|
export type DirectorDashboardRiskSeverity = 'high' | 'medium' | 'low';
|
||||||
export type DirectorDashboardAction = 'openAcknowledgments';
|
export type DirectorDashboardAction = 'openAcknowledgments' | 'openQuizResults';
|
||||||
export type DirectorOverviewIconId = 'clock' | 'shield' | 'eye' | 'users' | 'clipboard';
|
export type DirectorOverviewIconId = 'clock' | 'shield' | 'eye' | 'users' | 'clipboard';
|
||||||
export type DirectorOverviewTone = 'orange' | 'blue' | 'amber' | 'purple' | 'emerald';
|
export type DirectorOverviewTone = 'orange' | 'blue' | 'amber' | 'purple' | 'emerald';
|
||||||
export type DirectorFrameSectionLetter = 'F' | 'R' | 'A' | 'M' | 'E';
|
export type DirectorFrameSectionLetter = 'F' | 'R' | 'A' | 'M' | 'E';
|
||||||
@ -41,16 +41,24 @@ export interface DirectorFramePreview {
|
|||||||
readonly sections: readonly DirectorFrameSectionPreview[];
|
readonly sections: readonly DirectorFrameSectionPreview[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DirectorQuizResultRow {
|
export interface DirectorQuizResultDetail {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly staffName: string;
|
|
||||||
readonly role: string;
|
|
||||||
readonly quiz: string;
|
readonly quiz: string;
|
||||||
readonly result: string;
|
readonly result: string;
|
||||||
readonly date: string;
|
readonly date: string;
|
||||||
readonly status: DirectorQuizResultStatus;
|
readonly status: DirectorQuizResultStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DirectorQuizResultRow {
|
||||||
|
readonly id: string;
|
||||||
|
readonly staffName: string;
|
||||||
|
readonly tenant: string;
|
||||||
|
readonly role: string;
|
||||||
|
readonly completedCount: number;
|
||||||
|
readonly totalCount: number;
|
||||||
|
readonly details: readonly DirectorQuizResultDetail[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DirectorAcknowledgmentMissingStaff {
|
export interface DirectorAcknowledgmentMissingStaff {
|
||||||
readonly userId: string;
|
readonly userId: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import {
|
import {
|
||||||
createFrameEntry,
|
createFrameEntry,
|
||||||
deleteFrameEntry,
|
deleteFrameEntry,
|
||||||
|
type FrameEntryFilter,
|
||||||
listFrameEntries,
|
listFrameEntries,
|
||||||
updateFrameEntry,
|
updateFrameEntry,
|
||||||
} from '@/shared/api/frame';
|
} from '@/shared/api/frame';
|
||||||
@ -56,10 +57,10 @@ function isValidDraft(entry: EditableFrameEntry): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFrameEntries() {
|
export function useFrameEntries(filter?: FrameEntryFilter) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: FRAME_QUERY_KEYS.entries,
|
queryKey: [...FRAME_QUERY_KEYS.entries, filter],
|
||||||
queryFn: () => mapApiListRows(listFrameEntries(), toFrameEntryViewModel),
|
queryFn: () => mapApiListRows(listFrameEntries(filter), toFrameEntryViewModel),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { ChevronDown, ClipboardCheck } from 'lucide-react';
|
||||||
|
|
||||||
import type { DirectorAcknowledgmentDocumentRow } from '@/business/director-dashboard/types';
|
import type { DirectorAcknowledgmentDocumentRow } from '@/business/director-dashboard/types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface DirectorAcknowledgmentTrackingPanelProps {
|
interface DirectorAcknowledgmentTrackingPanelProps {
|
||||||
readonly documents: readonly DirectorAcknowledgmentDocumentRow[];
|
readonly documents: readonly DirectorAcknowledgmentDocumentRow[];
|
||||||
readonly showHeader?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DirectorAcknowledgmentTrackingPanel({
|
export function DirectorAcknowledgmentTrackingPanel({
|
||||||
documents,
|
documents,
|
||||||
showHeader = true,
|
|
||||||
}: DirectorAcknowledgmentTrackingPanelProps) {
|
}: DirectorAcknowledgmentTrackingPanelProps) {
|
||||||
|
const [isPanelExpanded, setIsPanelExpanded] = useState(false);
|
||||||
const [expandedDocumentIds, setExpandedDocumentIds] = useState<ReadonlySet<string>>(
|
const [expandedDocumentIds, setExpandedDocumentIds] = useState<ReadonlySet<string>>(
|
||||||
() => new Set(),
|
() => new Set(),
|
||||||
);
|
);
|
||||||
const groupedDocuments = useMemo(() => groupDocumentsByCategory(documents), [documents]);
|
const groupedDocuments = useMemo(() => groupDocumentsByCategory(documents), [documents]);
|
||||||
|
const missingAcknowledgmentCount = documents.reduce(
|
||||||
|
(total, document) => total + document.missingCount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
function toggleDocument(documentId: string) {
|
function toggleDocument(documentId: string) {
|
||||||
setExpandedDocumentIds((current) => {
|
setExpandedDocumentIds((current) => {
|
||||||
@ -31,104 +35,117 @@ export function DirectorAcknowledgmentTrackingPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-2xl border border-violet-100 bg-white p-5 text-gray-900 shadow-sm">
|
<section className="rounded-2xl border border-violet-100 bg-white p-5 text-gray-900 shadow-sm">
|
||||||
{showHeader && (
|
<button
|
||||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
type="button"
|
||||||
<div>
|
onClick={() => setIsPanelExpanded((current) => !current)}
|
||||||
<h3 className="flex items-center gap-2 font-semibold text-gray-800">
|
className="mb-4 flex w-full items-center justify-between gap-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2"
|
||||||
<ClipboardCheck size={18} className="text-emerald-600" />
|
aria-expanded={isPanelExpanded}
|
||||||
Document Acknowledgments
|
aria-controls="director-acknowledgment-results-panel"
|
||||||
</h3>
|
>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<span>
|
||||||
Current safety protocol and handbook versions for this scope.
|
<span className="flex items-center gap-2 font-semibold text-gray-800">
|
||||||
</p>
|
<ClipboardCheck size={18} className="text-emerald-600" />
|
||||||
</div>
|
Document Acknowledgments
|
||||||
</div>
|
</span>
|
||||||
)}
|
<span className="mt-1 block text-sm text-gray-500">
|
||||||
|
{documents.length} documents · {missingAcknowledgmentCount} missing acknowledgments
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={20}
|
||||||
|
className={cn('shrink-0 text-gray-400 transition-transform', isPanelExpanded && 'rotate-180')}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
{groupedDocuments.length === 0 ? (
|
<div id="director-acknowledgment-results-panel">
|
||||||
<p className="rounded-xl bg-gray-50 px-4 py-3 text-sm text-gray-500">
|
{isPanelExpanded && groupedDocuments.length === 0 ? (
|
||||||
No active documents are assigned to this scope yet.
|
<p className="rounded-xl bg-gray-50 px-4 py-3 text-sm text-gray-500">
|
||||||
</p>
|
No active documents are assigned to this scope yet.
|
||||||
) : (
|
</p>
|
||||||
<div className="space-y-5">
|
) : isPanelExpanded ? (
|
||||||
{groupedDocuments.map((group) => (
|
<div className="space-y-5">
|
||||||
<div key={group.category} className="space-y-2">
|
{groupedDocuments.map((group) => (
|
||||||
<p className="text-xs font-bold uppercase text-gray-500">
|
<div key={group.category} className="space-y-2">
|
||||||
{group.label}
|
<p className="text-xs font-bold uppercase text-gray-500">
|
||||||
</p>
|
{group.label}
|
||||||
<div className="overflow-hidden rounded-xl border border-gray-100">
|
</p>
|
||||||
{group.documents.map((document, index) => {
|
<div className="overflow-hidden rounded-xl border border-gray-100">
|
||||||
const isExpanded = expandedDocumentIds.has(document.id);
|
{group.documents.map((document, index) => {
|
||||||
return (
|
const isExpanded = expandedDocumentIds.has(document.id);
|
||||||
<div
|
return (
|
||||||
key={document.id}
|
<div
|
||||||
className={index > 0 ? 'border-t border-gray-100' : undefined}
|
key={document.id}
|
||||||
>
|
className={index > 0 ? 'border-t border-gray-100' : undefined}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleDocument(document.id)}
|
|
||||||
className="flex w-full items-center justify-between gap-4 bg-white px-4 py-3 text-left transition-transform hover:scale-[1.005] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2"
|
|
||||||
aria-expanded={isExpanded}
|
|
||||||
aria-controls={`acknowledgment-${document.id}`}
|
|
||||||
>
|
>
|
||||||
<span className="min-w-0">
|
<button
|
||||||
<span className="block truncate text-sm font-semibold text-gray-800">
|
type="button"
|
||||||
{document.title}
|
onClick={() => toggleDocument(document.id)}
|
||||||
</span>
|
className="flex w-full items-center justify-between gap-4 bg-white px-4 py-3 text-left transition-transform hover:scale-[1.005] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2"
|
||||||
<span className="mt-1 block text-xs text-gray-500">
|
aria-expanded={isExpanded}
|
||||||
Version {document.version}
|
aria-controls={`acknowledgment-${document.id}`}
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="flex shrink-0 items-center gap-3">
|
|
||||||
<span className={getStatusClassName(document.missingCount)}>
|
|
||||||
{document.acknowledgedCount}/{document.totalStaff}
|
|
||||||
</span>
|
|
||||||
<ChevronDown
|
|
||||||
size={18}
|
|
||||||
className={`text-gray-400 transition-transform ${
|
|
||||||
isExpanded ? 'rotate-180' : ''
|
|
||||||
}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{isExpanded && (
|
|
||||||
<div
|
|
||||||
id={`acknowledgment-${document.id}`}
|
|
||||||
className="bg-gray-50 px-4 py-3"
|
|
||||||
>
|
>
|
||||||
{document.missingStaff.length === 0 ? (
|
<span className="min-w-0">
|
||||||
<p className="text-sm text-emerald-700">
|
<span className="block truncate text-sm font-semibold text-gray-800">
|
||||||
Every staff member in scope has acknowledged this version.
|
{document.title}
|
||||||
</p>
|
</span>
|
||||||
) : (
|
<span className="mt-1 block text-xs text-gray-500">
|
||||||
<div className="space-y-2">
|
Version {document.version}
|
||||||
<p className="text-xs font-semibold uppercase text-gray-500">
|
</span>
|
||||||
Not acknowledged
|
</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-3">
|
||||||
|
<span className={getStatusClassName(document.missingCount)}>
|
||||||
|
{document.acknowledgedCount}/{document.totalStaff}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={18}
|
||||||
|
className={cn('text-gray-400 transition-transform', isExpanded && 'rotate-180')}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{isExpanded && (
|
||||||
|
<div
|
||||||
|
id={`acknowledgment-${document.id}`}
|
||||||
|
className="bg-gray-50 px-4 py-3"
|
||||||
|
>
|
||||||
|
{document.missingStaff.length === 0 ? (
|
||||||
|
<p className="text-sm text-emerald-700">
|
||||||
|
Every staff member in scope has acknowledged this version.
|
||||||
</p>
|
</p>
|
||||||
{document.missingStaff.map((staff) => (
|
) : (
|
||||||
<div
|
<div className="space-y-2">
|
||||||
key={`${document.id}-${staff.userId}`}
|
<p className="text-xs font-semibold uppercase text-gray-500">
|
||||||
className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-white px-3 py-2 text-sm"
|
Not acknowledged
|
||||||
>
|
</p>
|
||||||
<span className="font-medium text-gray-800">{staff.name}</span>
|
{document.missingStaff.map((staff) => (
|
||||||
<span className="text-xs capitalize text-gray-500">
|
<div
|
||||||
{staff.role}
|
key={`${document.id}-${staff.userId}`}
|
||||||
</span>
|
className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-white px-3 py-2 text-sm"
|
||||||
</div>
|
>
|
||||||
))}
|
<span className="font-medium text-gray-800">{staff.name}</span>
|
||||||
</div>
|
<span className="text-xs capitalize text-gray-500">
|
||||||
)}
|
{staff.role}
|
||||||
</div>
|
</span>
|
||||||
)}
|
</div>
|
||||||
</div>
|
))}
|
||||||
);
|
</div>
|
||||||
})}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<p className="rounded-xl bg-gray-50 px-4 py-3 text-sm text-gray-500">
|
||||||
|
Expand to review document acknowledgment status by category.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,8 +23,8 @@ export function DirectorDashboardHeader({
|
|||||||
title={title}
|
title={title}
|
||||||
icon={BarChart3}
|
icon={BarChart3}
|
||||||
iconClassName="bg-gradient-to-br from-purple-500 to-purple-700"
|
iconClassName="bg-gradient-to-br from-purple-500 to-purple-700"
|
||||||
titleClassName="text-gray-800"
|
iconShadowClassName="shadow-purple-500/30"
|
||||||
descriptionClassName="text-gray-500 flex items-center gap-2"
|
descriptionClassName="flex items-center gap-2"
|
||||||
description={(
|
description={(
|
||||||
<>
|
<>
|
||||||
{scopeLabel
|
{scopeLabel
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useRef } from 'react';
|
||||||
|
|
||||||
import type { ModuleId } from '@/shared/types/app';
|
import type { ModuleId } from '@/shared/types/app';
|
||||||
import type { DirectorDashboardPage } from '@/business/director-dashboard/types';
|
import type { DirectorDashboardPage } from '@/business/director-dashboard/types';
|
||||||
import { DirectorDashboardHeader } from '@/components/director-dashboard/DirectorDashboardHeader';
|
import { DirectorDashboardHeader } from '@/components/director-dashboard/DirectorDashboardHeader';
|
||||||
import { DirectorAcknowledgmentTrackingModal } from '@/components/director-dashboard/DirectorAcknowledgmentTrackingModal';
|
import { DirectorAcknowledgmentTrackingPanel } from '@/components/director-dashboard/DirectorAcknowledgmentTrackingPanel';
|
||||||
import { DirectorOverviewCards } from '@/components/director-dashboard/DirectorOverviewCards';
|
import { DirectorOverviewCards } from '@/components/director-dashboard/DirectorOverviewCards';
|
||||||
import { DirectorQuickActions } from '@/components/director-dashboard/DirectorQuickActions';
|
import { DirectorQuickActions } from '@/components/director-dashboard/DirectorQuickActions';
|
||||||
import { DirectorQuizResultsPanel } from '@/components/director-dashboard/DirectorQuizResultsPanel';
|
import { DirectorQuizResultsPanel } from '@/components/director-dashboard/DirectorQuizResultsPanel';
|
||||||
@ -22,9 +22,24 @@ export function DirectorDashboardView({
|
|||||||
page,
|
page,
|
||||||
onOpenModule,
|
onOpenModule,
|
||||||
}: DirectorDashboardViewProps) {
|
}: DirectorDashboardViewProps) {
|
||||||
const [isAcknowledgmentModalOpen, setIsAcknowledgmentModalOpen] = useState(false);
|
const quizResultsRef = useRef<HTMLDivElement>(null);
|
||||||
|
const acknowledgmentResultsRef = useRef<HTMLDivElement>(null);
|
||||||
const errorMessage = getOptionalErrorMessage(page.error);
|
const errorMessage = getOptionalErrorMessage(page.error);
|
||||||
|
|
||||||
|
function scrollToQuizResults() {
|
||||||
|
quizResultsRef.current?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToAcknowledgments() {
|
||||||
|
acknowledgmentResultsRef.current?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (page.isLoading) {
|
if (page.isLoading) {
|
||||||
return <PageSkeleton />;
|
return <PageSkeleton />;
|
||||||
}
|
}
|
||||||
@ -48,16 +63,23 @@ export function DirectorDashboardView({
|
|||||||
<DirectorOverviewCards
|
<DirectorOverviewCards
|
||||||
cards={page.overviewCards}
|
cards={page.overviewCards}
|
||||||
onOpenModule={onOpenModule}
|
onOpenModule={onOpenModule}
|
||||||
onOpenAcknowledgments={() => setIsAcknowledgmentModalOpen(true)}
|
onOpenAcknowledgments={scrollToAcknowledgments}
|
||||||
|
onOpenQuizResults={scrollToQuizResults}
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<DirectorRiskList
|
<DirectorRiskList
|
||||||
risks={page.riskAreas}
|
risks={page.riskAreas}
|
||||||
onOpenModule={onOpenModule}
|
onOpenModule={onOpenModule}
|
||||||
onOpenAcknowledgments={() => setIsAcknowledgmentModalOpen(true)}
|
onOpenAcknowledgments={scrollToAcknowledgments}
|
||||||
|
onOpenQuizResults={scrollToQuizResults}
|
||||||
/>
|
/>
|
||||||
<DirectorQuizResultsPanel results={page.quizResults} />
|
<div ref={quizResultsRef}>
|
||||||
|
<DirectorQuizResultsPanel results={page.quizResults} />
|
||||||
|
</div>
|
||||||
|
<div ref={acknowledgmentResultsRef}>
|
||||||
|
<DirectorAcknowledgmentTrackingPanel documents={page.acknowledgmentDocuments} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<DirectorRecentFramePanel
|
<DirectorRecentFramePanel
|
||||||
@ -70,11 +92,6 @@ export function DirectorDashboardView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DirectorAcknowledgmentTrackingModal
|
|
||||||
documents={page.acknowledgmentDocuments}
|
|
||||||
open={isAcknowledgmentModalOpen}
|
|
||||||
onOpenChange={setIsAcknowledgmentModalOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { ModuleId } from '@/shared/types/app';
|
import type { ModuleId } from '@/shared/types/app';
|
||||||
import type { DirectorOverviewCard } from '@/business/director-dashboard/types';
|
import type { DirectorOverviewCard } from '@/business/director-dashboard/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { DirectorScaleButton } from '@/components/director-dashboard/DirectorScaleButton';
|
||||||
import {
|
import {
|
||||||
directorOverviewIcons,
|
directorOverviewIcons,
|
||||||
directorOverviewToneClasses,
|
directorOverviewToneClasses,
|
||||||
@ -12,12 +12,14 @@ interface DirectorOverviewCardsProps {
|
|||||||
readonly cards: readonly DirectorOverviewCard[];
|
readonly cards: readonly DirectorOverviewCard[];
|
||||||
readonly onOpenModule: (module: ModuleId) => void;
|
readonly onOpenModule: (module: ModuleId) => void;
|
||||||
readonly onOpenAcknowledgments?: () => void;
|
readonly onOpenAcknowledgments?: () => void;
|
||||||
|
readonly onOpenQuizResults?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DirectorOverviewCards({
|
export function DirectorOverviewCards({
|
||||||
cards,
|
cards,
|
||||||
onOpenModule,
|
onOpenModule,
|
||||||
onOpenAcknowledgments,
|
onOpenAcknowledgments,
|
||||||
|
onOpenQuizResults,
|
||||||
}: DirectorOverviewCardsProps) {
|
}: DirectorOverviewCardsProps) {
|
||||||
function handleCardClick(card: DirectorOverviewCard) {
|
function handleCardClick(card: DirectorOverviewCard) {
|
||||||
if (card.action === 'openAcknowledgments' && onOpenAcknowledgments) {
|
if (card.action === 'openAcknowledgments' && onOpenAcknowledgments) {
|
||||||
@ -25,6 +27,11 @@ export function DirectorOverviewCards({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (card.action === 'openQuizResults' && onOpenQuizResults) {
|
||||||
|
onOpenQuizResults();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
onOpenModule(card.module);
|
onOpenModule(card.module);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,11 +42,11 @@ export function DirectorOverviewCards({
|
|||||||
const TrendIcon = directorTrendIcons[card.trend];
|
const TrendIcon = directorTrendIcons[card.trend];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<DirectorScaleButton
|
||||||
key={card.label}
|
key={card.label}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleCardClick(card)}
|
onClick={() => handleCardClick(card)}
|
||||||
className="h-auto bg-white rounded-2xl border border-violet-100 shadow-sm p-5 text-left hover:shadow-md transition-all hover:-translate-y-0.5 group justify-start"
|
className="h-auto rounded-2xl border border-violet-100 bg-white p-5 text-left shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
@ -54,7 +61,7 @@ export function DirectorOverviewCards({
|
|||||||
<p className="text-2xl font-bold text-gray-800">{card.value}</p>
|
<p className="text-2xl font-bold text-gray-800">{card.value}</p>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">{card.label}</p>
|
<p className="text-xs text-gray-500 mt-0.5">{card.label}</p>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</DirectorScaleButton>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { ModuleId } from '@/shared/types/app';
|
import type { ModuleId } from '@/shared/types/app';
|
||||||
import type { DirectorQuickActionConfig } from '@/shared/constants/directorDashboard';
|
import type { DirectorQuickActionConfig } from '@/shared/constants/directorDashboard';
|
||||||
import { Button } from '@/components/ui/button';
|
import { DirectorScaleButton } from '@/components/director-dashboard/DirectorScaleButton';
|
||||||
import {
|
import {
|
||||||
directorQuickActionIcons,
|
directorQuickActionIcons,
|
||||||
directorQuickActionToneClasses,
|
directorQuickActionToneClasses,
|
||||||
@ -23,15 +23,15 @@ export function DirectorQuickActions({
|
|||||||
const ActionIcon = action.iconId ? directorQuickActionIcons[action.iconId] : undefined;
|
const ActionIcon = action.iconId ? directorQuickActionIcons[action.iconId] : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<DirectorScaleButton
|
||||||
key={action.label}
|
key={action.label}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpenModule(action.module)}
|
onClick={() => onOpenModule(action.module)}
|
||||||
className={`w-full h-auto text-left px-4 py-2.5 rounded-xl text-sm font-medium transition-all flex items-center gap-2 justify-start ${directorQuickActionToneClasses[action.tone]}`}
|
className={`flex h-auto w-full items-center justify-start gap-2 rounded-xl px-4 py-2.5 text-left text-sm font-medium ${directorQuickActionToneClasses[action.tone]}`}
|
||||||
>
|
>
|
||||||
{ActionIcon && <ActionIcon size={14} />}
|
{ActionIcon && <ActionIcon size={14} />}
|
||||||
{action.label}
|
{action.label}
|
||||||
</Button>
|
</DirectorScaleButton>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,65 +1,151 @@
|
|||||||
import { Users } from 'lucide-react';
|
import { useState } from 'react';
|
||||||
|
import { ChevronDown, Users } from 'lucide-react';
|
||||||
|
|
||||||
import { StatePanel } from '@/components/ui/state-panel';
|
import { StatePanel } from '@/components/ui/state-panel';
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table';
|
|
||||||
import type { DirectorQuizResultRow } from '@/business/director-dashboard/types';
|
import type { DirectorQuizResultRow } from '@/business/director-dashboard/types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface DirectorQuizResultsPanelProps {
|
interface DirectorQuizResultsPanelProps {
|
||||||
readonly results: readonly DirectorQuizResultRow[];
|
readonly results: readonly DirectorQuizResultRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelProps) {
|
export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelProps) {
|
||||||
|
const [isPanelExpanded, setIsPanelExpanded] = useState(false);
|
||||||
|
const [expandedUserIds, setExpandedUserIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||||
|
const completedQuizCount = results.reduce(
|
||||||
|
(total, result) => total + result.completedCount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const totalQuizCount = results.reduce(
|
||||||
|
(total, result) => total + result.totalCount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleUser(userId: string) {
|
||||||
|
setExpandedUserIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(userId)) {
|
||||||
|
next.delete(userId);
|
||||||
|
} else {
|
||||||
|
next.add(userId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5">
|
<div className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5">
|
||||||
<h3 className="font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
<button
|
||||||
<Users size={18} className="text-violet-500" />
|
type="button"
|
||||||
Quiz Results from Database
|
onClick={() => setIsPanelExpanded((current) => !current)}
|
||||||
</h3>
|
className="mb-4 flex w-full items-center justify-between gap-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2"
|
||||||
{results.length > 0 ? (
|
aria-expanded={isPanelExpanded}
|
||||||
<Table>
|
aria-controls="director-quiz-results-panel"
|
||||||
<TableHeader>
|
>
|
||||||
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
<span>
|
||||||
<TableHead className="h-auto p-3 text-left text-gray-500">Staff</TableHead>
|
<span className="font-semibold text-gray-800 flex items-center gap-2">
|
||||||
<TableHead className="h-auto p-3 text-left text-gray-500">Quiz</TableHead>
|
<Users size={18} className="text-violet-500" />
|
||||||
<TableHead className="h-auto p-3 text-center text-gray-500">Role</TableHead>
|
Quiz Results
|
||||||
<TableHead className="h-auto p-3 text-center text-gray-500">Result</TableHead>
|
</span>
|
||||||
<TableHead className="h-auto p-3 text-center text-gray-500">Date</TableHead>
|
<span className="mt-1 block text-sm text-gray-500">
|
||||||
</TableRow>
|
{results.length} staff · {completedQuizCount}/{totalQuizCount} quizzes complete
|
||||||
</TableHeader>
|
</span>
|
||||||
<TableBody>
|
</span>
|
||||||
{results.map((result) => (
|
<ChevronDown
|
||||||
<TableRow key={result.id} className="border-t border-gray-50">
|
size={20}
|
||||||
<TableCell className="p-3 font-medium text-gray-700">{result.staffName}</TableCell>
|
className={cn('shrink-0 text-gray-400 transition-transform', isPanelExpanded && 'rotate-180')}
|
||||||
<TableCell className="p-3 text-sm text-gray-600">{result.quiz}</TableCell>
|
aria-hidden="true"
|
||||||
<TableCell className="p-3 text-center text-xs text-gray-500 capitalize">{result.role}</TableCell>
|
/>
|
||||||
<TableCell className="p-3 text-center">
|
</button>
|
||||||
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${
|
|
||||||
result.status === 'complete'
|
<div id="director-quiz-results-panel">
|
||||||
? 'bg-emerald-100 text-emerald-700'
|
{isPanelExpanded && results.length > 0 ? (
|
||||||
: 'bg-amber-100 text-amber-700'
|
<div className="overflow-hidden rounded-xl border border-gray-100">
|
||||||
}`}>
|
<div className="grid grid-cols-[minmax(0,1.5fr)_minmax(0,1.2fr)_minmax(8rem,0.8fr)_minmax(7rem,0.7fr)_2rem] gap-3 bg-gray-50 px-4 py-3 text-xs font-semibold uppercase text-gray-500">
|
||||||
{result.result}
|
<span>Staff</span>
|
||||||
</span>
|
<span>Tenant</span>
|
||||||
</TableCell>
|
<span className="text-center">Role</span>
|
||||||
<TableCell className="p-3 text-center text-xs text-gray-400">
|
<span className="text-center">Quizzes</span>
|
||||||
{result.date}
|
<span className="sr-only">Toggle</span>
|
||||||
</TableCell>
|
</div>
|
||||||
</TableRow>
|
<div className="divide-y divide-gray-100">
|
||||||
))}
|
{results.map((result) => {
|
||||||
</TableBody>
|
const isExpanded = expandedUserIds.has(result.id);
|
||||||
</Table>
|
return (
|
||||||
) : (
|
<div key={result.id}>
|
||||||
<StatePanel className="border-0 bg-transparent" alignment="center">
|
<button
|
||||||
No staff are in this completion scope yet.
|
type="button"
|
||||||
</StatePanel>
|
onClick={() => toggleUser(result.id)}
|
||||||
)}
|
className="grid w-full grid-cols-[minmax(0,1.5fr)_minmax(0,1.2fr)_minmax(8rem,0.8fr)_minmax(7rem,0.7fr)_2rem] items-center gap-3 bg-white px-4 py-3 text-left transition-transform hover:scale-[1.005] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2"
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
aria-controls={`quiz-results-${result.id}`}
|
||||||
|
>
|
||||||
|
<span className="truncate font-medium text-gray-700">{result.staffName}</span>
|
||||||
|
<span className="truncate text-sm text-gray-500">{result.tenant}</span>
|
||||||
|
<span className="text-center text-xs capitalize text-gray-500">{result.role}</span>
|
||||||
|
<span className="text-center">
|
||||||
|
<span className={cn(
|
||||||
|
'rounded-lg px-2.5 py-1 text-xs font-semibold',
|
||||||
|
result.completedCount === result.totalCount
|
||||||
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
|
: 'bg-amber-100 text-amber-700',
|
||||||
|
)}>
|
||||||
|
{result.completedCount}/{result.totalCount}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={18}
|
||||||
|
className={cn('text-gray-400 transition-transform', isExpanded && 'rotate-180')}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{isExpanded && (
|
||||||
|
<div id={`quiz-results-${result.id}`} className="bg-gray-50 px-4 py-3">
|
||||||
|
<div className="overflow-hidden rounded-lg border border-gray-100 bg-white">
|
||||||
|
<div className="grid grid-cols-[minmax(0,1.3fr)_8rem_minmax(0,1fr)] gap-3 border-b border-gray-100 px-3 py-2 text-xs font-semibold uppercase text-gray-500">
|
||||||
|
<span>Quiz</span>
|
||||||
|
<span>Date</span>
|
||||||
|
<span>Result</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{result.details.map((detail) => (
|
||||||
|
<div
|
||||||
|
key={`${result.id}-${detail.id}`}
|
||||||
|
className="grid grid-cols-[minmax(0,1.3fr)_8rem_minmax(0,1fr)] items-center gap-3 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-gray-700">{detail.quiz}</span>
|
||||||
|
<span className="text-xs text-gray-500">{detail.date}</span>
|
||||||
|
<span>
|
||||||
|
<span className={cn(
|
||||||
|
'rounded-lg px-2 py-0.5 text-xs font-semibold',
|
||||||
|
detail.status === 'complete'
|
||||||
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
|
: 'bg-amber-100 text-amber-700',
|
||||||
|
)}>
|
||||||
|
{detail.result}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isPanelExpanded ? (
|
||||||
|
<StatePanel className="border-0 bg-transparent" alignment="center">
|
||||||
|
No staff are in this completion scope yet.
|
||||||
|
</StatePanel>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl bg-gray-50 px-4 py-3 text-sm text-gray-500">
|
||||||
|
Expand to review each staff member's quiz completion details.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Eye } from 'lucide-react';
|
|||||||
|
|
||||||
import type { ModuleId } from '@/shared/types/app';
|
import type { ModuleId } from '@/shared/types/app';
|
||||||
import type { DirectorFramePreview } from '@/business/director-dashboard/types';
|
import type { DirectorFramePreview } from '@/business/director-dashboard/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { DirectorScaleButton } from '@/components/director-dashboard/DirectorScaleButton';
|
||||||
import { StatePanel } from '@/components/ui/state-panel';
|
import { StatePanel } from '@/components/ui/state-panel';
|
||||||
import {
|
import {
|
||||||
directorFrameSectionClasses,
|
directorFrameSectionClasses,
|
||||||
@ -47,14 +47,14 @@ export function DirectorRecentFramePanel({
|
|||||||
No F.R.A.M.E. entries are available yet.
|
No F.R.A.M.E. entries are available yet.
|
||||||
</StatePanel>
|
</StatePanel>
|
||||||
)}
|
)}
|
||||||
<Button
|
<DirectorScaleButton
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpenModule('frame')}
|
onClick={() => onOpenModule('frame')}
|
||||||
className="w-full mt-3 bg-transparent hover:bg-transparent text-sm text-amber-600 hover:text-amber-800 font-medium flex items-center justify-center gap-1"
|
className="mt-3 flex w-full items-center justify-center gap-1 bg-transparent text-sm font-medium text-amber-600"
|
||||||
>
|
>
|
||||||
View All Entries
|
View All Entries
|
||||||
<NavigateIcon size={14} />
|
<NavigateIcon size={14} />
|
||||||
</Button>
|
</DirectorScaleButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { ModuleId } from '@/shared/types/app';
|
import type { ModuleId } from '@/shared/types/app';
|
||||||
import type { DirectorRiskArea } from '@/business/director-dashboard/types';
|
import type { DirectorRiskArea } from '@/business/director-dashboard/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { DirectorScaleButton } from '@/components/director-dashboard/DirectorScaleButton';
|
||||||
import {
|
import {
|
||||||
directorNavigateIcon,
|
directorNavigateIcon,
|
||||||
directorRiskIcon,
|
directorRiskIcon,
|
||||||
@ -11,12 +11,14 @@ interface DirectorRiskListProps {
|
|||||||
readonly risks: readonly DirectorRiskArea[];
|
readonly risks: readonly DirectorRiskArea[];
|
||||||
readonly onOpenModule: (module: ModuleId) => void;
|
readonly onOpenModule: (module: ModuleId) => void;
|
||||||
readonly onOpenAcknowledgments?: () => void;
|
readonly onOpenAcknowledgments?: () => void;
|
||||||
|
readonly onOpenQuizResults?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DirectorRiskList({
|
export function DirectorRiskList({
|
||||||
risks,
|
risks,
|
||||||
onOpenModule,
|
onOpenModule,
|
||||||
onOpenAcknowledgments,
|
onOpenAcknowledgments,
|
||||||
|
onOpenQuizResults,
|
||||||
}: DirectorRiskListProps) {
|
}: DirectorRiskListProps) {
|
||||||
const RiskIcon = directorRiskIcon;
|
const RiskIcon = directorRiskIcon;
|
||||||
const NavigateIcon = directorNavigateIcon;
|
const NavigateIcon = directorNavigateIcon;
|
||||||
@ -27,6 +29,11 @@ export function DirectorRiskList({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (risk.action === 'openQuizResults' && onOpenQuizResults) {
|
||||||
|
onOpenQuizResults();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
onOpenModule(risk.module);
|
onOpenModule(risk.module);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,11 +45,11 @@ export function DirectorRiskList({
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{risks.map((risk) => (
|
{risks.map((risk) => (
|
||||||
<Button
|
<DirectorScaleButton
|
||||||
key={`${risk.module}-${risk.issue}`}
|
key={`${risk.module}-${risk.issue}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRiskClick(risk)}
|
onClick={() => handleRiskClick(risk)}
|
||||||
className={`w-full h-auto text-left p-4 rounded-xl border ${directorRiskSeverityClasses[risk.severity]} flex items-center justify-between gap-3 hover:shadow-sm transition-all`}
|
className={`flex h-auto w-full items-center justify-between gap-3 rounded-xl border p-4 text-left ${directorRiskSeverityClasses[risk.severity]}`}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<span className={`px-2 py-1 rounded-lg text-[10px] font-bold uppercase ${directorRiskSeverityClasses[risk.severity]}`}>
|
<span className={`px-2 py-1 rounded-lg text-[10px] font-bold uppercase ${directorRiskSeverityClasses[risk.severity]}`}>
|
||||||
@ -51,7 +58,7 @@ export function DirectorRiskList({
|
|||||||
<p className="min-w-0 break-words text-sm font-medium text-gray-700">{risk.issue}</p>
|
<p className="min-w-0 break-words text-sm font-medium text-gray-700">{risk.issue}</p>
|
||||||
</div>
|
</div>
|
||||||
<NavigateIcon size={14} className="shrink-0 text-gray-400" />
|
<NavigateIcon size={14} className="shrink-0 text-gray-400" />
|
||||||
</Button>
|
</DirectorScaleButton>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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 {
|
import {
|
||||||
DIRECTOR_DASHBOARD_TIME_RANGES,
|
DIRECTOR_DASHBOARD_TIME_RANGES,
|
||||||
type DirectorDashboardTimeRange,
|
type DirectorDashboardTimeRange,
|
||||||
@ -16,18 +16,18 @@ export function DirectorTimeRangeTabs({
|
|||||||
return (
|
return (
|
||||||
<div className="flex gap-2 bg-white rounded-xl border border-violet-100 p-1">
|
<div className="flex gap-2 bg-white rounded-xl border border-violet-100 p-1">
|
||||||
{DIRECTOR_DASHBOARD_TIME_RANGES.map((range) => (
|
{DIRECTOR_DASHBOARD_TIME_RANGES.map((range) => (
|
||||||
<Button
|
<DirectorScaleButton
|
||||||
key={range}
|
key={range}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onTimeRangeChange(range)}
|
onClick={() => onTimeRangeChange(range)}
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
className={`rounded-lg px-4 py-2 text-sm font-medium ${
|
||||||
timeRange === range
|
timeRange === range
|
||||||
? 'bg-purple-500 text-white shadow-sm'
|
? 'bg-purple-500 text-white shadow-sm'
|
||||||
: 'bg-transparent text-gray-500 hover:text-gray-700 hover:bg-transparent'
|
: 'bg-transparent text-gray-500'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{range.charAt(0).toUpperCase() + range.slice(1)}
|
{range.charAt(0).toUpperCase() + range.slice(1)}
|
||||||
</Button>
|
</DirectorScaleButton>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -71,10 +71,10 @@ export const directorFrameSectionClasses: Record<DirectorFrameSectionLetter, str
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const directorQuickActionToneClasses: Record<DirectorQuickActionTone, string> = {
|
export const directorQuickActionToneClasses: Record<DirectorQuickActionTone, string> = {
|
||||||
indigo: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200',
|
indigo: 'bg-indigo-100 text-indigo-700',
|
||||||
amber: 'bg-amber-100 text-amber-700 hover:bg-amber-200',
|
amber: 'bg-amber-100 text-amber-700',
|
||||||
blue: 'bg-blue-100 text-blue-700 hover:bg-blue-200',
|
blue: 'bg-blue-100 text-blue-700',
|
||||||
orange: 'bg-orange-100 text-orange-700 hover:bg-orange-200',
|
orange: 'bg-orange-100 text-orange-700',
|
||||||
rose: 'bg-rose-100 text-rose-700 hover:bg-rose-200',
|
rose: 'bg-rose-100 text-rose-700',
|
||||||
red: 'bg-red-100 text-red-700 hover:bg-red-200',
|
red: 'bg-red-100 text-red-700',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -35,6 +35,17 @@ describe('frame API', () => {
|
|||||||
expect(apiRequestMock).toHaveBeenCalledWith('/frame_entries');
|
expect(apiRequestMock).toHaveBeenCalledWith('/frame_entries');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lists FRAME entries with date filters', () => {
|
||||||
|
void listFrameEntries({
|
||||||
|
startDate: '2026-04-01',
|
||||||
|
endDate: '2026-06-19',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||||
|
'/frame_entries?startDate=2026-04-01&endDate=2026-06-19',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('creates FRAME entries with POST body wrapped in data', () => {
|
it('creates FRAME entries with POST body wrapped in data', () => {
|
||||||
void createFrameEntry(frameRequest);
|
void createFrameEntry(frameRequest);
|
||||||
|
|
||||||
|
|||||||
@ -4,8 +4,32 @@ import { FrameEntryDto, FrameEntryMutationDto } from '@/shared/types/frame';
|
|||||||
|
|
||||||
const FRAME_ENTRIES_PATH = '/frame_entries';
|
const FRAME_ENTRIES_PATH = '/frame_entries';
|
||||||
|
|
||||||
export function listFrameEntries(): Promise<ApiListResponse<FrameEntryDto>> {
|
export interface FrameEntryFilter {
|
||||||
return apiRequest<ApiListResponse<FrameEntryDto>>(FRAME_ENTRIES_PATH);
|
readonly startDate?: string;
|
||||||
|
readonly endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSearchParams(filter?: FrameEntryFilter): string {
|
||||||
|
if (!filter) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (filter.startDate) {
|
||||||
|
params.set('startDate', filter.startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.endDate) {
|
||||||
|
params.set('endDate', filter.endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = params.toString();
|
||||||
|
return query ? `?${query}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFrameEntries(filter?: FrameEntryFilter): Promise<ApiListResponse<FrameEntryDto>> {
|
||||||
|
return apiRequest<ApiListResponse<FrameEntryDto>>(`${FRAME_ENTRIES_PATH}${toSearchParams(filter)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFrameEntry(request: FrameEntryMutationDto): Promise<FrameEntryDto> {
|
export function createFrameEntry(request: FrameEntryMutationDto): Promise<FrameEntryDto> {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export const DIRECTOR_DASHBOARD_TIME_RANGES = ['week', 'month', 'quarter'] as co
|
|||||||
|
|
||||||
export type DirectorDashboardTimeRange = typeof DIRECTOR_DASHBOARD_TIME_RANGES[number];
|
export type DirectorDashboardTimeRange = typeof DIRECTOR_DASHBOARD_TIME_RANGES[number];
|
||||||
|
|
||||||
|
export const DIRECTOR_DASHBOARD_TIME_RANGE_STORAGE_KEY = 'director-dashboard-time-range';
|
||||||
export const DIRECTOR_DASHBOARD_FRAME_PREVIEW_LIMIT = 3;
|
export const DIRECTOR_DASHBOARD_FRAME_PREVIEW_LIMIT = 3;
|
||||||
export const DIRECTOR_DASHBOARD_TEXT_PREVIEW_LENGTH = 60;
|
export const DIRECTOR_DASHBOARD_TEXT_PREVIEW_LENGTH = 60;
|
||||||
export const DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD = 50;
|
export const DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD = 50;
|
||||||
|
|||||||
@ -52,10 +52,11 @@ async function login(page: Page, email: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function findAudio(page: Page, title: string): Promise<AudioRow | undefined> {
|
async function findAudio(page: Page, title: string): Promise<AudioRow | undefined> {
|
||||||
const res = await page.request.get(AUDIO);
|
const params = new URLSearchParams({ title, limit: '1' });
|
||||||
|
const res = await page.request.get(`${AUDIO}?${params.toString()}`);
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
const body = (await res.json()) as { rows?: AudioRow[] };
|
const body = (await res.json()) as { rows?: AudioRow[] };
|
||||||
return (body.rows ?? []).find((row) => row.title === title);
|
return body.rows?.[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Audio library', () => {
|
test.describe('Audio library', () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user