Classroom support strategies CRUD

This commit is contained in:
Dmitri 2026-06-17 21:45:57 +02:00
parent 5e9b4995ab
commit 40e60165d4
35 changed files with 928 additions and 87 deletions

View File

@ -27,13 +27,14 @@ 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.
- `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.
## Tenant Scope
Content records can be tenant-scoped through nullable `organizationId`, `schoolId`, `campusId`, and `classId` columns:
- 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.
- Org-scoped types read the caller's organization 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.
- Shared/global types use all-null tenant ids.
Management list excludes tenant-scoped content because those records are edited through dedicated per-type editors.
@ -48,6 +49,8 @@ Management list excludes tenant-scoped content because those records are edited
- `create` looks up any existing row by `content_type` plus its exact tenant owner with `paranoid: false`. If a non-deleted row exists it throws `ValidationError`. If a soft-deleted row exists it is restored and updated inside `withTransaction`; otherwise a new row is created.
- `update` and `delete` run inside `withTransaction` and throw `ValidationError('contentCatalogNotFound')` when the row is missing. `delete` sets `active = false` then soft-deletes (`destroy`).
- `findManagedByType` is the authenticated variant of `findByType`: it enforces manage access and then returns the active record.
- For `classroom-strategies`, `findManagedByType`, `create`, `update`, and `delete` also require organization effective scope so organization leaders manage the shared strategy library and descendant scopes remain read-only.
- Managed `classroom-strategies` images are uploaded through the file subsystem first. The content payload stores the returned private URL; production uploads use the configured GCloud bucket path from `file.md`.
- Missing/inactive content types fail explicitly with `ValidationError` rather than returning empty payloads.
### Seeded content types
@ -55,13 +58,15 @@ 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.
New tenant creation uses `ContentCatalogSeedService.seedDefaultContentForTenant`: org creation presets org-scoped content such as `classroom-strategies`, school creation presets school-scoped content, and campus creation presets only per-tenant campus content. This keeps the Classroom Support library shared at the organization level.
### Content authoring rules
- Add editable or tenant-scoped production content records to backend seed payloads, not frontend constants.
- Frontend constants stay limited to UI config, labels, query keys, timing values, presentation tokens, and product-static catalogs.
- If a catalog needs complex workflow state, approvals, or per-campus variants, replace the generic catalog entry with typed, tenant-scoped backend tables and CRUD APIs.
## Tests
Coverage lives in `src/services/content_catalog.test.ts` and `src/services/content_catalog_seed.test.ts`.
Coverage lives in `src/services/content_catalog.test.ts` and `src/services/content_catalog_seed.test.ts`. It covers tenant read scoping, organization-only management for `classroom-strategies`, and organization-only seeding for the preset Classroom Support library.
## Related
- Frontend: `frontend/docs/content-catalog-integration.md`.

View File

@ -4,6 +4,8 @@ import assert from 'node:assert/strict';
import db from '@/db/models';
import ContentCatalogService from '@/services/content_catalog';
import { createGlobalAccessUser } from '@/test-utils';
import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions';
import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles';
function catalogRecord(payload: unknown) {
return {
@ -16,6 +18,24 @@ function catalogRecord(payload: unknown) {
};
}
function organizationContentManager() {
return createGlobalAccessUser({
app_role: {
name: ROLE_NAMES.SUPER_ADMIN,
scope: ROLE_SCOPES.SYSTEM,
globalAccess: true,
permissions: [{ name: FEATURE_PERMISSIONS.MANAGE_CONTENT_CATALOG }],
},
activeScope: {
level: ROLE_SCOPES.ORGANIZATION,
organizationId: 'org-1',
schoolId: null,
campusId: null,
classId: null,
},
});
}
afterEach(() => {
mock.restoreAll();
});
@ -109,4 +129,47 @@ describe('ContentCatalogService tenant scoping', () => {
classId: null,
});
});
test('allows classroom strategy management from organization scope', async () => {
let capturedWhere: Record<string, unknown> | null = null;
mock.method(db.content_catalog, 'findOne', (async (options: { where?: Record<string, unknown> }) => {
capturedWhere = options.where ?? null;
return catalogRecord([]);
}) as typeof db.content_catalog.findOne);
await ContentCatalogService.findManagedByType(
'classroom-strategies',
organizationContentManager(),
);
assert.deepEqual(capturedWhere, {
content_type: 'classroom-strategies',
active: true,
organizationId: 'org-1',
});
});
test('rejects classroom strategy management outside organization scope', async () => {
await assert.rejects(
() => ContentCatalogService.findManagedByType(
'classroom-strategies',
createGlobalAccessUser({
app_role: {
name: ROLE_NAMES.SUPER_ADMIN,
scope: ROLE_SCOPES.SYSTEM,
globalAccess: true,
permissions: [{ name: FEATURE_PERMISSIONS.MANAGE_CONTENT_CATALOG }],
},
activeScope: {
level: ROLE_SCOPES.CAMPUS,
organizationId: 'org-1',
schoolId: 'school-1',
campusId: 'campus-1',
classId: null,
},
}),
),
{ name: 'ForbiddenError' },
);
});
});

View File

@ -5,6 +5,7 @@ import { resolvePagination } from '@/shared/constants/pagination';
import ForbiddenError from '@/shared/errors/forbidden';
import ValidationError from '@/shared/errors/validation';
import {
getRoleScope,
getOwnTenant,
getOrganizationId,
getSchoolId,
@ -13,12 +14,14 @@ import {
tenantStamp,
} from '@/services/shared/access';
import {
CLASSROOM_SUPPORT_CONTENT_TYPE,
PER_TENANT_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 type { ContentCatalog } from '@/db/models/content_catalog';
import type { CurrentUser } from '@/db/api/types';
@ -100,7 +103,7 @@ function assertCanManageContentCatalog(currentUser?: CurrentUser): void {
* Edit authorization per content type. Tenant scoping constrains the row; the
* right to edit content catalog data is a single effective permission.
*/
function assertCanManageType(currentUser?: CurrentUser): void {
function assertCanManageType(contentType: string, currentUser?: CurrentUser): void {
if (
!hasFeaturePermission(
currentUser,
@ -109,6 +112,13 @@ function assertCanManageType(currentUser?: CurrentUser): void {
) {
throw new ForbiddenError();
}
if (
contentType === CLASSROOM_SUPPORT_CONTENT_TYPE
&& getRoleScope(currentUser) !== ROLE_SCOPES.ORGANIZATION
) {
throw new ForbiddenError();
}
}
function assertValidContentType(contentType: unknown): string {
@ -175,14 +185,15 @@ class ContentCatalogService {
}
static async findManagedByType(contentType: unknown, currentUser?: CurrentUser) {
assertCanManageContentCatalog(currentUser);
const normalizedContentType = assertValidContentType(contentType);
assertCanManageType(normalizedContentType, currentUser);
return this.findByType(contentType, currentUser);
return this.findByType(normalizedContentType, currentUser);
}
static async create(data: ContentCatalogInput, currentUser?: CurrentUser) {
const contentType = assertValidContentType(data?.content_type);
assertCanManageType(currentUser);
assertCanManageType(contentType, currentUser);
const payload = assertValidPayload(data?.payload);
const tenantWhere = tenantWhereFor(contentType, currentUser);
@ -232,7 +243,7 @@ class ContentCatalogService {
currentUser?: CurrentUser,
) {
const normalizedContentType = assertValidContentType(contentType);
assertCanManageType(currentUser);
assertCanManageType(normalizedContentType, currentUser);
const payload = assertValidPayload(data?.payload);
@ -263,7 +274,7 @@ class ContentCatalogService {
static async delete(contentType: unknown, currentUser?: CurrentUser) {
const normalizedContentType = assertValidContentType(contentType);
assertCanManageType(currentUser);
assertCanManageType(normalizedContentType, currentUser);
return withTransaction(async (transaction) => {
const record = await db.content_catalog.findOne({

View File

@ -34,4 +34,42 @@ describe('seedDefaultContentForTenant', () => {
assert.equal(quoteRow.classId, null);
assert.equal(quoteRow.active, true);
});
test('seeds classroom strategies only at organization scope', async () => {
const createdRows: Array<Record<string, unknown>> = [];
mock.method(db.content_catalog, 'findOne', (async () => null) as typeof db.content_catalog.findOne);
mock.method(db.content_catalog, 'create', (async (payload: Record<string, unknown>) => {
createdRows.push(payload);
return payload;
}) as typeof db.content_catalog.create);
await seedDefaultContentForTenant({
level: 'organization',
organizationId: 'org-1',
});
await seedDefaultContentForTenant({
level: 'school',
organizationId: 'org-1',
schoolId: 'school-1',
});
await seedDefaultContentForTenant({
level: 'campus',
organizationId: 'org-1',
schoolId: 'school-1',
campusId: 'campus-1',
});
const classroomRows = createdRows.filter((row) =>
row.content_type === 'classroom-strategies',
);
assert.equal(classroomRows.length, 1);
assert.equal(classroomRows[0]?.organizationId, 'org-1');
assert.equal(classroomRows[0]?.schoolId, null);
assert.equal(classroomRows[0]?.campusId, null);
assert.equal(classroomRows[0]?.classId, null);
assert.equal(classroomRows[0]?.active, true);
assert.ok(Array.isArray(classroomRows[0]?.payload));
});
});

View File

@ -48,7 +48,7 @@ describe('UserProgressService drilled scope persistence guard', () => {
permissions: [],
},
});
let createdPayload: Record<string, unknown> | null = null;
let createdPayload: Record<string, unknown> | undefined;
mock.method(db.sequelize, 'transaction', async () => ({
commit: async () => undefined,
@ -76,14 +76,13 @@ describe('UserProgressService drilled scope persistence guard', () => {
actor,
);
assert.notEqual(createdPayload, null);
const payload = createdPayload as Record<string, unknown>;
assert.equal(payload.progress_type, USER_PROGRESS_TYPES.CLASSROOM_STRATEGY_FAVORITE);
assert.equal(payload.item_id, 'token-economy');
assert.equal(payload.value, 'Token Economy System');
assert.equal(payload.organizationId, 'org-1');
assert.equal(payload.campusId, 'campus-1');
assert.equal(payload.userId, 'user-1');
assert.ok(createdPayload);
assert.equal(createdPayload.progress_type, USER_PROGRESS_TYPES.CLASSROOM_STRATEGY_FAVORITE);
assert.equal(createdPayload.item_id, 'token-economy');
assert.equal(createdPayload.value, 'Token Economy System');
assert.equal(createdPayload.organizationId, 'org-1');
assert.equal(createdPayload.campusId, 'campus-1');
assert.equal(createdPayload.userId, 'user-1');
assert.equal(result?.item_id, 'token-economy');
});

View File

@ -1,4 +1,4 @@
/** Classroom Support — org-scoped but also editable by a campus director. */
/** Classroom Support — org-scoped and managed from organization scope. */
export const CLASSROOM_SUPPORT_CONTENT_TYPE = 'classroom-strategies';
/** The safety/QBS quiz content type, dedicated per tenant (org/school/campus). */

View File

@ -13,11 +13,13 @@ View:
- `frontend/src/components/frameworks/ClassroomSupport.tsx`
- `frontend/src/components/classroom-support/ClassroomSupportView.tsx`
- `frontend/src/components/classroom-support/ClassroomSupportHeader.tsx`
- `frontend/src/components/classroom-support/ClassroomSupportManagementPanel.tsx`
- `frontend/src/components/classroom-support/ClassroomSupportTryToday.tsx`
- `frontend/src/components/classroom-support/ClassroomSupportFilters.tsx`
- `frontend/src/components/classroom-support/ClassroomStrategyGrid.tsx`
- `frontend/src/components/classroom-support/ClassroomStrategyCard.tsx`
- `frontend/src/components/classroom-support/ClassroomStrategyDetailModal.tsx`
- `frontend/src/components/common/ConfirmationDialog.tsx`
Business logic:
@ -37,9 +39,14 @@ The page reads:
- `GET /api/content-catalog/read/classroom-strategies`
Organization-level strategy managers update:
- `PUT /api/content-catalog/classroom-strategies`
- `POST /api/file/upload/classroom-support/strategy-images`
The 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`
Each strategy payload supports:
@ -49,13 +56,17 @@ Each strategy payload supports:
- `category`
- `ageGroup`
- `zone`
- `image`
- `image` (uploaded file private URL for newly managed cards; seeded presets may use static asset paths)
- `implementationTip`
## Behavior
- `useClassroomSupportPage` loads strategies through the shared content catalog hook.
- The app-shell scope selector exposes this module at organization, school, campus, and class effective tiers when the user has `READ_CLASSROOM`. Organization-level users can maintain classroom strategy content for descendant schools and campuses through the same backend-owned content catalog.
- The management panel is shown only when the effective tenant is an organization and the user has `MANAGE_CONTENT_CATALOG`. The backend enforces the same organization-scope rule for `classroom-strategies` management endpoints.
- Add, edit, and delete operations update the organization-scoped `classroom-strategies` payload. Strategy images are uploaded through the shared file subsystem before the content payload is saved, so production storage uses the configured bucket path and the payload stores the returned private URL.
- Delete uses the shared confirmation dialog so destructive classroom strategy actions stay consistent with project modal styling.
- Seeded strategy cards are preset for each organization through content catalog seeding. New school and campus tenants do not receive independent classroom-strategy rows; they read the organization library.
- Favorite strategy IDs load and persist through `GET/POST/DELETE /api/user_progress` with `progress_type = classroom_strategy_favorite`. Favorite controls are enabled only when the user is viewing their own scope; parent users drilled into a child tenant do not load or write child-scope favorites.
- Selectors handle search, category, age, zone, favorites-only filtering, and the daily strategy selection.
- View components receive a prepared page model and do not call API/data access modules.
@ -67,4 +78,6 @@ Each strategy payload supports:
- Do not add classroom strategy records to frontend constants.
- Do not add frontend fallback strategy payloads.
- Keep frontend constants limited to filter options, style classes, query-independent timing values, and UI labels.
- Keep favorites out of `content_catalog`; they are per-user rows in `user_progress`.
- Keep uploaded card images in the shared file subsystem (`classroom-support/strategy-images`) and store only the private URL in the strategy payload.
- Test-only fixtures may live in selector tests or `frontend/src/test-seeds/`.

