Added ESA functionality
This commit is contained in:
parent
9531ea34f2
commit
b1b08fea70
@ -27,6 +27,7 @@ DTO fields: `id`, `content_type`, `payload`, `updatedAt`.
|
||||
## Access Rules
|
||||
- `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` and campus effective scope. 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.
|
||||
- `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.
|
||||
@ -37,6 +38,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.
|
||||
- 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.
|
||||
@ -65,7 +67,7 @@ The seeder (`20260608103000-content-catalog.ts`) loads the following `content_ty
|
||||
|
||||
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 only per-tenant campus content. This keeps shared libraries and editable organization-wide content owned at the organization level.
|
||||
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.
|
||||
|
||||
### Content authoring rules
|
||||
- Add editable or tenant-scoped production content records to backend seed payloads, not frontend constants.
|
||||
|
||||
@ -22,6 +22,11 @@ entity it replaced has been removed):
|
||||
are gated by effective policy-document permissions. Acknowledgment is **persisted** via `policy_acknowledgments`
|
||||
(`usePolicyAcknowledgments` / `useAcknowledgePolicy`), replacing the former
|
||||
local-state set.
|
||||
- **ESA Funding Information** uses the same handbook-policy document store for
|
||||
staff acknowledgment tracking. Each campus owns one active handbook document
|
||||
tagged `ESA Funding`; ESA content edits bump that document version and append
|
||||
the updated section name to the document body so header notifications can
|
||||
point staff back to `/esa-funding`.
|
||||
- **Safety Protocols** (`business/safety-protocols`) consumes
|
||||
`category = safety_protocol`, rendering author-filled `steps` + autism
|
||||
considerations with a static per-`tag` card icon (fire/shield/heart). It shares
|
||||
@ -55,7 +60,8 @@ entity it replaced has been removed):
|
||||
(`safety_protocol` | `handbook_policy` — selects the page), `tag` (nullable
|
||||
finer **sub-category**; the Handbook page maps its
|
||||
Operations/Behavior/Safety/Communication/Legal categorisation onto it, and the
|
||||
Safety page uses it to pick the static card icon), `author` (display name of
|
||||
Safety page uses it to pick the static card icon; ESA Funding uses
|
||||
`tag = "ESA Funding"` for its campus acknowledgment document), `author` (display name of
|
||||
the **creating user**, set server-side at creation and not changed on update),
|
||||
`steps` + `autism_considerations` (JSONB string arrays — **author-filled
|
||||
structured content** for safety protocols; null for handbook policies),
|
||||
@ -124,12 +130,16 @@ visible active document version is acknowledged.
|
||||
re-acknowledgment bump, and `formatPersonName` (author rendering).
|
||||
- **Backend service** (`backend/src/services/policy_acknowledgments.test.ts`):
|
||||
acknowledgment listing plus the drilled-child no-op rule for parent users.
|
||||
- **Content catalog service** (`backend/src/services/content_catalog.test.ts`):
|
||||
ESA funding content updates bump the linked policy-document version and store
|
||||
the updated section name for staff notifications.
|
||||
- **Frontend unit** (`vitest`): `business/policies/mappers.test.ts` (handbook;
|
||||
tag↔category, author), `business/safety-protocols/mappers.test.ts` (steps +
|
||||
autism considerations), `business/safety-protocols/selectors.test.ts`
|
||||
(management grant + draft validation for the authoring form),
|
||||
`business/director-dashboard/selectors.test.ts` (dashboard acknowledgment rows
|
||||
and risk areas), and `business/profile/selectors.test.ts` (current-version
|
||||
and risk areas), `business/top-bar/selectors.test.ts` (ESA updated-section
|
||||
notifications), and `business/profile/selectors.test.ts` (current-version
|
||||
profile checklist rows, missing-first sorting, and old-version acknowledgment
|
||||
exclusion).
|
||||
- **Seeded e2e** (`frontend/tests/e2e/policy-acknowledgments.seeded.e2e.ts`,
|
||||
|
||||
@ -0,0 +1,124 @@
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { QueryTypes, type QueryInterface } from 'sequelize';
|
||||
import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions';
|
||||
import { POLICY_DOCUMENT_CATEGORIES } from '@/shared/constants/policy-documents';
|
||||
import { CONTENT_CATALOG_SEED_PAYLOADS } from '@/db/seeders/content-catalog-data/content-catalog-seed-payloads';
|
||||
|
||||
const ESA_CONTENT_TYPE = 'esa-funding-content';
|
||||
const ESA_POLICY_TAG = 'ESA Funding';
|
||||
const ESA_POLICY_TITLE = 'ESA Funding Information';
|
||||
|
||||
function isIdRow(value: unknown): value is { id: string } {
|
||||
return (
|
||||
value !== null
|
||||
&& typeof value === 'object'
|
||||
&& 'id' in value
|
||||
&& typeof value.id === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function firstId(value: unknown): string | null {
|
||||
return Array.isArray(value) ? value.find(isIdRow)?.id ?? null : null;
|
||||
}
|
||||
|
||||
export default {
|
||||
up: async (queryInterface: QueryInterface) => {
|
||||
const now = new Date();
|
||||
|
||||
const [permissionRows] = await queryInterface.sequelize.query(
|
||||
'SELECT "id" FROM "permissions" WHERE "name" = :name LIMIT 1',
|
||||
{ replacements: { name: FEATURE_PERMISSIONS.MANAGE_ESA_FUNDING_CONTENT } },
|
||||
);
|
||||
let permissionId = firstId(permissionRows);
|
||||
|
||||
if (!permissionId) {
|
||||
permissionId = uuid();
|
||||
await queryInterface.bulkInsert('permissions', [{
|
||||
id: permissionId,
|
||||
name: FEATURE_PERMISSIONS.MANAGE_ESA_FUNDING_CONTENT,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}]);
|
||||
}
|
||||
|
||||
const campuses = await queryInterface.sequelize.query<{
|
||||
id: string;
|
||||
organizationId: string;
|
||||
}>(
|
||||
`SELECT "id", "organizationId" FROM "campuses" WHERE "deletedAt" IS NULL`,
|
||||
{ type: QueryTypes.SELECT },
|
||||
);
|
||||
|
||||
const contentRows = campuses.map((campus) => ({
|
||||
id: uuid(),
|
||||
content_type: ESA_CONTENT_TYPE,
|
||||
payload: JSON.stringify(CONTENT_CATALOG_SEED_PAYLOADS.esaFundingContent),
|
||||
active: true,
|
||||
importHash: `content-catalog-${ESA_CONTENT_TYPE}-${campus.id}`,
|
||||
organizationId: campus.organizationId,
|
||||
schoolId: null,
|
||||
campusId: campus.id,
|
||||
classId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
|
||||
if (contentRows.length > 0) {
|
||||
const existingContentRows = await queryInterface.sequelize.query<{ importHash: string }>(
|
||||
`SELECT "importHash" FROM content_catalog WHERE "importHash" IN (:importHashes)`,
|
||||
{
|
||||
replacements: { importHashes: contentRows.map((row) => row.importHash) },
|
||||
type: QueryTypes.SELECT,
|
||||
},
|
||||
);
|
||||
const existingContentHashes = new Set(existingContentRows.map((row) => row.importHash));
|
||||
const missingContentRows = contentRows.filter((row) => !existingContentHashes.has(row.importHash));
|
||||
|
||||
if (missingContentRows.length > 0) {
|
||||
await queryInterface.bulkInsert('content_catalog', missingContentRows);
|
||||
}
|
||||
}
|
||||
|
||||
const policyRows = campuses.map((campus) => ({
|
||||
id: uuid(),
|
||||
title: ESA_POLICY_TITLE,
|
||||
body: 'Staff must review the current ESA Funding Information page and acknowledge understanding of the campus guidance.',
|
||||
category: POLICY_DOCUMENT_CATEGORIES.HANDBOOK_POLICY,
|
||||
tag: ESA_POLICY_TAG,
|
||||
author: null,
|
||||
steps: null,
|
||||
autism_considerations: null,
|
||||
version: 1,
|
||||
active: true,
|
||||
importHash: `policy-doc-esa-funding-${campus.id}`,
|
||||
organizationId: campus.organizationId,
|
||||
schoolId: null,
|
||||
campusId: campus.id,
|
||||
classId: null,
|
||||
createdById: null,
|
||||
updatedById: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
|
||||
if (policyRows.length > 0) {
|
||||
const existingPolicyRows = await queryInterface.sequelize.query<{ importHash: string }>(
|
||||
`SELECT "importHash" FROM policy_documents WHERE "importHash" IN (:importHashes)`,
|
||||
{
|
||||
replacements: { importHashes: policyRows.map((row) => row.importHash) },
|
||||
type: QueryTypes.SELECT,
|
||||
},
|
||||
);
|
||||
const existingPolicyHashes = new Set(existingPolicyRows.map((row) => row.importHash));
|
||||
const missingPolicyRows = policyRows.filter((row) => !existingPolicyHashes.has(row.importHash));
|
||||
|
||||
if (missingPolicyRows.length > 0) {
|
||||
await queryInterface.bulkInsert('policy_documents', missingPolicyRows);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
down: async () => {
|
||||
// Keep permission and seeded ESA rows on rollback; campus content may be edited.
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,86 @@
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { QueryInterface } from 'sequelize';
|
||||
import { ROLE_NAMES } from '@/shared/constants/roles';
|
||||
import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions';
|
||||
|
||||
const ESA_MANAGEMENT_ROLES = [
|
||||
ROLE_NAMES.SYSTEM_ADMIN,
|
||||
ROLE_NAMES.OWNER,
|
||||
ROLE_NAMES.SUPERINTENDENT,
|
||||
ROLE_NAMES.PRINCIPAL,
|
||||
ROLE_NAMES.DIRECTOR,
|
||||
ROLE_NAMES.OFFICE_MANAGER,
|
||||
] as const;
|
||||
|
||||
function isIdRow(value: unknown): value is { id: string } {
|
||||
return (
|
||||
value !== null
|
||||
&& typeof value === 'object'
|
||||
&& 'id' in value
|
||||
&& typeof value.id === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function idRows(value: unknown): readonly { id: string }[] {
|
||||
return Array.isArray(value) ? value.filter(isIdRow) : [];
|
||||
}
|
||||
|
||||
function firstId(value: unknown): string | null {
|
||||
return idRows(value)[0]?.id ?? null;
|
||||
}
|
||||
|
||||
export default {
|
||||
up: async (queryInterface: QueryInterface) => {
|
||||
const now = new Date();
|
||||
const [permissionRows] = await queryInterface.sequelize.query(
|
||||
'SELECT "id" FROM "permissions" WHERE "name" = :name LIMIT 1',
|
||||
{ replacements: { name: FEATURE_PERMISSIONS.MANAGE_ESA_FUNDING_CONTENT } },
|
||||
);
|
||||
let permissionId = firstId(permissionRows);
|
||||
|
||||
if (!permissionId) {
|
||||
permissionId = uuid();
|
||||
await queryInterface.bulkInsert('permissions', [{
|
||||
id: permissionId,
|
||||
name: FEATURE_PERMISSIONS.MANAGE_ESA_FUNDING_CONTENT,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}]);
|
||||
}
|
||||
|
||||
const [roleRows] = await queryInterface.sequelize.query(
|
||||
'SELECT "id" FROM "roles" WHERE "name" IN (:names)',
|
||||
{ replacements: { names: ESA_MANAGEMENT_ROLES } },
|
||||
);
|
||||
const roleIds = idRows(roleRows).map((row) => row.id);
|
||||
|
||||
if (roleIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [existingRows] = await queryInterface.sequelize.query(
|
||||
`SELECT "roles_permissionsId" AS "id"
|
||||
FROM "rolesPermissionsPermissions"
|
||||
WHERE "roles_permissionsId" IN (:roleIds)
|
||||
AND "permissionId" = :permissionId`,
|
||||
{ replacements: { roleIds, permissionId } },
|
||||
);
|
||||
const existingRoleIds = new Set(idRows(existingRows).map((row) => row.id));
|
||||
const missingRows = roleIds
|
||||
.filter((roleId) => !existingRoleIds.has(roleId))
|
||||
.map((roleId) => ({
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
roles_permissionsId: roleId,
|
||||
permissionId,
|
||||
}));
|
||||
|
||||
if (missingRows.length > 0) {
|
||||
await queryInterface.bulkInsert('rolesPermissionsPermissions', missingRows);
|
||||
}
|
||||
},
|
||||
|
||||
down: async () => {
|
||||
// Keep permission grants on rollback; permissions may be assigned manually.
|
||||
},
|
||||
};
|
||||
@ -107,6 +107,7 @@ export const MODULE_PERMISSIONS_BY_ROLE: Partial<Record<RoleName, readonly strin
|
||||
...MODULE_READ_PARENT_COMM,
|
||||
...MODULE_READ_EXTERNAL,
|
||||
...MODULE_ACTIONS,
|
||||
'MANAGE_ESA_FUNDING_CONTENT',
|
||||
'READ_AUDIO_FILES', 'MANAGE_AUDIO_FILES',
|
||||
],
|
||||
[ROLE_NAMES.TEACHER]: [
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
} from '@/shared/constants/seed-fixtures';
|
||||
import {
|
||||
PER_TENANT_CONTENT_TYPES,
|
||||
CAMPUS_SCOPED_CONTENT_TYPES,
|
||||
SCHOOL_SCOPED_CONTENT_TYPES,
|
||||
ORG_SCOPED_CONTENT_TYPES,
|
||||
} from '@/shared/constants/content-catalog';
|
||||
@ -54,6 +55,16 @@ function seedTenants(contentType: string): Array<{
|
||||
{ organizationId: SEED_ORGANIZATION_2_ID, schoolId: null, campusId: SEED_SECONDARY_CAMPUS_ID },
|
||||
];
|
||||
}
|
||||
if (CAMPUS_SCOPED_CONTENT_TYPES.has(contentType)) {
|
||||
return [
|
||||
...demoCampusIds.map((campusId) => ({
|
||||
organizationId: SEED_ORGANIZATION_ID,
|
||||
schoolId: null,
|
||||
campusId,
|
||||
})),
|
||||
{ organizationId: SEED_ORGANIZATION_2_ID, schoolId: null, campusId: SEED_SECONDARY_CAMPUS_ID },
|
||||
];
|
||||
}
|
||||
if (SCHOOL_SCOPED_CONTENT_TYPES.has(contentType)) {
|
||||
return [
|
||||
{ organizationId: SEED_ORGANIZATION_ID, schoolId: SEED_SCHOOL_ID, campusId: null },
|
||||
|
||||
@ -864,50 +864,83 @@ const CONTENT_CATALOG_SEED_PAYLOADS = Object.freeze({
|
||||
{ label: 'Growth Trend', value: '+8%', color: 'text-pink-400', bar: 'bg-pink-500', width: '65%' },
|
||||
],
|
||||
esaFundingContent: {
|
||||
approvedUses: [
|
||||
{ iconId: 'school', title: 'Private School Tuition', description: 'Full or partial tuition at approved private schools, including autism-focused programs', color: 'from-violet-500 to-violet-600' },
|
||||
{ iconId: 'heart', title: 'Specialized Therapies', description: 'Speech therapy, occupational therapy, ABA, physical therapy, and counseling', color: 'from-pink-500 to-rose-600' },
|
||||
{ iconId: 'book', title: 'Tutoring Services', description: 'One-on-one or small group tutoring from approved educational providers', color: 'from-blue-500 to-blue-600' },
|
||||
{ iconId: 'puzzle', title: 'Curriculum & Materials', description: 'Textbooks, workbooks, educational software, and approved learning materials', color: 'from-amber-500 to-orange-600' },
|
||||
{ iconId: 'graduation', title: 'Online Learning', description: 'Approved online courses, virtual tutoring, and digital learning platforms', color: 'from-emerald-500 to-green-600' },
|
||||
{ iconId: 'users', title: 'Educational Services', description: 'Social skills groups, life skills training, vocational preparation, and more', color: 'from-cyan-500 to-teal-600' },
|
||||
],
|
||||
keyPoints: [
|
||||
{ iconId: 'arrow', label: 'Education money that follows the child' },
|
||||
{ iconId: 'school', label: 'Instead of staying with one school' },
|
||||
{ iconId: 'users', label: 'Parents choose the best fit for their child' },
|
||||
{ iconId: 'check', label: 'Covers tuition, therapies, tutoring & more' },
|
||||
{ iconId: 'star', label: 'More flexibility and control for families' },
|
||||
{ iconId: 'heart', label: 'Especially impactful for students with disabilities' },
|
||||
],
|
||||
stateChecklist: [
|
||||
'Whether your state offers an ESA or similar school choice program',
|
||||
'What the specific eligibility requirements are in your state',
|
||||
'What expenses and services ESA funds can be applied toward in your state',
|
||||
'The application process and deadlines for your state\'s program',
|
||||
'Any reporting or documentation requirements unique to your state',
|
||||
],
|
||||
schoolImpactItems: [
|
||||
'ESA makes specialized autism-focused programs accessible to more families',
|
||||
'Families can use ESA funds to cover tuition at approved schools',
|
||||
'Therapeutic services such as speech, OT, and ABA may be ESA-eligible',
|
||||
'It can remove financial barriers for families seeking the best fit for their child',
|
||||
'More enrolled students can support program expansion and staffing',
|
||||
{ id: 'impact-access', text: 'ESA makes specialized autism-focused programs accessible to more families' },
|
||||
{ id: 'impact-tuition', text: 'Families can use ESA funds to cover tuition at approved schools' },
|
||||
{ id: 'impact-therapies', text: 'Therapeutic services such as speech, OT, and ABA may be ESA-eligible' },
|
||||
{ id: 'impact-barriers', text: 'It can remove financial barriers for families seeking the best fit for their child' },
|
||||
{ id: 'impact-growth', text: 'More enrolled students can support program expansion and staffing' },
|
||||
],
|
||||
staffRoleItems: [
|
||||
{ title: 'Be Informed', description: 'Understand the basics of ESA so you can answer general questions from parents' },
|
||||
{ title: 'Direct to Office', description: 'For detailed ESA questions, guide families to the office team for personalized support' },
|
||||
{ title: 'Document Accurately', description: 'Ensure student services and progress are documented properly for ESA reporting' },
|
||||
{ title: 'Stay Focused', description: 'Continue providing excellent, individualized education — ESA is about access, not changing what staff do' },
|
||||
{ id: 'staff-informed', title: 'Be Informed', description: 'Understand the basics of ESA so you can answer general questions from parents' },
|
||||
{ id: 'staff-office', title: 'Direct to Office', description: 'For detailed ESA questions, guide families to the office team for personalized support' },
|
||||
{ id: 'staff-document', title: 'Document Accurately', description: 'Ensure student services and progress are documented properly for ESA reporting' },
|
||||
{ id: 'staff-focused', title: 'Stay Focused', description: 'Continue providing excellent, individualized education — ESA is about access, not changing what staff do' },
|
||||
],
|
||||
faqs: [
|
||||
{
|
||||
id: 'faq-what-is-esa',
|
||||
audience: 'all',
|
||||
question: 'What is an ESA (Empowerment Scholarship Account)?',
|
||||
answer: 'An ESA is a state-funded account that provides education dollars directly to families. Instead of funding going only to a single public school, ESA money follows the child, allowing parents to choose the learning environment and services that best fit their child\'s unique needs.',
|
||||
},
|
||||
{
|
||||
id: 'faq-eligible',
|
||||
audience: 'all',
|
||||
question: 'Who is eligible for ESA funding?',
|
||||
answer: 'Eligibility varies by state, but ESA programs typically prioritize students with disabilities including autism, students from low-income families, students in underperforming schools, foster children, and children of active-duty military. In many states, all K-12 students are now eligible.',
|
||||
},
|
||||
{
|
||||
id: 'faq-uses',
|
||||
audience: 'all',
|
||||
question: 'What can ESA funds be used for?',
|
||||
answer: 'ESA funds can be used for approved educational expenses including private school tuition, specialized therapies, tutoring services, curriculum and textbooks, educational technology, online learning programs, and other approved educational services.',
|
||||
},
|
||||
{
|
||||
id: 'faq-school-impact',
|
||||
audience: 'all',
|
||||
question: 'How does ESA funding affect our school?',
|
||||
answer: 'ESA funding is a positive opportunity for our school community. Families who choose our programs can use ESA funds to pay for tuition and specialized services we offer. This means more families can access the autism-focused education and therapies we provide, regardless of their financial situation.',
|
||||
},
|
||||
{
|
||||
id: 'faq-amount',
|
||||
audience: 'all',
|
||||
question: 'How much funding does each student receive?',
|
||||
answer: 'The amount varies by state and student need. ESA amounts often range from several thousand dollars per year for general education students to higher amounts for students with disabilities who require specialized services and therapies.',
|
||||
},
|
||||
{
|
||||
id: 'faq-apply',
|
||||
audience: 'all',
|
||||
question: 'How do families apply for ESA funding?',
|
||||
answer: 'Families apply through their state ESA program, often through the Department of Education website. The process typically involves submitting an application, providing proof of eligibility, receiving approval, and directing funds to chosen educational providers.',
|
||||
},
|
||||
{
|
||||
id: 'faq-therapies',
|
||||
audience: 'all',
|
||||
question: 'Can ESA funds be used for therapies at our school?',
|
||||
answer: 'Yes. ESA funds can cover many specialized services, including speech therapy, occupational therapy, behavioral therapy, social skills groups, and other therapeutic interventions when those services are approved by the state program.',
|
||||
},
|
||||
{
|
||||
id: 'faq-staff-role',
|
||||
audience: 'staff',
|
||||
question: 'What is our role as staff in the ESA process?',
|
||||
answer: 'Staff should understand the basics, direct families to the office for detailed guidance, document student services accurately for ESA reporting, and continue providing individualized education regardless of how services are funded.',
|
||||
},
|
||||
],
|
||||
parentConversationReferences: [
|
||||
{
|
||||
id: 'parent-reference-overview',
|
||||
question: 'If a parent asks about ESA, here is a simple way to explain it:',
|
||||
answer: '"ESA stands for Empowerment Scholarship Account. It is state funding set aside for your child\'s education. Instead of the money only going to one school, it follows your child, so you can use it to pay for the school, therapies, tutoring, and services that work best for them. Our office team can walk you through the application process and help you understand what is covered."',
|
||||
},
|
||||
],
|
||||
parentConversationScript: '"ESA stands for Empowerment Scholarship Account. It is state funding set aside for your child\'s education. Instead of the money only going to one school, it follows your child, so you can use it to pay for the school, therapies, tutoring, and services that work best for them. Our office team can walk you through the application process and help you understand what is covered."',
|
||||
resources: [
|
||||
{ title: 'AZ ESA Program', description: 'Arizona Empowerment Scholarship Account official page', url: '#' },
|
||||
{ title: 'ESA Eligible Expenses', description: 'Full list of approved uses for ESA funds', url: '#' },
|
||||
{ title: 'Family Application Guide', description: 'Step-by-step guide for families applying', url: '#' },
|
||||
{ title: 'Provider Registration', description: 'How schools register as ESA providers', url: '#' },
|
||||
{ title: 'ESA FAQ (State)', description: 'Official state FAQ for families and schools', url: '#' },
|
||||
{ title: 'Internal ESA Procedures', description: 'School ESA documentation process', url: '#' },
|
||||
{ id: 'resource-az-program', title: 'AZ ESA Program', description: 'Arizona Empowerment Scholarship Account official page', url: '#' },
|
||||
{ id: 'resource-expenses', title: 'ESA Eligible Expenses', description: 'Full list of approved uses for ESA funds', url: '#' },
|
||||
{ id: 'resource-family-guide', title: 'Family Application Guide', description: 'Step-by-step guide for families applying', url: '#' },
|
||||
{ id: 'resource-provider-registration', title: 'Provider Registration', description: 'How schools register as ESA providers', url: '#' },
|
||||
{ id: 'resource-state-faq', title: 'ESA FAQ (State)', description: 'Official state FAQ for families and schools', url: '#' },
|
||||
{ id: 'resource-internal-procedures', title: 'Internal ESA Procedures', description: 'School ESA documentation process', url: '#' },
|
||||
],
|
||||
},
|
||||
safetyProtocols: [
|
||||
|
||||
@ -129,6 +129,21 @@ describe('user-role seed permission contract', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('ESA funding management grants match campus and parent-scope managers', () => {
|
||||
const esaManagers = Object.values(ROLE_NAMES).filter((role) =>
|
||||
granted(role).includes(FEATURE_PERMISSIONS.MANAGE_ESA_FUNDING_CONTENT),
|
||||
);
|
||||
|
||||
assert.deepEqual(esaManagers, [
|
||||
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);
|
||||
|
||||
@ -36,6 +36,19 @@ function organizationContentManager() {
|
||||
});
|
||||
}
|
||||
|
||||
function campusEsaManager() {
|
||||
return createGlobalAccessUser({
|
||||
app_role: {
|
||||
name: ROLE_NAMES.DIRECTOR,
|
||||
scope: ROLE_SCOPES.CAMPUS,
|
||||
globalAccess: false,
|
||||
permissions: [{ name: FEATURE_PERMISSIONS.MANAGE_ESA_FUNDING_CONTENT }],
|
||||
},
|
||||
organizationId: 'org-1',
|
||||
campusId: 'campus-1',
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mock.restoreAll();
|
||||
});
|
||||
@ -374,4 +387,59 @@ describe('ContentCatalogService tenant scoping', () => {
|
||||
assert.equal(result.id, 'catalog-new');
|
||||
assert.deepEqual(commits, ['commit']);
|
||||
});
|
||||
|
||||
test('ESA funding updates bump acknowledgment policy version with section summary', async () => {
|
||||
const commits: string[] = [];
|
||||
const catalogUpdates: unknown[] = [];
|
||||
const policyUpdates: unknown[] = [];
|
||||
|
||||
mock.method(db.sequelize, 'transaction', (async () => ({
|
||||
commit: async () => { commits.push('commit'); },
|
||||
rollback: async () => { commits.push('rollback'); },
|
||||
})) as typeof db.sequelize.transaction);
|
||||
|
||||
mock.method(db.content_catalog, 'findOne', (async () => ({
|
||||
update: async (data: unknown) => {
|
||||
catalogUpdates.push(data);
|
||||
return {
|
||||
get: () => ({
|
||||
id: 'esa-content-1',
|
||||
content_type: 'esa-funding-content',
|
||||
payload: { resources: [] },
|
||||
updatedAt: new Date('2026-06-22T00:00:00Z'),
|
||||
}),
|
||||
};
|
||||
},
|
||||
get: () => ({
|
||||
id: 'esa-content-1',
|
||||
content_type: 'esa-funding-content',
|
||||
payload: { resources: [] },
|
||||
updatedAt: new Date('2026-06-22T00:00:00Z'),
|
||||
}),
|
||||
})) as unknown as typeof db.content_catalog.findOne);
|
||||
|
||||
mock.method(db.policy_documents, 'findOne', (async () => ({
|
||||
version: 4,
|
||||
update: async (data: unknown) => {
|
||||
policyUpdates.push(data);
|
||||
return null;
|
||||
},
|
||||
})) as unknown as typeof db.policy_documents.findOne);
|
||||
|
||||
await ContentCatalogService.update(
|
||||
'esa-funding-content',
|
||||
{
|
||||
payload: { resources: [] },
|
||||
changeSummary: 'Helpful Resources',
|
||||
},
|
||||
campusEsaManager(),
|
||||
);
|
||||
|
||||
assert.deepEqual(catalogUpdates, [{ payload: { resources: [] }, active: true }]);
|
||||
assert.deepEqual(policyUpdates, [{
|
||||
body: 'Staff must review the current ESA Funding Information page and acknowledge understanding of the campus guidance.\n\nUpdated section: Helpful Resources',
|
||||
version: 5,
|
||||
}]);
|
||||
assert.deepEqual(commits, ['commit']);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Op } from 'sequelize';
|
||||
import type { Transaction } from 'sequelize';
|
||||
import db from '@/db/models';
|
||||
import { withTransaction } from '@/db/with-transaction';
|
||||
import { resolvePagination } from '@/shared/constants/pagination';
|
||||
@ -8,6 +9,7 @@ import {
|
||||
getRoleScope,
|
||||
getOwnTenant,
|
||||
getOrganizationId,
|
||||
getCampusId,
|
||||
getSchoolId,
|
||||
hasFeaturePermission,
|
||||
tenantExactWhere,
|
||||
@ -15,17 +17,20 @@ import {
|
||||
} from '@/services/shared/access';
|
||||
import {
|
||||
CLASSROOM_SUPPORT_CONTENT_TYPE,
|
||||
ESA_CONTENT_TYPE,
|
||||
EI_ASSESSMENT_CONTENT_TYPE,
|
||||
PERSONALITY_QUIZ_CONTENT_TYPE,
|
||||
SIGN_LANGUAGE_ITEMS_CONTENT_TYPE,
|
||||
SAFETY_QUIZ_CONTENT_TYPE,
|
||||
PER_TENANT_CONTENT_TYPES,
|
||||
CAMPUS_SCOPED_CONTENT_TYPES,
|
||||
SCHOOL_SCOPED_CONTENT_TYPES,
|
||||
ORG_SCOPED_CONTENT_TYPES,
|
||||
TENANT_SCOPED_CONTENT_TYPES,
|
||||
} from '@/shared/constants/content-catalog';
|
||||
import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions';
|
||||
import { ROLE_SCOPES } from '@/shared/constants/roles';
|
||||
import { POLICY_DOCUMENT_CATEGORIES } from '@/shared/constants/policy-documents';
|
||||
import type { ContentCatalog } from '@/db/models/content_catalog';
|
||||
import type { CurrentUser } from '@/db/api/types';
|
||||
|
||||
@ -37,6 +42,16 @@ function tenantWhereFor(
|
||||
if (PER_TENANT_CONTENT_TYPES.has(contentType)) {
|
||||
return tenantExactWhere(getOwnTenant(currentUser));
|
||||
}
|
||||
if (CAMPUS_SCOPED_CONTENT_TYPES.has(contentType)) {
|
||||
const tenant = getOwnTenant(currentUser);
|
||||
const campusId = getCampusId(currentUser) ?? tenant.campusId ?? null;
|
||||
return {
|
||||
organizationId: tenant.organizationId,
|
||||
schoolId: null,
|
||||
campusId,
|
||||
classId: null,
|
||||
};
|
||||
}
|
||||
if (SCHOOL_SCOPED_CONTENT_TYPES.has(contentType)) {
|
||||
// Everyone in the school reads their school's row (the user's school is
|
||||
// resolved from their own school/campus chain).
|
||||
@ -53,6 +68,16 @@ function tenantStampFor(contentType: string, currentUser?: CurrentUser) {
|
||||
if (PER_TENANT_CONTENT_TYPES.has(contentType)) {
|
||||
return tenantStamp(getOwnTenant(currentUser));
|
||||
}
|
||||
if (CAMPUS_SCOPED_CONTENT_TYPES.has(contentType)) {
|
||||
const tenant = getOwnTenant(currentUser);
|
||||
const campusId = getCampusId(currentUser) ?? tenant.campusId;
|
||||
return {
|
||||
organizationId: tenant.organizationId,
|
||||
schoolId: null,
|
||||
campusId,
|
||||
classId: null,
|
||||
};
|
||||
}
|
||||
if (SCHOOL_SCOPED_CONTENT_TYPES.has(contentType)) {
|
||||
return {
|
||||
organizationId: getOrganizationId(currentUser),
|
||||
@ -77,6 +102,7 @@ interface ContentCatalogInput {
|
||||
payload?: unknown;
|
||||
active?: boolean;
|
||||
importHash?: string | null;
|
||||
changeSummary?: unknown;
|
||||
}
|
||||
|
||||
const VERSIONED_CONTENT_TYPES = new Set([
|
||||
@ -85,6 +111,9 @@ const VERSIONED_CONTENT_TYPES = new Set([
|
||||
PERSONALITY_QUIZ_CONTENT_TYPE,
|
||||
]);
|
||||
|
||||
const ESA_POLICY_TAG = 'ESA Funding';
|
||||
const ESA_POLICY_BODY = 'Staff must review the current ESA Funding Information page and acknowledge understanding of the campus guidance.';
|
||||
|
||||
function toContentCatalogDto(record: ContentCatalog) {
|
||||
const plain = record.get({ plain: true });
|
||||
|
||||
@ -119,6 +148,20 @@ function assertCanManageType(contentType: string, currentUser?: CurrentUser): vo
|
||||
currentUser,
|
||||
FEATURE_PERMISSIONS.MANAGE_CONTENT_CATALOG,
|
||||
)
|
||||
&& !(
|
||||
contentType === ESA_CONTENT_TYPE
|
||||
&& hasFeaturePermission(
|
||||
currentUser,
|
||||
FEATURE_PERMISSIONS.MANAGE_ESA_FUNDING_CONTENT,
|
||||
)
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
|
||||
if (
|
||||
contentType === ESA_CONTENT_TYPE
|
||||
&& getRoleScope(currentUser) !== ROLE_SCOPES.CAMPUS
|
||||
) {
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
@ -153,6 +196,68 @@ function assertValidPayload(payload: unknown): unknown {
|
||||
return payload;
|
||||
}
|
||||
|
||||
function optionalChangeSummary(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed.slice(0, 120) : null;
|
||||
}
|
||||
|
||||
function esaPolicyBody(changeSummary: string | null): string {
|
||||
return changeSummary
|
||||
? `${ESA_POLICY_BODY}\n\nUpdated section: ${changeSummary}`
|
||||
: ESA_POLICY_BODY;
|
||||
}
|
||||
|
||||
async function touchEsaPolicyDocument(
|
||||
contentType: string,
|
||||
currentUser: CurrentUser | undefined,
|
||||
changeSummary: string | null,
|
||||
transaction?: Transaction,
|
||||
): Promise<void> {
|
||||
if (contentType !== ESA_CONTENT_TYPE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const where = {
|
||||
category: POLICY_DOCUMENT_CATEGORIES.HANDBOOK_POLICY,
|
||||
tag: ESA_POLICY_TAG,
|
||||
active: true,
|
||||
...tenantWhereFor(ESA_CONTENT_TYPE, currentUser),
|
||||
};
|
||||
const existing = await db.policy_documents.findOne({ where, transaction });
|
||||
|
||||
if (existing) {
|
||||
await existing.update(
|
||||
{
|
||||
body: esaPolicyBody(changeSummary),
|
||||
version: (existing.version ?? 1) + 1,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await db.policy_documents.create(
|
||||
{
|
||||
title: 'ESA Funding Information',
|
||||
body: esaPolicyBody(changeSummary),
|
||||
category: POLICY_DOCUMENT_CATEGORIES.HANDBOOK_POLICY,
|
||||
tag: ESA_POLICY_TAG,
|
||||
author: null,
|
||||
steps: null,
|
||||
autism_considerations: null,
|
||||
version: 1,
|
||||
active: true,
|
||||
importHash: null,
|
||||
...tenantStampFor(ESA_CONTENT_TYPE, currentUser),
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
|
||||
class ContentCatalogService {
|
||||
static async list(
|
||||
filter: { limit?: number | string; page?: number | string } = {},
|
||||
@ -212,6 +317,7 @@ class ContentCatalogService {
|
||||
assertCanManageType(contentType, currentUser);
|
||||
|
||||
const payload = assertValidPayload(data?.payload);
|
||||
const changeSummary = optionalChangeSummary(data?.changeSummary);
|
||||
const tenantWhere = tenantWhereFor(contentType, currentUser);
|
||||
const stamp = tenantStampFor(contentType, currentUser);
|
||||
|
||||
@ -249,6 +355,8 @@ class ContentCatalogService {
|
||||
);
|
||||
}
|
||||
|
||||
await touchEsaPolicyDocument(contentType, currentUser, changeSummary, transaction);
|
||||
|
||||
return toContentCatalogDto(record);
|
||||
});
|
||||
}
|
||||
@ -262,6 +370,7 @@ class ContentCatalogService {
|
||||
assertCanManageType(normalizedContentType, currentUser);
|
||||
|
||||
const payload = assertValidPayload(data?.payload);
|
||||
const changeSummary = optionalChangeSummary(data?.changeSummary);
|
||||
|
||||
return withTransaction(async (transaction) => {
|
||||
const record = await db.content_catalog.findOne({
|
||||
@ -301,6 +410,8 @@ class ContentCatalogService {
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await touchEsaPolicyDocument(normalizedContentType, currentUser, changeSummary, transaction);
|
||||
|
||||
return toContentCatalogDto(record);
|
||||
});
|
||||
}
|
||||
@ -325,6 +436,7 @@ class ContentCatalogService {
|
||||
|
||||
await record.update({ active: false }, { transaction });
|
||||
await record.destroy({ transaction });
|
||||
await touchEsaPolicyDocument(normalizedContentType, currentUser, 'Content removed', transaction);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,10 +1,16 @@
|
||||
import { afterEach, describe, mock, test } from 'node:test';
|
||||
import { afterEach, beforeEach, describe, mock, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import db from '@/db/models';
|
||||
import { CONTENT_CATALOG_SEED_PAYLOADS } from '@/db/seeders/content-catalog-data/content-catalog-seed-payloads';
|
||||
import { seedDefaultContentForTenant } from '@/services/content_catalog_seed';
|
||||
|
||||
beforeEach(() => {
|
||||
mock.method(db.policy_documents, 'findOne', (async () => null) as typeof db.policy_documents.findOne);
|
||||
mock.method(db.policy_documents, 'create', (async (payload: Record<string, unknown>) =>
|
||||
payload) as typeof db.policy_documents.create);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restoreAll();
|
||||
});
|
||||
@ -239,4 +245,65 @@ describe('seedDefaultContentForTenant', () => {
|
||||
);
|
||||
assert.ok(Array.isArray(zoneRows.find((row) => row.content_type === 'regulation-zones')?.payload));
|
||||
});
|
||||
|
||||
test('seeds ESA funding content and acknowledgment document only at campus scope', async () => {
|
||||
const createdRows: Array<Record<string, unknown>> = [];
|
||||
const createdPolicies: 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);
|
||||
mock.method(db.policy_documents, 'create', (async (payload: Record<string, unknown>) => {
|
||||
createdPolicies.push(payload);
|
||||
return payload;
|
||||
}) as typeof db.policy_documents.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 esaRows = createdRows.filter((row) =>
|
||||
row.content_type === 'esa-funding-content',
|
||||
);
|
||||
|
||||
assert.equal(esaRows.length, 1);
|
||||
assert.equal(esaRows[0]?.organizationId, 'org-1');
|
||||
assert.equal(esaRows[0]?.schoolId, null);
|
||||
assert.equal(esaRows[0]?.campusId, 'campus-1');
|
||||
assert.equal(esaRows[0]?.classId, null);
|
||||
assert.equal(esaRows[0]?.active, true);
|
||||
assert.deepEqual(esaRows[0]?.payload, CONTENT_CATALOG_SEED_PAYLOADS.esaFundingContent);
|
||||
|
||||
assert.equal(createdPolicies.length, 1);
|
||||
assert.deepEqual(createdPolicies[0], {
|
||||
title: 'ESA Funding Information',
|
||||
body: 'Staff must review the current ESA Funding Information page and acknowledge understanding of the campus guidance.',
|
||||
category: 'handbook_policy',
|
||||
tag: 'ESA Funding',
|
||||
author: null,
|
||||
steps: null,
|
||||
autism_considerations: null,
|
||||
version: 1,
|
||||
active: true,
|
||||
importHash: 'policy-doc-esa-funding-campus-1',
|
||||
organizationId: 'org-1',
|
||||
schoolId: null,
|
||||
campusId: 'campus-1',
|
||||
classId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,9 +2,11 @@ import type { Transaction } from 'sequelize';
|
||||
import db from '@/db/models';
|
||||
import {
|
||||
PER_TENANT_CONTENT_TYPES,
|
||||
CAMPUS_SCOPED_CONTENT_TYPES,
|
||||
SCHOOL_SCOPED_CONTENT_TYPES,
|
||||
ORG_SCOPED_CONTENT_TYPES,
|
||||
} from '@/shared/constants/content-catalog';
|
||||
import { POLICY_DOCUMENT_CATEGORIES } from '@/shared/constants/policy-documents';
|
||||
import { CONTENT_CATALOG_DEFAULT_ROWS } from '@/db/seeders/content-catalog-data/content-catalog-seed-payloads';
|
||||
|
||||
export type TenantSeedLevel = 'organization' | 'school' | 'campus';
|
||||
@ -27,8 +29,8 @@ interface OwnerStamp {
|
||||
* created — or `null` if this type is not preset at this level. Per-tenant
|
||||
* org-scoped content such as the safety quiz exists only at organization level;
|
||||
* dashboard + parent templates exist at org/school/campus; school-scoped only
|
||||
* at school; truly global types are seeded once (with no tenant) when the first
|
||||
* org is created.
|
||||
* at school; campus-scoped only at campus; truly global types are seeded once
|
||||
* (with no tenant) when the first org is created.
|
||||
*/
|
||||
function stampForLevel(
|
||||
contentType: string,
|
||||
@ -48,6 +50,12 @@ function stampForLevel(
|
||||
: null;
|
||||
}
|
||||
|
||||
if (CAMPUS_SCOPED_CONTENT_TYPES.has(contentType)) {
|
||||
return ctx.level === 'campus' && ctx.campusId
|
||||
? { organizationId: org, schoolId: null, campusId: ctx.campusId }
|
||||
: null;
|
||||
}
|
||||
|
||||
if (PER_TENANT_CONTENT_TYPES.has(contentType)) {
|
||||
if (ctx.level === 'organization') {
|
||||
return { organizationId: org, schoolId: null, campusId: null };
|
||||
@ -110,4 +118,35 @@ export async function seedDefaultContentForTenant(
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
|
||||
if (ctx.level === 'campus' && ctx.campusId) {
|
||||
const importHash = `policy-doc-esa-funding-${ctx.campusId}`;
|
||||
const existing = await db.policy_documents.findOne({
|
||||
where: { importHash },
|
||||
paranoid: false,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await db.policy_documents.create(
|
||||
{
|
||||
title: 'ESA Funding Information',
|
||||
body: 'Staff must review the current ESA Funding Information page and acknowledge understanding of the campus guidance.',
|
||||
category: POLICY_DOCUMENT_CATEGORIES.HANDBOOK_POLICY,
|
||||
tag: 'ESA Funding',
|
||||
author: null,
|
||||
steps: null,
|
||||
autism_considerations: null,
|
||||
version: 1,
|
||||
active: true,
|
||||
importHash,
|
||||
organizationId: ctx.organizationId,
|
||||
schoolId: null,
|
||||
campusId: ctx.campusId,
|
||||
classId: null,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ export const PERSONALITY_QUIZ_CONTENT_TYPE = 'emotional-intelligence-personality
|
||||
/** Sign card library, owned and managed at organization scope. */
|
||||
export const SIGN_LANGUAGE_ITEMS_CONTENT_TYPE = 'sign-language-items';
|
||||
|
||||
/** ESA funding content — school-scoped (rules depend on the school's locale). */
|
||||
/** ESA funding content — campus-scoped so each campus owns its local guidance. */
|
||||
export const ESA_CONTENT_TYPE = 'esa-funding-content';
|
||||
|
||||
/**
|
||||
@ -28,11 +28,14 @@ export const PER_TENANT_CONTENT_TYPES: ReadonlySet<string> = new Set([
|
||||
'dashboard-compliance-items',
|
||||
]);
|
||||
|
||||
/** **School-scoped** content types (one per school). */
|
||||
export const SCHOOL_SCOPED_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,
|
||||
]);
|
||||
|
||||
/** **School-scoped** content types (one per school). */
|
||||
export const SCHOOL_SCOPED_CONTENT_TYPES: ReadonlySet<string> = new Set([]);
|
||||
|
||||
/** **Org-scoped** content types (one per organization; preset at org creation). */
|
||||
export const ORG_SCOPED_CONTENT_TYPES: ReadonlySet<string> = new Set([
|
||||
SAFETY_QUIZ_CONTENT_TYPE,
|
||||
@ -52,11 +55,13 @@ export const ORG_SCOPED_CONTENT_TYPES: ReadonlySet<string> = new Set([
|
||||
]);
|
||||
|
||||
/**
|
||||
* All tenant-scoped content types (per-tenant ∪ school ∪ org). Truly global
|
||||
* personality and classroom-timer catalogs live in frontend static constants.
|
||||
* All tenant-scoped content types (per-tenant ∪ campus ∪ school ∪ org). Truly
|
||||
* global personality and classroom-timer catalogs live in frontend static
|
||||
* constants.
|
||||
*/
|
||||
export const TENANT_SCOPED_CONTENT_TYPES: ReadonlySet<string> = new Set([
|
||||
...PER_TENANT_CONTENT_TYPES,
|
||||
...CAMPUS_SCOPED_CONTENT_TYPES,
|
||||
...SCHOOL_SCOPED_CONTENT_TYPES,
|
||||
...ORG_SCOPED_CONTENT_TYPES,
|
||||
]);
|
||||
|
||||
@ -63,6 +63,7 @@ export const MODULE_MANAGEMENT_PERMISSIONS = [
|
||||
'MANAGE_WALKTHROUGH',
|
||||
'MANAGE_INTERNAL_COMM',
|
||||
'MANAGE_CONTENT_CATALOG',
|
||||
'MANAGE_ESA_FUNDING_CONTENT',
|
||||
'READ_STAFF_ATTENDANCE_REPORTS',
|
||||
'READ_SAFETY_QUIZ_REPORTS',
|
||||
'READ_PERSONALITY_REPORTS',
|
||||
@ -107,6 +108,7 @@ export const FEATURE_PERMISSIONS = Object.freeze({
|
||||
MANAGE_WALKTHROUGH: 'MANAGE_WALKTHROUGH',
|
||||
MANAGE_INTERNAL_COMM: 'MANAGE_INTERNAL_COMM',
|
||||
MANAGE_CONTENT_CATALOG: 'MANAGE_CONTENT_CATALOG',
|
||||
MANAGE_ESA_FUNDING_CONTENT: 'MANAGE_ESA_FUNDING_CONTENT',
|
||||
READ_STAFF_ATTENDANCE_REPORTS: 'READ_STAFF_ATTENDANCE_REPORTS',
|
||||
READ_SAFETY_QUIZ_REPORTS: 'READ_SAFETY_QUIZ_REPORTS',
|
||||
READ_PERSONALITY_REPORTS: 'READ_PERSONALITY_REPORTS',
|
||||
|
||||
@ -129,7 +129,11 @@ These dashboard catalog rows are seeded per tenant at organization, school, and
|
||||
|
||||
## Editable ESA Funding Content
|
||||
|
||||
ESA funding approved uses, key points, state checklist items, school impact items, staff role guidance, parent conversation script, and resource records are part of the `esa-funding-content` content catalog payload. Static ESA intro copy and FAQs live in `frontend/src/shared/constants/esaFunding.ts` because they are stable training copy, not editable runtime records.
|
||||
ESA funding school impact items, staff role guidance, FAQ items with per-question audience visibility, parent conversation Q&A references, and resource records are part of the campus-scoped `esa-funding-content` content catalog payload. Users with `MANAGE_ESA_FUNDING_CONTENT` can edit that payload only from a campus effective scope; organization/school/platform users manage it by drilling into a campus.
|
||||
|
||||
Static ESA intro copy, the state-variation notice/checklist, key points, and approved-use cards live in `frontend/src/shared/constants/esaFunding.ts` because they are stable training copy, not editable runtime records. Student and guardian views read the same campus content but hide staff-only FAQ entries, staff role guidance, parent conversation references, and the staff acknowledgment card.
|
||||
|
||||
Editing `esa-funding-content` sends the changed section name to the backend. The content catalog service bumps the linked campus `policy_documents` row tagged `ESA Funding`, which resets current-version staff acknowledgment state and drives header notifications plus leadership acknowledgment reporting.
|
||||
|
||||
## Editable Community Organization Content
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
View -> Business Logic -> API/Data Access -> Backend
|
||||
```
|
||||
|
||||
Static ESA explanatory copy and FAQs live in dedicated frontend constants. Editable ESA lists, staff role guidance, parent conversation script, and resource records belong to the backend content catalog. The frontend also owns local UI state, URL validation, acknowledgement interaction state, and presentation.
|
||||
Static ESA explanatory copy, the state-variation notice/checklist, key points, and approved-use cards live in dedicated frontend constants. Editable campus ESA content lives in the backend content catalog. Staff acknowledgments use the existing policy-document acknowledgment subsystem, so they appear in the header notification flow and leadership acknowledgment reporting.
|
||||
|
||||
## Frontend Layers
|
||||
|
||||
@ -26,6 +26,7 @@ View:
|
||||
- `frontend/src/components/esa-funding/EsaFundingQuickReference.tsx`
|
||||
- `frontend/src/components/esa-funding/EsaFundingResources.tsx`
|
||||
- `frontend/src/components/esa-funding/EsaFundingAcknowledgement.tsx`
|
||||
- `frontend/src/components/esa-funding/EsaFundingEditor.tsx`
|
||||
- `frontend/src/components/esa-funding/EsaFundingIcon.tsx`
|
||||
|
||||
Business logic:
|
||||
@ -45,24 +46,31 @@ Shared contracts:
|
||||
The page reads:
|
||||
|
||||
- `GET /api/content-catalog/read/esa-funding-content`
|
||||
- `GET /api/policy_documents?category=handbook_policy&tag=ESA+Funding`
|
||||
- `GET /api/policy_acknowledgments`
|
||||
- `POST /api/policy_acknowledgments`
|
||||
|
||||
Content payload is seeded in:
|
||||
|
||||
- `backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.js`
|
||||
- `backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts`
|
||||
|
||||
## Behavior
|
||||
|
||||
- `useEsaFundingPage` loads ESA funding content and owns local FAQ expansion plus acknowledgement state.
|
||||
- Static ESA intro, state notice copy, and FAQs are read from `frontend/src/shared/constants/esaFunding.ts`.
|
||||
- Selectors handle FAQ toggling and resource URL validation.
|
||||
- `useEsaFundingPage` loads campus-scoped ESA funding content and owns local FAQ expansion state.
|
||||
- Static ESA intro, state notice/checklist, key points, and approved-use cards are read from `frontend/src/shared/constants/esaFunding.ts`.
|
||||
- Selectors normalize legacy payloads, filter staff-only FAQ items for student/guardian users, handle FAQ toggling, and validate resource URLs.
|
||||
- View components receive a prepared page model and do not call API/data access modules.
|
||||
- Loading and error states are explicit through `StatePanel`.
|
||||
- Resource records with valid `http` or `https` URLs render as external links. Invalid or placeholder URLs render as unavailable instead of using no-op click handlers.
|
||||
- `MANAGE_ESA_FUNDING_CONTENT` users can edit the campus-scoped dynamic payload from campus effective scope. Parent-scope users manage a campus by drilling into that campus.
|
||||
- ESA funding content editing is embedded inside each dynamic content section as a collapsible local editor next to the content it changes.
|
||||
- Student and guardian users do not see staff-role guidance, the parent-conversation quick reference, or the staff acknowledgment card.
|
||||
- Staff acknowledgments are persisted per current ESA policy-document version through `policy_acknowledgments`; editing ESA content sends the section name to the backend, bumps the linked document version, and drives the header notification text.
|
||||
|
||||
## Data Ownership Rules
|
||||
|
||||
- Static ESA explanatory copy and FAQs may live in `frontend/src/shared/constants/esaFunding.ts`.
|
||||
- Do not add editable ESA funding records such as approved uses, key points, checklist items, role guidance, conversation scripts, or resource records to frontend constants.
|
||||
- Static ESA explanatory copy, state checklist, key points, and approved-use cards may live in `frontend/src/shared/constants/esaFunding.ts`.
|
||||
- Editable ESA funding records such as school impact items, staff role guidance, FAQs, parent conversation Q&A references, and resource records belong to backend `esa-funding-content`.
|
||||
- Do not add frontend fallback ESA content payloads.
|
||||
- Keep frontend logic limited to workflow state, resource URL validation, icon mapping, and presentation.
|
||||
- Keep frontend logic limited to workflow state, audience filtering, resource URL validation, icon mapping, and presentation.
|
||||
- Test-only fixtures may live in selector tests or `frontend/src/test-seeds/`.
|
||||
|
||||
@ -30,10 +30,14 @@ export function useContentCatalogPayload<TPayload>(
|
||||
};
|
||||
}
|
||||
|
||||
export function useManagedContentCatalog<TPayload>(contentType: string) {
|
||||
export function useManagedContentCatalog<TPayload>(
|
||||
contentType: string,
|
||||
options?: { readonly enabled?: boolean },
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, 'managed', contentType],
|
||||
queryFn: () => getManagedContentCatalog<TPayload>(contentType),
|
||||
enabled: options?.enabled ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,38 +1,154 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useContentCatalogPayload } from '@/business/content-catalog/hooks';
|
||||
import { toggleEsaFaq } from '@/business/esa-funding/selectors';
|
||||
import {
|
||||
useContentCatalogPayload,
|
||||
useManagedContentCatalog,
|
||||
} from '@/business/content-catalog/hooks';
|
||||
import {
|
||||
filterEsaFaqsByAudience,
|
||||
isEsaExternalAudience,
|
||||
normalizeEsaFundingContent,
|
||||
toggleEsaFaq,
|
||||
} from '@/business/esa-funding/selectors';
|
||||
import type { EsaFundingPage } from '@/business/esa-funding/types';
|
||||
import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog';
|
||||
import { ESA_FUNDING_FAQS } from '@/shared/constants/esaFunding';
|
||||
import type { EsaFundingContent } from '@/shared/types/esaFunding';
|
||||
import { useAuth } from '@/shared/app/useAuth';
|
||||
import { useScopeContext } from '@/shared/app/scope-context';
|
||||
import { usePermissions } from '@/shared/app/usePermissions';
|
||||
import {
|
||||
useAcknowledgePolicy,
|
||||
useEsaFundingPolicyDocument,
|
||||
usePolicyAcknowledgments,
|
||||
} from '@/business/policies/hooks';
|
||||
import { isPolicyDocumentAcknowledged } from '@/business/policies/selectors';
|
||||
import {
|
||||
createManagedContentCatalog,
|
||||
updateManagedContentCatalog,
|
||||
} from '@/shared/api/contentCatalog';
|
||||
import { CONTENT_CATALOG_QUERY_KEYS } from '@/shared/constants/contentCatalog';
|
||||
import { POLICY_QUERY_KEYS } from '@/shared/constants/policies';
|
||||
|
||||
interface EsaFundingSaveInput {
|
||||
readonly content: EsaFundingContent;
|
||||
readonly sectionName: string;
|
||||
}
|
||||
|
||||
export const EMPTY_ESA_FUNDING_CONTENT: EsaFundingContent = {
|
||||
approvedUses: [],
|
||||
keyPoints: [],
|
||||
stateChecklist: [],
|
||||
schoolImpactItems: [],
|
||||
staffRoleItems: [],
|
||||
parentConversationScript: '',
|
||||
faqs: [],
|
||||
parentConversationReferences: [],
|
||||
resources: [],
|
||||
};
|
||||
|
||||
export function useEsaFundingPage(): EsaFundingPage {
|
||||
const { user } = useAuth();
|
||||
const { effectiveTenant } = useScopeContext();
|
||||
const permissions = usePermissions();
|
||||
const queryClient = useQueryClient();
|
||||
const canManage = permissions.has('MANAGE_ESA_FUNDING_CONTENT')
|
||||
&& effectiveTenant?.level === 'campus';
|
||||
const externalAudience = isEsaExternalAudience(user);
|
||||
const hasCampusContentScope = effectiveTenant?.level === 'campus'
|
||||
|| effectiveTenant?.level === 'class'
|
||||
|| Boolean(user?.campusId);
|
||||
const contentQuery = useContentCatalogPayload<EsaFundingContent>(
|
||||
CONTENT_CATALOG_TYPES.esaFundingContent,
|
||||
EMPTY_ESA_FUNDING_CONTENT,
|
||||
{ enabled: hasCampusContentScope },
|
||||
);
|
||||
const managedQuery = useManagedContentCatalog<EsaFundingContent>(
|
||||
CONTENT_CATALOG_TYPES.esaFundingContent,
|
||||
{ enabled: canManage },
|
||||
);
|
||||
const saveContentMutation = useMutation({
|
||||
mutationFn: ({ content: nextContent, sectionName }: EsaFundingSaveInput) => (
|
||||
managedQuery.data
|
||||
? updateManagedContentCatalog(CONTENT_CATALOG_TYPES.esaFundingContent, {
|
||||
payload: nextContent,
|
||||
changeSummary: sectionName,
|
||||
})
|
||||
: createManagedContentCatalog({
|
||||
content_type: CONTENT_CATALOG_TYPES.esaFundingContent,
|
||||
payload: nextContent,
|
||||
changeSummary: sectionName,
|
||||
})
|
||||
),
|
||||
onSuccess: async (response) => {
|
||||
const normalizedPayload = normalizeEsaFundingContent(response.payload);
|
||||
|
||||
queryClient.setQueryData(
|
||||
[...CONTENT_CATALOG_QUERY_KEYS.content, CONTENT_CATALOG_TYPES.esaFundingContent],
|
||||
normalizedPayload,
|
||||
);
|
||||
queryClient.setQueryData(
|
||||
[...CONTENT_CATALOG_QUERY_KEYS.content, 'managed', CONTENT_CATALOG_TYPES.esaFundingContent],
|
||||
{
|
||||
...response,
|
||||
payload: normalizedPayload,
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, CONTENT_CATALOG_TYPES.esaFundingContent],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, 'managed', CONTENT_CATALOG_TYPES.esaFundingContent],
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: POLICY_QUERY_KEYS.esaDocuments }),
|
||||
queryClient.invalidateQueries({ queryKey: POLICY_QUERY_KEYS.acknowledgments }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
const canAcknowledge = !externalAudience && permissions.has('ACK_POLICY');
|
||||
const policyDocumentQuery = useEsaFundingPolicyDocument(canAcknowledge);
|
||||
const acknowledgmentsQuery = usePolicyAcknowledgments(canAcknowledge);
|
||||
const acknowledgePolicy = useAcknowledgePolicy();
|
||||
const [expandedFAQ, setExpandedFAQ] = useState<number | null>(0);
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
const normalizedContent = normalizeEsaFundingContent(contentQuery.payload);
|
||||
const content = externalAudience
|
||||
? {
|
||||
...normalizedContent,
|
||||
staffRoleItems: [],
|
||||
parentConversationReferences: [],
|
||||
}
|
||||
: normalizedContent;
|
||||
const policyDocument = policyDocumentQuery.data ?? null;
|
||||
const acknowledged = canAcknowledge && policyDocument
|
||||
? isPolicyDocumentAcknowledged(
|
||||
acknowledgmentsQuery.data ?? [],
|
||||
policyDocument.id,
|
||||
policyDocument.version,
|
||||
)
|
||||
: false;
|
||||
|
||||
return {
|
||||
content: contentQuery.payload,
|
||||
faqs: ESA_FUNDING_FAQS,
|
||||
content,
|
||||
faqs: filterEsaFaqsByAudience(content.faqs, externalAudience),
|
||||
expandedFAQ,
|
||||
acknowledged,
|
||||
canManage,
|
||||
canAcknowledge,
|
||||
isSaving: saveContentMutation.isPending || managedQuery.isLoading,
|
||||
isLoading: contentQuery.isLoading,
|
||||
error: contentQuery.error,
|
||||
error: contentQuery.error
|
||||
?? managedQuery.error
|
||||
?? saveContentMutation.error
|
||||
?? acknowledgePolicy.error,
|
||||
toggleFAQ: (index) => setExpandedFAQ((currentIndex) => toggleEsaFaq(currentIndex, index)),
|
||||
toggleAcknowledged: () => setAcknowledged((current) => !current),
|
||||
toggleAcknowledged: () => {
|
||||
if (canAcknowledge && policyDocument && !acknowledged) {
|
||||
acknowledgePolicy.mutate(policyDocument.id);
|
||||
}
|
||||
},
|
||||
saveContent: async (nextContent, sectionName) => {
|
||||
await saveContentMutation.mutateAsync({
|
||||
content: normalizeEsaFundingContent(nextContent),
|
||||
sectionName,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
isValidEsaResourceUrl,
|
||||
normalizeEsaFundingContent,
|
||||
toggleEsaFaq,
|
||||
} from '@/business/esa-funding/selectors';
|
||||
|
||||
@ -19,4 +20,48 @@ describe('ESA funding selectors', () => {
|
||||
expect(isValidEsaResourceUrl('mailto:test@example.com')).toBe(false);
|
||||
expect(isValidEsaResourceUrl('not a url')).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes multiple parent conversation references', () => {
|
||||
const content = normalizeEsaFundingContent({
|
||||
parentConversationReferences: [
|
||||
{ id: 'reference-one', question: 'First question', answer: 'First answer' },
|
||||
{ question: 'Second question', answer: 'Second answer' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(content.parentConversationReferences).toEqual([
|
||||
{ id: 'reference-one', question: 'First question', answer: 'First answer' },
|
||||
{ id: 'parent-reference-second-question', question: 'Second question', answer: 'Second answer' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('converts legacy parent conversation reference text into Q&A format', () => {
|
||||
const content = normalizeEsaFundingContent({
|
||||
parentConversationReferences: [
|
||||
{ id: 'reference-one', text: 'First reference' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(content.parentConversationReferences).toEqual([
|
||||
{
|
||||
id: 'reference-one',
|
||||
question: 'If a parent asks about ESA, here is a simple way to explain it:',
|
||||
answer: 'First reference',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('converts legacy parent conversation script into one reference', () => {
|
||||
const content = normalizeEsaFundingContent({
|
||||
parentConversationScript: 'Legacy script',
|
||||
});
|
||||
|
||||
expect(content.parentConversationReferences).toEqual([
|
||||
{
|
||||
id: 'parent-reference-legacy-script',
|
||||
question: 'If a parent asks about ESA, here is a simple way to explain it:',
|
||||
answer: 'Legacy script',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,3 +1,16 @@
|
||||
import type { CurrentUser } from '@/shared/types/auth';
|
||||
import { ESA_FUNDING_STATIC_COPY } from '@/shared/constants/esaFunding';
|
||||
import type {
|
||||
EsaFaqItem,
|
||||
EsaFundingContent,
|
||||
EsaParentConversationReference,
|
||||
EsaResource,
|
||||
EsaSchoolImpactItem,
|
||||
EsaStaffRoleItem,
|
||||
} from '@/shared/types/esaFunding';
|
||||
|
||||
const DEFAULT_PARENT_CONVERSATION_QUESTION = ESA_FUNDING_STATIC_COPY.parentConversationIntro;
|
||||
|
||||
export function toggleEsaFaq(currentIndex: number | null, nextIndex: number): number | null {
|
||||
return currentIndex === nextIndex ? null : nextIndex;
|
||||
}
|
||||
@ -15,3 +28,222 @@ export function isValidEsaResourceUrl(url: string): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isEsaExternalAudience(user: CurrentUser | null | undefined): boolean {
|
||||
const roleName = user?.app_role?.name;
|
||||
return roleName === 'student' || roleName === 'guardian';
|
||||
}
|
||||
|
||||
export function filterEsaFaqsByAudience(
|
||||
faqs: readonly EsaFaqItem[],
|
||||
externalAudience: boolean,
|
||||
): readonly EsaFaqItem[] {
|
||||
return faqs.filter((faq) => !externalAudience || faq.audience === 'all');
|
||||
}
|
||||
|
||||
function stableId(prefix: string, value: string, index: number): string {
|
||||
const slug = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 40);
|
||||
|
||||
return `${prefix}-${slug || index + 1}`;
|
||||
}
|
||||
|
||||
function normalizeSchoolImpactItems(value: unknown): readonly EsaSchoolImpactItem[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item, index): EsaSchoolImpactItem | null => {
|
||||
if (typeof item === 'string') {
|
||||
return { id: stableId('impact', item, index), text: item };
|
||||
}
|
||||
if (
|
||||
item
|
||||
&& typeof item === 'object'
|
||||
&& 'text' in item
|
||||
&& typeof item.text === 'string'
|
||||
) {
|
||||
return {
|
||||
id: 'id' in item && typeof item.id === 'string'
|
||||
? item.id
|
||||
: stableId('impact', item.text, index),
|
||||
text: item.text,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is EsaSchoolImpactItem => item !== null);
|
||||
}
|
||||
|
||||
function normalizeStaffRoleItems(value: unknown): readonly EsaStaffRoleItem[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item, index): EsaStaffRoleItem | null => {
|
||||
if (
|
||||
item
|
||||
&& typeof item === 'object'
|
||||
&& 'title' in item
|
||||
&& typeof item.title === 'string'
|
||||
&& 'description' in item
|
||||
&& typeof item.description === 'string'
|
||||
) {
|
||||
return {
|
||||
id: 'id' in item && typeof item.id === 'string'
|
||||
? item.id
|
||||
: stableId('staff', item.title, index),
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is EsaStaffRoleItem => item !== null);
|
||||
}
|
||||
|
||||
function normalizeFaqs(value: unknown): readonly EsaFaqItem[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item, index): EsaFaqItem | null => {
|
||||
if (
|
||||
item
|
||||
&& typeof item === 'object'
|
||||
&& 'question' in item
|
||||
&& typeof item.question === 'string'
|
||||
&& 'answer' in item
|
||||
&& typeof item.answer === 'string'
|
||||
) {
|
||||
const audience = 'audience' in item && item.audience === 'staff'
|
||||
? 'staff'
|
||||
: 'all';
|
||||
return {
|
||||
id: 'id' in item && typeof item.id === 'string'
|
||||
? item.id
|
||||
: stableId('faq', item.question, index),
|
||||
question: item.question,
|
||||
answer: item.answer,
|
||||
audience,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is EsaFaqItem => item !== null);
|
||||
}
|
||||
|
||||
function normalizeResources(value: unknown): readonly EsaResource[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item, index): EsaResource | null => {
|
||||
if (
|
||||
item
|
||||
&& typeof item === 'object'
|
||||
&& 'title' in item
|
||||
&& typeof item.title === 'string'
|
||||
&& 'description' in item
|
||||
&& typeof item.description === 'string'
|
||||
&& 'url' in item
|
||||
&& typeof item.url === 'string'
|
||||
) {
|
||||
return {
|
||||
id: 'id' in item && typeof item.id === 'string'
|
||||
? item.id
|
||||
: stableId('resource', item.title, index),
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
url: item.url,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is EsaResource => item !== null);
|
||||
}
|
||||
|
||||
function normalizeParentConversationReferences(
|
||||
value: unknown,
|
||||
legacyScript: unknown,
|
||||
): readonly EsaParentConversationReference[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item, index): EsaParentConversationReference | null => {
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
id: stableId('parent-reference', item, index),
|
||||
question: DEFAULT_PARENT_CONVERSATION_QUESTION,
|
||||
answer: item,
|
||||
};
|
||||
}
|
||||
if (
|
||||
item
|
||||
&& typeof item === 'object'
|
||||
) {
|
||||
if (
|
||||
'question' in item
|
||||
&& typeof item.question === 'string'
|
||||
&& 'answer' in item
|
||||
&& typeof item.answer === 'string'
|
||||
) {
|
||||
return {
|
||||
id: 'id' in item && typeof item.id === 'string'
|
||||
? item.id
|
||||
: stableId('parent-reference', item.question, index),
|
||||
question: item.question,
|
||||
answer: item.answer,
|
||||
};
|
||||
}
|
||||
if ('text' in item && typeof item.text === 'string') {
|
||||
return {
|
||||
id: 'id' in item && typeof item.id === 'string'
|
||||
? item.id
|
||||
: stableId('parent-reference', item.text, index),
|
||||
question: DEFAULT_PARENT_CONVERSATION_QUESTION,
|
||||
answer: item.text,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is EsaParentConversationReference => item !== null);
|
||||
}
|
||||
|
||||
if (typeof legacyScript === 'string' && legacyScript.trim().length > 0) {
|
||||
return [{
|
||||
id: stableId('parent-reference', legacyScript, 0),
|
||||
question: DEFAULT_PARENT_CONVERSATION_QUESTION,
|
||||
answer: legacyScript,
|
||||
}];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function normalizeEsaFundingContent(content: EsaFundingContent | unknown): EsaFundingContent {
|
||||
const source = content && typeof content === 'object' ? content : {};
|
||||
|
||||
return {
|
||||
schoolImpactItems: normalizeSchoolImpactItems(
|
||||
'schoolImpactItems' in source ? source.schoolImpactItems : [],
|
||||
),
|
||||
staffRoleItems: normalizeStaffRoleItems(
|
||||
'staffRoleItems' in source ? source.staffRoleItems : [],
|
||||
),
|
||||
faqs: normalizeFaqs('faqs' in source ? source.faqs : []),
|
||||
parentConversationReferences: normalizeParentConversationReferences(
|
||||
'parentConversationReferences' in source ? source.parentConversationReferences : undefined,
|
||||
'parentConversationScript' in source ? source.parentConversationScript : '',
|
||||
),
|
||||
resources: normalizeResources('resources' in source ? source.resources : []),
|
||||
};
|
||||
}
|
||||
|
||||
@ -6,8 +6,12 @@ export interface EsaFundingPage {
|
||||
readonly faqs: readonly EsaFaqItem[];
|
||||
readonly expandedFAQ: number | null;
|
||||
readonly acknowledged: boolean;
|
||||
readonly canManage: boolean;
|
||||
readonly canAcknowledge: boolean;
|
||||
readonly isSaving: boolean;
|
||||
readonly isLoading: boolean;
|
||||
readonly error: Error | null;
|
||||
readonly toggleFAQ: (index: number) => void;
|
||||
readonly toggleAcknowledged: () => void;
|
||||
readonly saveContent: (content: EsaFundingContent, sectionName: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
POLICY_DOCUMENT_PAGE_CATEGORY,
|
||||
POLICY_QUERY_KEYS,
|
||||
} from '@/shared/constants/policies';
|
||||
import { ESA_FUNDING_POLICY_TAG } from '@/shared/constants/esaFunding';
|
||||
import { toPolicyDocumentMutationDto, toPolicyViewModel } from '@/business/policies/mappers';
|
||||
import type { PolicyFormInput } from '@/business/policies/types';
|
||||
import { getApiListRows, mapApiListRows } from '@/shared/business/apiListRows';
|
||||
@ -32,10 +33,27 @@ export function usePolicies(enabled = true) {
|
||||
mapApiListRows(
|
||||
listPolicyDocuments(POLICY_DOCUMENT_PAGE_CATEGORY.handbookPolicies),
|
||||
toPolicyViewModel,
|
||||
).then((policies) =>
|
||||
policies.filter((policy) => policy.tag !== ESA_FUNDING_POLICY_TAG),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useEsaFundingPolicyDocument(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: POLICY_QUERY_KEYS.esaDocuments,
|
||||
enabled,
|
||||
queryFn: async () => {
|
||||
const rows = await getApiListRows(
|
||||
listPolicyDocuments(POLICY_DOCUMENT_PAGE_CATEGORY.handbookPolicies, {
|
||||
tag: ESA_FUNDING_POLICY_TAG,
|
||||
}),
|
||||
);
|
||||
return rows[0] ? toPolicyViewModel(rows[0]) : null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePolicy() {
|
||||
return useInvalidatingMutation({
|
||||
mutationFn: (input: PolicyFormInput) =>
|
||||
|
||||
@ -38,6 +38,7 @@ describe('policy mappers', () => {
|
||||
id: 'policy-1',
|
||||
title: 'Incident Response',
|
||||
category: 'Safety',
|
||||
tag: 'Safety',
|
||||
content: 'Use the approved incident response process.',
|
||||
version: 2,
|
||||
lastUpdated: '2026-06-08',
|
||||
@ -54,6 +55,7 @@ describe('policy mappers', () => {
|
||||
id: 'policy-1',
|
||||
title: '',
|
||||
category: POLICY_DEFAULT_CATEGORY,
|
||||
tag: 'Unknown Tag',
|
||||
content: '',
|
||||
version: 2,
|
||||
lastUpdated: POLICY_DATE_NOT_RECORDED_LABEL,
|
||||
|
||||
@ -36,6 +36,7 @@ export function toPolicyViewModel(dto: PolicyDocumentDto): PolicyViewModel {
|
||||
id: dto.id,
|
||||
title: dto.title || '',
|
||||
category: toPolicyCategory(dto.tag),
|
||||
tag: dto.tag,
|
||||
content: dto.body || '',
|
||||
version: dto.version,
|
||||
lastUpdated: toDateOnly(dto.updatedAt),
|
||||
|
||||
@ -15,7 +15,7 @@ import {
|
||||
isPolicyFormValid,
|
||||
} from '@/business/policies/selectors';
|
||||
import { canPersistPersonalScopeResults } from '@/business/scope/selectors';
|
||||
import type { PolicyCategory, PolicyFormInput } from '@/business/policies/types';
|
||||
import type { PolicyCategory, PolicyFormInput, PolicyViewModel } from '@/business/policies/types';
|
||||
import { POLICY_DEFAULT_CATEGORY } from '@/shared/constants/policies';
|
||||
import { usePermissions } from '@/shared/app/usePermissions';
|
||||
import { useScopeContext } from '@/shared/app/scope-context';
|
||||
@ -24,7 +24,7 @@ export interface PoliciesPageState {
|
||||
readonly canManage: boolean;
|
||||
readonly canAcknowledge: boolean;
|
||||
readonly canPersistAcknowledgments: boolean;
|
||||
readonly filteredPolicies: NonNullable<ReturnType<typeof usePolicies>['data']>;
|
||||
readonly filteredPolicies: readonly PolicyViewModel[];
|
||||
readonly categories: readonly (PolicyCategory | 'all')[];
|
||||
readonly searchQuery: string;
|
||||
readonly categoryFilter: PolicyCategory | 'all';
|
||||
|
||||
@ -15,6 +15,7 @@ const policies: readonly PolicyViewModel[] = [
|
||||
id: 'policy-1',
|
||||
title: 'Emergency Communication',
|
||||
category: 'Communication',
|
||||
tag: 'Communication',
|
||||
content: 'Call families after a campus-wide emergency.',
|
||||
version: 1,
|
||||
lastUpdated: '2026-06-08',
|
||||
@ -24,6 +25,7 @@ const policies: readonly PolicyViewModel[] = [
|
||||
id: 'policy-2',
|
||||
title: 'Safety Drill',
|
||||
category: 'Safety',
|
||||
tag: 'Safety',
|
||||
content: 'Monthly drill expectations.',
|
||||
version: 1,
|
||||
lastUpdated: '2026-06-07',
|
||||
@ -33,6 +35,7 @@ const policies: readonly PolicyViewModel[] = [
|
||||
id: 'policy-3',
|
||||
title: 'Behavior Support',
|
||||
category: 'Behavior',
|
||||
tag: 'Behavior',
|
||||
content: 'Use approved de-escalation supports.',
|
||||
version: 1,
|
||||
lastUpdated: '2026-06-06',
|
||||
|
||||
@ -6,6 +6,7 @@ export interface PolicyViewModel {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly category: PolicyCategory;
|
||||
readonly tag: string | null;
|
||||
readonly content: string;
|
||||
readonly version: number;
|
||||
readonly lastUpdated: string;
|
||||
|
||||
@ -55,6 +55,7 @@ function createPolicy(overrides: Partial<PolicyViewModel> = {}): PolicyViewModel
|
||||
id: 'policy-1',
|
||||
title: 'Staff Handbook',
|
||||
category: 'Operations',
|
||||
tag: 'Operations',
|
||||
content: 'Handbook content',
|
||||
version: 2,
|
||||
lastUpdated: '2026-06-18T10:00:00.000Z',
|
||||
|
||||
@ -24,7 +24,11 @@ import {
|
||||
normalizeSignLanguageItems,
|
||||
selectSignLanguageSignOfWeek,
|
||||
} from '@/business/sign-language/selectors';
|
||||
import { usePolicies, usePolicyAcknowledgments } from '@/business/policies/hooks';
|
||||
import {
|
||||
useEsaFundingPolicyDocument,
|
||||
usePolicies,
|
||||
usePolicyAcknowledgments,
|
||||
} from '@/business/policies/hooks';
|
||||
import { useCurrentPersonalityResult } from '@/business/personality/queryHooks';
|
||||
import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks';
|
||||
import { canPersistPersonalScopeResults } from '@/business/scope/selectors';
|
||||
@ -169,10 +173,12 @@ export function useTopBarPage({
|
||||
const canReceivePolicyNotifications = canPersistPersonalResults && hasPermission(user, 'ACK_POLICY');
|
||||
const canReadHandbook = canReceivePolicyNotifications && accessibleModuleIds.has('handbook');
|
||||
const canReadSafetyProtocols = canReceivePolicyNotifications && accessibleModuleIds.has('safety');
|
||||
const canReadEsaFunding = canReceivePolicyNotifications && accessibleModuleIds.has('esa');
|
||||
const policyAcknowledgments = usePolicyAcknowledgments(canReceivePolicyNotifications && (
|
||||
canReadHandbook || canReadSafetyProtocols
|
||||
canReadHandbook || canReadSafetyProtocols || canReadEsaFunding
|
||||
));
|
||||
const handbookPolicies = usePolicies(canReadHandbook);
|
||||
const esaPolicyDocument = useEsaFundingPolicyDocument(canReadEsaFunding);
|
||||
const safetyProtocols = useSafetyProtocols(canReadSafetyProtocols);
|
||||
// Header search = accessible modules (local) + their product content from the
|
||||
// content catalog. Content is fetched lazily — only once the user types, and
|
||||
@ -251,7 +257,12 @@ export function useTopBarPage({
|
||||
needsPersonalityQuiz,
|
||||
communicationEvents: communicationEvents.data ?? [],
|
||||
acknowledgedCommunicationEventIds,
|
||||
handbookPolicies: handbookPolicies.data ?? [],
|
||||
handbookPolicies: [
|
||||
...(handbookPolicies.data ?? []),
|
||||
...(esaPolicyDocument.data
|
||||
? [esaPolicyDocument.data]
|
||||
: []),
|
||||
],
|
||||
safetyProtocols: safetyProtocols.data ?? [],
|
||||
policyAcknowledgments: policyAcknowledgments.data ?? [],
|
||||
});
|
||||
|
||||
@ -186,6 +186,7 @@ describe('top bar selectors', () => {
|
||||
id: 'handbook-1',
|
||||
title: 'Parent Communication',
|
||||
category: 'Communication',
|
||||
tag: 'Communication',
|
||||
content: 'Document families contact.',
|
||||
version: 2,
|
||||
lastUpdated: '2026-06-15',
|
||||
@ -216,6 +217,33 @@ describe('top bar selectors', () => {
|
||||
}]);
|
||||
});
|
||||
|
||||
it('surfaces ESA funding updates with the updated section name', () => {
|
||||
const unread = buildTopBarNotifications({
|
||||
needsZoneCheckIn: false,
|
||||
handbookPolicies: [{
|
||||
id: 'esa-policy-1',
|
||||
title: 'ESA Funding Information',
|
||||
category: 'Operations',
|
||||
tag: 'ESA Funding',
|
||||
content: 'Staff must review the current ESA Funding Information page.\n\nUpdated section: Helpful Resources',
|
||||
version: 3,
|
||||
lastUpdated: '2026-06-22',
|
||||
updatedBy: 'Director',
|
||||
}],
|
||||
policyAcknowledgments: [
|
||||
createPolicyAcknowledgment({ policyDocumentId: 'esa-policy-1', version: 2 }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(unread).toEqual([{
|
||||
id: 'handbook-policy-esa-policy-1-v3',
|
||||
text: 'Updated ESA funding section: Helpful Resources',
|
||||
time: 'Version 3',
|
||||
unread: true,
|
||||
href: APP_ROUTE_PATHS.esa,
|
||||
}]);
|
||||
});
|
||||
|
||||
it('falls back to default campus label', () => {
|
||||
expect(getTopBarCampusLabel()).toBe('Current Campus');
|
||||
});
|
||||
|
||||
@ -11,6 +11,7 @@ import type { PolicyViewModel } from '@/business/policies/types';
|
||||
import type { SafetyProtocolViewModel } from '@/business/safety-protocols/types';
|
||||
import type { PolicyAcknowledgmentDto } from '@/shared/types/policyDocuments';
|
||||
import { isPolicyDocumentAcknowledged } from '@/business/policies/selectors';
|
||||
import { ESA_FUNDING_POLICY_TAG } from '@/shared/constants/esaFunding';
|
||||
|
||||
export function getTopBarInitials(name: string): string {
|
||||
return name
|
||||
@ -171,12 +172,20 @@ export function buildTopBarNotifications(input: {
|
||||
continue;
|
||||
}
|
||||
|
||||
const esaUpdatedSection = policy.tag === ESA_FUNDING_POLICY_TAG
|
||||
? getEsaUpdatedSection(policy)
|
||||
: null;
|
||||
|
||||
notifications.push({
|
||||
id: `handbook-policy-${policy.id}-v${policy.version}`,
|
||||
text: `Unread handbook policy: ${policy.title}`,
|
||||
text: policy.tag === ESA_FUNDING_POLICY_TAG
|
||||
? `Updated ESA funding section: ${esaUpdatedSection ?? policy.title}`
|
||||
: `Unread handbook policy: ${policy.title}`,
|
||||
time: `Version ${policy.version}`,
|
||||
unread: true,
|
||||
href: APP_ROUTE_PATHS.handbook,
|
||||
href: policy.tag === ESA_FUNDING_POLICY_TAG
|
||||
? APP_ROUTE_PATHS.esa
|
||||
: APP_ROUTE_PATHS.handbook,
|
||||
});
|
||||
}
|
||||
|
||||
@ -196,3 +205,18 @@ export function buildTopBarNotifications(input: {
|
||||
|
||||
return notifications;
|
||||
}
|
||||
|
||||
function getEsaUpdatedSection(policy: PolicyViewModel): string | null {
|
||||
const marker = 'Updated section:';
|
||||
const line = policy.content
|
||||
.split('\n')
|
||||
.map((item) => item.trim())
|
||||
.find((item) => item.startsWith(marker));
|
||||
|
||||
if (!line) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sectionName = line.slice(marker.length).trim();
|
||||
return sectionName.length > 0 ? sectionName : null;
|
||||
}
|
||||
|
||||
374
frontend/src/components/esa-funding/EsaFundingEditor.tsx
Normal file
374
frontend/src/components/esa-funding/EsaFundingEditor.tsx
Normal file
@ -0,0 +1,374 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Plus, Save, Trash2, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { NativeSelect } from '@/components/ui/native-select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type {
|
||||
EsaFaqItem,
|
||||
EsaFundingContent,
|
||||
EsaParentConversationReference,
|
||||
EsaResource,
|
||||
EsaSchoolImpactItem,
|
||||
EsaStaffRoleItem,
|
||||
} from '@/shared/types/esaFunding';
|
||||
|
||||
interface EsaSectionEditorProps {
|
||||
readonly content: EsaFundingContent;
|
||||
readonly isSaving: boolean;
|
||||
readonly onSave: (content: EsaFundingContent, sectionName: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface InlineEditorShellProps {
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly isSaving: boolean;
|
||||
readonly onAdd?: () => void;
|
||||
readonly onSave: () => Promise<void>;
|
||||
readonly children: ReactNode;
|
||||
}
|
||||
|
||||
function createId(prefix: string): string {
|
||||
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function textInputId(section: string, id: string, field: string): string {
|
||||
return `esa-${section}-${id}-${field}`;
|
||||
}
|
||||
|
||||
function InlineEditorShell({
|
||||
title,
|
||||
description,
|
||||
isSaving,
|
||||
onAdd,
|
||||
onSave,
|
||||
children,
|
||||
}: InlineEditorShellProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
async function submitForm() {
|
||||
await onSave();
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-xl border border-slate-700/60 bg-slate-950/35">
|
||||
<div className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{title}</p>
|
||||
<p className="text-xs text-slate-400">{description}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
leadingIcon={isOpen ? <X size={14} /> : undefined}
|
||||
>
|
||||
{isOpen ? 'Cancel' : 'Edit'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isOpen ? (
|
||||
<form
|
||||
className="space-y-4 border-t border-slate-700/60 p-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void submitForm();
|
||||
}}
|
||||
>
|
||||
{onAdd ? (
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onAdd} leadingIcon={<Plus size={14} />}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-3">{children}</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" size="sm" loading={isSaving} leadingIcon={<Save size={14} />}>
|
||||
Save section
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteButton({ onClick }: { readonly onClick: () => void }) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClick}
|
||||
aria-label="Delete item"
|
||||
className="text-rose-300 hover:text-rose-200"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function EsaFundingSchoolImpactEditor({
|
||||
content,
|
||||
isSaving,
|
||||
onSave,
|
||||
}: EsaSectionEditorProps) {
|
||||
const [items, setItems] = useState<readonly EsaSchoolImpactItem[]>(content.schoolImpactItems);
|
||||
|
||||
function updateItem(id: string, text: string) {
|
||||
setItems((current) => current.map((item) => (item.id === id ? { ...item, text } : item)));
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
setItems((current) => [...current, { id: createId('impact'), text: '' }]);
|
||||
}
|
||||
|
||||
function deleteItem(id: string) {
|
||||
setItems((current) => current.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<InlineEditorShell
|
||||
title="Edit school impact"
|
||||
description="Manage the points shown in this section."
|
||||
isSaving={isSaving}
|
||||
onAdd={addItem}
|
||||
onSave={() => onSave({ ...content, schoolImpactItems: items }, 'Why This Matters for Our School')}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="flex gap-2">
|
||||
<Input
|
||||
id={textInputId('impact', item.id, 'text')}
|
||||
value={item.text}
|
||||
onChange={(event) => updateItem(item.id, event.target.value)}
|
||||
aria-label="School impact item"
|
||||
/>
|
||||
<DeleteButton onClick={() => deleteItem(item.id)} />
|
||||
</div>
|
||||
))}
|
||||
</InlineEditorShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function EsaFundingStaffRoleEditor({
|
||||
content,
|
||||
isSaving,
|
||||
onSave,
|
||||
}: EsaSectionEditorProps) {
|
||||
const [items, setItems] = useState<readonly EsaStaffRoleItem[]>(content.staffRoleItems);
|
||||
|
||||
function updateItem(id: string, patch: Partial<EsaStaffRoleItem>) {
|
||||
setItems((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item)));
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
setItems((current) => [...current, { id: createId('staff'), title: '', description: '' }]);
|
||||
}
|
||||
|
||||
function deleteItem(id: string) {
|
||||
setItems((current) => current.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<InlineEditorShell
|
||||
title="Edit staff guidance"
|
||||
description="Manage the staff-only guidance shown here."
|
||||
isSaving={isSaving}
|
||||
onAdd={addItem}
|
||||
onSave={() => onSave({ ...content, staffRoleItems: items }, 'Your Role as Staff')}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="grid gap-2 rounded-xl border border-slate-700/50 p-3">
|
||||
<Label htmlFor={textInputId('staff', item.id, 'title')}>Title</Label>
|
||||
<Input
|
||||
id={textInputId('staff', item.id, 'title')}
|
||||
value={item.title}
|
||||
onChange={(event) => updateItem(item.id, { title: event.target.value })}
|
||||
/>
|
||||
<Label htmlFor={textInputId('staff', item.id, 'description')}>Description</Label>
|
||||
<Textarea
|
||||
id={textInputId('staff', item.id, 'description')}
|
||||
value={item.description}
|
||||
onChange={(event) => updateItem(item.id, { description: event.target.value })}
|
||||
/>
|
||||
<DeleteButton onClick={() => deleteItem(item.id)} />
|
||||
</div>
|
||||
))}
|
||||
</InlineEditorShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function EsaFundingFaqEditor({
|
||||
content,
|
||||
isSaving,
|
||||
onSave,
|
||||
}: EsaSectionEditorProps) {
|
||||
const [items, setItems] = useState<readonly EsaFaqItem[]>(content.faqs);
|
||||
|
||||
function updateItem(id: string, patch: Partial<EsaFaqItem>) {
|
||||
setItems((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item)));
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
setItems((current) => [...current, { id: createId('faq'), question: '', answer: '', audience: 'all' }]);
|
||||
}
|
||||
|
||||
function deleteItem(id: string) {
|
||||
setItems((current) => current.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<InlineEditorShell
|
||||
title="Edit FAQ"
|
||||
description="Manage questions and their student/guardian visibility."
|
||||
isSaving={isSaving}
|
||||
onAdd={addItem}
|
||||
onSave={() => onSave({ ...content, faqs: items }, 'Frequently Asked Questions')}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="grid gap-2 rounded-xl border border-slate-700/50 p-3">
|
||||
<Label htmlFor={textInputId('faq', item.id, 'question')}>Question</Label>
|
||||
<Input
|
||||
id={textInputId('faq', item.id, 'question')}
|
||||
value={item.question}
|
||||
onChange={(event) => updateItem(item.id, { question: event.target.value })}
|
||||
/>
|
||||
<Label htmlFor={textInputId('faq', item.id, 'answer')}>Answer</Label>
|
||||
<Textarea
|
||||
id={textInputId('faq', item.id, 'answer')}
|
||||
value={item.answer}
|
||||
onChange={(event) => updateItem(item.id, { answer: event.target.value })}
|
||||
/>
|
||||
<Label htmlFor={textInputId('faq', item.id, 'audience')}>Audience</Label>
|
||||
<NativeSelect
|
||||
id={textInputId('faq', item.id, 'audience')}
|
||||
value={item.audience}
|
||||
onChange={(event) => updateItem(item.id, {
|
||||
audience: event.target.value === 'staff' ? 'staff' : 'all',
|
||||
})}
|
||||
>
|
||||
<option value="all">Show for everybody</option>
|
||||
<option value="staff">Hide from students and guardians</option>
|
||||
</NativeSelect>
|
||||
<DeleteButton onClick={() => deleteItem(item.id)} />
|
||||
</div>
|
||||
))}
|
||||
</InlineEditorShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function EsaFundingQuickReferenceEditor({
|
||||
content,
|
||||
isSaving,
|
||||
onSave,
|
||||
}: EsaSectionEditorProps) {
|
||||
const [items, setItems] = useState<readonly EsaParentConversationReference[]>(content.parentConversationReferences);
|
||||
|
||||
function updateItem(id: string, patch: Partial<EsaParentConversationReference>) {
|
||||
setItems((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item)));
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
setItems((current) => [...current, {
|
||||
id: createId('parent-reference'),
|
||||
question: '',
|
||||
answer: '',
|
||||
}]);
|
||||
}
|
||||
|
||||
function deleteItem(id: string) {
|
||||
setItems((current) => current.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<InlineEditorShell
|
||||
title="Edit quick reference"
|
||||
description="Manage the staff-only parent conversation references."
|
||||
isSaving={isSaving}
|
||||
onAdd={addItem}
|
||||
onSave={() => onSave({ ...content, parentConversationReferences: items }, 'Quick Reference for Parent Conversations')}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<div key={item.id} className="grid gap-2 rounded-xl border border-slate-700/50 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-slate-200">Parent conversation reference {index + 1}</p>
|
||||
<DeleteButton onClick={() => deleteItem(item.id)} />
|
||||
</div>
|
||||
<Label htmlFor={textInputId('parent-reference', item.id, 'question')}>Question</Label>
|
||||
<Input
|
||||
id={textInputId('parent-reference', item.id, 'question')}
|
||||
value={item.question}
|
||||
onChange={(event) => updateItem(item.id, { question: event.target.value })}
|
||||
placeholder="If a parent asks about ESA, here is a simple way to explain it:"
|
||||
/>
|
||||
<Label htmlFor={textInputId('parent-reference', item.id, 'answer')}>Answer</Label>
|
||||
<Textarea
|
||||
id={textInputId('parent-reference', item.id, 'answer')}
|
||||
value={item.answer}
|
||||
onChange={(event) => updateItem(item.id, { answer: event.target.value })}
|
||||
placeholder="ESA stands for Empowerment Scholarship Account..."
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</InlineEditorShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function EsaFundingResourcesEditor({
|
||||
content,
|
||||
isSaving,
|
||||
onSave,
|
||||
}: EsaSectionEditorProps) {
|
||||
const [items, setItems] = useState<readonly EsaResource[]>(content.resources);
|
||||
|
||||
function updateItem(id: string, patch: Partial<EsaResource>) {
|
||||
setItems((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item)));
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
setItems((current) => [...current, { id: createId('resource'), title: '', description: '', url: '#' }]);
|
||||
}
|
||||
|
||||
function deleteItem(id: string) {
|
||||
setItems((current) => current.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<InlineEditorShell
|
||||
title="Edit resources"
|
||||
description="Manage the links shown in this section."
|
||||
isSaving={isSaving}
|
||||
onAdd={addItem}
|
||||
onSave={() => onSave({ ...content, resources: items }, 'Helpful Resources')}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="grid gap-2 rounded-xl border border-slate-700/50 p-3">
|
||||
<Label htmlFor={textInputId('resource', item.id, 'title')}>Title</Label>
|
||||
<Input
|
||||
id={textInputId('resource', item.id, 'title')}
|
||||
value={item.title}
|
||||
onChange={(event) => updateItem(item.id, { title: event.target.value })}
|
||||
/>
|
||||
<Label htmlFor={textInputId('resource', item.id, 'description')}>Description</Label>
|
||||
<Input
|
||||
id={textInputId('resource', item.id, 'description')}
|
||||
value={item.description}
|
||||
onChange={(event) => updateItem(item.id, { description: event.target.value })}
|
||||
/>
|
||||
<Label htmlFor={textInputId('resource', item.id, 'url')}>URL</Label>
|
||||
<Input
|
||||
id={textInputId('resource', item.id, 'url')}
|
||||
value={item.url}
|
||||
onChange={(event) => updateItem(item.id, { url: event.target.value })}
|
||||
/>
|
||||
<DeleteButton onClick={() => deleteItem(item.id)} />
|
||||
</div>
|
||||
))}
|
||||
</InlineEditorShell>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { ChevronDown, ChevronUp, HelpCircle } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { EsaFaqItem } from '@/shared/types/esaFunding';
|
||||
@ -8,14 +9,16 @@ interface EsaFundingFaqProps {
|
||||
readonly items: readonly EsaFaqItem[];
|
||||
readonly expandedFAQ: number | null;
|
||||
readonly onToggleFAQ: (index: number) => void;
|
||||
readonly editor?: ReactNode;
|
||||
}
|
||||
|
||||
export function EsaFundingFaq({
|
||||
items,
|
||||
expandedFAQ,
|
||||
onToggleFAQ,
|
||||
editor,
|
||||
}: EsaFundingFaqProps) {
|
||||
if (items.length === 0) {
|
||||
if (items.length === 0 && !editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -31,7 +34,7 @@ export function EsaFundingFaq({
|
||||
|
||||
return (
|
||||
<div
|
||||
key={faq.question}
|
||||
key={faq.id}
|
||||
className={cn(
|
||||
'bg-slate-800/60 backdrop-blur-sm rounded-xl border transition-all',
|
||||
expanded ? 'border-emerald-500/30 shadow-lg shadow-emerald-500/5' : 'border-slate-700/50',
|
||||
@ -73,6 +76,7 @@ export function EsaFundingFaq({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{editor}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,17 +1,22 @@
|
||||
import { CheckCircle2, Info, Shield } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { EsaStaffRoleItem } from '@/shared/types/esaFunding';
|
||||
import type { EsaSchoolImpactItem, EsaStaffRoleItem } from '@/shared/types/esaFunding';
|
||||
|
||||
interface EsaFundingImpactRolesProps {
|
||||
readonly schoolImpactItems: readonly string[];
|
||||
readonly schoolImpactItems: readonly EsaSchoolImpactItem[];
|
||||
readonly staffRoleItems: readonly EsaStaffRoleItem[];
|
||||
readonly schoolImpactEditor?: ReactNode;
|
||||
readonly staffRoleEditor?: ReactNode;
|
||||
}
|
||||
|
||||
export function EsaFundingImpactRoles({
|
||||
schoolImpactItems,
|
||||
staffRoleItems,
|
||||
schoolImpactEditor,
|
||||
staffRoleEditor,
|
||||
}: EsaFundingImpactRolesProps) {
|
||||
if (schoolImpactItems.length === 0 && staffRoleItems.length === 0) {
|
||||
if (schoolImpactItems.length === 0 && staffRoleItems.length === 0 && !schoolImpactEditor && !staffRoleEditor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -24,12 +29,13 @@ export function EsaFundingImpactRoles({
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{schoolImpactItems.map((item) => (
|
||||
<div key={item} className="flex items-start gap-2.5">
|
||||
<div key={item.id} className="flex items-start gap-2.5">
|
||||
<CheckCircle2 size={16} className="text-violet-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-slate-300 leading-relaxed">{item}</span>
|
||||
<span className="text-sm text-slate-300 leading-relaxed">{item.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{schoolImpactEditor}
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-amber-500/10 to-amber-600/5 rounded-2xl p-6 border border-amber-500/20">
|
||||
@ -50,6 +56,7 @@ export function EsaFundingImpactRoles({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{staffRoleEditor}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
import { FileText, Info } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { ESA_FUNDING_STATIC_COPY } from '@/shared/constants/esaFunding';
|
||||
import type { EsaFundingContent } from '@/shared/types/esaFunding';
|
||||
|
||||
interface EsaFundingQuickReferenceProps {
|
||||
readonly content: EsaFundingContent;
|
||||
readonly editor?: ReactNode;
|
||||
}
|
||||
|
||||
export function EsaFundingQuickReference({ content }: EsaFundingQuickReferenceProps) {
|
||||
if (!content.parentConversationScript) {
|
||||
export function EsaFundingQuickReference({ content, editor }: EsaFundingQuickReferenceProps) {
|
||||
if (content.parentConversationReferences.length === 0 && !editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -19,15 +21,22 @@ export function EsaFundingQuickReference({ content }: EsaFundingQuickReferencePr
|
||||
<h3 className="font-bold text-white">Quick Reference for Parent Conversations</h3>
|
||||
</div>
|
||||
<div className="bg-slate-900/60 rounded-xl p-5 border border-slate-700/40">
|
||||
<p className="text-sm text-slate-300 italic leading-relaxed mb-4">{ESA_FUNDING_STATIC_COPY.parentConversationIntro}</p>
|
||||
<div className="bg-emerald-500/10 rounded-xl p-4 border border-emerald-500/20">
|
||||
<p className="text-sm text-emerald-100 leading-relaxed">{content.parentConversationScript}</p>
|
||||
<div className="space-y-4">
|
||||
{content.parentConversationReferences.map((reference) => (
|
||||
<div key={reference.id} className="space-y-4">
|
||||
<p className="text-sm text-slate-300 italic leading-relaxed">{reference.question}</p>
|
||||
<div className="bg-emerald-500/10 rounded-xl p-4 border border-emerald-500/20">
|
||||
<p className="text-sm text-emerald-100 leading-relaxed">{reference.answer}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2 text-xs text-slate-500">
|
||||
<Info size={14} />
|
||||
<span>{ESA_FUNDING_STATIC_COPY.parentConversationFooter}</span>
|
||||
</div>
|
||||
</div>
|
||||
{editor}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { isValidEsaResourceUrl } from '@/business/esa-funding/selectors';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -7,10 +8,11 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
interface EsaFundingResourcesProps {
|
||||
readonly resources: readonly EsaResource[];
|
||||
readonly editor?: ReactNode;
|
||||
}
|
||||
|
||||
export function EsaFundingResources({ resources }: EsaFundingResourcesProps) {
|
||||
if (resources.length === 0) {
|
||||
export function EsaFundingResources({ resources, editor }: EsaFundingResourcesProps) {
|
||||
if (resources.length === 0 && !editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -24,25 +26,25 @@ export function EsaFundingResources({ resources }: EsaFundingResourcesProps) {
|
||||
{resources.map((resource) => {
|
||||
const validUrl = isValidEsaResourceUrl(resource.url);
|
||||
const cardClassName = cn(
|
||||
'bg-slate-900/60 rounded-xl p-4 border border-slate-700/40 transition-all text-left group h-auto items-start whitespace-normal',
|
||||
'flex flex-col justify-start gap-1.5 bg-slate-900/60 rounded-xl p-4 border border-slate-700/40 transition-all text-left group h-auto items-start whitespace-normal',
|
||||
validUrl ? 'hover:border-emerald-500/30' : 'opacity-70 cursor-not-allowed',
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
<span className="flex items-center justify-between mb-1.5 w-full">
|
||||
<span className="text-sm font-semibold text-white group-hover:text-emerald-400 transition-colors">
|
||||
<span className="flex w-full items-start justify-between gap-3">
|
||||
<span className="text-sm font-semibold leading-snug text-white transition-colors group-hover:text-emerald-400">
|
||||
{resource.title}
|
||||
</span>
|
||||
<ExternalLink size={14} className="text-slate-600 group-hover:text-emerald-400 transition-colors" />
|
||||
<ExternalLink size={14} className="mt-0.5 shrink-0 text-slate-600 transition-colors group-hover:text-emerald-400" />
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{resource.description}</span>
|
||||
<span className="block text-xs leading-relaxed text-slate-500">{resource.description}</span>
|
||||
{!validUrl && <span className="text-[10px] text-amber-400 mt-2 block">Link not configured</span>}
|
||||
</>
|
||||
);
|
||||
|
||||
if (validUrl) {
|
||||
return (
|
||||
<Button key={resource.title} asChild variant="ghost" className={cardClassName}>
|
||||
<Button key={resource.id} asChild variant="ghost" className={cardClassName}>
|
||||
<a href={resource.url} target="_blank" rel="noopener noreferrer">
|
||||
{content}
|
||||
</a>
|
||||
@ -51,12 +53,13 @@ export function EsaFundingResources({ resources }: EsaFundingResourcesProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={resource.title} className={cardClassName} aria-disabled="true">
|
||||
<div key={resource.id} className={cardClassName} aria-disabled="true">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{editor}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,14 +1,13 @@
|
||||
import { CheckCircle2, Info } from 'lucide-react';
|
||||
|
||||
import { ESA_FUNDING_STATIC_COPY } from '@/shared/constants/esaFunding';
|
||||
import type { EsaFundingContent } from '@/shared/types/esaFunding';
|
||||
|
||||
interface EsaFundingStateNoticeProps {
|
||||
readonly content: EsaFundingContent;
|
||||
readonly items: readonly string[];
|
||||
}
|
||||
|
||||
export function EsaFundingStateNotice({ content }: EsaFundingStateNoticeProps) {
|
||||
if (content.stateChecklist.length === 0) {
|
||||
export function EsaFundingStateNotice({ items }: EsaFundingStateNoticeProps) {
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -24,7 +23,7 @@ export function EsaFundingStateNotice({ content }: EsaFundingStateNoticeProps) {
|
||||
<div className="bg-slate-800/60 rounded-xl p-4 border border-slate-700/40 space-y-2">
|
||||
<p className="text-sm text-slate-200 font-semibold">{ESA_FUNDING_STATIC_COPY.stateNoticeChecklistTitle}</p>
|
||||
<div className="space-y-1.5">
|
||||
{content.stateChecklist.map((item) => (
|
||||
{items.map((item) => (
|
||||
<div key={item} className="flex items-start gap-2">
|
||||
<CheckCircle2 size={14} className="text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-slate-400 leading-relaxed">{item}</span>
|
||||
|
||||
@ -3,6 +3,13 @@ import { AlertTriangle, Wallet } from 'lucide-react';
|
||||
import type { EsaFundingPage } from '@/business/esa-funding/types';
|
||||
import { EsaFundingAcknowledgement } from '@/components/esa-funding/EsaFundingAcknowledgement';
|
||||
import { EsaFundingApprovedUses } from '@/components/esa-funding/EsaFundingApprovedUses';
|
||||
import {
|
||||
EsaFundingFaqEditor,
|
||||
EsaFundingQuickReferenceEditor,
|
||||
EsaFundingResourcesEditor,
|
||||
EsaFundingSchoolImpactEditor,
|
||||
EsaFundingStaffRoleEditor,
|
||||
} from '@/components/esa-funding/EsaFundingEditor';
|
||||
import { EsaFundingFaq } from '@/components/esa-funding/EsaFundingFaq';
|
||||
import { EsaFundingHeader } from '@/components/esa-funding/EsaFundingHeader';
|
||||
import { EsaFundingHero } from '@/components/esa-funding/EsaFundingHero';
|
||||
@ -12,6 +19,11 @@ import { EsaFundingQuickReference } from '@/components/esa-funding/EsaFundingQui
|
||||
import { EsaFundingResources } from '@/components/esa-funding/EsaFundingResources';
|
||||
import { EsaFundingStateNotice } from '@/components/esa-funding/EsaFundingStateNotice';
|
||||
import { StatePanel } from '@/components/ui/state-panel';
|
||||
import {
|
||||
ESA_FUNDING_STATIC_APPROVED_USES,
|
||||
ESA_FUNDING_STATIC_KEY_POINTS,
|
||||
ESA_FUNDING_STATIC_STATE_CHECKLIST,
|
||||
} from '@/shared/constants/esaFunding';
|
||||
|
||||
interface EsaFundingViewProps {
|
||||
readonly page: EsaFundingPage;
|
||||
@ -50,27 +62,68 @@ export function EsaFundingView({ page }: EsaFundingViewProps) {
|
||||
|
||||
{!page.isLoading && !page.error && (
|
||||
<>
|
||||
<EsaFundingStateNotice content={page.content} />
|
||||
<EsaFundingKeyPoints items={page.content.keyPoints} />
|
||||
<EsaFundingApprovedUses items={page.content.approvedUses} />
|
||||
<EsaFundingStateNotice items={ESA_FUNDING_STATIC_STATE_CHECKLIST} />
|
||||
<EsaFundingKeyPoints items={ESA_FUNDING_STATIC_KEY_POINTS} />
|
||||
<EsaFundingApprovedUses items={ESA_FUNDING_STATIC_APPROVED_USES} />
|
||||
<EsaFundingImpactRoles
|
||||
schoolImpactItems={page.content.schoolImpactItems}
|
||||
staffRoleItems={page.content.staffRoleItems}
|
||||
schoolImpactEditor={page.canManage ? (
|
||||
<EsaFundingSchoolImpactEditor
|
||||
content={page.content}
|
||||
isSaving={page.isSaving}
|
||||
onSave={page.saveContent}
|
||||
/>
|
||||
) : undefined}
|
||||
staffRoleEditor={page.canManage ? (
|
||||
<EsaFundingStaffRoleEditor
|
||||
content={page.content}
|
||||
isSaving={page.isSaving}
|
||||
onSave={page.saveContent}
|
||||
/>
|
||||
) : undefined}
|
||||
/>
|
||||
<EsaFundingFaq
|
||||
items={page.faqs}
|
||||
expandedFAQ={page.expandedFAQ}
|
||||
onToggleFAQ={page.toggleFAQ}
|
||||
editor={page.canManage ? (
|
||||
<EsaFundingFaqEditor
|
||||
content={page.content}
|
||||
isSaving={page.isSaving}
|
||||
onSave={page.saveContent}
|
||||
/>
|
||||
) : undefined}
|
||||
/>
|
||||
<EsaFundingQuickReference
|
||||
content={page.content}
|
||||
editor={page.canManage ? (
|
||||
<EsaFundingQuickReferenceEditor
|
||||
content={page.content}
|
||||
isSaving={page.isSaving}
|
||||
onSave={page.saveContent}
|
||||
/>
|
||||
) : undefined}
|
||||
/>
|
||||
<EsaFundingResources
|
||||
resources={page.content.resources}
|
||||
editor={page.canManage ? (
|
||||
<EsaFundingResourcesEditor
|
||||
content={page.content}
|
||||
isSaving={page.isSaving}
|
||||
onSave={page.saveContent}
|
||||
/>
|
||||
) : undefined}
|
||||
/>
|
||||
<EsaFundingQuickReference content={page.content} />
|
||||
<EsaFundingResources resources={page.content.resources} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<EsaFundingAcknowledgement
|
||||
acknowledged={page.acknowledged}
|
||||
onToggle={page.toggleAcknowledged}
|
||||
/>
|
||||
{page.canAcknowledge && (
|
||||
<EsaFundingAcknowledgement
|
||||
acknowledged={page.acknowledged}
|
||||
onToggle={page.toggleAcknowledged}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,8 +10,12 @@ const POLICY_DOCUMENTS_PATH = '/policy_documents';
|
||||
|
||||
export function listPolicyDocuments(
|
||||
category: PolicyDocumentCategory,
|
||||
filter?: { readonly tag?: string },
|
||||
): Promise<ApiListResponse<PolicyDocumentDto>> {
|
||||
const query = new URLSearchParams({ category }).toString();
|
||||
const query = new URLSearchParams({
|
||||
category,
|
||||
...(filter?.tag ? { tag: filter.tag } : {}),
|
||||
}).toString();
|
||||
return apiRequest<ApiListResponse<PolicyDocumentDto>>(
|
||||
`${POLICY_DOCUMENTS_PATH}?${query}`,
|
||||
);
|
||||
|
||||
@ -31,7 +31,8 @@ export const MODULE_PERMISSIONS = [
|
||||
'READ_VOCATIONAL', 'READ_ESA', 'READ_WALKTHROUGH', 'READ_DIRECTOR_DASHBOARD',
|
||||
'FILL_ATTENDANCE', 'TAKE_QUIZ', 'ACK_READ_RECEIPT', 'ACK_POLICY', 'ZONE_CHECKIN',
|
||||
'MANAGE_FRAME', 'MANAGE_WALKTHROUGH', 'MANAGE_INTERNAL_COMM',
|
||||
'MANAGE_CONTENT_CATALOG', 'READ_STAFF_ATTENDANCE_REPORTS',
|
||||
'MANAGE_CONTENT_CATALOG', 'MANAGE_ESA_FUNDING_CONTENT',
|
||||
'READ_STAFF_ATTENDANCE_REPORTS',
|
||||
'READ_SAFETY_QUIZ_REPORTS', 'READ_PERSONALITY_REPORTS',
|
||||
'READ_ZONE_CHECKIN_REPORTS', 'READ_POLICY_ACKNOWLEDGMENT_REPORTS',
|
||||
'READ_AUDIO_FILES', 'MANAGE_AUDIO_FILES',
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import type { EsaApprovedUse, EsaKeyPoint } from '@/shared/types/esaFunding';
|
||||
|
||||
export const ESA_FUNDING_STATIC_COPY = {
|
||||
heroEyebrow: 'What is ESA?',
|
||||
heroTitle:
|
||||
@ -16,45 +18,30 @@ export const ESA_FUNDING_STATIC_COPY = {
|
||||
parentConversationFooter: 'For detailed questions, always direct families to the front office or administration.',
|
||||
} as const;
|
||||
|
||||
export const ESA_FUNDING_FAQS = [
|
||||
{
|
||||
question: 'What is an ESA (Empowerment Scholarship Account)?',
|
||||
answer:
|
||||
'An ESA is a state-funded account that provides education dollars directly to families. Instead of funding going only to a single public school, ESA money follows the child, allowing parents to choose the learning environment and services that best fit their child\'s unique needs.',
|
||||
},
|
||||
{
|
||||
question: 'Who is eligible for ESA funding?',
|
||||
answer:
|
||||
'Eligibility varies by state, but ESA programs typically prioritize students with disabilities including autism, students from low-income families, students in underperforming schools, foster children, and children of active-duty military. In many states, all K-12 students are now eligible.',
|
||||
},
|
||||
{
|
||||
question: 'What can ESA funds be used for?',
|
||||
answer:
|
||||
'ESA funds can be used for approved educational expenses including private school tuition, specialized therapies, tutoring services, curriculum and textbooks, educational technology, online learning programs, and other approved educational services.',
|
||||
},
|
||||
{
|
||||
question: 'How does ESA funding affect our school?',
|
||||
answer:
|
||||
'ESA funding is a positive opportunity for our school community. Families who choose our programs can use ESA funds to pay for tuition and specialized services we offer. This means more families can access the autism-focused education and therapies we provide, regardless of their financial situation.',
|
||||
},
|
||||
{
|
||||
question: 'How much funding does each student receive?',
|
||||
answer:
|
||||
'The amount varies by state and student need. ESA amounts often range from several thousand dollars per year for general education students to higher amounts for students with disabilities who require specialized services and therapies.',
|
||||
},
|
||||
{
|
||||
question: 'How do families apply for ESA funding?',
|
||||
answer:
|
||||
'Families apply through their state ESA program, often through the Department of Education website. The process typically involves submitting an application, providing proof of eligibility, receiving approval, and directing funds to chosen educational providers.',
|
||||
},
|
||||
{
|
||||
question: 'Can ESA funds be used for therapies at our school?',
|
||||
answer:
|
||||
'Yes. ESA funds can cover many specialized services, including speech therapy, occupational therapy, behavioral therapy, social skills groups, and other therapeutic interventions when those services are approved by the state program.',
|
||||
},
|
||||
{
|
||||
question: 'What is our role as staff in the ESA process?',
|
||||
answer:
|
||||
'Staff should understand the basics, direct families to the office for detailed guidance, document student services accurately for ESA reporting, and continue providing individualized education regardless of how services are funded.',
|
||||
},
|
||||
] as const;
|
||||
export const ESA_FUNDING_STATIC_KEY_POINTS: readonly EsaKeyPoint[] = [
|
||||
{ iconId: 'arrow', label: 'Education money that follows the child' },
|
||||
{ iconId: 'school', label: 'Instead of staying with one school' },
|
||||
{ iconId: 'users', label: 'Parents choose the best fit for their child' },
|
||||
{ iconId: 'check', label: 'Covers tuition, therapies, tutoring & more' },
|
||||
{ iconId: 'star', label: 'More flexibility and control for families' },
|
||||
{ iconId: 'heart', label: 'Especially impactful for students with disabilities' },
|
||||
];
|
||||
|
||||
export const ESA_FUNDING_STATIC_STATE_CHECKLIST: readonly string[] = [
|
||||
'Whether your state offers an ESA or similar school choice program',
|
||||
'What the specific eligibility requirements are in your state',
|
||||
'What expenses and services ESA funds can be applied toward in your state',
|
||||
'The application process and deadlines for your state\'s program',
|
||||
'Any reporting or documentation requirements unique to your state',
|
||||
];
|
||||
|
||||
export const ESA_FUNDING_STATIC_APPROVED_USES: readonly EsaApprovedUse[] = [
|
||||
{ iconId: 'school', title: 'Private School Tuition', description: 'Full or partial tuition at approved private schools, including autism-focused programs', color: 'from-violet-500 to-violet-600' },
|
||||
{ iconId: 'heart', title: 'Specialized Therapies', description: 'Speech therapy, occupational therapy, ABA, physical therapy, and counseling', color: 'from-pink-500 to-rose-600' },
|
||||
{ iconId: 'book', title: 'Tutoring Services', description: 'One-on-one or small group tutoring from approved educational providers', color: 'from-blue-500 to-blue-600' },
|
||||
{ iconId: 'puzzle', title: 'Curriculum & Materials', description: 'Textbooks, workbooks, educational software, and approved learning materials', color: 'from-amber-500 to-orange-600' },
|
||||
{ iconId: 'graduation', title: 'Online Learning', description: 'Approved online courses, virtual tutoring, and digital learning platforms', color: 'from-emerald-500 to-green-600' },
|
||||
{ iconId: 'users', title: 'Educational Services', description: 'Social skills groups, life skills training, vocational preparation, and more', color: 'from-cyan-500 to-teal-600' },
|
||||
];
|
||||
|
||||
export const ESA_FUNDING_POLICY_TAG = 'ESA Funding';
|
||||
|
||||
@ -8,6 +8,7 @@ export const POLICY_DATE_NOT_RECORDED_LABEL = 'Not recorded';
|
||||
export const POLICY_QUERY_KEYS = {
|
||||
documents: ['policies', 'documents'],
|
||||
safetyDocuments: ['policies', 'safety-documents'],
|
||||
esaDocuments: ['policies', 'esa-documents'],
|
||||
acknowledgments: ['policies', 'acknowledgments'],
|
||||
acknowledgmentReport: ['policies', 'acknowledgment-report'],
|
||||
} as const;
|
||||
|
||||
@ -10,4 +10,5 @@ export interface ContentCatalogMutationDto<TPayload> {
|
||||
readonly payload: TPayload;
|
||||
readonly active?: boolean;
|
||||
readonly importHash?: string | null;
|
||||
readonly changeSummary?: string;
|
||||
}
|
||||
|
||||
@ -10,8 +10,10 @@ export type EsaIconId =
|
||||
| 'star';
|
||||
|
||||
export interface EsaFaqItem {
|
||||
readonly id: string;
|
||||
readonly question: string;
|
||||
readonly answer: string;
|
||||
readonly audience: 'all' | 'staff';
|
||||
}
|
||||
|
||||
export interface EsaApprovedUse {
|
||||
@ -27,22 +29,33 @@ export interface EsaKeyPoint {
|
||||
}
|
||||
|
||||
export interface EsaStaffRoleItem {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
export interface EsaResource {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
export interface EsaSchoolImpactItem {
|
||||
readonly id: string;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export interface EsaParentConversationReference {
|
||||
readonly id: string;
|
||||
readonly question: string;
|
||||
readonly answer: string;
|
||||
}
|
||||
|
||||
export interface EsaFundingContent {
|
||||
readonly approvedUses: readonly EsaApprovedUse[];
|
||||
readonly keyPoints: readonly EsaKeyPoint[];
|
||||
readonly stateChecklist: readonly string[];
|
||||
readonly schoolImpactItems: readonly string[];
|
||||
readonly schoolImpactItems: readonly EsaSchoolImpactItem[];
|
||||
readonly staffRoleItems: readonly EsaStaffRoleItem[];
|
||||
readonly parentConversationScript: string;
|
||||
readonly faqs: readonly EsaFaqItem[];
|
||||
readonly parentConversationReferences: readonly EsaParentConversationReference[];
|
||||
readonly resources: readonly EsaResource[];
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user