Added Awards Earned & Reviews, Certifications & Degrees, Autism Support Steps pages with CRUD and permissions
This commit is contained in:
parent
1b9ea3fdd6
commit
dd5e9c6ad4
@ -28,6 +28,8 @@ DTO fields: `id`, `content_type`, `payload`, `updatedAt`.
|
||||
- `GET /api/content-catalog/read/:contentType` requires an authenticated user and returns only records where `active = true`.
|
||||
- All `/api/content-catalog` management endpoints require `MANAGE_CONTENT_CATALOG`. `custom_permissions` can grant it and `custom_permissions_filter` can remove it for non-global users. Tenant/content-type scoping still constrains which row is edited.
|
||||
- `esa-funding-content` management requires `MANAGE_ESA_FUNDING_CONTENT`, campus effective scope, and `organizations.esaEnabled = true`. Parent users manage campus ESA content by drilling into a campus. Editing ESA content also stores the updated section name, bumps the linked `policy_documents` ESA acknowledgment document version, and makes staff re-acknowledge.
|
||||
- `autism-support-steps` management requires `MANAGE_AUTISM_SUPPORT_STEPS` and campus effective scope. The payload stores editable step cards and visual lanyard card entries; uploaded visual card images are stored through the file subsystem and referenced by private URL.
|
||||
- `para-certifications` management requires `MANAGE_PARA_CERTIFICATIONS` and campus effective scope. The payload stores the hero image, highlight cards, and editable certification/degree resource cards.
|
||||
- `classroom-strategies` is an organization-scoped catalog. Management requires `MANAGE_CONTENT_CATALOG` and an effective organization scope; school, campus, and class drill-down scopes can read it but cannot update the organization strategy library.
|
||||
- `safety-qbs-quiz` is also organization-scoped. Organization users with `MANAGE_CONTENT_CATALOG` manage the weekly quiz and key reminders once for the organization; school, campus, and class users only read and complete that organization-owned quiz.
|
||||
- `emotional-intelligence-assessment-questions` and `emotional-intelligence-personality-quiz` are organization-scoped catalogs. Organization users with `MANAGE_CONTENT_CATALOG` manage the active quiz content once for the organization; descendant scopes read and complete that organization-owned content.
|
||||
@ -38,7 +40,7 @@ Content records can be tenant-scoped through nullable `organizationId`, `schoolI
|
||||
|
||||
- Per-tenant types use the caller's own tenant (`getOwnTenant`) and exact owner matching. Dashboard content (`dashboard-encouraging-quotes`, `dashboard-compliance-items`, `dashboard-sign-of-week`, and related dashboard assets) is seeded at organization, school, and campus scope so leadership dashboards work at every supported drill-down level.
|
||||
- School-scoped types read the caller's resolved school row.
|
||||
- Campus-scoped types read the caller's resolved campus row; class-scoped and external users with a campus read the campus row.
|
||||
- Campus-scoped types read the caller's resolved campus row; class-scoped and external users with a campus read the campus row. Current campus-scoped content types are `esa-funding-content`, `autism-support-steps`, and `para-certifications`.
|
||||
- Org-scoped types read the caller's organization row. `classroom-strategies` is org-scoped: every organization gets one preset editable strategy library, while school, campus, and classroom views read that organization library instead of owning duplicate rows.
|
||||
- `safety-qbs-quiz` is org-scoped for the same reason: there is one weekly QBS quiz payload per organization, and descendant scopes read the organization payload.
|
||||
- Emotional Intelligence self-assessment and Personality Type quiz payloads are org-scoped for the same reason: each organization owns its active quiz versions and descendant scopes read the organization payloads.
|
||||
@ -63,11 +65,17 @@ Management list excludes tenant-scoped content because those records are edited
|
||||
- Missing/inactive content types fail explicitly with `ValidationError` rather than returning empty payloads.
|
||||
|
||||
### Seeded content types
|
||||
The seeder (`20260608103000-content-catalog.ts`) loads the following `content_type` keys from `content-catalog-seed-payloads.ts`: `classroom-strategies`, `safety-qbs-quiz`, `sign-language-items`, `sign-language-page-content`, `regulation-zones`, `zones-of-regulation-page-content`, `dashboard-teacher-images`, `dashboard-encouraging-quotes`, `dashboard-compliance-items`, `dashboard-sign-of-week`, `community-organizations`, `vocational-opportunities`, `emotional-intelligence-assessment-questions`, `emotional-intelligence-personality-quiz`, `emotional-intelligence-weekly-topics`, `emotional-intelligence-growth-tips`, `emotional-intelligence-team-wellness-metrics`, `emotional-intelligence-weekly-focus`, `esa-funding-content`.
|
||||
The seeder (`20260608103000-content-catalog.ts`) loads the following `content_type` keys from `content-catalog-seed-payloads.ts`: `classroom-strategies`, `safety-qbs-quiz`, `sign-language-items`, `sign-language-page-content`, `regulation-zones`, `zones-of-regulation-page-content`, `dashboard-teacher-images`, `dashboard-encouraging-quotes`, `dashboard-compliance-items`, `dashboard-sign-of-week`, `community-organizations`, `vocational-opportunities`, `emotional-intelligence-assessment-questions`, `emotional-intelligence-personality-quiz`, `emotional-intelligence-weekly-topics`, `emotional-intelligence-growth-tips`, `emotional-intelligence-team-wellness-metrics`, `emotional-intelligence-weekly-focus`, `esa-funding-content`, `autism-support-steps`, `para-certifications`.
|
||||
|
||||
Staff-support defaults:
|
||||
|
||||
- `autism-support-steps` contains six editable staff step cards and eight printable visual/lanyard cards copied from the legacy template payload.
|
||||
- `para-certifications` contains the hero image/text, three highlight cards, and eight editable certification/degree resource cards copied from the legacy template payload.
|
||||
- Both content types are campus-scoped. New campus creation seeds one row for each type; existing campuses are backfilled by `20260626110000-staff-support-content.ts`.
|
||||
|
||||
The migration `20260614090000-drop-global-content-catalog-rows.ts` removes the former global static rows: `personality-*` and `classroom-timer-*`. The migration `20260616170000-backfill-dashboard-content-scopes.ts` backfills per-tenant dashboard catalog defaults for existing organization, school, and campus records. The migration `20260618131000-backfill-emotional-intelligence-quiz-content.ts` backfills missing Emotional Intelligence and Personality Type quiz content for existing organizations.
|
||||
|
||||
New tenant creation uses `ContentCatalogSeedService.seedDefaultContentForTenant`: org creation presets org-scoped content such as `classroom-strategies`, `safety-qbs-quiz`, Emotional Intelligence self-assessment questions, the Personality Type quiz, and Zones of Regulation content; school creation presets school-scoped content; campus creation presets per-tenant campus content plus the campus-scoped ESA funding content and its policy-acknowledgment document. This keeps shared libraries and editable organization-wide content owned at the organization level while ESA stays campus-owned.
|
||||
New tenant creation uses `ContentCatalogSeedService.seedDefaultContentForTenant`: org creation presets org-scoped content such as `classroom-strategies`, `safety-qbs-quiz`, Emotional Intelligence self-assessment questions, the Personality Type quiz, and Zones of Regulation content; school creation presets school-scoped content; campus creation presets per-tenant campus content plus campus-scoped ESA funding, Autism Support Steps, and Para Certifications content. This keeps shared libraries and editable organization-wide content owned at the organization level while campus-specific staff guidance stays campus-owned.
|
||||
|
||||
### Content authoring rules
|
||||
- Add editable or tenant-scoped production content records to backend seed payloads, not frontend constants.
|
||||
@ -76,7 +84,7 @@ New tenant creation uses `ContentCatalogSeedService.seedDefaultContentForTenant`
|
||||
- `dashboard-sign-of-week` stores the selected sign card id, current Sunday-start `weekOf`, and fallback word, description, alt text, and image URL. Frontend dashboard and notification code resolve the id against `sign-language-items`; the fallback fields preserve compatibility with older seeded payloads.
|
||||
|
||||
## Tests
|
||||
Coverage lives in `src/services/content_catalog.test.ts` and `src/services/content_catalog_seed.test.ts`. It covers tenant read scoping, organization-only management for organization-owned content, organization-only sign library management, organization-only seeding for preset organization-owned content, seeded Sign of the Week selector alignment with the Help sign card, and Zones of Regulation content. Personality result tests cover active quiz consumption, reporting, and parent-drill save blocking.
|
||||
Coverage lives in `src/services/content_catalog.test.ts` and `src/services/content_catalog_seed.test.ts`. It covers tenant read scoping, organization-only management for organization-owned content, organization-only sign library management, campus-only management for staff-support content, campus-only seeding for staff-support content, organization-only seeding for preset organization-owned content, seeded Sign of the Week selector alignment with the Help sign card, and Zones of Regulation content. Personality result tests cover active quiz consumption, reporting, and parent-drill save blocking.
|
||||
|
||||
## Related
|
||||
- Frontend: `frontend/docs/content-catalog-integration.md`.
|
||||
|
||||
@ -28,6 +28,7 @@ Also: every tenant-owned table carries `organizationId` (+ optional `campusId`)
|
||||
| Weekly F.R.A.M.E. entry | **`frame_entries`** | `week_of` is the canonical Sunday-start ISO (`shared/constants/week.ts`). |
|
||||
| Per-user progress / daily self-state | **`user_progress`** | `progress_type` + `item_id` + `value`. Backs sign-learned, Classroom Support favorites, and the daily zone check-in (`item_id` = campus-local date). |
|
||||
| Backend-owned editable content | **`content_catalog`** | scoped/editable JSONB payloads by `content_type`; truly global static catalogs stay in frontend constants. |
|
||||
| Staff award/review records with attachments and leader-scoped access | **`staff_awards_reviews`** | per staff user + campus records for Awards Earned & Reviews. This intentionally does **not** use `user_progress` because leaders can create/read records for scoped staff users. |
|
||||
| File upload/download | the **file subsystem** + `file` table | see `file.md`; downloads enforce per-file ownership. |
|
||||
|
||||
## Organization-level feature flags
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
## Overview
|
||||
|
||||
- **Engine:** PostgreSQL via **Sequelize 6** (models in `backend/src/db/models`, typed data access in `backend/src/db/api`).
|
||||
- **Models:** 35 tables.
|
||||
- **Models:** 36 tables.
|
||||
- **Primary keys:** every table has a `uuid` `id` (default `UUIDV4`).
|
||||
- **Soft delete:** all tables are `paranoid` — rows are flagged with `deletedAt` instead of being physically removed.
|
||||
- **Timestamps:** `createdAt` / `updatedAt` are managed automatically.
|
||||
@ -25,7 +25,7 @@ Types below are the SQL column types. A few Sequelize types are returned as JS `
|
||||
- **Academics:** `academic_years`, `grades`, `subjects`, `classes`, `class_enrollments`, `class_subjects`, `timetables`, `timetable_periods`, `assessments`, `assessment_results`
|
||||
- **Attendance:** `attendance_sessions`, `attendance_records`, `campus_attendance_config`, `campus_attendance_summaries`, `staff_attendance_records`
|
||||
- **Communication:** `messages`, `message_recipients`, `communication_events`
|
||||
- **Content & Product modules:** `content_catalog`, `frame_entries`, `user_progress`, `safety_quiz_results`, `walkthrough_checkins`, `personality_quiz_results`
|
||||
- **Content & Product modules:** `content_catalog`, `frame_entries`, `user_progress`, `safety_quiz_results`, `walkthrough_checkins`, `personality_quiz_results`, `staff_awards_reviews`
|
||||
- **Policy & Audio:** `policy_documents`, `policy_acknowledgments`, `audio_files`
|
||||
- **System:** `file`, `auth_refresh_tokens`
|
||||
|
||||
@ -84,6 +84,10 @@ erDiagram
|
||||
organizations ||--o{ staff_attendance_records : "organization"
|
||||
campuses ||--o{ staff_attendance_records : "campus"
|
||||
users ||--o{ staff_attendance_records : "user"
|
||||
organizations ||--o{ staff_awards_reviews : "organization"
|
||||
schools ||--o{ staff_awards_reviews : "school"
|
||||
campuses ||--o{ staff_awards_reviews : "campus"
|
||||
users ||--o{ staff_awards_reviews : "staffUser"
|
||||
organizations ||--o{ subjects : "organization"
|
||||
organizations ||--o{ timetable_periods : "organization"
|
||||
timetables ||--o{ timetable_periods : "timetable"
|
||||
@ -678,6 +682,37 @@ _Relations:_
|
||||
- **belongs to** `campuses` as `campus` (FK `campusId`)
|
||||
- **belongs to** `users` as `user` (FK `userId`)
|
||||
|
||||
#### `staff_awards_reviews`
|
||||
|
||||
Staff awards and Director review records with optional uploaded attachment metadata.
|
||||
|
||||
| Column | Type | Null | Default | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `id` | uuid | no | UUIDV4 | PK |
|
||||
| `title` | text | no | — | |
|
||||
| `record_type` | enum | no | — | `award` \| `review` |
|
||||
| `year_label` | text | no | — | display school year or review year |
|
||||
| `notes` | text | yes | — | |
|
||||
| `file_name` | text | yes | — | original uploaded file name |
|
||||
| `file_url` | varchar | yes | — | private file subsystem URL |
|
||||
| `file_is_image` | boolean | no | false | controls inline image preview |
|
||||
| `organizationId` | uuid | no | — | FK |
|
||||
| `schoolId` | uuid | yes | — | FK |
|
||||
| `campusId` | uuid | no | — | FK |
|
||||
| `staffUserId` | uuid | no | — | FK to the staff user |
|
||||
| `createdById` | uuid | yes | — | FK, audit |
|
||||
| `updatedById` | uuid | yes | — | FK, audit |
|
||||
| `createdAt` | timestamptz | yes | — | audit |
|
||||
| `updatedAt` | timestamptz | yes | — | audit |
|
||||
| `deletedAt` | timestamptz | yes | — | audit |
|
||||
|
||||
_Relations:_
|
||||
|
||||
- **belongs to** `organizations` as `organization` (FK `organizationId`)
|
||||
- **belongs to** `schools` as `school` (FK `schoolId`)
|
||||
- **belongs to** `campuses` as `campus` (FK `campusId`)
|
||||
- **belongs to** `users` as `staffUser` (FK `staffUserId`)
|
||||
|
||||
### Communication
|
||||
|
||||
#### `messages`
|
||||
|
||||
@ -45,6 +45,7 @@ Tenant Scope / Data Contract / Behavior / Tests / Related).
|
||||
- [`policy-documents.md`](policy-documents.md): unified Safety Protocols + Handbook & Policies store and per-version acknowledgments.
|
||||
- [`safety-quiz-results.md`](safety-quiz-results.md)
|
||||
- [`staff-attendance.md`](staff-attendance.md)
|
||||
- [`staff-awards-reviews.md`](staff-awards-reviews.md)
|
||||
- [`user-progress.md`](user-progress.md)
|
||||
- [`walkthrough-checkins.md`](walkthrough-checkins.md)
|
||||
- [`zone-checkin.md`](zone-checkin.md): daily staff self-regulation check-in (campus-timezone "today" + nudge).
|
||||
|
||||
73
backend/docs/staff-awards-reviews.md
Normal file
73
backend/docs/staff-awards-reviews.md
Normal file
@ -0,0 +1,73 @@
|
||||
# Staff Awards Reviews Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
`staff_awards_reviews` stores Awards Earned & Reviews page records for campus staff. It is a dedicated table instead of `user_progress` because the workflow is not self-only: staff can maintain their own records, and users with `MANAGE_AWARDS_REVIEWS` can list scoped staff and create, update, or delete records for those staff users.
|
||||
|
||||
## Slice Files
|
||||
|
||||
- Route: `src/routes/staff_awards_reviews.ts` (`GET /staff`, `GET /`, `POST /`, `PUT /:id`, `DELETE /:id`).
|
||||
- Controller: `src/api/controllers/staff_awards_reviews.controller.ts`.
|
||||
- Service: `src/services/staff_awards_reviews.ts`.
|
||||
- Model: `src/db/models/staff_awards_reviews.ts`.
|
||||
- Migration: `src/db/migrations/20260626110000-staff-support-content.ts`.
|
||||
- Seeder: `src/db/seeders/20260613060000-staff-awards-reviews.ts`.
|
||||
|
||||
## API
|
||||
|
||||
Mounted at `/api/staff_awards_reviews` behind JWT authentication and active-scope resolution.
|
||||
|
||||
- `GET /staff` lists staff visible in the current scope. Requires `READ_AWARDS_REVIEWS` and `MANAGE_AWARDS_REVIEWS`.
|
||||
- `GET /?staffUserId=<id>` lists records for the selected staff user when the caller can manage, otherwise it lists the current user's records.
|
||||
- `POST /` creates a record. Staff users create for themselves; managers may pass `staffUserId`.
|
||||
- `PUT /:id` updates a record in scope.
|
||||
- `DELETE /:id` soft-deletes a record in scope.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- Page read requires `READ_AWARDS_REVIEWS`.
|
||||
- Staff list and cross-staff mutation require `MANAGE_AWARDS_REVIEWS`.
|
||||
- Scope is derived from the current effective role scope, including drill-down active scope:
|
||||
- organization scope sees organization staff;
|
||||
- school scope sees school and descendant campus/class staff;
|
||||
- campus and class scopes see the resolved campus staff;
|
||||
- non-managers are restricted to their own `staffUserId`.
|
||||
- Staff classification for the staff picker is based on first-class role names (`director`, `office_manager`, `teacher`, `support_staff`) as data classification, not as a feature-access fallback.
|
||||
|
||||
Seeded role permissions:
|
||||
|
||||
- Read: `system_admin`, `owner`, `superintendent`, `principal`, `director`, `office_manager`, `teacher`, and `support_staff`.
|
||||
- Manage: `system_admin`, `owner`, `superintendent`, `principal`, `director`, and `office_manager`.
|
||||
- `registrar` is intentionally excluded from this staff-support surface even though it has other school-level audit reads.
|
||||
|
||||
## Data Contract
|
||||
|
||||
Columns:
|
||||
|
||||
- `title`, `record_type` (`award` or `review`), `year_label`, `notes`;
|
||||
- optional attachment metadata: `file_name`, `file_url`, `file_is_image`;
|
||||
- tenant/scope columns: `organizationId`, `schoolId`, `campusId`, `staffUserId`;
|
||||
- audit columns: `createdById`, `updatedById`, timestamps, `deletedAt`.
|
||||
|
||||
Attachments are uploaded through `POST /api/file/upload/staff-awards-reviews/attachments`; the awards/reviews row stores the returned private URL.
|
||||
|
||||
## Seeded Defaults
|
||||
|
||||
The demo and rival seed tenants each receive Awards Earned & Reviews rows for their seeded teacher and support staff users. Each seeded staff user receives:
|
||||
|
||||
- `Teacher of the Year` (`award`, `2025-2026`);
|
||||
- `Annual Director Review` (`review`, `2025-2026`);
|
||||
- `Perfect Attendance Award` (`award`, `2024-2025`).
|
||||
|
||||
The seeder uses stable UUIDs and skips rows that already exist, so reseeding is idempotent and does not overwrite user-created records.
|
||||
|
||||
## Tests
|
||||
|
||||
- `src/services/staff_awards_reviews.test.ts` covers self-service reads, management-only staff lists, school/campus scope filtering, and manager-created record tenant/campus ownership.
|
||||
- `src/db/seeders/user-roles.test.ts` covers the seeded read/manage permission matrix, including registrar exclusion.
|
||||
- Frontend API route construction is covered by `frontend/src/shared/api/staffAwardsReviews.test.ts`.
|
||||
|
||||
## Related
|
||||
|
||||
- Content pages backed by `content_catalog`: `autism-support-steps`, `para-certifications`.
|
||||
- Frontend: `frontend/docs/staff-support-pages.md`.
|
||||
@ -0,0 +1,32 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { paramStr } from '@/api/http/request';
|
||||
import StaffAwardsReviewsService from '@/services/staff_awards_reviews';
|
||||
|
||||
export async function listStaff(req: Request, res: Response): Promise<void> {
|
||||
const payload = await StaffAwardsReviewsService.listStaff(req.query, req.currentUser);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function list(req: Request, res: Response): Promise<void> {
|
||||
const payload = await StaffAwardsReviewsService.list(req.query, req.currentUser);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function create(req: Request, res: Response): Promise<void> {
|
||||
const payload = await StaffAwardsReviewsService.create(req.body.data, req.currentUser);
|
||||
res.status(201).send(payload);
|
||||
}
|
||||
|
||||
export async function update(req: Request, res: Response): Promise<void> {
|
||||
const payload = await StaffAwardsReviewsService.update(
|
||||
paramStr(req.params.id),
|
||||
req.body.data,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function remove(req: Request, res: Response): Promise<void> {
|
||||
const payload = await StaffAwardsReviewsService.remove(paramStr(req.params.id), req.currentUser);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
@ -0,0 +1,225 @@
|
||||
import { DataTypes, QueryTypes, type QueryInterface } from 'sequelize';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { CONTENT_CATALOG_SEED_PAYLOADS } from '@/db/seeders/content-catalog-data/content-catalog-seed-payloads';
|
||||
|
||||
async function tableExists(
|
||||
queryInterface: QueryInterface,
|
||||
table: string,
|
||||
): Promise<boolean> {
|
||||
const [results] = await queryInterface.sequelize.query(`
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = '${table}'
|
||||
`);
|
||||
return (results as unknown[]).length > 0;
|
||||
}
|
||||
|
||||
async function indexExists(
|
||||
queryInterface: QueryInterface,
|
||||
indexName: string,
|
||||
): Promise<boolean> {
|
||||
const [results] = await queryInterface.sequelize.query(`
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE schemaname = 'public' AND indexname = '${indexName}'
|
||||
`);
|
||||
return (results as unknown[]).length > 0;
|
||||
}
|
||||
|
||||
async function permissionIdByName(
|
||||
queryInterface: QueryInterface,
|
||||
name: string,
|
||||
timestamp: Date,
|
||||
): Promise<string> {
|
||||
const rows = await queryInterface.sequelize.query<{ id: string }>(
|
||||
`SELECT id FROM permissions WHERE name = :name LIMIT 1`,
|
||||
{ replacements: { name }, type: QueryTypes.SELECT },
|
||||
);
|
||||
if (rows[0]) {
|
||||
return rows[0].id;
|
||||
}
|
||||
|
||||
const created = await queryInterface.sequelize.query<{ id: string }>(
|
||||
`INSERT INTO permissions (id, name, "createdAt", "updatedAt")
|
||||
VALUES (gen_random_uuid(), :name, :createdAt, :updatedAt)
|
||||
RETURNING id`,
|
||||
{
|
||||
replacements: { name, createdAt: timestamp, updatedAt: timestamp },
|
||||
type: QueryTypes.SELECT,
|
||||
},
|
||||
);
|
||||
return created[0].id;
|
||||
}
|
||||
|
||||
async function grantPermissionToRoles(
|
||||
queryInterface: QueryInterface,
|
||||
permissionName: string,
|
||||
roleNames: readonly string[],
|
||||
timestamp: Date,
|
||||
): Promise<void> {
|
||||
const permissionId = await permissionIdByName(queryInterface, permissionName, timestamp);
|
||||
const roles = await queryInterface.sequelize.query<{ id: string }>(
|
||||
`SELECT id FROM roles WHERE name IN (:roleNames)`,
|
||||
{ replacements: { roleNames }, type: QueryTypes.SELECT },
|
||||
);
|
||||
|
||||
for (const role of roles) {
|
||||
await queryInterface.sequelize.query(
|
||||
`INSERT INTO "rolesPermissionsPermissions"
|
||||
("roles_permissionsId", "permissionId", "createdAt", "updatedAt")
|
||||
SELECT :roleId, :permissionId, :createdAt, :updatedAt
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "rolesPermissionsPermissions"
|
||||
WHERE "roles_permissionsId" = :roleId AND "permissionId" = :permissionId
|
||||
)`,
|
||||
{
|
||||
replacements: {
|
||||
roleId: role.id,
|
||||
permissionId,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedCampusContent(
|
||||
queryInterface: QueryInterface,
|
||||
contentType: string,
|
||||
payload: unknown,
|
||||
timestamp: Date,
|
||||
): Promise<void> {
|
||||
const campuses = await queryInterface.sequelize.query<{
|
||||
id: string;
|
||||
organizationId: string;
|
||||
}>(
|
||||
`SELECT id, "organizationId" FROM campuses WHERE "deletedAt" IS NULL`,
|
||||
{ type: QueryTypes.SELECT },
|
||||
);
|
||||
|
||||
for (const campus of campuses) {
|
||||
const existing = await queryInterface.sequelize.query<{ id: string }>(
|
||||
`SELECT id FROM content_catalog
|
||||
WHERE content_type = :contentType
|
||||
AND "organizationId" = :organizationId
|
||||
AND "schoolId" IS NULL
|
||||
AND "campusId" = :campusId
|
||||
AND "classId" IS NULL
|
||||
LIMIT 1`,
|
||||
{
|
||||
replacements: {
|
||||
contentType,
|
||||
organizationId: campus.organizationId,
|
||||
campusId: campus.id,
|
||||
},
|
||||
type: QueryTypes.SELECT,
|
||||
},
|
||||
);
|
||||
if (existing[0]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await queryInterface.bulkInsert('content_catalog', [
|
||||
{
|
||||
id: randomUUID(),
|
||||
content_type: contentType,
|
||||
payload: JSON.stringify(payload),
|
||||
active: true,
|
||||
importHash: `content-catalog-${contentType}-${campus.id}`,
|
||||
organizationId: campus.organizationId,
|
||||
schoolId: null,
|
||||
campusId: campus.id,
|
||||
classId: null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
up: async (queryInterface: QueryInterface) => {
|
||||
const timestamp = new Date();
|
||||
|
||||
await queryInterface.sequelize.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE TYPE "public"."enum_staff_awards_reviews_record_type" AS ENUM ('award', 'review');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
if (!(await tableExists(queryInterface, 'staff_awards_reviews'))) {
|
||||
await queryInterface.createTable('staff_awards_reviews', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
title: { type: DataTypes.TEXT, allowNull: false },
|
||||
record_type: {
|
||||
type: DataTypes.ENUM('award', 'review'),
|
||||
allowNull: false,
|
||||
},
|
||||
year_label: { type: DataTypes.TEXT, allowNull: false },
|
||||
notes: { type: DataTypes.TEXT, allowNull: true },
|
||||
file_name: { type: DataTypes.TEXT, allowNull: true },
|
||||
file_url: { type: DataTypes.STRING(2083), allowNull: true },
|
||||
file_is_image: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
organizationId: { type: DataTypes.UUID, allowNull: false },
|
||||
schoolId: { type: DataTypes.UUID, allowNull: true },
|
||||
campusId: { type: DataTypes.UUID, allowNull: false },
|
||||
staffUserId: { type: DataTypes.UUID, allowNull: false },
|
||||
createdById: { type: DataTypes.UUID, allowNull: true },
|
||||
updatedById: { type: DataTypes.UUID, allowNull: true },
|
||||
createdAt: { type: DataTypes.DATE, allowNull: false },
|
||||
updatedAt: { type: DataTypes.DATE, allowNull: false },
|
||||
deletedAt: { type: DataTypes.DATE, allowNull: true },
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await indexExists(queryInterface, 'staff_awards_reviews_staff_scope_idx'))) {
|
||||
await queryInterface.addIndex('staff_awards_reviews', {
|
||||
fields: ['organizationId', 'campusId', 'staffUserId'],
|
||||
name: 'staff_awards_reviews_staff_scope_idx',
|
||||
});
|
||||
}
|
||||
|
||||
await seedCampusContent(
|
||||
queryInterface,
|
||||
'autism-support-steps',
|
||||
CONTENT_CATALOG_SEED_PAYLOADS.autismSupportSteps,
|
||||
timestamp,
|
||||
);
|
||||
await seedCampusContent(
|
||||
queryInterface,
|
||||
'para-certifications',
|
||||
CONTENT_CATALOG_SEED_PAYLOADS.paraCertifications,
|
||||
timestamp,
|
||||
);
|
||||
|
||||
const readRoles = ['system_admin', 'owner', 'superintendent', 'principal', 'director', 'office_manager', 'teacher', 'support_staff'];
|
||||
const manageRoles = ['system_admin', 'owner', 'superintendent', 'principal', 'director', 'office_manager'];
|
||||
await grantPermissionToRoles(queryInterface, 'READ_AUTISM_SUPPORT_STEPS', readRoles, timestamp);
|
||||
await grantPermissionToRoles(queryInterface, 'READ_PARA_CERTIFICATIONS', readRoles, timestamp);
|
||||
await grantPermissionToRoles(queryInterface, 'READ_AWARDS_REVIEWS', readRoles, timestamp);
|
||||
await grantPermissionToRoles(queryInterface, 'MANAGE_AUTISM_SUPPORT_STEPS', manageRoles, timestamp);
|
||||
await grantPermissionToRoles(queryInterface, 'MANAGE_PARA_CERTIFICATIONS', manageRoles, timestamp);
|
||||
await grantPermissionToRoles(queryInterface, 'MANAGE_AWARDS_REVIEWS', manageRoles, timestamp);
|
||||
},
|
||||
|
||||
down: async (queryInterface: QueryInterface) => {
|
||||
if (await tableExists(queryInterface, 'staff_awards_reviews')) {
|
||||
await queryInterface.dropTable('staff_awards_reviews');
|
||||
}
|
||||
await queryInterface.sequelize.query('DROP TYPE IF EXISTS "public"."enum_staff_awards_reviews_record_type";');
|
||||
await queryInterface.sequelize.query(
|
||||
`DELETE FROM content_catalog
|
||||
WHERE "importHash" LIKE 'content-catalog-autism-support-steps-%'
|
||||
OR "importHash" LIKE 'content-catalog-para-certifications-%'`,
|
||||
);
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,64 @@
|
||||
import { QueryTypes, type QueryInterface } from 'sequelize';
|
||||
|
||||
const REGISTRAR_ROLE = 'registrar';
|
||||
const STAFF_SUPPORT_READ_PERMISSIONS = [
|
||||
'READ_AUTISM_SUPPORT_STEPS',
|
||||
'READ_PARA_CERTIFICATIONS',
|
||||
'READ_AWARDS_REVIEWS',
|
||||
] as const;
|
||||
|
||||
export default {
|
||||
up: async (queryInterface: QueryInterface) => {
|
||||
await queryInterface.sequelize.query(
|
||||
`DELETE FROM "rolesPermissionsPermissions" role_permissions
|
||||
USING roles, permissions
|
||||
WHERE role_permissions."roles_permissionsId" = roles.id
|
||||
AND role_permissions."permissionId" = permissions.id
|
||||
AND roles.name = :roleName
|
||||
AND permissions.name IN (:permissionNames)`,
|
||||
{
|
||||
replacements: {
|
||||
roleName: REGISTRAR_ROLE,
|
||||
permissionNames: [...STAFF_SUPPORT_READ_PERMISSIONS],
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
down: async (queryInterface: QueryInterface) => {
|
||||
const registrarRoles = await queryInterface.sequelize.query<{ id: string }>(
|
||||
`SELECT id FROM roles WHERE name = :roleName`,
|
||||
{ replacements: { roleName: REGISTRAR_ROLE }, type: QueryTypes.SELECT },
|
||||
);
|
||||
const permissions = await queryInterface.sequelize.query<{ id: string }>(
|
||||
`SELECT id FROM permissions WHERE name IN (:permissionNames)`,
|
||||
{
|
||||
replacements: { permissionNames: [...STAFF_SUPPORT_READ_PERMISSIONS] },
|
||||
type: QueryTypes.SELECT,
|
||||
},
|
||||
);
|
||||
const timestamp = new Date();
|
||||
|
||||
for (const role of registrarRoles) {
|
||||
for (const permission of permissions) {
|
||||
await queryInterface.sequelize.query(
|
||||
`INSERT INTO "rolesPermissionsPermissions"
|
||||
("roles_permissionsId", "permissionId", "createdAt", "updatedAt")
|
||||
SELECT :roleId, :permissionId, :createdAt, :updatedAt
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "rolesPermissionsPermissions"
|
||||
WHERE "roles_permissionsId" = :roleId AND "permissionId" = :permissionId
|
||||
)`,
|
||||
{
|
||||
replacements: {
|
||||
roleId: role.id,
|
||||
permissionId: permission.id,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -40,6 +40,7 @@ import roles from './roles';
|
||||
import safety_quiz_results from './safety_quiz_results';
|
||||
import schools from './schools';
|
||||
import staff_attendance_records from './staff_attendance_records';
|
||||
import staff_awards_reviews from './staff_awards_reviews';
|
||||
import subjects from './subjects';
|
||||
import timetable_periods from './timetable_periods';
|
||||
import timetables from './timetables';
|
||||
@ -141,6 +142,7 @@ const models = {
|
||||
safety_quiz_results: safety_quiz_results(sequelize),
|
||||
schools: schools(sequelize),
|
||||
staff_attendance_records: staff_attendance_records(sequelize),
|
||||
staff_awards_reviews: staff_awards_reviews(sequelize),
|
||||
subjects: subjects(sequelize),
|
||||
timetable_periods: timetable_periods(sequelize),
|
||||
timetables: timetables(sequelize),
|
||||
|
||||
140
backend/src/db/models/staff_awards_reviews.ts
Normal file
140
backend/src/db/models/staff_awards_reviews.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import {
|
||||
DataTypes,
|
||||
Model,
|
||||
type CreationOptional,
|
||||
type InferAttributes,
|
||||
type InferCreationAttributes,
|
||||
type NonAttribute,
|
||||
type Sequelize,
|
||||
} from 'sequelize';
|
||||
import type {
|
||||
BelongsToGetAssociationMixin,
|
||||
BelongsToSetAssociationMixin,
|
||||
} from 'sequelize';
|
||||
import type { Db } from '@/db/types';
|
||||
import type { Campuses } from './campuses';
|
||||
import type { Organizations } from './organizations';
|
||||
import type { Schools } from './schools';
|
||||
import type { Users } from './users';
|
||||
|
||||
export type StaffAwardReviewType = 'award' | 'review';
|
||||
|
||||
export class StaffAwardsReviews extends Model<
|
||||
InferAttributes<StaffAwardsReviews>,
|
||||
InferCreationAttributes<StaffAwardsReviews>
|
||||
> {
|
||||
declare id: CreationOptional<string>;
|
||||
declare title: string;
|
||||
declare record_type: StaffAwardReviewType;
|
||||
declare year_label: string;
|
||||
declare notes: CreationOptional<string | null>;
|
||||
declare file_name: CreationOptional<string | null>;
|
||||
declare file_url: CreationOptional<string | null>;
|
||||
declare file_is_image: CreationOptional<boolean>;
|
||||
declare organizationId: string;
|
||||
declare schoolId: CreationOptional<string | null>;
|
||||
declare campusId: string;
|
||||
declare staffUserId: string;
|
||||
declare createdById: CreationOptional<string | null>;
|
||||
declare updatedById: CreationOptional<string | null>;
|
||||
declare createdAt: CreationOptional<Date>;
|
||||
declare updatedAt: CreationOptional<Date>;
|
||||
declare deletedAt: CreationOptional<Date | null>;
|
||||
|
||||
declare organization?: NonAttribute<Organizations>;
|
||||
declare school?: NonAttribute<Schools>;
|
||||
declare campus?: NonAttribute<Campuses>;
|
||||
declare staffUser?: NonAttribute<Users>;
|
||||
declare createdBy?: NonAttribute<Users>;
|
||||
declare updatedBy?: NonAttribute<Users>;
|
||||
|
||||
declare getOrganization: BelongsToGetAssociationMixin<Organizations>;
|
||||
declare setOrganization: BelongsToSetAssociationMixin<Organizations, string>;
|
||||
declare getSchool: BelongsToGetAssociationMixin<Schools>;
|
||||
declare setSchool: BelongsToSetAssociationMixin<Schools, string>;
|
||||
declare getCampus: BelongsToGetAssociationMixin<Campuses>;
|
||||
declare setCampus: BelongsToSetAssociationMixin<Campuses, string>;
|
||||
declare getStaffUser: BelongsToGetAssociationMixin<Users>;
|
||||
declare setStaffUser: BelongsToSetAssociationMixin<Users, string>;
|
||||
declare getCreatedBy: BelongsToGetAssociationMixin<Users>;
|
||||
declare setCreatedBy: BelongsToSetAssociationMixin<Users, string>;
|
||||
declare getUpdatedBy: BelongsToGetAssociationMixin<Users>;
|
||||
declare setUpdatedBy: BelongsToSetAssociationMixin<Users, string>;
|
||||
|
||||
static associate(db: Db): void {
|
||||
db.staff_awards_reviews.belongsTo(db.organizations, {
|
||||
as: 'organization',
|
||||
foreignKey: { name: 'organizationId' },
|
||||
constraints: false,
|
||||
});
|
||||
db.staff_awards_reviews.belongsTo(db.schools, {
|
||||
as: 'school',
|
||||
foreignKey: { name: 'schoolId' },
|
||||
constraints: false,
|
||||
});
|
||||
db.staff_awards_reviews.belongsTo(db.campuses, {
|
||||
as: 'campus',
|
||||
foreignKey: { name: 'campusId' },
|
||||
constraints: false,
|
||||
});
|
||||
db.staff_awards_reviews.belongsTo(db.users, {
|
||||
as: 'staffUser',
|
||||
foreignKey: { name: 'staffUserId' },
|
||||
constraints: false,
|
||||
});
|
||||
db.staff_awards_reviews.belongsTo(db.users, {
|
||||
as: 'createdBy',
|
||||
foreignKey: { name: 'createdById' },
|
||||
constraints: false,
|
||||
});
|
||||
db.staff_awards_reviews.belongsTo(db.users, {
|
||||
as: 'updatedBy',
|
||||
foreignKey: { name: 'updatedById' },
|
||||
constraints: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default function (sequelize: Sequelize): typeof StaffAwardsReviews {
|
||||
StaffAwardsReviews.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
title: { type: DataTypes.TEXT, allowNull: false },
|
||||
record_type: {
|
||||
type: DataTypes.ENUM('award', 'review'),
|
||||
allowNull: false,
|
||||
},
|
||||
year_label: { type: DataTypes.TEXT, allowNull: false },
|
||||
notes: { type: DataTypes.TEXT, allowNull: true },
|
||||
file_name: { type: DataTypes.TEXT, allowNull: true },
|
||||
file_url: { type: DataTypes.STRING(2083), allowNull: true },
|
||||
file_is_image: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
organizationId: { type: DataTypes.UUID, allowNull: false },
|
||||
schoolId: { type: DataTypes.UUID, allowNull: true },
|
||||
campusId: { type: DataTypes.UUID, allowNull: false },
|
||||
staffUserId: { type: DataTypes.UUID, allowNull: false },
|
||||
createdById: { type: DataTypes.UUID, allowNull: true },
|
||||
updatedById: { type: DataTypes.UUID, allowNull: true },
|
||||
createdAt: { type: DataTypes.DATE },
|
||||
updatedAt: { type: DataTypes.DATE },
|
||||
deletedAt: { type: DataTypes.DATE },
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
modelName: 'staff_awards_reviews',
|
||||
timestamps: true,
|
||||
paranoid: true,
|
||||
freezeTableName: true,
|
||||
},
|
||||
);
|
||||
|
||||
return StaffAwardsReviews;
|
||||
}
|
||||
@ -8,6 +8,7 @@ import {
|
||||
import { SEED_ALL_USERS } from '@/shared/constants/seed-fixtures';
|
||||
import {
|
||||
MODULE_READ_ALL_STAFF,
|
||||
MODULE_READ_STAFF_SUPPORT,
|
||||
MODULE_READ_INSTRUCTIONAL,
|
||||
MODULE_READ_PARENT_COMM,
|
||||
MODULE_READ_EXTERNAL,
|
||||
@ -86,8 +87,8 @@ export const EXTERNAL_ROLES: readonly RoleName[] = [
|
||||
* `student` gets external pages; `guardian` gets external pages plus parent comms.
|
||||
*/
|
||||
export const MODULE_PERMISSIONS_BY_ROLE: Partial<Record<RoleName, readonly string[]>> = {
|
||||
// Registrar: read every product surface across the school for audit, and
|
||||
// complete required staff safety training, but no operational write actions.
|
||||
// Registrar: read audit-oriented school surfaces and complete required staff
|
||||
// safety training, but no operational write actions or campus staff-support pages.
|
||||
[ROLE_NAMES.REGISTRAR]: [
|
||||
...MODULE_READ_ALL_STAFF,
|
||||
...MODULE_READ_INSTRUCTIONAL,
|
||||
@ -104,14 +105,19 @@ export const MODULE_PERMISSIONS_BY_ROLE: Partial<Record<RoleName, readonly strin
|
||||
],
|
||||
[ROLE_NAMES.OFFICE_MANAGER]: [
|
||||
...MODULE_READ_ALL_STAFF,
|
||||
...MODULE_READ_STAFF_SUPPORT,
|
||||
...MODULE_READ_PARENT_COMM,
|
||||
...MODULE_READ_EXTERNAL,
|
||||
...MODULE_ACTIONS,
|
||||
'MANAGE_ESA_FUNDING_CONTENT',
|
||||
'MANAGE_AUTISM_SUPPORT_STEPS',
|
||||
'MANAGE_PARA_CERTIFICATIONS',
|
||||
'MANAGE_AWARDS_REVIEWS',
|
||||
'READ_AUDIO_FILES', 'MANAGE_AUDIO_FILES',
|
||||
],
|
||||
[ROLE_NAMES.TEACHER]: [
|
||||
...MODULE_READ_ALL_STAFF,
|
||||
...MODULE_READ_STAFF_SUPPORT,
|
||||
...MODULE_READ_INSTRUCTIONAL,
|
||||
...MODULE_READ_PARENT_COMM,
|
||||
...MODULE_READ_EXTERNAL,
|
||||
@ -122,6 +128,7 @@ export const MODULE_PERMISSIONS_BY_ROLE: Partial<Record<RoleName, readonly strin
|
||||
],
|
||||
[ROLE_NAMES.SUPPORT_STAFF]: [
|
||||
...MODULE_READ_ALL_STAFF,
|
||||
...MODULE_READ_STAFF_SUPPORT,
|
||||
...MODULE_READ_INSTRUCTIONAL,
|
||||
...MODULE_READ_EXTERNAL,
|
||||
'TAKE_QUIZ', 'ACK_READ_RECEIPT', 'ACK_POLICY', 'ZONE_CHECKIN',
|
||||
|
||||
@ -112,6 +112,8 @@ const CONTENT_CATALOG_SEED_ROWS = Object.freeze([
|
||||
{ content_type: 'emotional-intelligence-growth-tips', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceGrowthTips },
|
||||
{ content_type: 'emotional-intelligence-team-wellness-metrics', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceTeamWellnessMetrics },
|
||||
{ content_type: 'esa-funding-content', payload: CONTENT_CATALOG_SEED_PAYLOADS.esaFundingContent },
|
||||
{ content_type: 'autism-support-steps', payload: CONTENT_CATALOG_SEED_PAYLOADS.autismSupportSteps },
|
||||
{ content_type: 'para-certifications', payload: CONTENT_CATALOG_SEED_PAYLOADS.paraCertifications },
|
||||
// `safety-protocols` is no longer served from the content catalog — safety
|
||||
// protocols are now seeded into `policy_documents` (see the policy-documents
|
||||
// seeder), which the Safety Protocols page reads.
|
||||
|
||||
155
backend/src/db/seeders/20260613060000-staff-awards-reviews.ts
Normal file
155
backend/src/db/seeders/20260613060000-staff-awards-reviews.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import {
|
||||
Op,
|
||||
QueryTypes,
|
||||
type QueryInterface,
|
||||
} from 'sequelize';
|
||||
import {
|
||||
SEED_CAMPUS_ID,
|
||||
SEED_FIXTURE_USERS,
|
||||
SEED_ORGANIZATION_ID,
|
||||
SEED_ORGANIZATION_2_ID,
|
||||
SEED_SCHOOL_ID,
|
||||
SEED_SECONDARY_CAMPUS_ID,
|
||||
SEED_SECONDARY_SCHOOL_ID,
|
||||
SEED_SECONDARY_USERS,
|
||||
} from '@/shared/constants/seed-fixtures';
|
||||
import { ROLE_NAMES } from '@/shared/constants/roles';
|
||||
|
||||
interface StaffAwardsReviewSeedRow {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly record_type: 'award' | 'review';
|
||||
readonly year_label: string;
|
||||
readonly notes: string;
|
||||
readonly file_name: null;
|
||||
readonly file_url: null;
|
||||
readonly file_is_image: false;
|
||||
readonly organizationId: string;
|
||||
readonly schoolId: string;
|
||||
readonly campusId: string;
|
||||
readonly staffUserId: string;
|
||||
readonly createdById: string | null;
|
||||
readonly updatedById: string | null;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
}
|
||||
|
||||
const primaryTeacher = SEED_FIXTURE_USERS.find((user) => user.role === ROLE_NAMES.TEACHER);
|
||||
const primarySupportStaff = SEED_FIXTURE_USERS.find((user) => user.role === ROLE_NAMES.SUPPORT_STAFF);
|
||||
const primaryDirector = SEED_FIXTURE_USERS.find((user) => user.role === ROLE_NAMES.DIRECTOR);
|
||||
const secondaryTeacher = SEED_SECONDARY_USERS.find((user) => user.role === ROLE_NAMES.TEACHER);
|
||||
const secondarySupportStaff = SEED_SECONDARY_USERS.find((user) => user.role === ROLE_NAMES.SUPPORT_STAFF);
|
||||
const secondaryDirector = SEED_SECONDARY_USERS.find((user) => user.role === ROLE_NAMES.DIRECTOR);
|
||||
|
||||
const STAFF_SEEDS = [
|
||||
{
|
||||
idPrefix: 'b1a7c0de-1000-4000-8000-00000000001',
|
||||
userId: primaryTeacher?.id,
|
||||
directorId: primaryDirector?.id,
|
||||
organizationId: SEED_ORGANIZATION_ID,
|
||||
schoolId: SEED_SCHOOL_ID,
|
||||
campusId: SEED_CAMPUS_ID,
|
||||
},
|
||||
{
|
||||
idPrefix: 'b1a7c0de-1000-4000-8000-00000000002',
|
||||
userId: primarySupportStaff?.id,
|
||||
directorId: primaryDirector?.id,
|
||||
organizationId: SEED_ORGANIZATION_ID,
|
||||
schoolId: SEED_SCHOOL_ID,
|
||||
campusId: SEED_CAMPUS_ID,
|
||||
},
|
||||
{
|
||||
idPrefix: 'b1a7c0de-1000-4000-8000-00000000003',
|
||||
userId: secondaryTeacher?.id,
|
||||
directorId: secondaryDirector?.id,
|
||||
organizationId: SEED_ORGANIZATION_2_ID,
|
||||
schoolId: SEED_SECONDARY_SCHOOL_ID,
|
||||
campusId: SEED_SECONDARY_CAMPUS_ID,
|
||||
},
|
||||
{
|
||||
idPrefix: 'b1a7c0de-1000-4000-8000-00000000004',
|
||||
userId: secondarySupportStaff?.id,
|
||||
directorId: secondaryDirector?.id,
|
||||
organizationId: SEED_ORGANIZATION_2_ID,
|
||||
schoolId: SEED_SECONDARY_SCHOOL_ID,
|
||||
campusId: SEED_SECONDARY_CAMPUS_ID,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const TEMPLATE_RECORDS = [
|
||||
{
|
||||
suffix: '1',
|
||||
title: 'Teacher of the Year',
|
||||
record_type: 'award' as const,
|
||||
year_label: '2025-2026',
|
||||
notes: 'Recognized for outstanding classroom management and parent partnership.',
|
||||
},
|
||||
{
|
||||
suffix: '2',
|
||||
title: 'Annual Director Review',
|
||||
record_type: 'review' as const,
|
||||
year_label: '2025-2026',
|
||||
notes: 'Exceeds expectations in de-escalation and team leadership. Goal: mentor 2 new staff.',
|
||||
},
|
||||
{
|
||||
suffix: '3',
|
||||
title: 'Perfect Attendance Award',
|
||||
record_type: 'award' as const,
|
||||
year_label: '2024-2025',
|
||||
notes: 'No unexcused absences across the school year.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
function seedRows(now: Date): StaffAwardsReviewSeedRow[] {
|
||||
return STAFF_SEEDS.flatMap((staff) => {
|
||||
const userId = staff.userId;
|
||||
if (!userId) {
|
||||
return [];
|
||||
}
|
||||
return TEMPLATE_RECORDS.map((record) => ({
|
||||
id: `${staff.idPrefix}${record.suffix}`,
|
||||
title: record.title,
|
||||
record_type: record.record_type,
|
||||
year_label: record.year_label,
|
||||
notes: record.notes,
|
||||
file_name: null,
|
||||
file_url: null,
|
||||
file_is_image: false,
|
||||
organizationId: staff.organizationId,
|
||||
schoolId: staff.schoolId,
|
||||
campusId: staff.campusId,
|
||||
staffUserId: userId,
|
||||
createdById: staff.directorId ?? null,
|
||||
updatedById: staff.directorId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
up: async (queryInterface: QueryInterface) => {
|
||||
const rows = seedRows(new Date());
|
||||
const existing = await queryInterface.sequelize.query<{ id: string }>(
|
||||
`SELECT id FROM staff_awards_reviews 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));
|
||||
|
||||
if (missingRows.length > 0) {
|
||||
await queryInterface.bulkInsert('staff_awards_reviews', missingRows);
|
||||
}
|
||||
},
|
||||
|
||||
down: async (queryInterface: QueryInterface) => {
|
||||
await queryInterface.bulkDelete(
|
||||
'staff_awards_reviews',
|
||||
{ id: { [Op.in]: seedRows(new Date()).map((row) => row.id) } },
|
||||
{},
|
||||
);
|
||||
},
|
||||
};
|
||||
@ -1011,6 +1011,112 @@ const CONTENT_CATALOG_SEED_PAYLOADS = Object.freeze({
|
||||
],
|
||||
},
|
||||
],
|
||||
autismSupportSteps: {
|
||||
steps: [
|
||||
{
|
||||
id: 'frequent-breaks',
|
||||
iconId: 'coffee',
|
||||
title: 'Give Frequent Breaks',
|
||||
subtitle: 'Short, scheduled breaks prevent overwhelm and reduce escalation.',
|
||||
spans: [
|
||||
'Offer a break BEFORE the student reaches the Yellow Zone',
|
||||
'Use a "break" card or the Break sign',
|
||||
'Keep breaks short (2-5 min) and predictable',
|
||||
'Have a calm-down corner ready',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'work-chunks',
|
||||
iconId: 'layers',
|
||||
title: 'Break Work into Chunks',
|
||||
subtitle: 'Large tasks feel impossible. Small steps build success and confidence.',
|
||||
spans: [
|
||||
'Show only one step at a time',
|
||||
'Use a "first / then" board',
|
||||
'Check off each chunk as it is finished',
|
||||
'Celebrate completion of every chunk',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'break-timer',
|
||||
iconId: 'timer',
|
||||
title: 'Set a Timer for Breaks',
|
||||
subtitle: 'A visual timer makes "how long" concrete and eases transitions.',
|
||||
spans: [
|
||||
'Set a visual or sand timer when a break begins',
|
||||
'Give a 1-minute warning before time is up',
|
||||
'Pair with the Wait sign',
|
||||
'Use the Classroom Timer module on the projector',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reward-system',
|
||||
iconId: 'award',
|
||||
title: 'Use a Reward System',
|
||||
subtitle: 'A token economy gives students something positive to work toward.',
|
||||
spans: [
|
||||
'Pick a clear, motivating reward',
|
||||
'Use a token board: earn X tokens, then reward',
|
||||
'Reinforce immediately and consistently',
|
||||
'Keep it simple and visual',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'visual-supports',
|
||||
iconId: 'eye',
|
||||
title: 'Provide Visual Supports',
|
||||
subtitle: 'Visuals reduce reliance on spoken language and lower anxiety.',
|
||||
spans: [
|
||||
'Post a picture schedule at eye level',
|
||||
'Use choice boards for autonomy',
|
||||
'Add visual boundary markers',
|
||||
'Reference visuals throughout the day',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sensory-demands',
|
||||
iconId: 'volume',
|
||||
title: 'Reduce Sensory Demands',
|
||||
subtitle: 'Lower the noise, light, and demand level during high-stress moments.',
|
||||
spans: [
|
||||
'Offer noise-canceling headphones',
|
||||
'Dim lights or move to a quiet space',
|
||||
'Reduce verbal language during escalation',
|
||||
'Allow movement and fidgets',
|
||||
],
|
||||
},
|
||||
],
|
||||
visualCards: [
|
||||
{ id: 'break', label: 'Break', imageUrl: null, iconText: '☕' },
|
||||
{ id: 'first-then', label: 'First / Then', imageUrl: null, iconText: '➡️' },
|
||||
{ id: 'timer', label: 'Timer', imageUrl: null, iconText: '⏲️' },
|
||||
{ id: 'reward', label: 'Reward', imageUrl: null, iconText: '⭐' },
|
||||
{ id: 'quiet', label: 'Quiet', imageUrl: null, iconText: '🤫' },
|
||||
{ id: 'help', label: 'Help', imageUrl: null, iconText: '🙋' },
|
||||
{ id: 'wait', label: 'Wait', imageUrl: null, iconText: '✋' },
|
||||
{ id: 'all-done', label: 'All Done', imageUrl: null, iconText: '✅' },
|
||||
],
|
||||
},
|
||||
paraCertifications: {
|
||||
heroImage: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1780710919817_963ae6a1.jpg',
|
||||
heroTitle: 'Already doing the job? Get certified!',
|
||||
heroSubtitle: 'This is stuff you already know. Enroll back in school, earn your credential, and grow your career.',
|
||||
highlights: [
|
||||
{ id: 'flexible-learning', iconId: 'book', title: 'Flexible Learning', subtitle: 'Online and evening options' },
|
||||
{ id: 'financial-aid', iconId: 'dollar', title: 'Financial Aid', subtitle: 'Grants and TEACH funding' },
|
||||
{ id: 'work-learn', iconId: 'clock', title: 'Work While You Learn', subtitle: 'Keep your current role' },
|
||||
],
|
||||
programs: [
|
||||
{ id: 'parapro', name: 'Paraprofessional Certification (ParaPro)', organization: 'ETS ParaPro Assessment', description: 'The standard assessment many states require for paraprofessional certification. Tests reading, math, and writing skills.', url: 'https://www.ets.org/parapro.html', tag: 'Certification' },
|
||||
{ id: 'aa-education', name: 'Associate of Arts in Education', organization: 'Community Colleges', description: 'A 2-year degree that counts toward a teaching credential. Many credits transfer to 4-year programs.', url: 'https://www.communitycollegereview.com/', tag: 'Degree' },
|
||||
{ id: 'cda', name: 'CDA - Child Development Associate', organization: 'Council for Professional Recognition', description: 'A nationally recognized early childhood credential - a great first step for support staff.', url: 'https://www.cdacouncil.org/', tag: 'Credential' },
|
||||
{ id: 'bachelor-education', name: 'Bachelor of Education', organization: 'State Universities (online options)', description: 'Complete your teaching degree online while you keep working. Financial aid and grants available.', url: 'https://studentaid.gov/', tag: 'Degree' },
|
||||
{ id: 'special-education-endorsement', name: 'Special Education Endorsement', organization: 'State Dept. of Education', description: 'Add a special education endorsement to your credential - high demand in autism-focused schools.', url: 'https://www.teachercertificationdegrees.com/certification/special-education/', tag: 'Endorsement' },
|
||||
{ id: 'fafsa', name: 'Federal Financial Aid (FAFSA)', organization: 'U.S. Department of Education', description: 'Apply for grants and low-interest loans. Many paraprofessionals qualify for significant aid.', url: 'https://studentaid.gov/h/apply-for-aid/fafsa', tag: 'Funding' },
|
||||
{ id: 'teach-grant', name: 'TEACH Grant', organization: 'U.S. Department of Education', description: 'Up to $4,000/year for students who agree to teach in a high-need field after graduating.', url: 'https://studentaid.gov/understand-aid/types/grants/teach', tag: 'Funding' },
|
||||
{ id: 'autism-certificates', name: 'Autism Certificate Programs', organization: 'Universities and Online', description: 'Specialized certificates in Autism Spectrum Disorders - directly relevant to the work you already do.', url: 'https://www.autismspeaks.org/', tag: 'Certificate' },
|
||||
],
|
||||
},
|
||||
emotionalIntelligenceWeeklyFocus: {
|
||||
title: 'Stress Regulation',
|
||||
description: 'Emotional regulation during escalations - pause, breathe, respond instead of react. Notice your own zone before intervening with a student.',
|
||||
@ -1046,5 +1152,7 @@ export const CONTENT_CATALOG_DEFAULT_ROWS: ReadonlyArray<{
|
||||
{ content_type: 'emotional-intelligence-growth-tips', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceGrowthTips },
|
||||
{ content_type: 'emotional-intelligence-team-wellness-metrics', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceTeamWellnessMetrics },
|
||||
{ content_type: 'esa-funding-content', payload: CONTENT_CATALOG_SEED_PAYLOADS.esaFundingContent },
|
||||
{ content_type: 'autism-support-steps', payload: CONTENT_CATALOG_SEED_PAYLOADS.autismSupportSteps },
|
||||
{ content_type: 'para-certifications', payload: CONTENT_CATALOG_SEED_PAYLOADS.paraCertifications },
|
||||
{ content_type: 'emotional-intelligence-weekly-focus', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceWeeklyFocus },
|
||||
]);
|
||||
|
||||
@ -176,6 +176,50 @@ describe('user-role seed permission contract', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('staff support page reads are seeded for campus staff and leaders', () => {
|
||||
for (const permission of [
|
||||
FEATURE_PERMISSIONS.READ_AUTISM_SUPPORT_STEPS,
|
||||
FEATURE_PERMISSIONS.READ_PARA_CERTIFICATIONS,
|
||||
FEATURE_PERMISSIONS.READ_AWARDS_REVIEWS,
|
||||
]) {
|
||||
const readers = Object.values(ROLE_NAMES).filter((role) =>
|
||||
granted(role).includes(permission),
|
||||
);
|
||||
|
||||
assert.deepEqual(readers, [
|
||||
ROLE_NAMES.SYSTEM_ADMIN,
|
||||
ROLE_NAMES.OWNER,
|
||||
ROLE_NAMES.SUPERINTENDENT,
|
||||
ROLE_NAMES.PRINCIPAL,
|
||||
ROLE_NAMES.DIRECTOR,
|
||||
ROLE_NAMES.OFFICE_MANAGER,
|
||||
ROLE_NAMES.TEACHER,
|
||||
ROLE_NAMES.SUPPORT_STAFF,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('staff support page management grants match leaders and office managers', () => {
|
||||
for (const permission of [
|
||||
FEATURE_PERMISSIONS.MANAGE_AUTISM_SUPPORT_STEPS,
|
||||
FEATURE_PERMISSIONS.MANAGE_PARA_CERTIFICATIONS,
|
||||
FEATURE_PERMISSIONS.MANAGE_AWARDS_REVIEWS,
|
||||
]) {
|
||||
const managers = Object.values(ROLE_NAMES).filter((role) =>
|
||||
granted(role).includes(permission),
|
||||
);
|
||||
|
||||
assert.deepEqual(managers, [
|
||||
ROLE_NAMES.SYSTEM_ADMIN,
|
||||
ROLE_NAMES.OWNER,
|
||||
ROLE_NAMES.SUPERINTENDENT,
|
||||
ROLE_NAMES.PRINCIPAL,
|
||||
ROLE_NAMES.DIRECTOR,
|
||||
ROLE_NAMES.OFFICE_MANAGER,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('seeded permission grants are unique per role', () => {
|
||||
for (const role of Object.values(ROLE_NAMES)) {
|
||||
const permissions = granted(role);
|
||||
|
||||
@ -70,6 +70,7 @@ import personalityQuizResultsRoutes from '@/routes/personality_quiz_results';
|
||||
import campusAttendanceRoutes from '@/routes/campus_attendance';
|
||||
import classAttendanceRoutes from '@/routes/class_attendance';
|
||||
import staffAttendanceRoutes from '@/routes/staff_attendance';
|
||||
import staffAwardsReviewsRoutes from '@/routes/staff_awards_reviews';
|
||||
import policyDocumentsRoutes from '@/routes/policy_documents';
|
||||
import policyAcknowledgmentsRoutes from '@/routes/policy_acknowledgments';
|
||||
import audioFilesRoutes from '@/routes/audio_files';
|
||||
@ -279,6 +280,7 @@ app.use(
|
||||
app.use('/api/campus_attendance', authenticated, campusAttendanceRoutes);
|
||||
app.use('/api/class_attendance', authenticated, classAttendanceRoutes);
|
||||
app.use('/api/staff_attendance', authenticated, staffAttendanceRoutes);
|
||||
app.use('/api/staff_awards_reviews', authenticated, staffAwardsReviewsRoutes);
|
||||
app.use('/api/content-catalog', authenticated, contentCatalogRoutes);
|
||||
app.use('/api/policy_documents', authenticated, policyDocumentsRoutes);
|
||||
app.use('/api/policy_acknowledgments', authenticated, policyAcknowledgmentsRoutes);
|
||||
|
||||
34
backend/src/routes/staff_awards_reviews.ts
Normal file
34
backend/src/routes/staff_awards_reviews.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import express from 'express';
|
||||
import { wrapAsync } from '@/api/http/request';
|
||||
import * as staff_awards_reviews from '@/api/controllers/staff_awards_reviews.controller';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/staff_awards_reviews:
|
||||
* get:
|
||||
* tags: [Staff Awards Reviews]
|
||||
* summary: List awards/reviews for the current or selected staff user.
|
||||
* post:
|
||||
* tags: [Staff Awards Reviews]
|
||||
* summary: Create an award or review.
|
||||
* /api/staff_awards_reviews/staff:
|
||||
* get:
|
||||
* tags: [Staff Awards Reviews]
|
||||
* summary: List staff visible to a manager in the current scope.
|
||||
* /api/staff_awards_reviews/{id}:
|
||||
* put:
|
||||
* tags: [Staff Awards Reviews]
|
||||
* summary: Update an award or review.
|
||||
* delete:
|
||||
* tags: [Staff Awards Reviews]
|
||||
* summary: Delete an award or review.
|
||||
*/
|
||||
router.get('/staff', wrapAsync(staff_awards_reviews.listStaff));
|
||||
router.get('/', wrapAsync(staff_awards_reviews.list));
|
||||
router.post('/', wrapAsync(staff_awards_reviews.create));
|
||||
router.put('/:id', wrapAsync(staff_awards_reviews.update));
|
||||
router.delete('/:id', wrapAsync(staff_awards_reviews.remove));
|
||||
|
||||
export default router;
|
||||
@ -163,6 +163,64 @@ describe('ContentCatalogService tenant scoping', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('allows staff support content management from a leader drilled to campus scope', async () => {
|
||||
let capturedWhere: Record<string, unknown> | null = null;
|
||||
mock.method(db.content_catalog, 'findOne', (async (options: { where?: Record<string, unknown> }) => {
|
||||
capturedWhere = options.where ?? null;
|
||||
return catalogRecord([]);
|
||||
}) as typeof db.content_catalog.findOne);
|
||||
|
||||
await ContentCatalogService.findManagedByType(
|
||||
'autism-support-steps',
|
||||
createGlobalAccessUser({
|
||||
app_role: {
|
||||
name: ROLE_NAMES.OWNER,
|
||||
scope: ROLE_SCOPES.ORGANIZATION,
|
||||
globalAccess: false,
|
||||
permissions: [{ name: FEATURE_PERMISSIONS.MANAGE_AUTISM_SUPPORT_STEPS }],
|
||||
},
|
||||
organizationId: 'org-1',
|
||||
organizations: { id: 'org-1' },
|
||||
activeScope: {
|
||||
level: ROLE_SCOPES.CAMPUS,
|
||||
organizationId: 'org-1',
|
||||
schoolId: 'school-1',
|
||||
campusId: 'campus-1',
|
||||
classId: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(capturedWhere, {
|
||||
content_type: 'autism-support-steps',
|
||||
active: true,
|
||||
organizationId: 'org-1',
|
||||
schoolId: null,
|
||||
campusId: 'campus-1',
|
||||
classId: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects staff support content management outside campus scope', async () => {
|
||||
await assert.rejects(
|
||||
() => ContentCatalogService.findManagedByType(
|
||||
'para-certifications',
|
||||
createGlobalAccessUser({
|
||||
app_role: {
|
||||
name: ROLE_NAMES.PRINCIPAL,
|
||||
scope: ROLE_SCOPES.SCHOOL,
|
||||
globalAccess: false,
|
||||
permissions: [{ name: FEATURE_PERMISSIONS.MANAGE_PARA_CERTIFICATIONS }],
|
||||
},
|
||||
organizationId: 'org-1',
|
||||
organizations: { id: 'org-1' },
|
||||
schoolId: 'school-1',
|
||||
}),
|
||||
),
|
||||
{ name: 'ForbiddenError' },
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects classroom strategy management outside organization scope', async () => {
|
||||
await assert.rejects(
|
||||
() => ContentCatalogService.findManagedByType(
|
||||
|
||||
@ -18,6 +18,8 @@ import {
|
||||
import {
|
||||
CLASSROOM_SUPPORT_CONTENT_TYPE,
|
||||
ESA_CONTENT_TYPE,
|
||||
AUTISM_SUPPORT_STEPS_CONTENT_TYPE,
|
||||
PARA_CERTIFICATIONS_CONTENT_TYPE,
|
||||
EI_ASSESSMENT_CONTENT_TYPE,
|
||||
PERSONALITY_QUIZ_CONTENT_TYPE,
|
||||
SIGN_LANGUAGE_ITEMS_CONTENT_TYPE,
|
||||
@ -163,12 +165,30 @@ function assertCanManageType(contentType: string, currentUser?: CurrentUser): vo
|
||||
FEATURE_PERMISSIONS.MANAGE_ESA_FUNDING_CONTENT,
|
||||
)
|
||||
)
|
||||
&& !(
|
||||
contentType === AUTISM_SUPPORT_STEPS_CONTENT_TYPE
|
||||
&& hasFeaturePermission(
|
||||
currentUser,
|
||||
FEATURE_PERMISSIONS.MANAGE_AUTISM_SUPPORT_STEPS,
|
||||
)
|
||||
)
|
||||
&& !(
|
||||
contentType === PARA_CERTIFICATIONS_CONTENT_TYPE
|
||||
&& hasFeaturePermission(
|
||||
currentUser,
|
||||
FEATURE_PERMISSIONS.MANAGE_PARA_CERTIFICATIONS,
|
||||
)
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
|
||||
if (
|
||||
contentType === ESA_CONTENT_TYPE
|
||||
(
|
||||
contentType === ESA_CONTENT_TYPE
|
||||
|| contentType === AUTISM_SUPPORT_STEPS_CONTENT_TYPE
|
||||
|| contentType === PARA_CERTIFICATIONS_CONTENT_TYPE
|
||||
)
|
||||
&& getRoleScope(currentUser) !== ROLE_SCOPES.CAMPUS
|
||||
) {
|
||||
throw new ForbiddenError();
|
||||
|
||||
@ -306,4 +306,73 @@ describe('seedDefaultContentForTenant', () => {
|
||||
classId: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('seeds staff support page content only at campus scope', async () => {
|
||||
const createdRows: Array<Record<string, unknown>> = [];
|
||||
|
||||
mock.method(db.content_catalog, 'findOne', (async () => null) as typeof db.content_catalog.findOne);
|
||||
mock.method(db.content_catalog, 'create', (async (payload: Record<string, unknown>) => {
|
||||
createdRows.push(payload);
|
||||
return payload;
|
||||
}) as typeof db.content_catalog.create);
|
||||
|
||||
await seedDefaultContentForTenant({
|
||||
level: 'organization',
|
||||
organizationId: 'org-1',
|
||||
});
|
||||
await seedDefaultContentForTenant({
|
||||
level: 'school',
|
||||
organizationId: 'org-1',
|
||||
schoolId: 'school-1',
|
||||
});
|
||||
await seedDefaultContentForTenant({
|
||||
level: 'campus',
|
||||
organizationId: 'org-1',
|
||||
schoolId: 'school-1',
|
||||
campusId: 'campus-1',
|
||||
});
|
||||
|
||||
const staffSupportRows = createdRows.filter((row) =>
|
||||
row.content_type === 'autism-support-steps' ||
|
||||
row.content_type === 'para-certifications',
|
||||
);
|
||||
|
||||
assert.equal(staffSupportRows.length, 2);
|
||||
assert.deepEqual(
|
||||
staffSupportRows.map((row) => ({
|
||||
content_type: row.content_type,
|
||||
organizationId: row.organizationId,
|
||||
schoolId: row.schoolId,
|
||||
campusId: row.campusId,
|
||||
classId: row.classId,
|
||||
active: row.active,
|
||||
})).sort((left, right) => String(left.content_type).localeCompare(String(right.content_type))),
|
||||
[
|
||||
{
|
||||
content_type: 'autism-support-steps',
|
||||
organizationId: 'org-1',
|
||||
schoolId: null,
|
||||
campusId: 'campus-1',
|
||||
classId: null,
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
content_type: 'para-certifications',
|
||||
organizationId: 'org-1',
|
||||
schoolId: null,
|
||||
campusId: 'campus-1',
|
||||
classId: null,
|
||||
active: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
staffSupportRows.find((row) => row.content_type === 'autism-support-steps')?.payload,
|
||||
CONTENT_CATALOG_SEED_PAYLOADS.autismSupportSteps,
|
||||
);
|
||||
assert.deepEqual(
|
||||
staffSupportRows.find((row) => row.content_type === 'para-certifications')?.payload,
|
||||
CONTENT_CATALOG_SEED_PAYLOADS.paraCertifications,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
225
backend/src/services/staff_awards_reviews.test.ts
Normal file
225
backend/src/services/staff_awards_reviews.test.ts
Normal file
@ -0,0 +1,225 @@
|
||||
import { afterEach, describe, mock, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Op } from 'sequelize';
|
||||
|
||||
import db from '@/db/models';
|
||||
import StaffAwardsReviewsService from '@/services/staff_awards_reviews';
|
||||
import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions';
|
||||
import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles';
|
||||
import { createTestUser } from '@/test-utils';
|
||||
|
||||
function permission(name: string) {
|
||||
return { name };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function staffUser(overrides: Record<string, unknown> = {}) {
|
||||
const row = {
|
||||
id: 'staff-1',
|
||||
email: 'teacher@flatlogic.com',
|
||||
firstName: 'Emily',
|
||||
lastName: 'Johnson',
|
||||
organizationId: 'org-1',
|
||||
schoolId: 'school-1',
|
||||
campusId: 'campus-1',
|
||||
app_role: { name: ROLE_NAMES.TEACHER },
|
||||
campus: { name: 'Tigers Campus', schoolId: 'school-1' },
|
||||
school: { name: 'Demo School' },
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
...row,
|
||||
get: () => row,
|
||||
};
|
||||
}
|
||||
|
||||
function awardRecord(overrides: Record<string, unknown> = {}) {
|
||||
const row = {
|
||||
id: 'record-1',
|
||||
title: 'Teacher of the Year',
|
||||
record_type: 'award',
|
||||
year_label: '2025-2026',
|
||||
notes: 'Outstanding classroom management.',
|
||||
file_name: null,
|
||||
file_url: null,
|
||||
file_is_image: false,
|
||||
staffUserId: 'staff-1',
|
||||
staffUser: staffUser(),
|
||||
createdAt: new Date('2026-06-26T12:00:00Z'),
|
||||
updatedAt: new Date('2026-06-26T12:00:00Z'),
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
...row,
|
||||
get: () => row,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mock.restoreAll();
|
||||
});
|
||||
|
||||
describe('StaffAwardsReviewsService', () => {
|
||||
test('non-managers list only their own awards and reviews', async () => {
|
||||
const actor = createTestUser({
|
||||
id: 'teacher-1',
|
||||
organizationId: 'org-1',
|
||||
organizations: { id: 'org-1' },
|
||||
campusId: 'campus-1',
|
||||
app_role: {
|
||||
name: ROLE_NAMES.TEACHER,
|
||||
scope: ROLE_SCOPES.CLASS,
|
||||
globalAccess: false,
|
||||
permissions: [permission(FEATURE_PERMISSIONS.READ_AWARDS_REVIEWS)],
|
||||
},
|
||||
});
|
||||
let capturedWhere: unknown = null;
|
||||
|
||||
mock.method(db.staff_awards_reviews, 'findAndCountAll', async (options: unknown) => {
|
||||
if (isRecord(options)) {
|
||||
capturedWhere = options.where;
|
||||
}
|
||||
return { rows: [awardRecord({ staffUserId: 'teacher-1' })], count: 1 };
|
||||
});
|
||||
|
||||
const result = await StaffAwardsReviewsService.list({ staffUserId: 'other-staff' }, actor);
|
||||
|
||||
assert.equal(result.canManage, false);
|
||||
assert.equal(result.selectedStaffUserId, 'teacher-1');
|
||||
assert.equal(isRecord(capturedWhere), true);
|
||||
if (isRecord(capturedWhere)) {
|
||||
assert.equal(capturedWhere.staffUserId, 'teacher-1');
|
||||
assert.equal(capturedWhere.campusId, 'campus-1');
|
||||
}
|
||||
});
|
||||
|
||||
test('managers list staff using school descendant scope', async () => {
|
||||
const actor = createTestUser({
|
||||
id: 'principal-1',
|
||||
organizationId: 'org-1',
|
||||
organizations: { id: 'org-1' },
|
||||
schoolId: 'school-1',
|
||||
campusId: null,
|
||||
app_role: {
|
||||
name: ROLE_NAMES.PRINCIPAL,
|
||||
scope: ROLE_SCOPES.SCHOOL,
|
||||
globalAccess: false,
|
||||
permissions: [
|
||||
permission(FEATURE_PERMISSIONS.READ_AWARDS_REVIEWS),
|
||||
permission(FEATURE_PERMISSIONS.MANAGE_AWARDS_REVIEWS),
|
||||
],
|
||||
},
|
||||
});
|
||||
let capturedWhere: unknown = null;
|
||||
|
||||
mock.method(db.users, 'findAndCountAll', async (options: unknown) => {
|
||||
if (isRecord(options)) {
|
||||
capturedWhere = options.where;
|
||||
}
|
||||
return { rows: [staffUser()], count: 1 };
|
||||
});
|
||||
|
||||
const result = await StaffAwardsReviewsService.listStaff({}, actor);
|
||||
|
||||
assert.equal(result.count, 1);
|
||||
assert.equal(result.rows[0]?.name, 'Emily Johnson');
|
||||
assert.equal(isRecord(capturedWhere), true);
|
||||
if (isRecord(capturedWhere)) {
|
||||
assert.equal(Object.getOwnPropertySymbols(capturedWhere).includes(Op.or), true);
|
||||
}
|
||||
});
|
||||
|
||||
test('manager create stores selected staff tenant and campus fields', async () => {
|
||||
const actor = createTestUser({
|
||||
id: 'director-1',
|
||||
organizationId: 'org-1',
|
||||
organizations: { id: 'org-1' },
|
||||
campusId: 'campus-1',
|
||||
app_role: {
|
||||
name: ROLE_NAMES.DIRECTOR,
|
||||
scope: ROLE_SCOPES.CAMPUS,
|
||||
globalAccess: false,
|
||||
permissions: [
|
||||
permission(FEATURE_PERMISSIONS.READ_AWARDS_REVIEWS),
|
||||
permission(FEATURE_PERMISSIONS.MANAGE_AWARDS_REVIEWS),
|
||||
],
|
||||
},
|
||||
});
|
||||
let staffWhere: unknown = null;
|
||||
let createdPayload: unknown = null;
|
||||
|
||||
mock.method(db.users, 'findOne', async (options: unknown) => {
|
||||
if (isRecord(options)) {
|
||||
staffWhere = options.where;
|
||||
}
|
||||
return staffUser({
|
||||
id: 'teacher-1',
|
||||
organizationId: 'org-1',
|
||||
schoolId: null,
|
||||
campusId: 'campus-1',
|
||||
campus: { schoolId: 'school-1', name: 'Tigers Campus' },
|
||||
});
|
||||
});
|
||||
mock.method(db.sequelize, 'transaction', async () => ({
|
||||
commit: async () => undefined,
|
||||
rollback: async () => undefined,
|
||||
}));
|
||||
mock.method(db.staff_awards_reviews, 'create', async (payload: unknown) => {
|
||||
createdPayload = payload;
|
||||
return awardRecord(isRecord(payload) ? payload : {});
|
||||
});
|
||||
|
||||
const created = await StaffAwardsReviewsService.create(
|
||||
{
|
||||
staffUserId: 'teacher-1',
|
||||
title: ' Teacher of the Year ',
|
||||
type: 'award',
|
||||
year: '2025-2026',
|
||||
notes: ' Great progress. ',
|
||||
fileName: 'award.pdf',
|
||||
fileUrl: '/private/files/award.pdf',
|
||||
fileIsImage: false,
|
||||
},
|
||||
actor,
|
||||
);
|
||||
|
||||
assert.equal(created.title, 'Teacher of the Year');
|
||||
assert.equal(isRecord(staffWhere), true);
|
||||
if (isRecord(staffWhere)) {
|
||||
assert.equal(staffWhere.id, 'teacher-1');
|
||||
assert.equal(Object.getOwnPropertySymbols(staffWhere).includes(Op.or), true);
|
||||
}
|
||||
assert.equal(isRecord(createdPayload), true);
|
||||
if (isRecord(createdPayload)) {
|
||||
assert.equal(createdPayload.organizationId, 'org-1');
|
||||
assert.equal(createdPayload.schoolId, 'school-1');
|
||||
assert.equal(createdPayload.campusId, 'campus-1');
|
||||
assert.equal(createdPayload.staffUserId, 'teacher-1');
|
||||
assert.equal(createdPayload.createdById, 'director-1');
|
||||
assert.equal(createdPayload.updatedById, 'director-1');
|
||||
}
|
||||
});
|
||||
|
||||
test('staff list requires awards/reviews management permission', async () => {
|
||||
const actor = createTestUser({
|
||||
id: 'teacher-1',
|
||||
organizationId: 'org-1',
|
||||
organizations: { id: 'org-1' },
|
||||
campusId: 'campus-1',
|
||||
app_role: {
|
||||
name: ROLE_NAMES.TEACHER,
|
||||
scope: ROLE_SCOPES.CLASS,
|
||||
globalAccess: false,
|
||||
permissions: [permission(FEATURE_PERMISSIONS.READ_AWARDS_REVIEWS)],
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => StaffAwardsReviewsService.listStaff({}, actor),
|
||||
{ name: 'ForbiddenError' },
|
||||
);
|
||||
});
|
||||
});
|
||||
420
backend/src/services/staff_awards_reviews.ts
Normal file
420
backend/src/services/staff_awards_reviews.ts
Normal file
@ -0,0 +1,420 @@
|
||||
import { Op, literal, type WhereAttributeHash } from 'sequelize';
|
||||
import db from '@/db/models';
|
||||
import { withTransaction } from '@/db/with-transaction';
|
||||
import {
|
||||
assertAuthenticatedTenantUser,
|
||||
getCampusId,
|
||||
getOrganizationId,
|
||||
getOrganizationIdOrGlobal,
|
||||
getRoleScope,
|
||||
getSchoolId,
|
||||
hasFeaturePermission,
|
||||
hasGlobalAccess,
|
||||
requireUserId,
|
||||
} from '@/services/shared/access';
|
||||
import { resolvePagination } from '@/shared/constants/pagination';
|
||||
import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions';
|
||||
import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles';
|
||||
import ForbiddenError from '@/shared/errors/forbidden';
|
||||
import NotFoundError from '@/shared/errors/not-found';
|
||||
import ValidationError from '@/shared/errors/validation';
|
||||
import type { StaffAwardsReviews, StaffAwardReviewType } from '@/db/models/staff_awards_reviews';
|
||||
import type { Users } from '@/db/models/users';
|
||||
import type { CurrentUser } from '@/db/api/types';
|
||||
|
||||
interface StaffAwardReviewInput {
|
||||
staffUserId?: unknown;
|
||||
title?: unknown;
|
||||
type?: unknown;
|
||||
year?: unknown;
|
||||
notes?: unknown;
|
||||
fileName?: unknown;
|
||||
fileUrl?: unknown;
|
||||
fileIsImage?: unknown;
|
||||
}
|
||||
|
||||
interface ListFilter {
|
||||
staffUserId?: string;
|
||||
limit?: number | string;
|
||||
page?: number | string;
|
||||
}
|
||||
|
||||
const STAFF_ROLE_NAMES = [
|
||||
ROLE_NAMES.DIRECTOR,
|
||||
ROLE_NAMES.OFFICE_MANAGER,
|
||||
ROLE_NAMES.TEACHER,
|
||||
ROLE_NAMES.SUPPORT_STAFF,
|
||||
] as const;
|
||||
|
||||
function assertCanRead(currentUser?: CurrentUser): void {
|
||||
if (!hasFeaturePermission(currentUser, FEATURE_PERMISSIONS.READ_AWARDS_REVIEWS)) {
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
}
|
||||
|
||||
function canManage(currentUser?: CurrentUser): boolean {
|
||||
return hasFeaturePermission(currentUser, FEATURE_PERMISSIONS.MANAGE_AWARDS_REVIEWS);
|
||||
}
|
||||
|
||||
function assertCanManage(currentUser?: CurrentUser): void {
|
||||
if (!canManage(currentUser)) {
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
}
|
||||
|
||||
function requireString(value: unknown, maxLength: number): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new ValidationError();
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new ValidationError();
|
||||
}
|
||||
return trimmed.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, maxLength: number): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed.slice(0, maxLength) : null;
|
||||
}
|
||||
|
||||
function recordType(value: unknown): StaffAwardReviewType {
|
||||
if (value === 'award' || value === 'review') {
|
||||
return value;
|
||||
}
|
||||
throw new ValidationError();
|
||||
}
|
||||
|
||||
function uuidOrNull(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function userScopeWhere(currentUser?: CurrentUser): WhereAttributeHash {
|
||||
if (hasGlobalAccess(currentUser)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const scope = getRoleScope(currentUser);
|
||||
const organizationId = getOrganizationId(currentUser);
|
||||
|
||||
if (scope === ROLE_SCOPES.ORGANIZATION && organizationId) {
|
||||
return { organizationId };
|
||||
}
|
||||
|
||||
if (scope === ROLE_SCOPES.SCHOOL) {
|
||||
const schoolId = getSchoolId(currentUser);
|
||||
if (!schoolId) return {};
|
||||
return {
|
||||
[Op.or]: [
|
||||
{ schoolId },
|
||||
{
|
||||
campusId: {
|
||||
[Op.in]: literal(
|
||||
`(SELECT "id" FROM "campuses" WHERE "schoolId" = ${db.sequelize.escape(schoolId)} AND "deletedAt" IS NULL)`,
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
classId: {
|
||||
[Op.in]: literal(
|
||||
`(SELECT "c"."id" FROM "classes" "c" JOIN "campuses" "cm" ON "cm"."id" = "c"."campusId" WHERE "cm"."schoolId" = ${db.sequelize.escape(schoolId)} AND "c"."deletedAt" IS NULL AND "cm"."deletedAt" IS NULL)`,
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (scope === ROLE_SCOPES.CAMPUS || scope === ROLE_SCOPES.CLASS) {
|
||||
const campusId = getCampusId(currentUser);
|
||||
if (!campusId) return {};
|
||||
return {
|
||||
[Op.or]: [
|
||||
{ campusId },
|
||||
{
|
||||
classId: {
|
||||
[Op.in]: literal(
|
||||
`(SELECT "id" FROM "classes" WHERE "campusId" = ${db.sequelize.escape(campusId)} AND "deletedAt" IS NULL)`,
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return organizationId ? { organizationId } : {};
|
||||
}
|
||||
|
||||
function recordScopeWhere(currentUser?: CurrentUser): WhereAttributeHash {
|
||||
if (hasGlobalAccess(currentUser)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const scope = getRoleScope(currentUser);
|
||||
const organizationId = getOrganizationId(currentUser);
|
||||
|
||||
if (scope === ROLE_SCOPES.ORGANIZATION && organizationId) {
|
||||
return { organizationId };
|
||||
}
|
||||
if (scope === ROLE_SCOPES.SCHOOL) {
|
||||
const schoolId = getSchoolId(currentUser);
|
||||
return schoolId ? { schoolId } : {};
|
||||
}
|
||||
if (scope === ROLE_SCOPES.CAMPUS || scope === ROLE_SCOPES.CLASS) {
|
||||
const campusId = getCampusId(currentUser);
|
||||
return campusId ? { campusId } : {};
|
||||
}
|
||||
return organizationId ? { organizationId } : {};
|
||||
}
|
||||
|
||||
function displayName(user: Users): string {
|
||||
const firstName = user.firstName ?? '';
|
||||
const lastName = user.lastName ?? '';
|
||||
const fullName = `${firstName} ${lastName}`.trim();
|
||||
return fullName || user.email;
|
||||
}
|
||||
|
||||
function userDto(user: Users) {
|
||||
const plain = user.get({ plain: true }) as Users & {
|
||||
app_role?: { name?: string | null } | null;
|
||||
campus?: { name?: string | null } | null;
|
||||
school?: { name?: string | null } | null;
|
||||
};
|
||||
|
||||
return {
|
||||
id: plain.id,
|
||||
name: displayName(user),
|
||||
email: plain.email,
|
||||
role: plain.app_role?.name ?? null,
|
||||
schoolName: plain.school?.name ?? null,
|
||||
campusName: plain.campus?.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function recordDto(record: StaffAwardsReviews) {
|
||||
const plain = record.get({ plain: true }) as StaffAwardsReviews & {
|
||||
staffUser?: Users | null;
|
||||
};
|
||||
|
||||
return {
|
||||
id: plain.id,
|
||||
title: plain.title,
|
||||
type: plain.record_type,
|
||||
year: plain.year_label,
|
||||
notes: plain.notes ?? '',
|
||||
fileName: plain.file_name ?? null,
|
||||
fileUrl: plain.file_url ?? null,
|
||||
fileIsImage: plain.file_is_image,
|
||||
staffUserId: plain.staffUserId,
|
||||
staffName: plain.staffUser ? displayName(plain.staffUser) : null,
|
||||
createdAt: plain.createdAt,
|
||||
updatedAt: plain.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function staffCampusId(user: Users): string | null {
|
||||
return user.campusId ?? null;
|
||||
}
|
||||
|
||||
function staffSchoolId(user: Users): string | null {
|
||||
const plain = user.get({ plain: true }) as Users & {
|
||||
campus?: { schoolId?: string | null } | null;
|
||||
};
|
||||
return user.schoolId ?? plain.campus?.schoolId ?? null;
|
||||
}
|
||||
|
||||
async function findStaffUserOrThrow(
|
||||
staffUserId: string,
|
||||
currentUser?: CurrentUser,
|
||||
): Promise<Users> {
|
||||
const staff = await db.users.findOne({
|
||||
where: {
|
||||
id: staffUserId,
|
||||
...userScopeWhere(currentUser),
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: db.roles,
|
||||
as: 'app_role',
|
||||
where: { name: { [Op.in]: [...STAFF_ROLE_NAMES] } },
|
||||
attributes: ['id', 'name', 'scope'],
|
||||
},
|
||||
{ model: db.schools, as: 'school', attributes: ['id', 'name'], required: false },
|
||||
{ model: db.campuses, as: 'campus', attributes: ['id', 'name', 'schoolId'], required: false },
|
||||
],
|
||||
});
|
||||
|
||||
if (!staff) {
|
||||
throw new NotFoundError();
|
||||
}
|
||||
if (!staffCampusId(staff)) {
|
||||
throw new ValidationError();
|
||||
}
|
||||
return staff;
|
||||
}
|
||||
|
||||
class StaffAwardsReviewsService {
|
||||
static async listStaff(filter: ListFilter, currentUser?: CurrentUser) {
|
||||
assertAuthenticatedTenantUser(currentUser);
|
||||
assertCanRead(currentUser);
|
||||
assertCanManage(currentUser);
|
||||
const { limit, offset } = resolvePagination(filter.limit, filter.page);
|
||||
const result = await db.users.findAndCountAll({
|
||||
where: userScopeWhere(currentUser),
|
||||
include: [
|
||||
{
|
||||
model: db.roles,
|
||||
as: 'app_role',
|
||||
where: { name: { [Op.in]: [...STAFF_ROLE_NAMES] } },
|
||||
attributes: ['id', 'name', 'scope'],
|
||||
},
|
||||
{ model: db.schools, as: 'school', attributes: ['id', 'name'], required: false },
|
||||
{ model: db.campuses, as: 'campus', attributes: ['id', 'name', 'schoolId'], required: false },
|
||||
],
|
||||
order: [['firstName', 'asc'], ['lastName', 'asc'], ['email', 'asc']],
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
return {
|
||||
rows: result.rows.map(userDto),
|
||||
count: result.count,
|
||||
};
|
||||
}
|
||||
|
||||
static async list(filter: ListFilter, currentUser?: CurrentUser) {
|
||||
assertAuthenticatedTenantUser(currentUser);
|
||||
assertCanRead(currentUser);
|
||||
const { limit, offset } = resolvePagination(filter.limit, filter.page);
|
||||
const targetUserId = canManage(currentUser)
|
||||
? (filter.staffUserId || requireUserId(currentUser))
|
||||
: requireUserId(currentUser);
|
||||
|
||||
if (targetUserId !== requireUserId(currentUser) || canManage(currentUser)) {
|
||||
await findStaffUserOrThrow(targetUserId, currentUser);
|
||||
}
|
||||
|
||||
const result = await db.staff_awards_reviews.findAndCountAll({
|
||||
where: {
|
||||
...recordScopeWhere(currentUser),
|
||||
staffUserId: targetUserId,
|
||||
},
|
||||
include: [{ model: db.users, as: 'staffUser' }],
|
||||
order: [['createdAt', 'desc']],
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
return {
|
||||
rows: result.rows.map(recordDto),
|
||||
count: result.count,
|
||||
canManage: canManage(currentUser),
|
||||
currentUserId: requireUserId(currentUser),
|
||||
selectedStaffUserId: targetUserId,
|
||||
};
|
||||
}
|
||||
|
||||
static async create(data: StaffAwardReviewInput, currentUser?: CurrentUser) {
|
||||
assertAuthenticatedTenantUser(currentUser);
|
||||
assertCanRead(currentUser);
|
||||
const staffUserId = canManage(currentUser)
|
||||
? uuidOrNull(data?.staffUserId) ?? requireUserId(currentUser)
|
||||
: requireUserId(currentUser);
|
||||
const staff = await findStaffUserOrThrow(staffUserId, currentUser);
|
||||
const organizationId = getOrganizationIdOrGlobal(currentUser) ?? staff.organizationId;
|
||||
const campusId = staffCampusId(staff);
|
||||
if (!organizationId || !campusId) {
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
|
||||
return withTransaction(async (transaction) => {
|
||||
const created = await db.staff_awards_reviews.create(
|
||||
{
|
||||
title: requireString(data?.title, 240),
|
||||
record_type: recordType(data?.type),
|
||||
year_label: requireString(data?.year, 80),
|
||||
notes: optionalString(data?.notes, 4000),
|
||||
file_name: optionalString(data?.fileName, 512),
|
||||
file_url: optionalString(data?.fileUrl, 2083),
|
||||
file_is_image: data?.fileIsImage === true,
|
||||
organizationId,
|
||||
schoolId: staffSchoolId(staff),
|
||||
campusId,
|
||||
staffUserId,
|
||||
createdById: requireUserId(currentUser),
|
||||
updatedById: requireUserId(currentUser),
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
return recordDto(created);
|
||||
});
|
||||
}
|
||||
|
||||
static async update(id: string, data: StaffAwardReviewInput, currentUser?: CurrentUser) {
|
||||
assertAuthenticatedTenantUser(currentUser);
|
||||
assertCanRead(currentUser);
|
||||
|
||||
const record = await db.staff_awards_reviews.findOne({
|
||||
where: {
|
||||
id,
|
||||
...recordScopeWhere(currentUser),
|
||||
...(canManage(currentUser) ? {} : { staffUserId: requireUserId(currentUser) }),
|
||||
},
|
||||
});
|
||||
if (!record) {
|
||||
throw new NotFoundError();
|
||||
}
|
||||
|
||||
const nextStaffUserId = canManage(currentUser)
|
||||
? uuidOrNull(data?.staffUserId) ?? record.staffUserId
|
||||
: record.staffUserId;
|
||||
const staff = await findStaffUserOrThrow(nextStaffUserId, currentUser);
|
||||
const organizationId = staff.organizationId;
|
||||
const campusId = staffCampusId(staff);
|
||||
if (!organizationId || !campusId) {
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
|
||||
await record.update({
|
||||
title: requireString(data?.title, 240),
|
||||
record_type: recordType(data?.type),
|
||||
year_label: requireString(data?.year, 80),
|
||||
notes: optionalString(data?.notes, 4000),
|
||||
file_name: optionalString(data?.fileName, 512),
|
||||
file_url: optionalString(data?.fileUrl, 2083),
|
||||
file_is_image: data?.fileIsImage === true,
|
||||
organizationId,
|
||||
staffUserId: nextStaffUserId,
|
||||
schoolId: staffSchoolId(staff),
|
||||
campusId,
|
||||
updatedById: requireUserId(currentUser),
|
||||
});
|
||||
|
||||
return recordDto(record);
|
||||
}
|
||||
|
||||
static async remove(id: string, currentUser?: CurrentUser) {
|
||||
assertAuthenticatedTenantUser(currentUser);
|
||||
assertCanRead(currentUser);
|
||||
|
||||
const record = await db.staff_awards_reviews.findOne({
|
||||
where: {
|
||||
id,
|
||||
...recordScopeWhere(currentUser),
|
||||
...(canManage(currentUser) ? {} : { staffUserId: requireUserId(currentUser) }),
|
||||
},
|
||||
});
|
||||
if (!record) {
|
||||
throw new NotFoundError();
|
||||
}
|
||||
|
||||
await record.destroy();
|
||||
return { deletedCount: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
export default StaffAwardsReviewsService;
|
||||
@ -16,6 +16,12 @@ export const SIGN_LANGUAGE_ITEMS_CONTENT_TYPE = 'sign-language-items';
|
||||
/** ESA funding content — campus-scoped so each campus owns its local guidance. */
|
||||
export const ESA_CONTENT_TYPE = 'esa-funding-content';
|
||||
|
||||
/** Autism Support Steps — campus-scoped so each campus owns local staff guidance. */
|
||||
export const AUTISM_SUPPORT_STEPS_CONTENT_TYPE = 'autism-support-steps';
|
||||
|
||||
/** Paraprofessional certifications — campus-scoped staff development resources. */
|
||||
export const PARA_CERTIFICATIONS_CONTENT_TYPE = 'para-certifications';
|
||||
|
||||
/**
|
||||
* **Per-tenant** content types (read/written at the user's own tenant level via
|
||||
* `getOwnTenant`; preset at organization + school + campus levels). Dashboard
|
||||
@ -31,6 +37,8 @@ export const PER_TENANT_CONTENT_TYPES: ReadonlySet<string> = new Set([
|
||||
/** **Campus-scoped** content types (class users read the campus row). */
|
||||
export const CAMPUS_SCOPED_CONTENT_TYPES: ReadonlySet<string> = new Set([
|
||||
ESA_CONTENT_TYPE,
|
||||
AUTISM_SUPPORT_STEPS_CONTENT_TYPE,
|
||||
PARA_CERTIFICATIONS_CONTENT_TYPE,
|
||||
]);
|
||||
|
||||
/** **School-scoped** content types (one per school). */
|
||||
|
||||
@ -20,6 +20,13 @@ export const MODULE_READ_ALL_STAFF = [
|
||||
'READ_HANDBOOK',
|
||||
] as const;
|
||||
|
||||
/** Staff-support pages requested for campus staff and leaders. */
|
||||
export const MODULE_READ_STAFF_SUPPORT = [
|
||||
'READ_AUTISM_SUPPORT_STEPS',
|
||||
'READ_PARA_CERTIFICATIONS',
|
||||
'READ_AWARDS_REVIEWS',
|
||||
] as const;
|
||||
|
||||
/** Platform dashboard page. Global users pass this through global access. */
|
||||
export const MODULE_READ_PLATFORM = ['READ_PLATFORM_DASHBOARD'] as const;
|
||||
|
||||
@ -64,6 +71,9 @@ export const MODULE_MANAGEMENT_PERMISSIONS = [
|
||||
'MANAGE_INTERNAL_COMM',
|
||||
'MANAGE_CONTENT_CATALOG',
|
||||
'MANAGE_ESA_FUNDING_CONTENT',
|
||||
'MANAGE_AUTISM_SUPPORT_STEPS',
|
||||
'MANAGE_PARA_CERTIFICATIONS',
|
||||
'MANAGE_AWARDS_REVIEWS',
|
||||
'READ_STAFF_ATTENDANCE_REPORTS',
|
||||
'READ_SAFETY_QUIZ_REPORTS',
|
||||
'READ_PERSONALITY_REPORTS',
|
||||
@ -78,6 +88,7 @@ export const AUDIO_PERMISSIONS = ['READ_AUDIO_FILES', 'MANAGE_AUDIO_FILES'] as c
|
||||
export const MODULE_PERMISSIONS: readonly string[] = Object.freeze([
|
||||
...MODULE_READ_PLATFORM,
|
||||
...MODULE_READ_ALL_STAFF,
|
||||
...MODULE_READ_STAFF_SUPPORT,
|
||||
...MODULE_READ_INSTRUCTIONAL,
|
||||
...MODULE_READ_PARENT_COMM,
|
||||
...MODULE_READ_EXTERNAL,
|
||||
@ -98,6 +109,9 @@ export const FEATURE_PERMISSIONS = Object.freeze({
|
||||
READ_INTERNAL_COMM: 'READ_INTERNAL_COMM',
|
||||
READ_PARENT_COMM: 'READ_PARENT_COMM',
|
||||
READ_SAFETY: 'READ_SAFETY',
|
||||
READ_AUTISM_SUPPORT_STEPS: 'READ_AUTISM_SUPPORT_STEPS',
|
||||
READ_PARA_CERTIFICATIONS: 'READ_PARA_CERTIFICATIONS',
|
||||
READ_AWARDS_REVIEWS: 'READ_AWARDS_REVIEWS',
|
||||
READ_WALKTHROUGH: 'READ_WALKTHROUGH',
|
||||
FILL_ATTENDANCE: 'FILL_ATTENDANCE',
|
||||
TAKE_QUIZ: 'TAKE_QUIZ',
|
||||
@ -109,6 +123,9 @@ export const FEATURE_PERMISSIONS = Object.freeze({
|
||||
MANAGE_INTERNAL_COMM: 'MANAGE_INTERNAL_COMM',
|
||||
MANAGE_CONTENT_CATALOG: 'MANAGE_CONTENT_CATALOG',
|
||||
MANAGE_ESA_FUNDING_CONTENT: 'MANAGE_ESA_FUNDING_CONTENT',
|
||||
MANAGE_AUTISM_SUPPORT_STEPS: 'MANAGE_AUTISM_SUPPORT_STEPS',
|
||||
MANAGE_PARA_CERTIFICATIONS: 'MANAGE_PARA_CERTIFICATIONS',
|
||||
MANAGE_AWARDS_REVIEWS: 'MANAGE_AWARDS_REVIEWS',
|
||||
READ_STAFF_ATTENDANCE_REPORTS: 'READ_STAFF_ATTENDANCE_REPORTS',
|
||||
READ_SAFETY_QUIZ_REPORTS: 'READ_SAFETY_QUIZ_REPORTS',
|
||||
READ_PERSONALITY_REPORTS: 'READ_PERSONALITY_REPORTS',
|
||||
|
||||
@ -207,6 +207,7 @@ The active frontend already has:
|
||||
- Safety quiz results under `frontend/src/business/safety-quiz/`, with typed API calls in `frontend/src/shared/api/safetyQuizResults.ts`.
|
||||
- Walk-through check-ins under `frontend/src/business/walkthrough/`, with typed API calls in `frontend/src/shared/api/walkthrough.ts`, shared constants in `frontend/src/shared/constants/walkthrough.ts`, and summary calculations in typed selectors.
|
||||
- Communications under `frontend/src/business/communications/`, with typed API calls in `frontend/src/shared/api/communications.ts` for parent messages, internal alerts, and dashboard upcoming events.
|
||||
- Staff support pages under `frontend/src/business/staff-support/`, with Autism Support Steps and Certifications & Degrees backed by campus-scoped `content_catalog` payloads and Awards Earned & Reviews backed by `frontend/src/shared/api/staffAwardsReviews.ts`.
|
||||
- My Class under `frontend/src/business/my-class/` and `frontend/src/pages/modules/MyClassPage.tsx`, with class roster queries plus student-only create/edit payload selectors that reuse the shared Users API while backend service guards enforce own-class scope.
|
||||
- EI/personality results under `frontend/src/business/personality/`, with typed API calls in `frontend/src/shared/api/personality.ts`, DTO mappers, distribution selectors, workflow-specific hook files, and explicit loading/error states in the view.
|
||||
- Campus attendance config and staff-only attendance rollups under `frontend/src/business/campus-attendance/`, with typed API calls through `frontend/src/shared/api/campusAttendance.ts` for config and `frontend/src/shared/api/staffAttendance.ts` for daily staff records, DTO mappers, staff rollup selectors, and explicit loading/error states in the view.
|
||||
|
||||
@ -49,6 +49,7 @@ Read the repository rules first, then use the frontend architecture document as
|
||||
- [`safety-quiz-integration.md`](safety-quiz-integration.md)
|
||||
- [`sign-language-integration.md`](sign-language-integration.md)
|
||||
- [`staff-attendance-integration.md`](staff-attendance-integration.md)
|
||||
- [`staff-support-pages.md`](staff-support-pages.md)
|
||||
- [`user-progress-integration.md`](user-progress-integration.md)
|
||||
- [`vocational-opportunities.md`](vocational-opportunities.md)
|
||||
- [`walkthrough-integration.md`](walkthrough-integration.md)
|
||||
|
||||
85
frontend/docs/staff-support-pages.md
Normal file
85
frontend/docs/staff-support-pages.md
Normal file
@ -0,0 +1,85 @@
|
||||
# Staff Support Pages
|
||||
|
||||
## Purpose
|
||||
|
||||
The Autism Support Steps, Certifications & Degrees, and Awards Earned & Reviews pages were adapted from the legacy `education-support-management` template into the current three-layer frontend.
|
||||
|
||||
## Frontend Layers
|
||||
|
||||
View:
|
||||
|
||||
- `frontend/src/pages/modules/AutismSupportStepsPage.tsx`
|
||||
- `frontend/src/pages/modules/ParaCertificationsPage.tsx`
|
||||
- `frontend/src/pages/modules/AwardsReviewsPage.tsx`
|
||||
- `frontend/src/components/frameworks/AutismSupportSteps.tsx`
|
||||
- `frontend/src/components/frameworks/ParaCertifications.tsx`
|
||||
- `frontend/src/components/frameworks/AwardsReviews.tsx`
|
||||
|
||||
Business:
|
||||
|
||||
- `frontend/src/business/staff-support/hooks.ts`
|
||||
- `frontend/src/business/staff-support/selectors.ts`
|
||||
|
||||
API/data:
|
||||
|
||||
- `frontend/src/shared/api/contentCatalog.ts`
|
||||
- `frontend/src/shared/api/staffAwardsReviews.ts`
|
||||
- `frontend/src/shared/api/files.ts`
|
||||
- `frontend/src/shared/types/staffSupport.ts`
|
||||
|
||||
## Backend Contracts
|
||||
|
||||
Autism Support Steps:
|
||||
|
||||
- Reads `GET /api/content-catalog/read/autism-support-steps`.
|
||||
- Managers update `PUT /api/content-catalog/autism-support-steps`.
|
||||
- Visual card images upload through `POST /api/file/upload/autism-support/visual-cards`.
|
||||
- Payload shape is `steps` plus `visualCards`. Step cards store `id`, `iconId`, `title`, `subtitle`, and `spans`. Visual cards store `id`, `label`, optional `imageUrl`, and fallback `iconText`.
|
||||
- The page renders normal read-only cards by default. Users with management access get a collapsed `Edit Content` panel for step cards and printable lanyard cards.
|
||||
- `Print Steps` prints the staff step cards; `Print Lanyards` prints the visual cards using the same payload.
|
||||
|
||||
Certifications & Degrees:
|
||||
|
||||
- Reads `GET /api/content-catalog/read/para-certifications`.
|
||||
- Managers update `PUT /api/content-catalog/para-certifications`.
|
||||
- Hero image uploads through `POST /api/file/upload/para-certifications/hero`.
|
||||
- Payload shape is `heroImage`, `heroTitle`, `heroSubtitle`, `highlights`, and `programs`.
|
||||
- Highlight cards store `id`, `iconId`, `title`, and `subtitle`. Program cards store `id`, `name`, `organization`, `description`, `url`, and `tag`.
|
||||
- The page renders normal read-only cards by default. Users with management access get a collapsed `Edit Content` panel for the hero, highlights, and certification cards.
|
||||
|
||||
Awards Earned & Reviews:
|
||||
|
||||
- Reads records through `GET /api/staff_awards_reviews`.
|
||||
- Managers list visible staff through `GET /api/staff_awards_reviews/staff`.
|
||||
- Creates, updates, and deletes records through `POST /`, `PUT /:id`, and `DELETE /:id`.
|
||||
- Attachments upload through `POST /api/file/upload/staff-awards-reviews/attachments`.
|
||||
- Staff users see only their own records and a collapsed add/edit form.
|
||||
- Leaders and office managers with management permission see a staff list first; selecting a staff member loads that staff member's records and enables cross-staff create/update/delete.
|
||||
|
||||
## Access
|
||||
|
||||
- Route visibility is driven by `READ_AUTISM_SUPPORT_STEPS`, `READ_PARA_CERTIFICATIONS`, and `READ_AWARDS_REVIEWS`.
|
||||
- Content management is driven by `MANAGE_AUTISM_SUPPORT_STEPS` and `MANAGE_PARA_CERTIFICATIONS`, and is enabled only at campus effective scope.
|
||||
- Awards/reviews self-service is available to readers for their own records. Cross-staff staff list and mutation require `MANAGE_AWARDS_REVIEWS`; backend scope checks decide which staff are visible.
|
||||
- Campus staff readers are `director`, `office_manager`, `teacher`, and `support_staff` through seeded preset permissions. Leaders (`owner`, `superintendent`, `principal`) access the pages through scope drilling and the same permission names.
|
||||
- `registrar` is intentionally not granted these pages.
|
||||
|
||||
## Data Ownership
|
||||
|
||||
- Autism Support Steps and Certifications & Degrees content belongs in backend `content_catalog` seed payloads and campus-scoped content rows.
|
||||
- Awards/reviews records belong in `staff_awards_reviews`, not `user_progress`, because leaders can maintain records for scoped staff users.
|
||||
- Uploaded images/files are stored in the shared file subsystem; payloads and rows store only the returned private URLs.
|
||||
|
||||
## Seeded Defaults
|
||||
|
||||
- `backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts` contains the default Autism Support Steps and Certifications & Degrees payloads.
|
||||
- `backend/src/db/seeders/20260608103000-content-catalog.ts` includes those payloads for standard seeding.
|
||||
- `ContentCatalogSeedService.seedDefaultContentForTenant` creates campus-scoped rows for every new campus so new organizations see the legacy template content immediately.
|
||||
- `backend/src/db/seeders/20260613060000-staff-awards-reviews.ts` seeds demo and rival teacher/support-staff award/review records, so seeded staff pages are not empty by default.
|
||||
|
||||
## Tests
|
||||
|
||||
- `frontend/src/business/staff-support/selectors.test.ts` covers payload normalization for Autism Support Steps and Certifications & Degrees.
|
||||
- `frontend/src/shared/api/staffAwardsReviews.test.ts` covers Awards Earned & Reviews API route construction and mutation payloads.
|
||||
- `frontend/src/business/app-shell/selectors.test.ts` covers sidebar visibility for the three staff-support modules across organization, school, campus, and class effective scopes.
|
||||
- Backend behavior is covered by `backend/src/services/content_catalog.test.ts`, `backend/src/services/content_catalog_seed.test.ts`, `backend/src/services/staff_awards_reviews.test.ts`, and `backend/src/db/seeders/user-roles.test.ts`.
|
||||
@ -7,6 +7,8 @@ import type { ModuleId } from '@/shared/types/app';
|
||||
import {
|
||||
CampusAttendancePage,
|
||||
CampusAttendanceDetailsPage,
|
||||
AutismSupportStepsPage,
|
||||
AwardsReviewsPage,
|
||||
ClassroomSupportPage,
|
||||
ClassroomTimerPage,
|
||||
CommunityPartnershipsPage,
|
||||
@ -20,6 +22,7 @@ import {
|
||||
MessagesPage,
|
||||
MyClassPage,
|
||||
OrganizationManagementPage,
|
||||
ParaCertificationsPage,
|
||||
PlatformDashboardPage,
|
||||
ProfilePage,
|
||||
QbsSafetyPage,
|
||||
@ -60,6 +63,9 @@ const MODULE_ELEMENTS: Record<ModuleId, ReactNode> = {
|
||||
community: <CommunityPartnershipsPage />,
|
||||
vocational: <VocationalOpportunitiesPage />,
|
||||
esa: <EsaFundingPage />,
|
||||
'autism-support': <AutismSupportStepsPage />,
|
||||
'para-certifications': <ParaCertificationsPage />,
|
||||
'awards-reviews': <AwardsReviewsPage />,
|
||||
walkthrough: <WalkthroughPage />,
|
||||
director: <DirectorDashboardPage />,
|
||||
};
|
||||
|
||||
@ -16,6 +16,9 @@ export const HandbookPoliciesPage = lazy(() => import('@/pages/modules/HandbookP
|
||||
export const CommunityPartnershipsPage = lazy(() => import('@/pages/modules/CommunityPartnershipsPage'));
|
||||
export const VocationalOpportunitiesPage = lazy(() => import('@/pages/modules/VocationalOpportunitiesPage'));
|
||||
export const EsaFundingPage = lazy(() => import('@/pages/modules/EsaFundingPage'));
|
||||
export const AutismSupportStepsPage = lazy(() => import('@/pages/modules/AutismSupportStepsPage'));
|
||||
export const ParaCertificationsPage = lazy(() => import('@/pages/modules/ParaCertificationsPage'));
|
||||
export const AwardsReviewsPage = lazy(() => import('@/pages/modules/AwardsReviewsPage'));
|
||||
export const WalkthroughPage = lazy(() => import('@/pages/modules/WalkthroughPage'));
|
||||
export const DirectorDashboardPage = lazy(() => import('@/pages/modules/DirectorDashboardPage'));
|
||||
export const PlatformDashboardPage = lazy(() => import('@/pages/modules/PlatformDashboardPage'));
|
||||
|
||||
@ -59,6 +59,9 @@ const scopedModules: readonly Module[] = [
|
||||
{ id: 'qbs', name: 'Behavior Management', icon: 'shield', permissions: ['READ_QBS'], color: '', routePath: '/qbs-safety' },
|
||||
{ id: 'zones', name: 'Zones', icon: 'layers', permissions: ['READ_ZONES'], color: '', routePath: '/zones-of-regulation' },
|
||||
{ id: 'esa', name: 'ESA Funding Info', icon: 'wallet', permissions: ['READ_ESA'], color: '', routePath: '/esa-funding' },
|
||||
{ id: 'autism-support', name: 'Autism Support Steps', icon: 'puzzle', permissions: ['READ_AUTISM_SUPPORT_STEPS'], color: '', routePath: '/autism-support-steps' },
|
||||
{ id: 'para-certifications', name: 'Certifications & Degrees', icon: 'graduation', permissions: ['READ_PARA_CERTIFICATIONS'], color: '', routePath: '/para-certifications' },
|
||||
{ id: 'awards-reviews', name: 'Awards & Reviews', icon: 'award', permissions: ['READ_AWARDS_REVIEWS'], color: '', routePath: '/awards-reviews' },
|
||||
{ id: 'director', name: 'Director', icon: 'chart', permissions: ['READ_DIRECTOR_DASHBOARD'], color: '', routePath: '/director-dashboard' },
|
||||
];
|
||||
|
||||
@ -130,6 +133,20 @@ describe('app-shell selectors', () => {
|
||||
expect(canAccessScopedModuleRoute(scopedModules, '/esa-funding', disabledEsaUser, 'campus', false)).toBe(false);
|
||||
});
|
||||
|
||||
it('shows staff support pages from organization through class scopes', () => {
|
||||
const staffSupportUser = user([
|
||||
'READ_AUTISM_SUPPORT_STEPS',
|
||||
'READ_PARA_CERTIFICATIONS',
|
||||
'READ_AWARDS_REVIEWS',
|
||||
]);
|
||||
const expected = ['autism-support', 'para-certifications', 'awards-reviews'];
|
||||
|
||||
expect(getScopedModules(scopedModules, staffSupportUser, 'organization', false).map((m) => m.id)).toEqual(expected);
|
||||
expect(getScopedModules(scopedModules, staffSupportUser, 'school', false).map((m) => m.id)).toEqual(expected);
|
||||
expect(getScopedModules(scopedModules, staffSupportUser, 'campus', false).map((m) => m.id)).toEqual(expected);
|
||||
expect(getScopedModules(scopedModules, staffSupportUser, 'class', false).map((m) => m.id)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('never shows the Director Dashboard via drill-down', () => {
|
||||
expect(
|
||||
getScopedModules(scopedModules, user(['READ_DASHBOARD', 'READ_DIRECTOR_DASHBOARD']), 'campus', true).map((m) => m.id),
|
||||
|
||||
@ -47,6 +47,9 @@ const MODULE_SCOPE_TIERS: Partial<Record<ModuleId, readonly ScopeTier[]>> = {
|
||||
director: ['organization', 'school', 'campus'],
|
||||
'parent-comm': ['school', 'campus', 'class', 'external'],
|
||||
esa: ['school', 'campus', 'class', 'external'],
|
||||
'autism-support': ['organization', 'school', 'campus', 'class'],
|
||||
'para-certifications': ['organization', 'school', 'campus', 'class'],
|
||||
'awards-reviews': ['organization', 'school', 'campus', 'class'],
|
||||
walkthrough: ['organization', 'school', 'campus'],
|
||||
'internal-comm': ['global', ...ALL_STAFF_TIERS],
|
||||
community: [...ALL_STAFF_TIERS, 'external'],
|
||||
|
||||
208
frontend/src/business/staff-support/hooks.ts
Normal file
208
frontend/src/business/staff-support/hooks.ts
Normal file
@ -0,0 +1,208 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
useContentCatalogPayload,
|
||||
useManagedContentCatalog,
|
||||
} from '@/business/content-catalog/hooks';
|
||||
import {
|
||||
createManagedContentCatalog,
|
||||
updateManagedContentCatalog,
|
||||
} from '@/shared/api/contentCatalog';
|
||||
import {
|
||||
createStaffAwardReview,
|
||||
deleteStaffAwardReview,
|
||||
listAwardsReviewStaff,
|
||||
listStaffAwardsReviews,
|
||||
updateStaffAwardReview,
|
||||
} from '@/shared/api/staffAwardsReviews';
|
||||
import { uploadFile } from '@/shared/api/files';
|
||||
import {
|
||||
CONTENT_CATALOG_QUERY_KEYS,
|
||||
CONTENT_CATALOG_TYPES,
|
||||
} from '@/shared/constants/contentCatalog';
|
||||
import { useScopeContext } from '@/shared/app/scope-context';
|
||||
import { usePermissions } from '@/shared/app/usePermissions';
|
||||
import type {
|
||||
AutismSupportStepsContent,
|
||||
ParaCertificationsContent,
|
||||
StaffAwardReviewMutationDto,
|
||||
} from '@/shared/types/staffSupport';
|
||||
import {
|
||||
EMPTY_AUTISM_SUPPORT_CONTENT,
|
||||
EMPTY_PARA_CERTIFICATIONS_CONTENT,
|
||||
normalizeAutismSupportContent,
|
||||
normalizeParaCertificationsContent,
|
||||
} from '@/business/staff-support/selectors';
|
||||
|
||||
const STAFF_AWARDS_REVIEWS_QUERY_KEY = ['staffAwardsReviews'] as const;
|
||||
|
||||
export function useAutismSupportStepsPage() {
|
||||
const { effectiveTenant } = useScopeContext();
|
||||
const permissions = usePermissions();
|
||||
const queryClient = useQueryClient();
|
||||
const canManage = effectiveTenant?.level === 'campus' && permissions.has('MANAGE_AUTISM_SUPPORT_STEPS');
|
||||
const contentQuery = useContentCatalogPayload<AutismSupportStepsContent>(
|
||||
CONTENT_CATALOG_TYPES.autismSupportSteps,
|
||||
EMPTY_AUTISM_SUPPORT_CONTENT,
|
||||
);
|
||||
const managedQuery = useManagedContentCatalog<AutismSupportStepsContent>(
|
||||
CONTENT_CATALOG_TYPES.autismSupportSteps,
|
||||
{ enabled: canManage },
|
||||
);
|
||||
const content = useMemo(
|
||||
() => normalizeAutismSupportContent(contentQuery.payload),
|
||||
[contentQuery.payload],
|
||||
);
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (nextContent: AutismSupportStepsContent) => (
|
||||
managedQuery.data
|
||||
? updateManagedContentCatalog(CONTENT_CATALOG_TYPES.autismSupportSteps, {
|
||||
payload: nextContent,
|
||||
changeSummary: 'Autism support steps',
|
||||
})
|
||||
: createManagedContentCatalog({
|
||||
content_type: CONTENT_CATALOG_TYPES.autismSupportSteps,
|
||||
payload: nextContent,
|
||||
changeSummary: 'Autism support steps',
|
||||
})
|
||||
),
|
||||
onSuccess: async (response) => {
|
||||
const normalized = normalizeAutismSupportContent(response.payload);
|
||||
queryClient.setQueryData(
|
||||
[...CONTENT_CATALOG_QUERY_KEYS.content, CONTENT_CATALOG_TYPES.autismSupportSteps],
|
||||
normalized,
|
||||
);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, CONTENT_CATALOG_TYPES.autismSupportSteps],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, 'managed', CONTENT_CATALOG_TYPES.autismSupportSteps],
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
content,
|
||||
canManage,
|
||||
isLoading: contentQuery.isLoading,
|
||||
isSaving: saveMutation.isPending || managedQuery.isLoading,
|
||||
error: contentQuery.error ?? managedQuery.error ?? saveMutation.error,
|
||||
saveContent: (nextContent: AutismSupportStepsContent) => saveMutation.mutateAsync(nextContent),
|
||||
uploadVisualCardImage: (file: File) => uploadFile('autism-support', 'visual-cards', file),
|
||||
};
|
||||
}
|
||||
|
||||
export function useParaCertificationsPage() {
|
||||
const { effectiveTenant } = useScopeContext();
|
||||
const permissions = usePermissions();
|
||||
const queryClient = useQueryClient();
|
||||
const canManage = effectiveTenant?.level === 'campus' && permissions.has('MANAGE_PARA_CERTIFICATIONS');
|
||||
const contentQuery = useContentCatalogPayload<ParaCertificationsContent>(
|
||||
CONTENT_CATALOG_TYPES.paraCertifications,
|
||||
EMPTY_PARA_CERTIFICATIONS_CONTENT,
|
||||
);
|
||||
const managedQuery = useManagedContentCatalog<ParaCertificationsContent>(
|
||||
CONTENT_CATALOG_TYPES.paraCertifications,
|
||||
{ enabled: canManage },
|
||||
);
|
||||
const content = useMemo(
|
||||
() => normalizeParaCertificationsContent(contentQuery.payload),
|
||||
[contentQuery.payload],
|
||||
);
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (nextContent: ParaCertificationsContent) => (
|
||||
managedQuery.data
|
||||
? updateManagedContentCatalog(CONTENT_CATALOG_TYPES.paraCertifications, {
|
||||
payload: nextContent,
|
||||
changeSummary: 'Certifications and degrees',
|
||||
})
|
||||
: createManagedContentCatalog({
|
||||
content_type: CONTENT_CATALOG_TYPES.paraCertifications,
|
||||
payload: nextContent,
|
||||
changeSummary: 'Certifications and degrees',
|
||||
})
|
||||
),
|
||||
onSuccess: async (response) => {
|
||||
const normalized = normalizeParaCertificationsContent(response.payload);
|
||||
queryClient.setQueryData(
|
||||
[...CONTENT_CATALOG_QUERY_KEYS.content, CONTENT_CATALOG_TYPES.paraCertifications],
|
||||
normalized,
|
||||
);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, CONTENT_CATALOG_TYPES.paraCertifications],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, 'managed', CONTENT_CATALOG_TYPES.paraCertifications],
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
content,
|
||||
canManage,
|
||||
isLoading: contentQuery.isLoading,
|
||||
isSaving: saveMutation.isPending || managedQuery.isLoading,
|
||||
error: contentQuery.error ?? managedQuery.error ?? saveMutation.error,
|
||||
saveContent: (nextContent: ParaCertificationsContent) => saveMutation.mutateAsync(nextContent),
|
||||
uploadHeroImage: (file: File) => uploadFile('para-certifications', 'hero', file),
|
||||
};
|
||||
}
|
||||
|
||||
export function useAwardsReviewsPage() {
|
||||
const permissions = usePermissions();
|
||||
const queryClient = useQueryClient();
|
||||
const canManage = permissions.has('MANAGE_AWARDS_REVIEWS');
|
||||
const [selectedStaffUserId, setSelectedStaffUserId] = useState<string | null>(null);
|
||||
const staffQuery = useQuery({
|
||||
queryKey: [...STAFF_AWARDS_REVIEWS_QUERY_KEY, 'staff'],
|
||||
queryFn: listAwardsReviewStaff,
|
||||
enabled: canManage,
|
||||
});
|
||||
const recordsQuery = useQuery({
|
||||
queryKey: [...STAFF_AWARDS_REVIEWS_QUERY_KEY, 'records', selectedStaffUserId],
|
||||
queryFn: () => listStaffAwardsReviews(selectedStaffUserId ?? undefined),
|
||||
});
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (input: { readonly id?: string; readonly data: StaffAwardReviewMutationDto }) => (
|
||||
input.id
|
||||
? updateStaffAwardReview(input.id, input.data)
|
||||
: createStaffAwardReview(input.data)
|
||||
),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [...STAFF_AWARDS_REVIEWS_QUERY_KEY, 'records', selectedStaffUserId],
|
||||
});
|
||||
},
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteStaffAwardReview,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [...STAFF_AWARDS_REVIEWS_QUERY_KEY, 'records', selectedStaffUserId],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
canManage,
|
||||
staff: staffQuery.data?.rows ?? [],
|
||||
selectedStaffUserId,
|
||||
setSelectedStaffUserId,
|
||||
records: recordsQuery.data?.rows ?? [],
|
||||
selectedStaffName: recordsQuery.data?.rows[0]?.staffName
|
||||
?? staffQuery.data?.rows.find((staff) => staff.id === selectedStaffUserId)?.name
|
||||
?? null,
|
||||
currentUserId: recordsQuery.data?.currentUserId ?? null,
|
||||
isLoading: recordsQuery.isLoading || staffQuery.isLoading,
|
||||
isSaving: saveMutation.isPending || deleteMutation.isPending,
|
||||
error: recordsQuery.error ?? staffQuery.error ?? saveMutation.error ?? deleteMutation.error,
|
||||
saveRecord: (id: string | undefined, data: StaffAwardReviewMutationDto) =>
|
||||
saveMutation.mutateAsync({ id, data }),
|
||||
deleteRecord: (id: string) => deleteMutation.mutateAsync(id),
|
||||
uploadAttachment: (file: File) => uploadFile('staff-awards-reviews', 'attachments', file),
|
||||
};
|
||||
}
|
||||
136
frontend/src/business/staff-support/selectors.test.ts
Normal file
136
frontend/src/business/staff-support/selectors.test.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
normalizeAutismSupportContent,
|
||||
normalizeParaCertificationsContent,
|
||||
} from '@/business/staff-support/selectors';
|
||||
|
||||
describe('staff support selectors', () => {
|
||||
it('normalizes Autism Support Steps cards and visual cards', () => {
|
||||
const content = normalizeAutismSupportContent({
|
||||
steps: [
|
||||
{
|
||||
id: 'breaks',
|
||||
iconId: '',
|
||||
title: 'Give Frequent Breaks',
|
||||
subtitle: 'Short scheduled breaks reduce escalation.',
|
||||
spans: ['Offer a break early', 42, 'Keep breaks predictable'],
|
||||
},
|
||||
],
|
||||
visualCards: [
|
||||
{
|
||||
id: 'first-then',
|
||||
label: 'First / Then',
|
||||
imageUrl: ' /private/files/first-then.png ',
|
||||
iconText: '1/2',
|
||||
},
|
||||
{
|
||||
label: 'Wait',
|
||||
imageUrl: '',
|
||||
iconText: '...',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(content.steps).toEqual([
|
||||
{
|
||||
id: 'breaks',
|
||||
iconId: 'puzzle',
|
||||
title: 'Give Frequent Breaks',
|
||||
subtitle: 'Short scheduled breaks reduce escalation.',
|
||||
spans: ['Offer a break early', 'Keep breaks predictable'],
|
||||
},
|
||||
]);
|
||||
expect(content.visualCards).toEqual([
|
||||
{
|
||||
id: 'first-then',
|
||||
label: 'First / Then',
|
||||
imageUrl: ' /private/files/first-then.png ',
|
||||
iconText: '1/2',
|
||||
},
|
||||
{
|
||||
id: expect.any(String),
|
||||
label: 'Wait',
|
||||
imageUrl: null,
|
||||
iconText: '...',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty Autism Support content for malformed payloads', () => {
|
||||
expect(normalizeAutismSupportContent(null)).toEqual({
|
||||
steps: [],
|
||||
visualCards: [],
|
||||
});
|
||||
expect(normalizeAutismSupportContent({ steps: 'bad', visualCards: {} })).toEqual({
|
||||
steps: [],
|
||||
visualCards: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes Certifications & Degrees hero, highlights, and programs', () => {
|
||||
const content = normalizeParaCertificationsContent({
|
||||
heroImage: '/private/files/hero.jpg',
|
||||
heroTitle: 'Grow Your Career',
|
||||
heroSubtitle: 'Earn credentials while working.',
|
||||
highlights: [
|
||||
{ id: 'flexible', iconId: '', title: 'Flexible Learning', subtitle: 'Online and evenings' },
|
||||
],
|
||||
programs: [
|
||||
{
|
||||
id: 'parapro',
|
||||
name: 'Paraprofessional Certification',
|
||||
organization: 'ETS',
|
||||
description: 'Assessment-based credential.',
|
||||
url: 'https://www.ets.org/parapro.html',
|
||||
tag: 'Certification',
|
||||
},
|
||||
{
|
||||
name: 'Associate Degree',
|
||||
organization: 'Community Colleges',
|
||||
description: 'Two-year degree.',
|
||||
url: 123,
|
||||
tag: 'Degree',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(content.heroTitle).toBe('Grow Your Career');
|
||||
expect(content.highlights).toEqual([
|
||||
{
|
||||
id: 'flexible',
|
||||
iconId: 'book',
|
||||
title: 'Flexible Learning',
|
||||
subtitle: 'Online and evenings',
|
||||
},
|
||||
]);
|
||||
expect(content.programs).toEqual([
|
||||
{
|
||||
id: 'parapro',
|
||||
name: 'Paraprofessional Certification',
|
||||
organization: 'ETS',
|
||||
description: 'Assessment-based credential.',
|
||||
url: 'https://www.ets.org/parapro.html',
|
||||
tag: 'Certification',
|
||||
},
|
||||
{
|
||||
id: expect.any(String),
|
||||
name: 'Associate Degree',
|
||||
organization: 'Community Colleges',
|
||||
description: 'Two-year degree.',
|
||||
url: '',
|
||||
tag: 'Degree',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty Certifications & Degrees content for malformed payloads', () => {
|
||||
expect(normalizeParaCertificationsContent([])).toEqual({
|
||||
heroImage: '',
|
||||
heroTitle: '',
|
||||
heroSubtitle: '',
|
||||
highlights: [],
|
||||
programs: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
101
frontend/src/business/staff-support/selectors.ts
Normal file
101
frontend/src/business/staff-support/selectors.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import type {
|
||||
AutismSupportStep,
|
||||
AutismSupportStepsContent,
|
||||
AutismVisualCard,
|
||||
ParaCertificationHighlight,
|
||||
ParaCertificationProgram,
|
||||
ParaCertificationsContent,
|
||||
} from '@/shared/types/staffSupport';
|
||||
|
||||
export const EMPTY_AUTISM_SUPPORT_CONTENT: AutismSupportStepsContent = {
|
||||
steps: [],
|
||||
visualCards: [],
|
||||
};
|
||||
|
||||
export const EMPTY_PARA_CERTIFICATIONS_CONTENT: ParaCertificationsContent = {
|
||||
heroImage: '',
|
||||
heroTitle: '',
|
||||
heroSubtitle: '',
|
||||
highlights: [],
|
||||
programs: [],
|
||||
};
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function str(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function nullableStr(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): readonly string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
|
||||
}
|
||||
|
||||
export function normalizeAutismSupportContent(value: unknown): AutismSupportStepsContent {
|
||||
const source = record(value);
|
||||
return {
|
||||
steps: Array.isArray(source.steps)
|
||||
? source.steps.map((item): AutismSupportStep => {
|
||||
const row = record(item);
|
||||
return {
|
||||
id: str(row.id) || crypto.randomUUID(),
|
||||
iconId: str(row.iconId) || 'puzzle',
|
||||
title: str(row.title),
|
||||
subtitle: str(row.subtitle),
|
||||
spans: stringArray(row.spans),
|
||||
};
|
||||
})
|
||||
: [],
|
||||
visualCards: Array.isArray(source.visualCards)
|
||||
? source.visualCards.map((item): AutismVisualCard => {
|
||||
const row = record(item);
|
||||
return {
|
||||
id: str(row.id) || crypto.randomUUID(),
|
||||
label: str(row.label),
|
||||
imageUrl: nullableStr(row.imageUrl),
|
||||
iconText: str(row.iconText),
|
||||
};
|
||||
})
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeParaCertificationsContent(value: unknown): ParaCertificationsContent {
|
||||
const source = record(value);
|
||||
return {
|
||||
heroImage: str(source.heroImage),
|
||||
heroTitle: str(source.heroTitle),
|
||||
heroSubtitle: str(source.heroSubtitle),
|
||||
highlights: Array.isArray(source.highlights)
|
||||
? source.highlights.map((item): ParaCertificationHighlight => {
|
||||
const row = record(item);
|
||||
return {
|
||||
id: str(row.id) || crypto.randomUUID(),
|
||||
iconId: str(row.iconId) || 'book',
|
||||
title: str(row.title),
|
||||
subtitle: str(row.subtitle),
|
||||
};
|
||||
})
|
||||
: [],
|
||||
programs: Array.isArray(source.programs)
|
||||
? source.programs.map((item): ParaCertificationProgram => {
|
||||
const row = record(item);
|
||||
return {
|
||||
id: str(row.id) || crypto.randomUUID(),
|
||||
name: str(row.name),
|
||||
organization: str(row.organization),
|
||||
description: str(row.description),
|
||||
url: str(row.url),
|
||||
tag: str(row.tag),
|
||||
};
|
||||
})
|
||||
: [],
|
||||
};
|
||||
}
|
||||
352
frontend/src/components/frameworks/AutismSupportSteps.tsx
Normal file
352
frontend/src/components/frameworks/AutismSupportSteps.tsx
Normal file
@ -0,0 +1,352 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Award,
|
||||
Coffee,
|
||||
Eye,
|
||||
Hand,
|
||||
Layers,
|
||||
ListChecks,
|
||||
Pencil,
|
||||
Plus,
|
||||
Printer,
|
||||
Puzzle,
|
||||
Save,
|
||||
Timer,
|
||||
Trash2,
|
||||
Upload,
|
||||
Volume2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useAutismSupportStepsPage } from '@/business/staff-support/hooks';
|
||||
import { fileAssetUrl } from '@/business/files/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ModuleHeader } from '@/components/ui/module-header';
|
||||
import { StatePanel } from '@/components/ui/state-panel';
|
||||
import { escapePrintHtml, printHtml } from '@/shared/utils/printHtml';
|
||||
import type {
|
||||
AutismSupportStep,
|
||||
AutismSupportStepsContent,
|
||||
AutismVisualCard,
|
||||
} from '@/shared/types/staffSupport';
|
||||
|
||||
const iconMap = {
|
||||
coffee: Coffee,
|
||||
layers: Layers,
|
||||
timer: Timer,
|
||||
award: Award,
|
||||
eye: Eye,
|
||||
volume: Volume2,
|
||||
puzzle: Puzzle,
|
||||
} as const;
|
||||
|
||||
const iconOptions = Object.keys(iconMap);
|
||||
|
||||
function newStep(): AutismSupportStep {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
iconId: 'puzzle',
|
||||
title: '',
|
||||
subtitle: '',
|
||||
spans: [''],
|
||||
};
|
||||
}
|
||||
|
||||
function newVisualCard(): AutismVisualCard {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
label: '',
|
||||
imageUrl: null,
|
||||
iconText: '*',
|
||||
};
|
||||
}
|
||||
|
||||
function printableSteps(content: AutismSupportStepsContent): string {
|
||||
const cards = content.steps.map((step, index) => (
|
||||
`<div class="card"><h3>Step ${index + 1}: ${escapePrintHtml(step.title)}</h3><p>${escapePrintHtml(step.subtitle)}</p><ul>${step.spans.map((span) => `<li>${escapePrintHtml(span)}</li>`).join('')}</ul></div>`
|
||||
)).join('');
|
||||
return `<h1>In-Class Support Steps for Students with Autism</h1><p class="muted">A quick reference for teachers and support staff.</p>${cards}`;
|
||||
}
|
||||
|
||||
function printableVisualCards(content: AutismSupportStepsContent): string {
|
||||
const cards = content.visualCards.map((card) => {
|
||||
const icon = card.imageUrl
|
||||
? `<img src="${escapePrintHtml(fileAssetUrl(card.imageUrl))}" alt="">`
|
||||
: escapePrintHtml(card.iconText);
|
||||
return `<div class="vcard"><div class="ic">${icon}</div><div class="lbl">${escapePrintHtml(card.label)}</div></div>`;
|
||||
}).join('');
|
||||
return `<h1>Visual Support Cards</h1><p class="muted">Cut out and attach to lanyards. Students and staff can refer back to these and to the FRAMEworks app.</p><div class="grid">${cards}</div>`;
|
||||
}
|
||||
|
||||
const AutismSupportSteps = () => {
|
||||
const page = useAutismSupportStepsPage();
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [draftState, setDraftState] = useState<{
|
||||
readonly source: AutismSupportStepsContent;
|
||||
readonly value: AutismSupportStepsContent;
|
||||
}>({ source: page.content, value: page.content });
|
||||
const draft = draftState.source === page.content ? draftState.value : page.content;
|
||||
const setDraft = (
|
||||
updater: AutismSupportStepsContent | ((current: AutismSupportStepsContent) => AutismSupportStepsContent),
|
||||
) => {
|
||||
setDraftState({
|
||||
source: page.content,
|
||||
value: typeof updater === 'function' ? updater(draft) : updater,
|
||||
});
|
||||
};
|
||||
|
||||
if (page.isLoading) {
|
||||
return <StatePanel loading title="Loading autism support steps" />;
|
||||
}
|
||||
if (page.error) {
|
||||
return <StatePanel tone="red" role="alert">Autism support content is unavailable.</StatePanel>;
|
||||
}
|
||||
|
||||
const content = page.content;
|
||||
|
||||
function openEditor() {
|
||||
setDraftState({ source: page.content, value: page.content });
|
||||
setIsEditorOpen(true);
|
||||
}
|
||||
|
||||
function updateStep(index: number, updates: Partial<AutismSupportStep>) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
steps: current.steps.map((step, stepIndex) => stepIndex === index ? { ...step, ...updates } : step),
|
||||
}));
|
||||
}
|
||||
|
||||
function updateStepSpan(stepIndex: number, spanIndex: number, value: string) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
steps: current.steps.map((step, index) => index === stepIndex
|
||||
? { ...step, spans: step.spans.map((span, itemIndex) => itemIndex === spanIndex ? value : span) }
|
||||
: step),
|
||||
}));
|
||||
}
|
||||
|
||||
function updateCard(index: number, updates: Partial<AutismVisualCard>) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
visualCards: current.visualCards.map((card, cardIndex) => cardIndex === index ? { ...card, ...updates } : card),
|
||||
}));
|
||||
}
|
||||
|
||||
async function uploadCardImage(index: number, file: File | undefined) {
|
||||
if (!file) return;
|
||||
const imageUrl = await page.uploadVisualCardImage(file);
|
||||
updateCard(index, { imageUrl, iconText: '' });
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
await page.saveContent(draft);
|
||||
setIsEditorOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<ModuleHeader
|
||||
title="Autism Support Steps (In-Class)"
|
||||
description="Practical steps staff can use inside the classroom to support students with autism."
|
||||
icon={Puzzle}
|
||||
iconClassName="bg-teal-600"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-teal-600 text-white hover:bg-teal-700"
|
||||
onClick={() => printHtml('In-Class Autism Support Steps', printableSteps(content))}
|
||||
leadingIcon={<Printer size={16} />}
|
||||
>
|
||||
Print Steps
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="border border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700"
|
||||
onClick={() => printHtml('Visual Lanyard Cards', printableVisualCards(content))}
|
||||
leadingIcon={<Printer size={16} />}
|
||||
>
|
||||
Print Lanyards
|
||||
</Button>
|
||||
{page.canManage && !isEditorOpen && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="border border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700"
|
||||
onClick={openEditor}
|
||||
leadingIcon={<Pencil size={16} />}
|
||||
>
|
||||
Edit Content
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{page.canManage && isEditorOpen && (
|
||||
<section className="rounded-2xl border border-slate-700/70 bg-slate-900/80 p-5 shadow-lg shadow-black/10">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h3 className="text-lg font-bold text-white">Edit Autism Support Content</h3>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => setIsEditorOpen(false)} aria-label="Close autism support editor">
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{draft.steps.map((step, index) => (
|
||||
<div key={step.id} className="rounded-xl border border-slate-700 bg-slate-950/60 p-4">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-[160px_1fr]">
|
||||
<select
|
||||
value={step.iconId}
|
||||
onChange={(event) => updateStep(index, { iconId: event.target.value })}
|
||||
className="h-11 rounded-lg border border-slate-700 bg-slate-950 px-3 text-sm text-white"
|
||||
aria-label={`Step ${index + 1} icon`}
|
||||
>
|
||||
{iconOptions.map((iconId) => <option key={iconId} value={iconId}>{iconId}</option>)}
|
||||
</select>
|
||||
<input
|
||||
value={step.title}
|
||||
onChange={(event) => updateStep(index, { title: event.target.value })}
|
||||
className="h-11 min-w-0 rounded-lg border border-slate-700 bg-slate-950 px-3 text-sm font-semibold text-white"
|
||||
aria-label={`Step ${index + 1} title`}
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
value={step.subtitle}
|
||||
onChange={(event) => updateStep(index, { subtitle: event.target.value })}
|
||||
rows={2}
|
||||
className="mt-3 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200"
|
||||
aria-label={`Step ${index + 1} subtitle`}
|
||||
/>
|
||||
<div className="mt-3 space-y-2">
|
||||
{step.spans.map((span, spanIndex) => (
|
||||
<div key={`${step.id}-${spanIndex}`} className="flex gap-2">
|
||||
<input
|
||||
value={span}
|
||||
onChange={(event) => updateStepSpan(index, spanIndex, event.target.value)}
|
||||
className="min-w-0 flex-1 rounded-md border border-slate-700 bg-slate-950 px-2 py-1.5 text-sm text-white"
|
||||
aria-label={`Step ${index + 1} span ${spanIndex + 1}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => updateStep(index, { spans: step.spans.filter((_, itemIndex) => itemIndex !== spanIndex) })}
|
||||
aria-label={`Remove span ${spanIndex + 1}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => updateStep(index, { spans: [...step.spans, ''] })} leadingIcon={<Plus size={14} />}>
|
||||
Add Span
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => setDraft((current) => ({ ...current, steps: current.steps.filter((item) => item.id !== step.id) }))}
|
||||
leadingIcon={<Trash2 size={14} />}
|
||||
>
|
||||
Delete Step
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="secondary" onClick={() => setDraft((current) => ({ ...current, steps: [...current.steps, newStep()] }))} leadingIcon={<Plus size={16} />}>
|
||||
Add Step
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-slate-700 pt-5">
|
||||
<h4 className="mb-3 font-semibold text-white">Lanyard Visual Cards</h4>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{draft.visualCards.map((card, index) => (
|
||||
<div key={card.id} className="rounded-xl border border-slate-700 bg-slate-950/60 p-3">
|
||||
<div className="mb-3 flex h-24 items-center justify-center rounded-lg border border-slate-700 bg-white text-slate-900">
|
||||
{card.imageUrl ? (
|
||||
<img src={fileAssetUrl(card.imageUrl)} alt="" className="h-20 w-20 object-contain" />
|
||||
) : (
|
||||
<span className="text-3xl">{card.iconText || '*'}</span>
|
||||
)}
|
||||
</div>
|
||||
<input value={card.label} onChange={(event) => updateCard(index, { label: event.target.value })} className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1.5 text-sm font-bold text-white" aria-label="Visual card label" />
|
||||
<input value={card.iconText} onChange={(event) => updateCard(index, { iconText: event.target.value, imageUrl: null })} className="mt-2 w-full rounded border border-slate-700 bg-slate-950 px-2 py-1.5 text-sm text-white" aria-label="Visual card icon text" />
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<label className="inline-flex cursor-pointer items-center justify-center gap-1 rounded border border-slate-700 px-2 py-1 text-xs text-slate-200">
|
||||
<Upload size={13} /> Image
|
||||
<input type="file" accept="image/*" className="hidden" onChange={(event) => void uploadCardImage(index, event.target.files?.[0])} />
|
||||
</label>
|
||||
<Button type="button" size="sm" variant="destructive" onClick={() => setDraft((current) => ({ ...current, visualCards: current.visualCards.filter((item) => item.id !== card.id) }))}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button type="button" className="mt-4" variant="secondary" onClick={() => setDraft((current) => ({ ...current, visualCards: [...current.visualCards, newVisualCard()] }))} leadingIcon={<Plus size={16} />}>
|
||||
Add Card
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<Button type="button" loading={page.isSaving} onClick={() => void saveDraft()} leadingIcon={<Save size={16} />}>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" disabled={page.isSaving} onClick={() => setIsEditorOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{content.steps.map((step, index) => {
|
||||
const Icon = iconMap[step.iconId as keyof typeof iconMap] ?? Puzzle;
|
||||
return (
|
||||
<section key={step.id} className="rounded-2xl border border-slate-700/50 bg-slate-800/50 p-5">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-teal-500/15 text-teal-300">
|
||||
<Icon size={22} />
|
||||
</div>
|
||||
<h3 className="font-bold text-white">Step {index + 1}: {step.title}</h3>
|
||||
</div>
|
||||
<p className="mb-3 text-sm text-slate-400">{step.subtitle}</p>
|
||||
<ul className="space-y-2">
|
||||
{step.spans.map((span, spanIndex) => (
|
||||
<li key={`${step.id}-${spanIndex}`} className="flex items-start gap-2 text-sm text-slate-300">
|
||||
<ListChecks size={15} className="mt-0.5 shrink-0 text-teal-300" />
|
||||
{span}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<section className="rounded-2xl border border-teal-500/20 bg-teal-500/10 p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Hand className="text-teal-300" size={20} />
|
||||
<h3 className="font-bold text-white">Printable Visual Cards for Lanyards</h3>
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-slate-400">Print these visual cards for student lanyards and classroom reminders. Students and staff can also refer back to the app at any time.</p>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{content.visualCards.map((card) => (
|
||||
<div key={card.id} className="rounded-xl border-2 border-teal-400 bg-white p-4 text-center text-slate-900">
|
||||
{card.imageUrl ? (
|
||||
<img src={fileAssetUrl(card.imageUrl)} alt="" className="mx-auto h-14 w-14 object-contain" />
|
||||
) : (
|
||||
<div className="text-3xl">{card.iconText}</div>
|
||||
)}
|
||||
<p className="mt-1 text-sm font-bold">{card.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutismSupportSteps;
|
||||
259
frontend/src/components/frameworks/AwardsReviews.tsx
Normal file
259
frontend/src/components/frameworks/AwardsReviews.tsx
Normal file
@ -0,0 +1,259 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Award,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Pencil,
|
||||
Plus,
|
||||
Printer,
|
||||
Save,
|
||||
Star,
|
||||
Trash2,
|
||||
Trophy,
|
||||
Upload,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useAwardsReviewsPage } from '@/business/staff-support/hooks';
|
||||
import { fileAssetUrl } from '@/business/files/api';
|
||||
import { AWARDS_REVIEWS_IMAGE } from '@/shared/constants/appData';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ModuleHeader } from '@/components/ui/module-header';
|
||||
import { StatePanel } from '@/components/ui/state-panel';
|
||||
import { escapePrintHtml, printHtml } from '@/shared/utils/printHtml';
|
||||
import type {
|
||||
StaffAwardReviewDto,
|
||||
StaffAwardReviewMutationDto,
|
||||
} from '@/shared/types/staffSupport';
|
||||
|
||||
interface Draft {
|
||||
readonly id?: string;
|
||||
readonly title: string;
|
||||
readonly type: 'award' | 'review';
|
||||
readonly year: string;
|
||||
readonly notes: string;
|
||||
readonly fileName: string | null;
|
||||
readonly fileUrl: string | null;
|
||||
readonly fileIsImage: boolean;
|
||||
}
|
||||
|
||||
const emptyDraft: Draft = {
|
||||
title: '',
|
||||
type: 'award',
|
||||
year: '2025-2026',
|
||||
notes: '',
|
||||
fileName: null,
|
||||
fileUrl: null,
|
||||
fileIsImage: false,
|
||||
};
|
||||
|
||||
function draftFromRecord(record: StaffAwardReviewDto): Draft {
|
||||
return {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
type: record.type,
|
||||
year: record.year,
|
||||
notes: record.notes,
|
||||
fileName: record.fileName,
|
||||
fileUrl: record.fileUrl,
|
||||
fileIsImage: record.fileIsImage,
|
||||
};
|
||||
}
|
||||
|
||||
function printableRecords(records: readonly StaffAwardReviewDto[], staffName: string | null): string {
|
||||
const rows = records.map((record) => (
|
||||
`<div class="card"><span class="badge">${record.type === 'award' ? 'Award' : 'Review'}</span> <strong>${escapePrintHtml(record.title)}</strong> <span class="muted">(${escapePrintHtml(record.year)})</span><p>${escapePrintHtml(record.notes)}</p>${record.fileName ? `<p class="muted">Attachment: ${escapePrintHtml(record.fileName)}</p>` : ''}</div>`
|
||||
)).join('');
|
||||
return `<h1>Awards Earned & Director Reviews</h1><p class="muted">${escapePrintHtml(staffName ?? 'Staff Member')} - Annual Record</p>${rows}`;
|
||||
}
|
||||
|
||||
const AwardsReviews = () => {
|
||||
const page = useAwardsReviewsPage();
|
||||
const [draft, setDraft] = useState<Draft>(emptyDraft);
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
|
||||
if (page.isLoading) {
|
||||
return <StatePanel loading title="Loading awards and reviews" />;
|
||||
}
|
||||
if (page.error) {
|
||||
return <StatePanel tone="red" role="alert">Awards and reviews are unavailable.</StatePanel>;
|
||||
}
|
||||
|
||||
const activeStaffUserId = page.selectedStaffUserId ?? page.currentUserId;
|
||||
|
||||
function openNewForm() {
|
||||
setDraft(emptyDraft);
|
||||
setIsFormOpen(true);
|
||||
}
|
||||
|
||||
function openEditForm(record: StaffAwardReviewDto) {
|
||||
setDraft(draftFromRecord(record));
|
||||
setIsFormOpen(true);
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
setDraft(emptyDraft);
|
||||
setIsFormOpen(false);
|
||||
}
|
||||
|
||||
async function uploadAttachment(file: File | undefined) {
|
||||
if (!file) return;
|
||||
const fileUrl = await page.uploadAttachment(file);
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
fileName: file.name,
|
||||
fileUrl,
|
||||
fileIsImage: file.type.startsWith('image/'),
|
||||
}));
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
if (!draft.title.trim()) return;
|
||||
const data: StaffAwardReviewMutationDto = {
|
||||
staffUserId: page.selectedStaffUserId ?? undefined,
|
||||
title: draft.title,
|
||||
type: draft.type,
|
||||
year: draft.year,
|
||||
notes: draft.notes,
|
||||
fileName: draft.fileName,
|
||||
fileUrl: draft.fileUrl,
|
||||
fileIsImage: draft.fileIsImage,
|
||||
};
|
||||
await page.saveRecord(draft.id, data);
|
||||
closeForm();
|
||||
}
|
||||
|
||||
const formTitle = draft.id ? 'Edit Award or Review' : 'Upload an Award or Review';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<ModuleHeader
|
||||
title="Awards Earned & Reviews"
|
||||
description="Upload awards and Director reviews for the year, then print them out."
|
||||
icon={Award}
|
||||
iconClassName="bg-yellow-600"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-yellow-600 text-white hover:bg-yellow-700"
|
||||
onClick={() => printHtml('Awards & Reviews', printableRecords(page.records, page.selectedStaffName))}
|
||||
leadingIcon={<Printer size={16} />}
|
||||
>
|
||||
Print All
|
||||
</Button>
|
||||
{!isFormOpen && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="border border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700"
|
||||
onClick={openNewForm}
|
||||
leadingIcon={<Plus size={16} />}
|
||||
>
|
||||
Add Record
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="relative overflow-hidden rounded-2xl border border-slate-700/50">
|
||||
<img src={AWARDS_REVIEWS_IMAGE} alt="Teacher holding an award" className="h-56 w-full object-cover md:h-72" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/40 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 p-6">
|
||||
<h2 className="flex items-center gap-2 text-2xl font-bold text-white"><Trophy className="text-yellow-300" /> Celebrate Your Achievements</h2>
|
||||
<p className="mt-1 max-w-xl text-sm text-slate-200">Keep a record of every award and review - proof of the incredible work you do every day.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={page.canManage ? 'grid grid-cols-1 gap-4 lg:grid-cols-[280px_minmax(0,1fr)]' : 'space-y-4'}>
|
||||
{page.canManage && (
|
||||
<aside className="rounded-2xl border border-slate-700/50 bg-slate-800/50 p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-white">Staff</h3>
|
||||
<div className="space-y-2">
|
||||
{page.staff.map((staff) => (
|
||||
<button
|
||||
key={staff.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
closeForm();
|
||||
page.setSelectedStaffUserId(staff.id);
|
||||
}}
|
||||
className={`w-full rounded-lg border px-3 py-2 text-left text-sm transition-colors ${activeStaffUserId === staff.id ? 'border-yellow-400 bg-yellow-500/10 text-white' : 'border-slate-700 bg-slate-950/60 text-slate-300 hover:border-slate-500'}`}
|
||||
>
|
||||
<span className="block font-semibold">{staff.name}</span>
|
||||
<span className="block text-xs text-slate-500">{staff.role ?? 'staff'} - {staff.campusName ?? 'Campus'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 space-y-4">
|
||||
{isFormOpen && (
|
||||
<section className="rounded-2xl border border-slate-700/50 bg-slate-800/50 p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h3 className="flex items-center gap-2 font-bold text-white"><Upload size={18} className="text-yellow-300" /> {formTitle}</h3>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={closeForm} aria-label="Close awards form">
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-[minmax(0,1fr)_180px_150px]">
|
||||
<input value={draft.title} onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))} placeholder="Title (e.g. Teacher of the Year)" className="min-w-0 rounded-xl border border-slate-700 bg-slate-950 px-3 py-2.5 text-sm text-white placeholder:text-slate-500" />
|
||||
<select value={draft.type} onChange={(event) => setDraft((current) => ({ ...current, type: event.target.value as Draft['type'] }))} className="rounded-xl border border-slate-700 bg-slate-950 px-3 py-2.5 text-sm text-white">
|
||||
<option value="award">Award</option>
|
||||
<option value="review">Director Review</option>
|
||||
</select>
|
||||
<input value={draft.year} onChange={(event) => setDraft((current) => ({ ...current, year: event.target.value }))} placeholder="Year" className="rounded-xl border border-slate-700 bg-slate-950 px-3 py-2.5 text-sm text-white" />
|
||||
</div>
|
||||
<textarea value={draft.notes} onChange={(event) => setDraft((current) => ({ ...current, notes: event.target.value }))} placeholder="Notes / summary..." rows={3} className="mt-3 w-full rounded-xl border border-slate-700 bg-slate-950 px-3 py-2.5 text-sm text-white placeholder:text-slate-500" />
|
||||
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<label className="flex min-h-11 min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-xl border border-dashed border-slate-600 bg-slate-950 px-3 py-2.5 text-sm text-slate-400 hover:border-yellow-400">
|
||||
<Upload size={16} className="shrink-0" />
|
||||
<span className="truncate">{draft.fileName ?? 'Choose file (PDF or image)'}</span>
|
||||
<input type="file" accept="image/*,.pdf" onChange={(event) => void uploadAttachment(event.target.files?.[0])} className="hidden" />
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" loading={page.isSaving} onClick={() => void saveDraft()} leadingIcon={<Save size={16} />}>
|
||||
{draft.id ? 'Save Record' : 'Add Record'}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" disabled={page.isSaving} onClick={closeForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{page.records.length === 0 ? (
|
||||
<StatePanel>No awards or reviews have been added yet.</StatePanel>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{page.records.map((record) => (
|
||||
<article key={record.id} className="rounded-2xl border border-slate-700/50 bg-slate-800/50 p-5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${record.type === 'award' ? 'bg-yellow-500/15 text-yellow-300' : 'bg-violet-500/15 text-violet-300'}`}>
|
||||
{record.type === 'award' ? 'Award' : 'Review'}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => openEditForm(record)} className="inline-flex items-center gap-1 text-xs font-semibold text-slate-400 hover:text-white"><Pencil size={12} /> Edit</button>
|
||||
<button type="button" onClick={() => void page.deleteRecord(record.id)} className="text-slate-500 hover:text-rose-300" aria-label={`Delete ${record.title}`}><Trash2 size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mt-2 flex items-center gap-2 font-bold text-white">
|
||||
{record.type === 'award' ? <Star size={16} className="text-yellow-300" /> : <FileText size={16} className="text-violet-300" />} {record.title}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-slate-500">{record.year}</p>
|
||||
{record.notes && <p className="mt-2 text-sm text-slate-400">{record.notes}</p>}
|
||||
{record.fileUrl && record.fileIsImage && <img src={fileAssetUrl(record.fileUrl)} alt={record.title} className="mt-3 h-40 w-full rounded-lg border border-slate-700 object-cover" />}
|
||||
{record.fileName && !record.fileIsImage && <p className="mt-3 flex items-center gap-2 text-xs text-slate-400"><ImageIcon size={14} /> {record.fileName}</p>}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AwardsReviews;
|
||||
251
frontend/src/components/frameworks/ParaCertifications.tsx
Normal file
251
frontend/src/components/frameworks/ParaCertifications.tsx
Normal file
@ -0,0 +1,251 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Award,
|
||||
BookOpen,
|
||||
Clock,
|
||||
DollarSign,
|
||||
ExternalLink,
|
||||
GraduationCap,
|
||||
Pencil,
|
||||
Plus,
|
||||
Printer,
|
||||
Save,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useParaCertificationsPage } from '@/business/staff-support/hooks';
|
||||
import { fileAssetUrl } from '@/business/files/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ModuleHeader } from '@/components/ui/module-header';
|
||||
import { StatePanel } from '@/components/ui/state-panel';
|
||||
import { escapePrintHtml, printHtml } from '@/shared/utils/printHtml';
|
||||
import type {
|
||||
ParaCertificationHighlight,
|
||||
ParaCertificationProgram,
|
||||
ParaCertificationsContent,
|
||||
} from '@/shared/types/staffSupport';
|
||||
|
||||
const tagColor: Record<string, string> = {
|
||||
Certification: 'text-violet-300 bg-violet-500/15',
|
||||
Degree: 'text-blue-300 bg-blue-500/15',
|
||||
Credential: 'text-emerald-300 bg-emerald-500/15',
|
||||
Endorsement: 'text-amber-300 bg-amber-500/15',
|
||||
Funding: 'text-rose-300 bg-rose-500/15',
|
||||
Certificate: 'text-teal-300 bg-teal-500/15',
|
||||
};
|
||||
|
||||
function newProgram(): ParaCertificationProgram {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: '',
|
||||
organization: '',
|
||||
description: '',
|
||||
url: '',
|
||||
tag: 'Certification',
|
||||
};
|
||||
}
|
||||
|
||||
function printPrograms(content: ParaCertificationsContent): string {
|
||||
const rows = content.programs.map((program) => (
|
||||
`<div class="card"><span class="badge">${escapePrintHtml(program.tag)}</span> <strong>${escapePrintHtml(program.name)}</strong><p class="muted">${escapePrintHtml(program.organization)}</p><p>${escapePrintHtml(program.description)}</p><p class="muted">Link: ${escapePrintHtml(program.url)}</p></div>`
|
||||
)).join('');
|
||||
return `<h1>Certifications & Degrees</h1><p>You're already doing the job - now get certified. These programs and links can help support staff advance their careers.</p>${rows}`;
|
||||
}
|
||||
|
||||
const highlightIcons = [BookOpen, DollarSign, Clock];
|
||||
const highlightIconColors = ['text-sky-300', 'text-emerald-300', 'text-amber-300'] as const;
|
||||
|
||||
const ParaCertifications = () => {
|
||||
const page = useParaCertificationsPage();
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [draftState, setDraftState] = useState<{
|
||||
readonly source: ParaCertificationsContent;
|
||||
readonly value: ParaCertificationsContent;
|
||||
}>({ source: page.content, value: page.content });
|
||||
const draft = draftState.source === page.content ? draftState.value : page.content;
|
||||
const setDraft = (
|
||||
updater: ParaCertificationsContent | ((current: ParaCertificationsContent) => ParaCertificationsContent),
|
||||
) => {
|
||||
setDraftState({
|
||||
source: page.content,
|
||||
value: typeof updater === 'function' ? updater(draft) : updater,
|
||||
});
|
||||
};
|
||||
|
||||
if (page.isLoading) {
|
||||
return <StatePanel loading title="Loading certifications" />;
|
||||
}
|
||||
if (page.error) {
|
||||
return <StatePanel tone="red" role="alert">Certification content is unavailable.</StatePanel>;
|
||||
}
|
||||
|
||||
const content = page.content;
|
||||
|
||||
function openEditor() {
|
||||
setDraftState({ source: page.content, value: page.content });
|
||||
setIsEditorOpen(true);
|
||||
}
|
||||
|
||||
function updateProgram(index: number, updates: Partial<ParaCertificationProgram>) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
programs: current.programs.map((program, programIndex) => programIndex === index ? { ...program, ...updates } : program),
|
||||
}));
|
||||
}
|
||||
|
||||
function updateHighlight(index: number, updates: Partial<ParaCertificationHighlight>) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
highlights: current.highlights.map((highlight, highlightIndex) => highlightIndex === index ? { ...highlight, ...updates } : highlight),
|
||||
}));
|
||||
}
|
||||
|
||||
async function uploadHero(file: File | undefined) {
|
||||
if (!file) return;
|
||||
const heroImage = await page.uploadHeroImage(file);
|
||||
setDraft((current) => ({ ...current, heroImage }));
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
await page.saveContent(draft);
|
||||
setIsEditorOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<ModuleHeader
|
||||
title="Certifications & Degrees"
|
||||
description="For paraprofessionals and support staff - you're already doing the job, now get certified."
|
||||
icon={GraduationCap}
|
||||
iconClassName="bg-sky-600"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="border border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700"
|
||||
onClick={() => printHtml('Certifications & Degrees for Paraprofessionals', printPrograms(content))}
|
||||
leadingIcon={<Printer size={16} />}
|
||||
>
|
||||
Print List
|
||||
</Button>
|
||||
{page.canManage && !isEditorOpen && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="border border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700"
|
||||
onClick={openEditor}
|
||||
leadingIcon={<Pencil size={16} />}
|
||||
>
|
||||
Edit Content
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{page.canManage && isEditorOpen && (
|
||||
<section className="rounded-2xl border border-slate-700/70 bg-slate-900/80 p-5 shadow-lg shadow-black/10">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h3 className="text-lg font-bold text-white">Edit Certifications & Degrees</h3>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => setIsEditorOpen(false)} aria-label="Close certifications editor">
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[1fr_auto]">
|
||||
<div className="space-y-3">
|
||||
<input value={draft.heroTitle} onChange={(event) => setDraft((current) => ({ ...current, heroTitle: event.target.value }))} className="w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm font-bold text-white" aria-label="Hero title" />
|
||||
<textarea value={draft.heroSubtitle} onChange={(event) => setDraft((current) => ({ ...current, heroSubtitle: event.target.value }))} rows={2} className="w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-100" aria-label="Hero subtitle" />
|
||||
</div>
|
||||
<label className="inline-flex h-11 cursor-pointer items-center justify-center gap-2 rounded-lg bg-slate-100 px-3 text-sm font-semibold text-slate-900">
|
||||
<Upload size={15} /> Upload Hero
|
||||
<input type="file" accept="image/*" className="hidden" onChange={(event) => void uploadHero(event.target.files?.[0])} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
{draft.highlights.map((highlight, index) => (
|
||||
<div key={highlight.id} className="rounded-xl border border-slate-700 bg-slate-950/60 p-3">
|
||||
<input value={highlight.title} onChange={(event) => updateHighlight(index, { title: event.target.value })} className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1.5 text-sm font-semibold text-white" aria-label={`Highlight ${index + 1} title`} />
|
||||
<input value={highlight.subtitle} onChange={(event) => updateHighlight(index, { subtitle: event.target.value })} className="mt-2 w-full rounded border border-slate-700 bg-slate-950 px-2 py-1.5 text-sm text-white" aria-label={`Highlight ${index + 1} subtitle`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{draft.programs.map((program, index) => (
|
||||
<div key={program.id} className="rounded-xl border border-slate-700 bg-slate-950/60 p-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<input value={program.name} onChange={(event) => updateProgram(index, { name: event.target.value })} placeholder="Program name" className="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-white" />
|
||||
<input value={program.tag} onChange={(event) => updateProgram(index, { tag: event.target.value })} placeholder="Tag" className="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-white" />
|
||||
</div>
|
||||
<input value={program.organization} onChange={(event) => updateProgram(index, { organization: event.target.value })} placeholder="Organization" className="mt-3 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-white" />
|
||||
<input value={program.url} onChange={(event) => updateProgram(index, { url: event.target.value })} placeholder="URL" className="mt-3 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-white" />
|
||||
<textarea value={program.description} onChange={(event) => updateProgram(index, { description: event.target.value })} rows={3} placeholder="Description" className="mt-3 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-white" />
|
||||
<Button type="button" className="mt-3" variant="destructive" size="sm" onClick={() => setDraft((current) => ({ ...current, programs: current.programs.filter((item) => item.id !== program.id) }))} leadingIcon={<Trash2 size={14} />}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="secondary" onClick={() => setDraft((current) => ({ ...current, programs: [...current.programs, newProgram()] }))} leadingIcon={<Plus size={16} />}>
|
||||
Add Certification
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<Button type="button" loading={page.isSaving} onClick={() => void saveDraft()} leadingIcon={<Save size={16} />}>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" disabled={page.isSaving} onClick={() => setIsEditorOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="relative overflow-hidden rounded-2xl border border-slate-700/50">
|
||||
{content.heroImage && (
|
||||
<img src={fileAssetUrl(content.heroImage)} alt="Support staff with certificate" className="h-56 w-full object-cover md:h-72" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/50 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 p-6">
|
||||
<h2 className="text-2xl font-bold text-white">{content.heroTitle}</h2>
|
||||
<p className="mt-1 max-w-xl text-sm text-slate-200">{content.heroSubtitle}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
{content.highlights.map((highlight, index) => {
|
||||
const Icon = highlightIcons[index] ?? BookOpen;
|
||||
const iconColor = highlightIconColors[index] ?? 'text-sky-300';
|
||||
return (
|
||||
<div key={highlight.id} className="flex items-center gap-3 rounded-2xl border border-slate-700/50 bg-slate-800/50 p-4">
|
||||
<Icon className={iconColor} />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-200">{highlight.title}</p>
|
||||
<p className="text-xs text-slate-500">{highlight.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{content.programs.map((program) => (
|
||||
<a key={program.id} href={program.url} target="_blank" rel="noopener noreferrer" className="group block rounded-2xl border border-slate-700/50 bg-slate-800/50 p-5 transition-colors hover:border-sky-500/40">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${tagColor[program.tag] ?? 'bg-slate-500/15 text-slate-300'}`}>{program.tag}</span>
|
||||
<ExternalLink size={16} className="text-slate-500 group-hover:text-sky-300" />
|
||||
</div>
|
||||
<h3 className="mt-2 flex items-center gap-2 font-bold text-white"><Award size={16} className="text-sky-300" /> {program.name}</h3>
|
||||
<p className="mt-0.5 text-xs text-sky-300/80">{program.organization}</p>
|
||||
<p className="mt-2 text-sm text-slate-400">{program.description}</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParaCertifications;
|
||||
@ -17,7 +17,9 @@ import {
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
MessageSquare,
|
||||
Puzzle,
|
||||
Shield,
|
||||
Trophy,
|
||||
Timer,
|
||||
Users,
|
||||
Wallet,
|
||||
@ -47,6 +49,8 @@ const sidebarIcons: Record<string, ReactNode> = {
|
||||
briefcase: <Briefcase size={SIDEBAR_ICON_SIZE} />,
|
||||
wallet: <Wallet size={SIDEBAR_ICON_SIZE} />,
|
||||
clipboard: <ClipboardCheck size={SIDEBAR_ICON_SIZE} />,
|
||||
puzzle: <Puzzle size={SIDEBAR_ICON_SIZE} />,
|
||||
award: <Trophy size={SIDEBAR_ICON_SIZE} />,
|
||||
};
|
||||
|
||||
export function getSidebarIcon(icon: string): ReactNode {
|
||||
|
||||
5
frontend/src/pages/modules/AutismSupportStepsPage.tsx
Normal file
5
frontend/src/pages/modules/AutismSupportStepsPage.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import AutismSupportSteps from '@/components/frameworks/AutismSupportSteps';
|
||||
|
||||
export default function AutismSupportStepsPage() {
|
||||
return <AutismSupportSteps />;
|
||||
}
|
||||
5
frontend/src/pages/modules/AwardsReviewsPage.tsx
Normal file
5
frontend/src/pages/modules/AwardsReviewsPage.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import AwardsReviews from '@/components/frameworks/AwardsReviews';
|
||||
|
||||
export default function AwardsReviewsPage() {
|
||||
return <AwardsReviews />;
|
||||
}
|
||||
5
frontend/src/pages/modules/ParaCertificationsPage.tsx
Normal file
5
frontend/src/pages/modules/ParaCertificationsPage.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import ParaCertifications from '@/components/frameworks/ParaCertifications';
|
||||
|
||||
export default function ParaCertificationsPage() {
|
||||
return <ParaCertifications />;
|
||||
}
|
||||
60
frontend/src/shared/api/staffAwardsReviews.test.ts
Normal file
60
frontend/src/shared/api/staffAwardsReviews.test.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createStaffAwardReview,
|
||||
deleteStaffAwardReview,
|
||||
listAwardsReviewStaff,
|
||||
listStaffAwardsReviews,
|
||||
updateStaffAwardReview,
|
||||
} from '@/shared/api/staffAwardsReviews';
|
||||
import { apiRequest } from '@/shared/api/httpClient';
|
||||
|
||||
vi.mock('@/shared/api/httpClient', () => ({
|
||||
apiRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
const apiRequestMock = vi.mocked(apiRequest);
|
||||
|
||||
describe('staff awards reviews API', () => {
|
||||
beforeEach(() => {
|
||||
apiRequestMock.mockReset();
|
||||
});
|
||||
|
||||
it('loads visible staff and selected staff records', () => {
|
||||
void listAwardsReviewStaff();
|
||||
void listStaffAwardsReviews('staff-1');
|
||||
void listStaffAwardsReviews();
|
||||
|
||||
expect(apiRequestMock).toHaveBeenNthCalledWith(1, '/staff_awards_reviews/staff');
|
||||
expect(apiRequestMock).toHaveBeenNthCalledWith(2, '/staff_awards_reviews?staffUserId=staff-1');
|
||||
expect(apiRequestMock).toHaveBeenNthCalledWith(3, '/staff_awards_reviews');
|
||||
});
|
||||
|
||||
it('creates, updates, and deletes award/review records', () => {
|
||||
const payload = {
|
||||
staffUserId: 'staff-1',
|
||||
title: 'Teacher of the Year',
|
||||
type: 'award' as const,
|
||||
year: '2025-2026',
|
||||
notes: 'Recognized for classroom leadership.',
|
||||
fileName: null,
|
||||
fileUrl: null,
|
||||
fileIsImage: false,
|
||||
};
|
||||
|
||||
void createStaffAwardReview(payload);
|
||||
void updateStaffAwardReview('record-1', payload);
|
||||
void deleteStaffAwardReview('record-1');
|
||||
|
||||
expect(apiRequestMock).toHaveBeenNthCalledWith(1, '/staff_awards_reviews', {
|
||||
method: 'POST',
|
||||
body: { data: payload },
|
||||
});
|
||||
expect(apiRequestMock).toHaveBeenNthCalledWith(2, '/staff_awards_reviews/record-1', {
|
||||
method: 'PUT',
|
||||
body: { data: payload },
|
||||
});
|
||||
expect(apiRequestMock).toHaveBeenNthCalledWith(3, '/staff_awards_reviews/record-1', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
});
|
||||
});
|
||||
57
frontend/src/shared/api/staffAwardsReviews.ts
Normal file
57
frontend/src/shared/api/staffAwardsReviews.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { apiRequest } from '@/shared/api/httpClient';
|
||||
import type { ApiListResponse } from '@/shared/types/api';
|
||||
import type {
|
||||
StaffAwardReviewDto,
|
||||
StaffAwardReviewMutationDto,
|
||||
StaffAwardsReviewsStaffDto,
|
||||
} from '@/shared/types/staffSupport';
|
||||
|
||||
const STAFF_AWARDS_REVIEWS_PATH = '/staff_awards_reviews';
|
||||
|
||||
export interface StaffAwardsReviewsListResponse extends ApiListResponse<StaffAwardReviewDto> {
|
||||
readonly canManage: boolean;
|
||||
readonly currentUserId: string;
|
||||
readonly selectedStaffUserId: string;
|
||||
}
|
||||
|
||||
export function listAwardsReviewStaff(): Promise<ApiListResponse<StaffAwardsReviewsStaffDto>> {
|
||||
return apiRequest<ApiListResponse<StaffAwardsReviewsStaffDto>>(`${STAFF_AWARDS_REVIEWS_PATH}/staff`);
|
||||
}
|
||||
|
||||
export function listStaffAwardsReviews(
|
||||
staffUserId?: string,
|
||||
): Promise<StaffAwardsReviewsListResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (staffUserId) {
|
||||
search.set('staffUserId', staffUserId);
|
||||
}
|
||||
const qs = search.toString();
|
||||
return apiRequest<StaffAwardsReviewsListResponse>(
|
||||
`${STAFF_AWARDS_REVIEWS_PATH}${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function createStaffAwardReview(
|
||||
data: StaffAwardReviewMutationDto,
|
||||
): Promise<StaffAwardReviewDto> {
|
||||
return apiRequest<StaffAwardReviewDto>(STAFF_AWARDS_REVIEWS_PATH, {
|
||||
method: 'POST',
|
||||
body: { data },
|
||||
});
|
||||
}
|
||||
|
||||
export function updateStaffAwardReview(
|
||||
id: string,
|
||||
data: StaffAwardReviewMutationDto,
|
||||
): Promise<StaffAwardReviewDto> {
|
||||
return apiRequest<StaffAwardReviewDto>(`${STAFF_AWARDS_REVIEWS_PATH}/${id}`, {
|
||||
method: 'PUT',
|
||||
body: { data },
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteStaffAwardReview(id: string): Promise<{ readonly deletedCount: number }> {
|
||||
return apiRequest<{ readonly deletedCount: number }>(`${STAFF_AWARDS_REVIEWS_PATH}/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@ -29,9 +29,11 @@ export const MODULE_PERMISSIONS = [
|
||||
'READ_EI', 'READ_ZONES', 'READ_SIGNS', 'READ_ATTENDANCE', 'READ_PARENT_COMM',
|
||||
'READ_INTERNAL_COMM', 'READ_SAFETY', 'READ_HANDBOOK', 'READ_COMMUNITY',
|
||||
'READ_VOCATIONAL', 'READ_ESA', 'READ_WALKTHROUGH', 'READ_DIRECTOR_DASHBOARD',
|
||||
'READ_AUTISM_SUPPORT_STEPS', 'READ_PARA_CERTIFICATIONS', 'READ_AWARDS_REVIEWS',
|
||||
'FILL_ATTENDANCE', 'TAKE_QUIZ', 'ACK_READ_RECEIPT', 'ACK_POLICY', 'ZONE_CHECKIN',
|
||||
'MANAGE_FRAME', 'MANAGE_WALKTHROUGH', 'MANAGE_INTERNAL_COMM',
|
||||
'MANAGE_CONTENT_CATALOG', 'MANAGE_ESA_FUNDING_CONTENT',
|
||||
'MANAGE_AUTISM_SUPPORT_STEPS', 'MANAGE_PARA_CERTIFICATIONS', 'MANAGE_AWARDS_REVIEWS',
|
||||
'READ_STAFF_ATTENDANCE_REPORTS',
|
||||
'READ_SAFETY_QUIZ_REPORTS', 'READ_PERSONALITY_REPORTS',
|
||||
'READ_ZONE_CHECKIN_REPORTS', 'READ_POLICY_ACKNOWLEDGMENT_REPORTS',
|
||||
|
||||
@ -20,6 +20,9 @@ export const MODULES: Module[] = [
|
||||
{ id: 'community', name: 'Community & Partnerships', icon: 'globe', permissions: ['READ_COMMUNITY'], color: 'bg-green-600', routePath: APP_ROUTE_PATHS.community },
|
||||
{ id: 'vocational', name: 'Vocational Opportunities', icon: 'briefcase', permissions: ['READ_VOCATIONAL'], color: 'bg-sky-600', routePath: APP_ROUTE_PATHS.vocational },
|
||||
{ id: 'esa', name: 'ESA Funding Info', icon: 'wallet', permissions: ['READ_ESA'], color: 'bg-emerald-600', routePath: APP_ROUTE_PATHS.esa },
|
||||
{ id: 'autism-support', name: 'Autism Support Steps', icon: 'puzzle', permissions: ['READ_AUTISM_SUPPORT_STEPS'], color: 'bg-teal-600', routePath: APP_ROUTE_PATHS.autismSupport },
|
||||
{ id: 'para-certifications', name: 'Certifications & Degrees', icon: 'graduation', permissions: ['READ_PARA_CERTIFICATIONS'], color: 'bg-sky-600', routePath: APP_ROUTE_PATHS.paraCertifications },
|
||||
{ id: 'awards-reviews', name: 'Awards & Reviews', icon: 'award', permissions: ['READ_AWARDS_REVIEWS'], color: 'bg-yellow-600', routePath: APP_ROUTE_PATHS.awardsReviews },
|
||||
{ id: 'walkthrough', name: 'Walk-Through Check-In', icon: 'clipboard', permissions: ['MANAGE_WALKTHROUGH'], color: 'bg-indigo-600', routePath: APP_ROUTE_PATHS.walkthrough },
|
||||
{ id: 'director', name: 'Director Dashboard', icon: 'chart', permissions: ['READ_DIRECTOR_DASHBOARD'], color: 'bg-purple-600', routePath: APP_ROUTE_PATHS.director },
|
||||
{ id: 'organization-management', name: 'Organizations & Locations', icon: 'building', permissions: ['READ_ORGANIZATIONS', 'CREATE_ORGANIZATIONS', 'UPDATE_ORGANIZATIONS', 'DELETE_ORGANIZATIONS', 'READ_SCHOOLS', 'CREATE_SCHOOLS', 'UPDATE_SCHOOLS', 'DELETE_SCHOOLS', 'READ_CAMPUSES', 'CREATE_CAMPUSES', 'UPDATE_CAMPUSES', 'DELETE_CAMPUSES', 'READ_CLASSES', 'CREATE_CLASSES', 'UPDATE_CLASSES', 'DELETE_CLASSES'], color: 'bg-fuchsia-600', routePath: APP_ROUTE_PATHS.organizations },
|
||||
@ -28,3 +31,4 @@ export const MODULES: Module[] = [
|
||||
|
||||
export const HERO_IMAGE = 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770658813159_517b0df6.jpg';
|
||||
export const HANDBOOK_IMAGE = 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770659087111_5a2d27c8.jpg';
|
||||
export const AWARDS_REVIEWS_IMAGE = 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1780710903198_8b1aff85.jpg';
|
||||
|
||||
@ -21,6 +21,8 @@ export const CONTENT_CATALOG_TYPES = {
|
||||
emotionalIntelligenceGrowthTips: 'emotional-intelligence-growth-tips',
|
||||
emotionalIntelligenceTeamWellnessMetrics: 'emotional-intelligence-team-wellness-metrics',
|
||||
esaFundingContent: 'esa-funding-content',
|
||||
autismSupportSteps: 'autism-support-steps',
|
||||
paraCertifications: 'para-certifications',
|
||||
safetyProtocols: 'safety-protocols',
|
||||
emotionalIntelligenceWeeklyFocus: 'emotional-intelligence-weekly-focus',
|
||||
} as const;
|
||||
|
||||
@ -17,6 +17,9 @@ export const APP_ROUTE_PATHS = {
|
||||
community: '/community-partnerships',
|
||||
vocational: '/vocational-opportunities',
|
||||
esa: '/esa-funding',
|
||||
autismSupport: '/autism-support-steps',
|
||||
paraCertifications: '/para-certifications',
|
||||
awardsReviews: '/awards-reviews',
|
||||
walkthrough: '/walkthrough',
|
||||
director: '/director-dashboard',
|
||||
platformDashboard: '/platform-dashboard',
|
||||
|
||||
@ -177,6 +177,9 @@ export type ModuleId =
|
||||
| 'community'
|
||||
| 'vocational'
|
||||
| 'esa'
|
||||
| 'autism-support'
|
||||
| 'para-certifications'
|
||||
| 'awards-reviews'
|
||||
| 'walkthrough';
|
||||
|
||||
export interface WalkthroughCheckin {
|
||||
|
||||
78
frontend/src/shared/types/staffSupport.ts
Normal file
78
frontend/src/shared/types/staffSupport.ts
Normal file
@ -0,0 +1,78 @@
|
||||
export interface AutismSupportStep {
|
||||
readonly id: string;
|
||||
readonly iconId: string;
|
||||
readonly title: string;
|
||||
readonly subtitle: string;
|
||||
readonly spans: readonly string[];
|
||||
}
|
||||
|
||||
export interface AutismVisualCard {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly imageUrl: string | null;
|
||||
readonly iconText: string;
|
||||
}
|
||||
|
||||
export interface AutismSupportStepsContent {
|
||||
readonly steps: readonly AutismSupportStep[];
|
||||
readonly visualCards: readonly AutismVisualCard[];
|
||||
}
|
||||
|
||||
export interface ParaCertificationHighlight {
|
||||
readonly id: string;
|
||||
readonly iconId: string;
|
||||
readonly title: string;
|
||||
readonly subtitle: string;
|
||||
}
|
||||
|
||||
export interface ParaCertificationProgram {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly organization: string;
|
||||
readonly description: string;
|
||||
readonly url: string;
|
||||
readonly tag: string;
|
||||
}
|
||||
|
||||
export interface ParaCertificationsContent {
|
||||
readonly heroImage: string;
|
||||
readonly heroTitle: string;
|
||||
readonly heroSubtitle: string;
|
||||
readonly highlights: readonly ParaCertificationHighlight[];
|
||||
readonly programs: readonly ParaCertificationProgram[];
|
||||
}
|
||||
|
||||
export interface StaffAwardsReviewsStaffDto {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
readonly role: string | null;
|
||||
readonly schoolName: string | null;
|
||||
readonly campusName: string | null;
|
||||
}
|
||||
|
||||
export interface StaffAwardReviewDto {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly type: 'award' | 'review';
|
||||
readonly year: string;
|
||||
readonly notes: string;
|
||||
readonly fileName: string | null;
|
||||
readonly fileUrl: string | null;
|
||||
readonly fileIsImage: boolean;
|
||||
readonly staffUserId: string;
|
||||
readonly staffName: string | null;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
export interface StaffAwardReviewMutationDto {
|
||||
readonly staffUserId?: string;
|
||||
readonly title: string;
|
||||
readonly type: 'award' | 'review';
|
||||
readonly year: string;
|
||||
readonly notes: string;
|
||||
readonly fileName?: string | null;
|
||||
readonly fileUrl?: string | null;
|
||||
readonly fileIsImage?: boolean;
|
||||
}
|
||||
38
frontend/src/shared/utils/printHtml.ts
Normal file
38
frontend/src/shared/utils/printHtml.ts
Normal file
@ -0,0 +1,38 @@
|
||||
export function escapePrintHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function printHtml(title: string, bodyHtml: string): void {
|
||||
const win = window.open('', '_blank', 'width=900,height=700');
|
||||
if (!win) return;
|
||||
|
||||
win.document.write(`<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>${escapePrintHtml(title)}</title>
|
||||
<style>
|
||||
body { font-family: Inter, Arial, sans-serif; margin: 24px; color: #0f172a; }
|
||||
h1 { font-size: 24px; margin: 0 0 8px; }
|
||||
.muted { color: #64748b; }
|
||||
.card { border: 1px solid #cbd5e1; border-radius: 10px; padding: 14px; margin: 12px 0; page-break-inside: avoid; }
|
||||
.badge { display: inline-block; border-radius: 999px; padding: 2px 8px; background: #e2e8f0; font-size: 12px; font-weight: 700; }
|
||||
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.vcard { border: 2px solid #14b8a6; border-radius: 12px; min-height: 112px; padding: 14px; text-align: center; page-break-inside: avoid; }
|
||||
.ic { font-size: 34px; min-height: 42px; display: flex; align-items: center; justify-content: center; }
|
||||
.ic img { max-width: 72px; max-height: 72px; object-fit: contain; }
|
||||
.lbl { font-weight: 800; margin-top: 8px; }
|
||||
@media print { .no-print { display: none; } body { margin: 14px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>${bodyHtml}
|
||||
<script>window.onload = function(){ setTimeout(function(){ window.print(); }, 350); }</script>
|
||||
</body>
|
||||
</html>`);
|
||||
win.document.close();
|
||||
win.focus();
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user