View File

@ -108,6 +108,8 @@ QBS quiz title, weekly focus, key reminders, questions, answer choices, correct
Classroom strategy titles, descriptions, images, categories, age groups, regulation zones, and implementation tips are part of the `classroom-strategies` content catalog payload. The frontend may keep filter labels and style tokens, but it must not keep strategy records or implementation copy in shared constants.
`classroom-strategies` is organization-scoped. Organization managers with `MANAGE_CONTENT_CATALOG` can add, edit, delete, and upload images for the shared strategy library; school, campus, and classroom views read the same organization-owned payload and can only persist per-user favorites when the user is in their own scope.
## Editable Sign Language Content
Sign records, teaching tips, video URLs, GIF URLs, step instructions, and page-level teaching reminders are part of the `sign-language-items` and `sign-language-page-content` content catalog payloads. The frontend renders these payloads and does not keep sign content or reminder copy in shared constants.

View File

@ -79,7 +79,7 @@ Current coverage includes architecture guardrails, API/data-access behavior, and
- `frontend/src/hooks/usePermissions.test.tsx`
- `frontend/src/components/sign-in-modal/SignInForm.test.tsx`
These tests verify import-boundary enforcement, centralized network access through `shared/api/httpClient`, alias-only source imports, required API contract-test coverage, HTTP client request normalization, cookie-backed auth refresh behavior, JSON body serialization, empty response handling, backend error propagation, API wrapper endpoint/query/body contracts, shared list/query/error helpers, route config, module route metadata, module access correction, sidebar navigation selectors, mobile sidebar overlay visibility, auth display/profile mapping, signup validation, campus mapping, dashboard and director dashboard selectors, classroom support selectors, timer formatting/progress/threshold parsing, DTO mapping, FRAME edit access, campus attendance mapping, calculations, and printable report output, staff attendance mapping and rollups, walkthrough create DTO mapping, walkthrough filtering, walkthrough check-in stats/history mapping, walkthrough scoring, generated summaries, repeated low-rating flags, communication role visibility, community catalog filtering and stats, ESA funding selectors, policy mapping/filtering/validation, safety-protocol mapping and authoring (steps/considerations) validation, audio-library management gating and the local recipe-generation stub, daily Zone check-in eligibility/nudge, header search over accessible modules + their content, outside-click dismissal, the shared American (Sunday-start) week canonicalization, F.R.A.M.E. week/label mapping, safety quiz compliance mapping and editable payload validation, sign language selectors, top bar display selectors, user progress normalization, vocational zip/category/search/stat calculations, zones selectors, personality DTO mapping, personality distribution grouping, EI thresholds, personality quiz progress, and MBTI-derived communication guidance.
These tests verify import-boundary enforcement, centralized network access through `shared/api/httpClient`, alias-only source imports, required API contract-test coverage, HTTP client request normalization, cookie-backed auth refresh behavior, JSON body serialization, empty response handling, backend error propagation, API wrapper endpoint/query/body contracts, shared list/query/error helpers, route config, module route metadata, module access correction, sidebar navigation selectors, mobile sidebar overlay visibility, auth display/profile mapping, signup validation, campus mapping, dashboard and director dashboard selectors, classroom support selectors including search over titles/descriptions and favorites-only filtering, timer formatting/progress/threshold parsing, DTO mapping, FRAME edit access, campus attendance mapping, calculations, and printable report output, staff attendance mapping and rollups, walkthrough create DTO mapping, walkthrough filtering, walkthrough check-in stats/history mapping, walkthrough scoring, generated summaries, repeated low-rating flags, communication role visibility, community catalog filtering and stats, ESA funding selectors, policy mapping/filtering/validation, safety-protocol mapping and authoring (steps/considerations) validation, audio-library management gating and the local recipe-generation stub, daily Zone check-in eligibility/nudge, header search over accessible modules + their content, outside-click dismissal, the shared American (Sunday-start) week canonicalization, F.R.A.M.E. week/label mapping, safety quiz compliance mapping and editable payload validation, sign language selectors, top bar display selectors, user progress normalization including Classroom Support favorite list/upsert API contracts, vocational zip/category/search/stat calculations, zones selectors, personality DTO mapping, personality distribution grouping, EI thresholds, personality quiz progress, and MBTI-derived communication guidance.
The component and hook tests verify SignInForm rendering, loading states, user interactions, form submission, password visibility toggling, auth session initialization, sign-in/sign-out flows, AuthExpiredError handling, modal workflow state management, and permissions hook behavior including has(), hasAny(), hasAll() methods with globalAccess support and explicit personal-workflow exclusions.

View File

@ -17,10 +17,11 @@ import { ScopeProvider } from '@/contexts/ScopeProvider';
import { APP_DEFAULT_THEME } from '@/shared/constants/theme';
import {
FORBIDDEN_ERROR_MESSAGE,
isAuthExpiredError,
isForbiddenError,
isUnauthorizedError,
} from '@/shared/errors/errorMessages';
import { AUTH_EXPIRED_EVENT } from '@/shared/constants/auth';
import { ApiError, AuthExpiredError } from '@/shared/api/httpClient';
// Single handler so a backend 403 (permission denied) degrades to a toast
// rather than a crash or silent failure, wherever it occurs.
@ -31,11 +32,11 @@ function notifyOnForbidden(error: unknown): void {
}
function shouldRetryQuery(failureCount: number, error: unknown): boolean {
if (error instanceof AuthExpiredError) {
if (isAuthExpiredError(error)) {
return false;
}
if (error instanceof ApiError && error.status === 401) {
if (isUnauthorizedError(error)) {
return false;
}

View File

@ -22,10 +22,10 @@ describe('app routes', () => {
?.map((route) => route.path)
.filter((path): path is string => typeof path === 'string');
// Module routes (in MODULES order) followed by the non-module shell routes
// (the self-service profile page).
// Module routes (in MODULES order) followed by non-module shell routes.
expect(childPaths).toEqual([
...MODULES.map((module) => module.routePath.slice(1)),
APP_ROUTE_PATHS.attendanceDetails.slice(1),
APP_ROUTE_PATHS.profile.slice(1),
]);
});

View File

@ -103,8 +103,8 @@ export const appRoutes: RouteObject[] = [
element: <AppLayout />,
children: [
{ index: true, element: <IndexRedirect /> },
...extraShellRoutes,
...moduleRoutes,
...extraShellRoutes,
],
},
],

View File

@ -116,19 +116,21 @@ export function useAppShell(options: UseAppShellOptions): AppShellState {
}
}
const redirectPath = getScopedModuleRouteRedirectPath(
MODULES,
location.pathname,
options.user,
effectiveTier,
isDrilled,
);
if (redirectPath && redirectPath !== location.pathname) {
navigate(redirectPath, {
replace: true,
state: { ...routeState, __scope: selectedTenant },
});
return;
if (scopeChanged) {
const redirectPath = getScopedModuleRouteRedirectPath(
MODULES,
location.pathname,
options.user,
effectiveTier,
isDrilled,
);
if (redirectPath && redirectPath !== location.pathname) {
navigate(redirectPath, {
replace: true,
state: { ...routeState, __scope: selectedTenant },
});
return;
}
}
if (!sameScopeTenant(routeScopeTenant, selectedTenant)) {

View File

@ -115,6 +115,7 @@ describe('app-shell selectors', () => {
expect(canAccessScopedModuleRoute(scopedModules, '/platform-dashboard', globalUser(), 'global', false)).toBe(true);
expect(canAccessScopedModuleRoute(scopedModules, '/dashboard', globalUser(), 'global', false)).toBe(false);
expect(canAccessScopedModuleRoute(scopedModules, '/zones-of-regulation', globalUser(), 'global', false)).toBe(false);
expect(canAccessScopedModuleRoute(scopedModules, '/director-dashboard', globalUser(), 'global', false)).toBe(true);
expect(canAccessScopedModuleRoute(scopedModules, '/classroom-support', user(['READ_CLASSROOM']), 'organization', false)).toBe(true);
expect(canAccessScopedModuleRoute(scopedModules, '/classroom-support', user(['READ_CLASSROOM']), 'school', false)).toBe(true);
expect(canAccessScopedModuleRoute(scopedModules, '/qbs-safety', user(['READ_QBS']), 'organization', false)).toBe(true);

View File

@ -79,6 +79,9 @@ export function canAccessScopedModuleRoute(
): boolean {
const module = getModuleByRoutePath(pathname);
if (!module) return true;
if (module.id === 'director' && user?.app_role?.globalAccess && hasAnyPermission(user, module.permissions)) {
return true;
}
return getScopedModules(modules, user, effectiveTier, isDrilled)
.some((allowed) => allowed.id === module.id);
}

View File

@ -113,6 +113,17 @@ export function useSaveCampusAttendanceSummary() {
});
}
export function useAttendanceDetailsChildCampuses(
level: TenantLevel | undefined,
tenantId: string | undefined,
) {
return useQuery({
queryKey: ['attendance-details-child-campuses', level, tenantId],
enabled: level === 'school' && Boolean(tenantId),
queryFn: () => getScopeChildren('school', tenantId ?? '', { limit: 500 }),
});
}
export function useSaveStaffAttendanceRecord() {
return useInvalidatingMutation({
mutationFn: (input: StaffAttendanceEntryDraft) => saveStaffAttendanceRecord(

View File

@ -1,4 +1,5 @@
import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useContentCatalogPayload } from '@/business/content-catalog/hooks';
import { canPersistPersonalScopeResults } from '@/business/scope/selectors';
@ -8,18 +9,82 @@ import {
} from '@/business/classroom-support/selectors';
import type { ClassroomSupportPage } from '@/business/classroom-support/types';
import { useClassroomStrategyFavorites } from '@/business/user-progress/hooks';
import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog';
import { updateManagedContentCatalog } from '@/shared/api/contentCatalog';
import {
CONTENT_CATALOG_QUERY_KEYS,
CONTENT_CATALOG_TYPES,
} from '@/shared/constants/contentCatalog';
import { useScopeContext } from '@/shared/app/scope-context';
import { usePermissions } from '@/shared/app/usePermissions';
import type {
ClassroomSupportAgeFilter,
ClassroomSupportCategoryFilter,
ClassroomSupportZoneFilter,
} from '@/shared/constants/classroomSupport';
import type { Strategy } from '@/shared/types/app';
import type { ClassroomStrategyDraft } from '@/business/classroom-support/types';
const DEFAULT_DRAFT: ClassroomStrategyDraft = {
id: null,
title: '',
description: '',
implementationTip: '',
category: 'visual-support',
ageGroup: 'All',
zone: 'green',
image: '',
};
function draftFromStrategy(strategy: Strategy): ClassroomStrategyDraft {
return {
id: strategy.id,
title: strategy.title,
description: strategy.description,
implementationTip: strategy.implementationTip ?? '',
category: strategy.category,
ageGroup: strategy.ageGroup,
zone: strategy.zone,
image: strategy.image,
};
}
function createStrategyId(): string {
return `strategy-${crypto.randomUUID()}`;
}
function strategyFromDraft(draft: ClassroomStrategyDraft): Strategy {
return {
id: draft.id ?? createStrategyId(),
title: draft.title.trim(),
description: draft.description.trim(),
implementationTip: draft.implementationTip.trim() || undefined,
category: draft.category,
ageGroup: draft.ageGroup,
zone: draft.zone,
image: draft.image.trim(),
};
}
function validateStrategyDraft(draft: ClassroomStrategyDraft): string | null {
if (!draft.title.trim()) {
return 'Strategy title is required.';
}
if (!draft.description.trim()) {
return 'Strategy description is required.';
}
if (!draft.image.trim()) {
return 'Strategy image URL is required.';
}
return null;
}
export function useClassroomSupportPage(now: Date = new Date()): ClassroomSupportPage {
const { ownTenant, selectedTenant } = useScopeContext();
const { effectiveTenant, ownTenant, selectedTenant } = useScopeContext();
const permissions = usePermissions();
const queryClient = useQueryClient();
const canPersistFavorites = canPersistPersonalScopeResults(ownTenant, selectedTenant);
const canManageStrategies =
effectiveTenant?.level === 'organization' && permissions.has('MANAGE_CONTENT_CATALOG');
const strategiesQuery = useContentCatalogPayload<readonly Strategy[]>(
CONTENT_CATALOG_TYPES.classroomStrategies,
[],
@ -31,7 +96,33 @@ export function useClassroomSupportPage(now: Date = new Date()): ClassroomSuppor
const [zoneFilter, setZoneFilter] = useState<ClassroomSupportZoneFilter>('all');
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
const [selectedStrategy, setSelectedStrategy] = useState<Strategy | null>(null);
const [strategyDraft, setStrategyDraft] = useState<ClassroomStrategyDraft | null>(null);
const [strategyDraftMode, setStrategyDraftMode] = useState<'create' | 'edit' | null>(null);
const [strategyDraftError, setStrategyDraftError] = useState<string | null>(null);
const [strategySaveMessage, setStrategySaveMessage] = useState<string | null>(null);
const [pendingDeleteStrategy, setPendingDeleteStrategy] = useState<Strategy | null>(null);
const strategies = strategiesQuery.payload;
const existingStrategyIds = useMemo(
() => new Set(strategies.map((strategy) => strategy.id)),
[strategies],
);
const visibleFavoriteStrategyIds = useMemo(
() => new Set([...favorites.favoriteStrategyIds].filter((id) => existingStrategyIds.has(id))),
[existingStrategyIds, favorites.favoriteStrategyIds],
);
const saveStrategies = useMutation({
mutationFn: async (nextStrategies: readonly Strategy[]) =>
updateManagedContentCatalog<readonly Strategy[]>(
CONTENT_CATALOG_TYPES.classroomStrategies,
{ payload: nextStrategies },
),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, CONTENT_CATALOG_TYPES.classroomStrategies],
});
setStrategySaveMessage('Classroom support cards saved.');
},
});
const filters = useMemo(
() => ({
searchQuery,
@ -43,8 +134,8 @@ export function useClassroomSupportPage(now: Date = new Date()): ClassroomSuppor
[ageFilter, categoryFilter, searchQuery, showFavoritesOnly, zoneFilter],
);
const filteredStrategies = useMemo(
() => filterClassroomSupportStrategies(strategies, filters, favorites.favoriteStrategyIds),
[favorites.favoriteStrategyIds, filters, strategies],
() => filterClassroomSupportStrategies(strategies, filters, visibleFavoriteStrategyIds),
[filters, strategies, visibleFavoriteStrategyIds],
);
const tryTodayStrategy = useMemo(
() => getClassroomSupportDailyStrategy(strategies, now),
@ -68,16 +159,110 @@ export function useClassroomSupportPage(now: Date = new Date()): ClassroomSuppor
setSearchQuery('');
}
function startCreateStrategy() {
if (!canManageStrategies) {
return;
}
setStrategyDraft(DEFAULT_DRAFT);
setStrategyDraftMode('create');
setStrategyDraftError(null);
setStrategySaveMessage(null);
}
function startEditStrategy(strategy: Strategy) {
if (!canManageStrategies) {
return;
}
setStrategyDraft(draftFromStrategy(strategy));
setStrategyDraftMode('edit');
setStrategyDraftError(null);
setStrategySaveMessage(null);
}
function updateStrategyDraft(updates: Partial<ClassroomStrategyDraft>) {
setStrategyDraft((current) => current ? { ...current, ...updates } : current);
setStrategyDraftError(null);
setStrategySaveMessage(null);
}
function cancelStrategyDraft() {
setStrategyDraft(null);
setStrategyDraftMode(null);
setStrategyDraftError(null);
}
function saveStrategyDraft() {
if (!canManageStrategies || !strategyDraft || !strategyDraftMode) {
return;
}
const validationError = validateStrategyDraft(strategyDraft);
if (validationError) {
setStrategyDraftError(validationError);
return;
}
const nextStrategy = strategyFromDraft(strategyDraft);
const nextStrategies =
strategyDraftMode === 'create'
? [...strategies, nextStrategy]
: strategies.map((strategy) => strategy.id === nextStrategy.id ? nextStrategy : strategy);
void saveStrategies.mutateAsync(nextStrategies).then(() => {
setStrategyDraft(null);
setStrategyDraftMode(null);
setStrategyDraftError(null);
}).catch(() => undefined);
}
function requestDeleteStrategy(strategy: Strategy) {
if (!canManageStrategies) {
return;
}
setPendingDeleteStrategy(strategy);
}
function cancelDeleteStrategy() {
setPendingDeleteStrategy(null);
}
function confirmDeleteStrategy() {
if (!canManageStrategies || !pendingDeleteStrategy) {
return;
}
const id = pendingDeleteStrategy.id;
const nextStrategies = strategies.filter((strategy) => strategy.id !== id);
void saveStrategies.mutateAsync(nextStrategies).then(() => {
setPendingDeleteStrategy(null);
if (strategyDraft?.id === id) {
cancelStrategyDraft();
}
if (selectedStrategy?.id === id) {
setSelectedStrategy(null);
}
}).catch(() => undefined);
}
return {
strategies,
filteredStrategies,
favoriteStrategyIds: favorites.favoriteStrategyIds,
favoriteCount: favorites.favoriteStrategyIds.size,
favoriteStrategyIds: visibleFavoriteStrategyIds,
favoriteCount: visibleFavoriteStrategyIds.size,
canPersistFavorites,
isSavingFavorite: favorites.isSaving,
favoriteError: favorites.error,
canManageStrategies,
strategyDraft,
strategyDraftMode,
strategyDraftError,
strategySaveMessage,
isSavingStrategies: saveStrategies.isPending,
strategyManagementError: saveStrategies.error,
filters,
selectedStrategy,
pendingDeleteStrategy,
tryTodayStrategy,
isLoading: strategiesQuery.isLoading,
error: strategiesQuery.error,
@ -90,5 +275,13 @@ export function useClassroomSupportPage(now: Date = new Date()): ClassroomSuppor
selectStrategy: setSelectedStrategy,
closeStrategy: () => setSelectedStrategy(null),
clearSearch,
startCreateStrategy,
startEditStrategy,
updateStrategyDraft,
cancelStrategyDraft,
saveStrategyDraft,
requestDeleteStrategy,
cancelDeleteStrategy,
confirmDeleteStrategy,
};
}

View File

@ -5,6 +5,19 @@ import type {
} from '@/shared/constants/classroomSupport';
import type { Strategy } from '@/shared/types/app';
export interface ClassroomStrategyDraft {
readonly id: string | null;
readonly title: string;
readonly description: string;
readonly implementationTip: string;
readonly category: Strategy['category'];
readonly ageGroup: Strategy['ageGroup'];
readonly zone: Strategy['zone'];
readonly image: string;
}
export type ClassroomStrategyDraftMode = 'create' | 'edit';
export interface ClassroomSupportFilters {
readonly searchQuery: string;
readonly categoryFilter: ClassroomSupportCategoryFilter;
@ -21,8 +34,16 @@ export interface ClassroomSupportPage {
readonly canPersistFavorites: boolean;
readonly isSavingFavorite: boolean;
readonly favoriteError: unknown;
readonly canManageStrategies: boolean;
readonly strategyDraft: ClassroomStrategyDraft | null;
readonly strategyDraftMode: ClassroomStrategyDraftMode | null;
readonly strategyDraftError: string | null;
readonly strategySaveMessage: string | null;
readonly isSavingStrategies: boolean;
readonly strategyManagementError: unknown;
readonly filters: ClassroomSupportFilters;
readonly selectedStrategy: Strategy | null;
readonly pendingDeleteStrategy: Strategy | null;
readonly tryTodayStrategy: Strategy | null;
readonly isLoading: boolean;
readonly error: unknown;
@ -35,4 +56,12 @@ export interface ClassroomSupportPage {
readonly selectStrategy: (strategy: Strategy) => void;
readonly closeStrategy: () => void;
readonly clearSearch: () => void;
readonly startCreateStrategy: () => void;
readonly startEditStrategy: (strategy: Strategy) => void;
readonly updateStrategyDraft: (updates: Partial<ClassroomStrategyDraft>) => void;
readonly cancelStrategyDraft: () => void;
readonly saveStrategyDraft: () => void;
readonly requestDeleteStrategy: (strategy: Strategy) => void;
readonly cancelDeleteStrategy: () => void;
readonly confirmDeleteStrategy: () => void;
}

View File

@ -1 +1 @@
export { fileDownloadUrl, uploadFile } from '@/shared/api/files';
export { fileAssetUrl, fileDownloadUrl, uploadFile } from '@/shared/api/files';

View File

@ -31,6 +31,8 @@ describe('staff attendance mappers', () => {
note: 'Traffic delay',
userName: 'Jordan Lee',
userRole: 'Teacher',
campusId: 'campus-1',
userId: 'user-1',
});
});

View File

@ -42,7 +42,11 @@ const AppLayout: React.FC = () => {
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<TopBar {...topBarProps} />
<main className="flex-1 overflow-y-auto p-4 md:p-6 lg:p-8">
<main
aria-label="Main content"
className="flex-1 overflow-y-auto p-4 outline-none md:p-6 lg:p-8"
tabIndex={0}
>
<div className="max-w-7xl mx-auto">
<Outlet context={shellOutletContext} />
</div>

View File

@ -1,8 +1,9 @@
import { Bookmark, BookmarkPlus } from 'lucide-react';
import { Bookmark, BookmarkPlus, Edit3, Trash2 } from 'lucide-react';
import type { KeyboardEvent } from 'react';
import { Button } from '@/components/ui/button';
import { fileAssetUrl } from '@/business/files/api';
import {
CLASSROOM_SUPPORT_CATEGORY_COLOR_CLASSES,
CLASSROOM_SUPPORT_ZONE_COLOR_CLASSES,
@ -13,16 +14,24 @@ interface ClassroomStrategyCardProps {
readonly strategy: Strategy;
readonly isFavorite: boolean;
readonly canPersistFavorite: boolean;
readonly canManageStrategy: boolean;
readonly isSavingStrategy: boolean;
readonly onSelect: (strategy: Strategy) => void;
readonly onToggleFavorite: (id: string) => void;
readonly onEditStrategy: (strategy: Strategy) => void;
readonly onDeleteStrategy: (strategy: Strategy) => void;
}
export function ClassroomStrategyCard({
strategy,
isFavorite,
canPersistFavorite,
canManageStrategy,
isSavingStrategy,
onSelect,
onToggleFavorite,
onEditStrategy,
onDeleteStrategy,
}: ClassroomStrategyCardProps) {
function openStrategy() {
onSelect(strategy);
@ -45,25 +54,55 @@ export function ClassroomStrategyCard({
className="absolute inset-0 z-0 w-full h-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50 focus-visible:ring-inset rounded-2xl"
/>
<div className="h-40 overflow-hidden relative">
<img src={strategy.image} alt={strategy.title} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
<img src={fileAssetUrl(strategy.image)} alt={strategy.title} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/80 via-transparent to-transparent" />
{canPersistFavorite && (
<Button
type="button"
onClick={(event) => {
event.stopPropagation();
onToggleFavorite(strategy.id);
}}
aria-label={isFavorite ? 'Remove strategy from favorites' : 'Save strategy to favorites'}
className="absolute top-2 right-2 z-10 w-8 h-8 bg-slate-900/60 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-slate-900/80 transition-colors p-0"
>
{isFavorite ? (
<Bookmark size={16} className="text-amber-400 fill-amber-400" />
) : (
<BookmarkPlus size={16} className="text-slate-300" />
)}
</Button>
)}
<div className="absolute right-2 top-2 z-10 flex gap-1.5">
{canManageStrategy && (
<>
<Button
type="button"
onClick={(event) => {
event.stopPropagation();
onEditStrategy(strategy);
}}
disabled={isSavingStrategy}
aria-label={`Edit ${strategy.title}`}
className="h-8 w-8 rounded-full bg-slate-900/60 p-0 text-slate-200 backdrop-blur-sm hover:bg-slate-900/80"
>
<Edit3 size={15} />
</Button>
<Button
type="button"
onClick={(event) => {
event.stopPropagation();
onDeleteStrategy(strategy);
}}
disabled={isSavingStrategy}
aria-label={`Delete ${strategy.title}`}
className="h-8 w-8 rounded-full bg-slate-900/60 p-0 text-red-300 backdrop-blur-sm hover:bg-red-950/80 hover:text-red-200"
>
<Trash2 size={15} />
</Button>
</>
)}
{canPersistFavorite && (
<Button
type="button"
onClick={(event) => {
event.stopPropagation();
onToggleFavorite(strategy.id);
}}
aria-label={isFavorite ? 'Remove strategy from favorites' : 'Save strategy to favorites'}
className="h-8 w-8 rounded-full bg-slate-900/60 p-0 backdrop-blur-sm hover:bg-slate-900/80"
>
{isFavorite ? (
<Bookmark size={16} className="text-amber-400 fill-amber-400" />
) : (
<BookmarkPlus size={16} className="text-slate-300" />
)}
</Button>
)}
</div>
<div className="absolute bottom-2 left-2 flex gap-1.5">
<span className={`px-2 py-0.5 rounded-lg text-[10px] font-semibold border ${CLASSROOM_SUPPORT_CATEGORY_COLOR_CLASSES[strategy.category]}`}>
{strategy.category.replace('-', ' ')}

View File

@ -1,6 +1,7 @@
import { Bookmark, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { fileAssetUrl } from '@/business/files/api';
import {
CLASSROOM_SUPPORT_CATEGORY_COLOR_CLASSES,
} from '@/shared/constants/classroomSupport';
@ -39,7 +40,7 @@ export function ClassroomStrategyDetailModal({
onClick={(event) => event.stopPropagation()}
>
<div className="h-52 overflow-hidden relative rounded-t-2xl">
<img src={strategy.image} alt={strategy.title} className="w-full h-full object-cover" />
<img src={fileAssetUrl(strategy.image)} alt={strategy.title} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-800/90 via-transparent to-transparent" />
<Button
type="button"

View File

@ -43,8 +43,12 @@ export function ClassroomStrategyGrid({ page }: ClassroomStrategyGridProps) {
strategy={strategy}
isFavorite={page.favoriteStrategyIds.has(strategy.id)}
canPersistFavorite={page.canPersistFavorites}
canManageStrategy={page.canManageStrategies}
isSavingStrategy={page.isSavingStrategies}
onSelect={page.selectStrategy}
onToggleFavorite={page.toggleFavorite}
onEditStrategy={page.startEditStrategy}
onDeleteStrategy={page.requestDeleteStrategy}
/>
))}
</div>

View File

@ -0,0 +1,223 @@
import { Plus, Save, X } from 'lucide-react';
import type {
ClassroomStrategyDraft,
ClassroomSupportPage,
} from '@/business/classroom-support/types';
import { Button } from '@/components/ui/button';
import { ImageUpload } from '@/components/common/ImageUpload';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
import { Textarea } from '@/components/ui/textarea';
import {
CLASSROOM_SUPPORT_AGE_FILTERS,
CLASSROOM_SUPPORT_CATEGORY_FILTERS,
CLASSROOM_SUPPORT_ZONE_FILTERS,
} from '@/shared/constants/classroomSupport';
import type { Strategy } from '@/shared/types/app';
interface ClassroomSupportManagementPanelProps {
readonly page: ClassroomSupportPage;
}
type DraftField = keyof ClassroomStrategyDraft;
function toStrategyCategory(value: string): Strategy['category'] {
switch (value) {
case 'transition':
case 'sensory':
case 'communication':
case 'behavior':
case 'social':
return value;
case 'visual-support':
default:
return 'visual-support';
}
}
function toStrategyAgeGroup(value: string): Strategy['ageGroup'] {
switch (value) {
case 'K-2':
case '3-5':
case '6-8':
return value;
case 'All':
default:
return 'All';
}
}
function toStrategyZone(value: string): Strategy['zone'] {
switch (value) {
case 'blue':
case 'yellow':
case 'red':
return value;
case 'green':
default:
return 'green';
}
}
export function ClassroomSupportManagementPanel({ page }: ClassroomSupportManagementPanelProps) {
if (!page.canManageStrategies) {
return null;
}
const draft = page.strategyDraft;
function updateTextField(field: DraftField, value: string) {
page.updateStrategyDraft({ [field]: value });
}
return (
<section className="rounded-2xl border border-slate-700/50 bg-slate-900/45 overflow-hidden">
<div className="flex flex-col gap-3 border-b border-slate-700/50 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-lg font-semibold text-white">Manage Strategy Cards</h3>
<p className="text-sm text-slate-400">Organization-level classroom support content</p>
</div>
<Button
type="button"
onClick={page.startCreateStrategy}
disabled={page.isSavingStrategies}
leadingIcon={<Plus size={16} />}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
>
Add Strategy
</Button>
</div>
<div className="space-y-5 p-5">
{draft && (
<div className="rounded-xl border border-slate-700/60 bg-slate-950/35 p-4">
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h4 className="text-base font-semibold text-white">
{page.strategyDraftMode === 'create' ? 'New Strategy' : 'Edit Strategy'}
</h4>
<Button
type="button"
variant="ghost"
size="sm"
onClick={page.cancelStrategyDraft}
leadingIcon={<X size={16} />}
>
Cancel
</Button>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<label className="space-y-2 text-sm font-medium text-slate-300">
Title
<Input
value={draft.title}
onChange={(event) => updateTextField('title', event.target.value)}
placeholder="Strategy title"
/>
</label>
<ImageUpload
value={draft.image || null}
onChange={(privateUrl) => page.updateStrategyDraft({ image: privateUrl ?? '' })}
table="classroom-support"
field="strategy-images"
label="Strategy image"
/>
<label className="space-y-2 text-sm font-medium text-slate-300 lg:col-span-2">
Description
<Textarea
value={draft.description}
onChange={(event) => updateTextField('description', event.target.value)}
placeholder="Short card description"
/>
</label>
<label className="space-y-2 text-sm font-medium text-slate-300 lg:col-span-2">
Implementation Tip
<Textarea
value={draft.implementationTip}
onChange={(event) => updateTextField('implementationTip', event.target.value)}
placeholder="Practical implementation detail"
/>
</label>
<label className="space-y-2 text-sm font-medium text-slate-300">
Category
<NativeSelect
value={draft.category}
onChange={(event) => page.updateStrategyDraft({ category: toStrategyCategory(event.target.value) })}
>
{CLASSROOM_SUPPORT_CATEGORY_FILTERS
.filter((category) => category.value !== 'all')
.map((category) => (
<option key={category.value} value={category.value}>
{category.label}
</option>
))}
</NativeSelect>
</label>
<label className="space-y-2 text-sm font-medium text-slate-300">
Age Group
<NativeSelect
value={draft.ageGroup}
onChange={(event) => page.updateStrategyDraft({ ageGroup: toStrategyAgeGroup(event.target.value) })}
>
{CLASSROOM_SUPPORT_AGE_FILTERS
.filter((age) => age.value !== 'all')
.map((age) => (
<option key={age.value} value={age.value}>
{age.label}
</option>
))}
</NativeSelect>
</label>
<label className="space-y-2 text-sm font-medium text-slate-300">
Zone
<NativeSelect
value={draft.zone}
onChange={(event) => page.updateStrategyDraft({ zone: toStrategyZone(event.target.value) })}
>
{CLASSROOM_SUPPORT_ZONE_FILTERS
.filter((zone) => zone.value !== 'all')
.map((zone) => (
<option key={zone.value} value={zone.value}>
{zone.label}
</option>
))}
</NativeSelect>
</label>
</div>
{page.strategyDraftError && (
<p className="mt-4 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm font-medium text-red-300">
{page.strategyDraftError}
</p>
)}
<div className="mt-5 flex justify-end">
<Button
type="button"
onClick={page.saveStrategyDraft}
loading={page.isSavingStrategies}
leadingIcon={<Save size={16} />}
className="bg-blue-600 hover:bg-blue-500 text-white"
>
Save Strategy
</Button>
</div>
</div>
)}
{Boolean(page.strategyManagementError) && (
<p className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm font-medium text-red-300">
Classroom support cards could not be saved.
</p>
)}
{page.strategySaveMessage && (
<p className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm font-medium text-emerald-300">
{page.strategySaveMessage}
</p>
)}
</div>
</section>
);
}

View File

@ -1,6 +1,8 @@
import type { ClassroomSupportPage } from '@/business/classroom-support/types';
import { ConfirmationDialog } from '@/components/common/ConfirmationDialog';
import { ClassroomSupportFilters } from '@/components/classroom-support/ClassroomSupportFilters';
import { ClassroomSupportHeader } from '@/components/classroom-support/ClassroomSupportHeader';
import { ClassroomSupportManagementPanel } from '@/components/classroom-support/ClassroomSupportManagementPanel';
import { ClassroomSupportTryToday } from '@/components/classroom-support/ClassroomSupportTryToday';
import { ClassroomStrategyDetailModal } from '@/components/classroom-support/ClassroomStrategyDetailModal';
import { ClassroomStrategyGrid } from '@/components/classroom-support/ClassroomStrategyGrid';
@ -18,6 +20,7 @@ export function ClassroomSupportView({ page }: ClassroomSupportViewProps) {
isLoading={page.isLoading}
error={page.error}
/>
<ClassroomSupportManagementPanel page={page} />
<ClassroomSupportFilters
filters={page.filters}
favoriteCount={page.favoriteCount}
@ -38,6 +41,22 @@ export function ClassroomSupportView({ page }: ClassroomSupportViewProps) {
onToggleFavorite={page.toggleFavorite}
onClose={page.closeStrategy}
/>
<ConfirmationDialog
open={Boolean(page.pendingDeleteStrategy)}
title="Delete classroom strategy?"
description={
page.pendingDeleteStrategy
? `This will remove "${page.pendingDeleteStrategy.title}" from the organization classroom support library.`
: 'This will remove the classroom support strategy.'
}
confirmLabel="Delete strategy"
loadingLabel="Deleting..."
loading={page.isSavingStrategies}
disabled={!page.pendingDeleteStrategy}
tone="danger"
onCancel={page.cancelDeleteStrategy}
onConfirm={page.confirmDeleteStrategy}
/>
</div>
);
}

View File

@ -0,0 +1,115 @@
import { AlertTriangle, Info } from 'lucide-react';
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
type ConfirmationDialogTone = 'danger' | 'warning' | 'info';
interface ConfirmationDialogProps {
readonly open: boolean;
readonly title: string;
readonly description: ReactNode;
readonly confirmLabel: string;
readonly cancelLabel?: string;
readonly loading?: boolean;
readonly loadingLabel?: string;
readonly disabled?: boolean;
readonly tone?: ConfirmationDialogTone;
readonly icon?: ReactNode;
readonly onCancel: () => void;
readonly onConfirm: () => void;
}
const toneClasses: Record<ConfirmationDialogTone, {
readonly panelAccent: string;
readonly iconWrap: string;
readonly confirmButton: string;
}> = {
danger: {
panelAccent: 'from-red-500/12 via-rose-500/8 to-transparent border-red-500/20',
iconWrap: 'border-red-500/30 bg-gradient-to-br from-red-500/25 to-rose-600/15 text-red-200 shadow-red-500/20',
confirmButton: 'bg-gradient-to-r from-red-500 to-rose-600 text-white hover:from-red-400 hover:to-rose-500 shadow-lg shadow-red-500/20',
},
warning: {
panelAccent: 'from-amber-500/12 via-orange-500/8 to-transparent border-amber-500/20',
iconWrap: 'border-amber-500/30 bg-gradient-to-br from-amber-400/25 to-orange-500/15 text-amber-200 shadow-amber-500/20',
confirmButton: 'bg-gradient-to-r from-amber-400 to-orange-500 text-slate-950 hover:from-amber-300 hover:to-orange-400 shadow-lg shadow-amber-500/20',
},
info: {
panelAccent: 'from-blue-500/12 via-cyan-500/8 to-transparent border-blue-500/20',
iconWrap: 'border-blue-500/30 bg-gradient-to-br from-blue-500/25 to-cyan-500/15 text-blue-200 shadow-blue-500/20',
confirmButton: 'bg-gradient-to-r from-blue-500 to-cyan-500 text-white hover:from-blue-400 hover:to-cyan-400 shadow-lg shadow-blue-500/20',
},
};
export function ConfirmationDialog({
open,
title,
description,
confirmLabel,
cancelLabel = 'Cancel',
loading = false,
loadingLabel,
disabled = false,
tone = 'danger',
icon,
onCancel,
onConfirm,
}: ConfirmationDialogProps) {
const fallbackIcon = tone === 'info' ? <Info size={22} /> : <AlertTriangle size={22} />;
const classes = toneClasses[tone];
return (
<Dialog open={open} onOpenChange={(nextOpen) => {
if (!nextOpen && !loading) {
onCancel();
}
}}
>
<DialogContent className="overflow-hidden rounded-2xl border-slate-700/60 bg-slate-900/95 p-0 text-white shadow-2xl shadow-black/40 backdrop-blur-xl sm:max-w-lg">
<DialogHeader className={cn('gap-0 border-b bg-gradient-to-br p-6 text-left', classes.panelAccent)}>
<div className="flex items-start gap-4">
<div className={cn('flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border shadow-lg', classes.iconWrap)}>
{icon ?? fallbackIcon}
</div>
<div className="space-y-2 pt-0.5">
<DialogTitle className="text-xl font-semibold leading-tight text-white">{title}</DialogTitle>
<DialogDescription className="text-sm leading-6 text-slate-300">
{description}
</DialogDescription>
</div>
</div>
</DialogHeader>
<DialogFooter className="gap-3 border-t border-slate-800/80 bg-slate-950/35 p-5 sm:space-x-0">
<Button
type="button"
onClick={onCancel}
disabled={loading}
className="h-11 rounded-xl border border-slate-700/70 bg-slate-800/70 px-5 text-slate-200 hover:bg-slate-700/80"
>
{cancelLabel}
</Button>
<Button
type="button"
onClick={onConfirm}
disabled={disabled || loading}
loading={loading}
loadingLabel={loadingLabel ?? confirmLabel}
className={cn(classes.confirmButton, 'h-11 rounded-xl px-5 disabled:cursor-not-allowed disabled:opacity-60')}
>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,9 +1,10 @@
import { useRef, useState } from 'react';
import { ImagePlus, Loader2 } from 'lucide-react';
import { ImagePlus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { uploadFile, fileDownloadUrl } from '@/business/files/api';
import { Skeleton } from '@/components/ui/skeleton';
import { fileAssetUrl, uploadFile } from '@/business/files/api';
import { getErrorMessage } from '@/shared/errors/errorMessages';
import { cn } from '@/lib/utils';
@ -54,8 +55,13 @@ export function ImageUpload({
shape === 'circle' ? 'rounded-full' : 'rounded-xl',
)}
>
{value ? (
<img src={fileDownloadUrl(value)} alt={label} className="h-full w-full object-cover" />
{uploading ? (
<Skeleton
aria-label={`${label} upload in progress`}
className="h-full w-full rounded-none bg-slate-700/70"
/>
) : value ? (
<img src={fileAssetUrl(value)} alt={label} className="h-full w-full object-cover" />
) : (
<ImagePlus size={20} />
)}
@ -75,8 +81,7 @@ export function ImageUpload({
onClick={() => inputRef.current?.click()}
className="h-auto px-3 py-1.5 text-xs"
>
{uploading && <Loader2 size={14} className="mr-1.5 animate-spin" />}
{value ? 'Replace' : 'Upload'}
{uploading ? 'Uploading...' : value ? 'Replace' : 'Upload'}
</Button>
{value && (
<Button

View File

@ -1,8 +1,10 @@
import { ArrowLeft, Calendar, UserCheck, Users } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Link, useParams } from 'react-router-dom';
import { useShellOutletContext } from '@/app/shellOutletContext';
import { useCampusAttendancePage } from '@/business/campus-attendance/hooks';
import {
useAttendanceDetailsChildCampuses,
useCampusAttendancePage,
} from '@/business/campus-attendance/hooks';
import { formatAttendanceDate } from '@/business/campus-attendance/selectors';
import { useStaffAttendanceRecords } from '@/business/staff-attendance/hooks';
import { CampusAttendanceLoadingState } from '@/components/campus-attendance/CampusAttendanceStatus';
@ -17,7 +19,6 @@ import {
} from '@/components/ui/table';
import { useScopeContext } from '@/contexts/scope-context';
import { APP_ROUTE_PATHS } from '@/shared/constants/routes';
import { getScopeChildren } from '@/shared/api/scope';
import type { StaffAttendanceStatus } from '@/shared/types/staffAttendance';
import type { TenantLevel } from '@/shared/types/scope';
@ -58,11 +59,7 @@ export default function CampusAttendanceDetailsPage() {
{ limit: 500 },
state.roleAccess.canReadStaffReports,
);
const childCampusesQuery = useQuery({
queryKey: ['attendance-details-child-campuses', level, tenantId],
enabled: level === 'school' && Boolean(tenantId),
queryFn: () => getScopeChildren('school', tenantId ?? '', { limit: 500 }),
});
const childCampusesQuery = useAttendanceDetailsChildCampuses(level, tenantId);
if (state.loading || childCampusesQuery.isLoading) {
return <CampusAttendanceLoadingState />;

View File

@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fileDownloadUrl, uploadFile } from '@/shared/api/files';
import { fileAssetUrl, fileDownloadUrl, uploadFile } from '@/shared/api/files';
import { apiFormRequest, createApiUrl } from '@/shared/api/httpClient';
vi.mock('@/shared/api/httpClient', () => ({
@ -27,4 +27,12 @@ describe('files API', () => {
);
expect(createApiUrl).toHaveBeenCalled();
});
it('resolves uploaded private URLs and leaves public image URLs intact', () => {
expect(fileAssetUrl('classroom-support/strategy-images/card.png')).toBe(
'http://localhost:8080/file/download?privateUrl=classroom-support%2Fstrategy-images%2Fcard.png',
);
expect(fileAssetUrl('/images/card.png')).toBe('/images/card.png');
expect(fileAssetUrl('https://cdn.example.com/card.png')).toBe('https://cdn.example.com/card.png');
});
});

View File

@ -36,3 +36,17 @@ export async function uploadFile(
export function fileDownloadUrl(privateUrl: string): string {
return fileUrl(`/file/download?privateUrl=${encodeURIComponent(privateUrl)}`);
}
export function fileAssetUrl(value: string): string {
if (
value.startsWith('/')
|| value.startsWith('http://')
|| value.startsWith('https://')
|| value.startsWith('data:')
|| value.startsWith('blob:')
) {
return value;
}
return fileDownloadUrl(value);
}

View File

@ -53,6 +53,21 @@ describe('user progress API', () => {
});
});
it('upserts classroom strategy favorites with the typed progress payload', () => {
const request: UserProgressMutationDto = {
progress_type: 'classroom_strategy_favorite',
item_id: 'first-then-board',
value: 'saved',
};
void upsertUserProgress(request);
expect(apiRequestMock).toHaveBeenCalledWith('/user_progress', {
method: 'POST',
body: { data: request },
});
});
it('deletes progress by item through the typed DELETE endpoint', () => {
void deleteUserProgressByItem('sign_learned', 'help');

View File

@ -10,6 +10,14 @@ export function isForbiddenError(error: unknown): boolean {
return error instanceof ApiError && error.status === 403;
}
export function isAuthExpiredError(error: unknown): boolean {
return error instanceof AuthExpiredError;
}
export function isUnauthorizedError(error: unknown): boolean {
return error instanceof ApiError && error.status === 401;
}
export function getErrorMessage(error: unknown, fallbackMessage = DEFAULT_ERROR_MESSAGE): string {
if (error instanceof AuthExpiredError) {
return AUTH_EXPIRED_ERROR_MESSAGE;

View File

@ -11,10 +11,15 @@ const TEST_USER = {
password: 'flatlogicUser123!',
};
async function authenticateViaPage(page: Page): Promise<void> {
const CLASS_SCOPE_TEST_USER = {
email: 'teacher@flatlogic.com',
password: 'flatlogicUser123!',
};
async function authenticateViaPage(page: Page, user = TEST_USER): Promise<void> {
await page.goto('/');
await page.getByPlaceholder('you@school.edu').fill(TEST_USER.email);
await page.getByPlaceholder('Enter your password').fill(TEST_USER.password);
await page.getByPlaceholder('you@school.edu').fill(user.email);
await page.getByPlaceholder('Enter your password').fill(user.password);
await page.getByRole('button', { name: 'Sign In', exact: true }).click();
await page.waitForURL('/', { timeout: 10000 });
}
@ -133,7 +138,7 @@ test.describe('seeded content catalog integration', () => {
});
test('renders static classroom timer content', async ({ page }) => {
await authenticateViaPage(page);
await authenticateViaPage(page, CLASS_SCOPE_TEST_USER);
await page.goto('/classroom-timer');
await expect(page.getByText('Loading classroom timer content...')).toHaveCount(0);

View File

@ -0,0 +1,11 @@
import { expect, test } from '@playwright/test';
test.describe('frontend smoke', () => {
test('renders the login page without a backend session', async ({ page }) => {
await page.goto('/login');
await expect(page.getByRole('heading', { name: /welcome back/i })).toBeVisible();
await expect(page.getByPlaceholder('you@school.edu')).toBeVisible();
await expect(page.getByRole('button', { name: 'Sign In', exact: true })).toBeVisible();
});
});