From 569c577beb6877ce5631dedf533c844cf9f9d689 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Sun, 21 Jun 2026 14:42:22 +0200 Subject: [PATCH] improved attendance reports, avatar and logos processing, --- backend/src/services/auth.ts | 1 + backend/src/services/auth.types.ts | 1 + .../src/services/personality_quiz_results.ts | 50 +++++ .../src/services/policy_acknowledgments.ts | 55 +++++ backend/src/services/safety_quiz_results.ts | 50 +++++ backend/src/services/staff_attendance.test.ts | 72 +++++++ backend/src/services/staff_attendance.ts | 28 ++- backend/src/services/zone-checkin.ts | 53 +++++ .../docs/campus-attendance-integration.md | 13 +- frontend/docs/frontend-architecture.md | 1 + frontend/src/business/app-shell/hooks.ts | 1 + .../src/business/campus-attendance/hooks.ts | 76 ++++++- .../campus-attendance/printReport.test.ts | 12 ++ .../business/campus-attendance/printReport.ts | 152 +++++++++++--- .../campus-attendance/selectors.test.ts | 178 +++++++++++++++- .../business/campus-attendance/selectors.ts | 198 +++++++++++++++++- .../src/business/campus-attendance/types.ts | 32 +++ .../director-dashboard/selectors.test.ts | 79 ++++++- .../business/director-dashboard/selectors.ts | 51 ++++- .../src/business/director-dashboard/types.ts | 5 + .../src/business/safety-quiz/mappers.test.ts | 27 ++- frontend/src/business/safety-quiz/mappers.ts | 6 + frontend/src/business/safety-quiz/types.ts | 3 + frontend/src/business/scope/selectors.ts | 9 +- frontend/src/business/top-bar/hooks.ts | 1 + frontend/src/business/top-bar/types.ts | 2 + .../IndividualCampusAttendanceView.tsx | 157 +++++++++----- .../SuperintendentAttendanceView.tsx | 5 +- frontend/src/components/common/TenantLogo.tsx | 48 +++++ frontend/src/components/common/UserAvatar.tsx | 45 ++++ .../DirectorAcknowledgmentTrackingPanel.tsx | 23 +- .../DirectorQuizResultsPanel.tsx | 20 +- .../src/components/scope/TenantSwitcher.tsx | 33 +-- .../components/top-bar/TopBarProfileMenu.tsx | 21 +- .../src/components/top-bar/TopBarView.tsx | 1 + .../src/pages/modules/CreateTenantPage.tsx | 54 +++-- frontend/src/pages/modules/UserAdminPage.tsx | 76 +++++-- frontend/src/shared/api/httpClient.test.ts | 14 ++ frontend/src/shared/api/httpClient.ts | 4 +- frontend/src/shared/api/users.ts | 8 +- frontend/src/shared/constants/dashboard.ts | 2 +- .../src/shared/constants/directorDashboard.ts | 1 - frontend/src/shared/types/auth.ts | 5 +- frontend/src/shared/types/personality.ts | 3 + frontend/src/shared/types/policyDocuments.ts | 3 + frontend/src/shared/types/safetyQuiz.ts | 3 + frontend/src/shared/types/zoneCheckins.ts | 3 + frontend/src/test-seeds/campusAttendance.ts | 37 ++++ 48 files changed, 1517 insertions(+), 205 deletions(-) create mode 100644 frontend/src/components/common/TenantLogo.tsx create mode 100644 frontend/src/components/common/UserAvatar.tsx diff --git a/backend/src/services/auth.ts b/backend/src/services/auth.ts index df18fb4..b6675a8 100644 --- a/backend/src/services/auth.ts +++ b/backend/src/services/auth.ts @@ -89,6 +89,7 @@ function toCampusDto(campus: unknown): CampusDto | null { id, name: asStringOrNull(plain.name), code: asStringOrNull(plain.code), + logo: asStringOrNull(plain.logo), }; } diff --git a/backend/src/services/auth.types.ts b/backend/src/services/auth.types.ts index 3568483..a92ff8a 100644 --- a/backend/src/services/auth.types.ts +++ b/backend/src/services/auth.types.ts @@ -23,4 +23,5 @@ export interface CampusDto { id: string; name: string | null; code: string | null; + logo?: string | null; } diff --git a/backend/src/services/personality_quiz_results.ts b/backend/src/services/personality_quiz_results.ts index f964a3a..a79aa20 100644 --- a/backend/src/services/personality_quiz_results.ts +++ b/backend/src/services/personality_quiz_results.ts @@ -64,6 +64,11 @@ const REPORT_STAFF_ROLES = Object.freeze([ ROLE_NAMES.SUPPORT_STAFF, ]); +interface StaffTenantInfo { + readonly tenantName: string | null; + readonly tenantLogo: string | null; +} + function normalizeQuizKind(value: unknown): string { if (typeof value !== 'string' || value.trim().length === 0) { return PERSONALITY_QUIZ_KIND; @@ -207,6 +212,18 @@ function displayNameOf(user: Users): string { || 'Staff Member'; } +function avatarOf(user: Users): string | null { + return user.avatar?.[0]?.privateUrl ?? null; +} + +function tenantOf(user: Users): StaffTenantInfo { + const tenant = user.class ?? user.campus ?? user.school ?? user.organizations ?? null; + return { + tenantName: tenant?.name ?? null, + tenantLogo: tenant?.logo ?? null, + }; +} + function latestResultsByUserAndKind( results: readonly PersonalityQuizResults[], ): ReadonlyMap { @@ -433,6 +450,35 @@ class PersonalityQuizResultsService { required: true, where: { name: REPORT_STAFF_ROLES }, }, + { + model: db.file, + as: 'avatar', + required: false, + }, + { + model: db.organizations, + as: 'organizations', + required: false, + attributes: ['id', 'name', 'logo'], + }, + { + model: db.schools, + as: 'school', + required: false, + attributes: ['id', 'name', 'logo'], + }, + { + model: db.campuses, + as: 'campus', + required: false, + attributes: ['id', 'name', 'logo'], + }, + { + model: db.classes, + as: 'class', + required: false, + attributes: ['id', 'name', 'logo'], + }, ], order: [ ['lastName', 'asc'], @@ -474,6 +520,7 @@ class PersonalityQuizResultsService { const rows = staffUsers.map((user) => { const selfAssessment = resultByUserAndKind.get(`${user.id}:${EI_SELF_ASSESSMENT_KIND}`) ?? null; const personality = resultByUserAndKind.get(`${user.id}:${PERSONALITY_QUIZ_KIND}`) ?? null; + const tenant = tenantOf(user); const completedKinds: string[] = []; if (selfAssessment) { completedKinds.push(EI_SELF_ASSESSMENT_KIND); @@ -486,6 +533,9 @@ class PersonalityQuizResultsService { userId: user.id, name: displayNameOf(user), email: user.email, + avatar: avatarOf(user), + tenantName: tenant.tenantName, + tenantLogo: tenant.tenantLogo, role: user.app_role?.name ?? null, status: completedKinds.length >= quizKinds.length ? 'complete' : 'pending', completedKinds, diff --git a/backend/src/services/policy_acknowledgments.ts b/backend/src/services/policy_acknowledgments.ts index 1e5ceb2..8318a98 100644 --- a/backend/src/services/policy_acknowledgments.ts +++ b/backend/src/services/policy_acknowledgments.ts @@ -36,6 +36,11 @@ const ACKNOWLEDGMENT_REPORT_STAFF_ROLES = Object.freeze([ ROLE_NAMES.SUPPORT_STAFF, ]); +interface TenantReference { + readonly name?: string | null; + readonly logo?: string | null; +} + function assertCanAcknowledge(currentUser?: CurrentUser): void { if (hasFeaturePermission(currentUser, FEATURE_PERMISSIONS.ACK_POLICY)) { return; @@ -110,6 +115,23 @@ function displayNameOf(user: { || 'Staff Member'; } +function avatarOf(user: { avatar?: readonly { privateUrl?: string | null }[] }): string | null { + return user.avatar?.[0]?.privateUrl ?? null; +} + +function tenantOf(user: { + readonly class?: TenantReference | null; + readonly campus?: TenantReference | null; + readonly school?: TenantReference | null; + readonly organizations?: TenantReference | null; +}): { tenantName: string | null; tenantLogo: string | null } { + const tenant = user.class ?? user.campus ?? user.school ?? user.organizations ?? null; + return { + tenantName: tenant?.name ?? null, + tenantLogo: tenant?.logo ?? null, + }; +} + class PolicyAcknowledgmentsService { /** A campus staff member's own acknowledgments (optionally for one document). */ static async list( @@ -220,6 +242,35 @@ class PolicyAcknowledgmentsService { required: true, where: { name: ACKNOWLEDGMENT_REPORT_STAFF_ROLES }, }, + { + model: db.file, + as: 'avatar', + required: false, + }, + { + model: db.organizations, + as: 'organizations', + required: false, + attributes: ['id', 'name', 'logo'], + }, + { + model: db.schools, + as: 'school', + required: false, + attributes: ['id', 'name', 'logo'], + }, + { + model: db.campuses, + as: 'campus', + required: false, + attributes: ['id', 'name', 'logo'], + }, + { + model: db.classes, + as: 'class', + required: false, + attributes: ['id', 'name', 'logo'], + }, ], order: [ ['lastName', 'asc'], @@ -272,6 +323,7 @@ class PolicyAcknowledgmentsService { }); const staffRows = staffUsers.map((user) => { + const tenant = tenantOf(user); const documentStatuses = documents.map((document) => { const acknowledgedAt = acknowledgedAtByKey.get( `${user.id}:${document.id}:${document.version}`, @@ -291,6 +343,9 @@ class PolicyAcknowledgmentsService { userId: user.id, name: displayNameOf(user), email: user.email, + avatar: avatarOf(user), + tenantName: tenant.tenantName, + tenantLogo: tenant.tenantLogo, role: user.app_role?.name ?? null, campusId: user.campusId ?? null, schoolId: user.schoolId ?? null, diff --git a/backend/src/services/safety_quiz_results.ts b/backend/src/services/safety_quiz_results.ts index 2f23d2c..7906627 100644 --- a/backend/src/services/safety_quiz_results.ts +++ b/backend/src/services/safety_quiz_results.ts @@ -51,6 +51,11 @@ const REPORT_STAFF_ROLES = Object.freeze([ ROLE_NAMES.SUPPORT_STAFF, ]); +interface StaffTenantInfo { + readonly tenantName: string | null; + readonly tenantLogo: string | null; +} + function getProductRole(currentUser?: CurrentUser): string { return currentUser?.app_role?.name ?? ROLE_NAMES.TEACHER; } @@ -181,6 +186,18 @@ function displayNameOf(user: Users): string { || 'Staff Member'; } +function avatarOf(user: Users): string | null { + return user.avatar?.[0]?.privateUrl ?? null; +} + +function tenantOf(user: Users): StaffTenantInfo { + const tenant = user.class ?? user.campus ?? user.school ?? user.organizations ?? null; + return { + tenantName: tenant?.name ?? null, + tenantLogo: tenant?.logo ?? null, + }; +} + function latestResultByUser( results: readonly SafetyQuizResults[], ): ReadonlyMap { @@ -294,6 +311,35 @@ class SafetyQuizResultsService { required: true, where: { name: REPORT_STAFF_ROLES }, }, + { + model: db.file, + as: 'avatar', + required: false, + }, + { + model: db.organizations, + as: 'organizations', + required: false, + attributes: ['id', 'name', 'logo'], + }, + { + model: db.schools, + as: 'school', + required: false, + attributes: ['id', 'name', 'logo'], + }, + { + model: db.campuses, + as: 'campus', + required: false, + attributes: ['id', 'name', 'logo'], + }, + { + model: db.classes, + as: 'class', + required: false, + attributes: ['id', 'name', 'logo'], + }, ], order: [ ['lastName', 'asc'], @@ -315,10 +361,14 @@ class SafetyQuizResultsService { const resultByUser = latestResultByUser(results); const rows = staffUsers.map((user) => { const result = resultByUser.get(user.id); + const tenant = tenantOf(user); return { userId: user.id, name: displayNameOf(user), email: user.email, + avatar: avatarOf(user), + tenantName: tenant.tenantName, + tenantLogo: tenant.tenantLogo, role: user.app_role?.name ?? null, status: result ? 'complete' : 'pending', result: result ? toDto(result) : null, diff --git a/backend/src/services/staff_attendance.test.ts b/backend/src/services/staff_attendance.test.ts index 6f623e6..0e238ed 100644 --- a/backend/src/services/staff_attendance.test.ts +++ b/backend/src/services/staff_attendance.test.ts @@ -116,4 +116,76 @@ describe('StaffAttendanceService', () => { assert.equal(userWhere.schoolId, schoolId); assert.equal(userWhere.campusId, null); }); + + test('upserts class staff attendance inside campus scope and stores class campus', async () => { + const organizationId = '11111111-1111-4111-8111-111111111111'; + const campusId = '33333333-3333-4333-8333-333333333333'; + const actor = createTestUser({ + id: '44444444-4444-4444-8444-444444444444', + organizationId, + organizations: { id: organizationId }, + campusId, + app_role: { + name: ROLE_NAMES.DIRECTOR, + scope: ROLE_SCOPES.CAMPUS, + globalAccess: false, + permissions: [permission(FEATURE_PERMISSIONS.FILL_ATTENDANCE)], + }, + }); + let userWhere: unknown = null; + let createdPayload: unknown = null; + + mock.method(db.users, 'findOne', async (options: unknown) => { + if (isRecord(options)) { + userWhere = options.where; + } + return { + get: () => ({ + id: '55555555-5555-4555-8555-555555555555', + firstName: 'Emily', + lastName: 'Johnson', + email: 'teacher@flatlogic.com', + campusId: null, + class: { campusId }, + app_role: { name: ROLE_NAMES.TEACHER }, + }), + }; + }); + mock.method(db.sequelize, 'transaction', async () => ({ + commit: async () => undefined, + rollback: async () => undefined, + })); + mock.method(db.staff_attendance_records, 'findOne', async () => null); + mock.method(db.staff_attendance_records, 'create', async (payload: unknown) => { + createdPayload = payload; + return { + get: () => ({ + id: '66666666-6666-4666-8666-666666666666', + ...(isRecord(payload) ? payload : {}), + createdAt: new Date('2026-06-17T12:00:00Z'), + updatedAt: new Date('2026-06-17T12:00:00Z'), + }), + }; + }); + + await StaffAttendanceService.upsertRecord( + { + userId: '55555555-5555-4555-8555-555555555555', + date: '2026-06-17', + status: 'present', + note: '', + }, + actor, + ); + + assert.equal(isRecord(userWhere), true); + if (isRecord(userWhere)) { + assert.equal(userWhere.organizationId, organizationId); + assert.equal(Object.getOwnPropertySymbols(userWhere).includes(Op.or), true); + } + assert.equal(isRecord(createdPayload), true); + if (isRecord(createdPayload)) { + assert.equal(createdPayload.campusId, campusId); + } + }); }); diff --git a/backend/src/services/staff_attendance.ts b/backend/src/services/staff_attendance.ts index 9a25fcd..5e4b901 100644 --- a/backend/src/services/staff_attendance.ts +++ b/backend/src/services/staff_attendance.ts @@ -72,6 +72,16 @@ function schoolUserIdSubquery(schoolId: string) { ); } +function campusClassIdSubquery(campusId: string) { + if (!STAFF_ATTENDANCE_UUID_RE.test(campusId)) { + return null; + } + + return literal( + `(SELECT "id" FROM "classes" WHERE "campusId" = '${campusId}' AND "deletedAt" IS NULL)`, + ); +} + /** * Restricts records to the staff member, or (for report roles) to the records * their scope allows: org-wide for owner/superintendent, the school's campuses @@ -213,7 +223,14 @@ function staffUserScope(currentUser?: CurrentUser): WhereOptions { if (!campusId) { throw new ForbiddenError(); } - return { ...base, campusId }; + const classSubquery = campusClassIdSubquery(campusId); + return { + ...base, + [Op.or]: [ + { campusId }, + ...(classSubquery ? [{ classId: { [Op.in]: classSubquery } }] : []), + ], + }; } return { ...base, schoolId: null, campusId: null }; @@ -328,6 +345,11 @@ class StaffAttendanceService { as: 'app_role', attributes: ['name'], }, + { + model: db.classes, + as: 'class', + attributes: ['campusId'], + }, ], }); @@ -341,8 +363,10 @@ class StaffAttendanceService { lastName?: string | null; email?: string | null; campusId?: string | null; + class?: { campusId?: string | null } | null; app_role?: { name?: string | null } | null; }; + const recordCampusId = plain.campusId ?? plain.class?.campusId ?? null; return withTransaction(async (transaction) => { const existing = await db.staff_attendance_records.findOne({ @@ -360,7 +384,7 @@ class StaffAttendanceService { user_name: staffUserName(plain), user_role: plain.app_role?.name ?? null, organizationId: requireOrganizationId(currentUser), - campusId: plain.campusId ?? null, + campusId: recordCampusId, userId, updatedById: requireUserId(currentUser), }; diff --git a/backend/src/services/zone-checkin.ts b/backend/src/services/zone-checkin.ts index c0da5ac..b00c1b8 100644 --- a/backend/src/services/zone-checkin.ts +++ b/backend/src/services/zone-checkin.ts @@ -49,6 +49,9 @@ export interface ZoneCheckinCompletionRow { readonly userId: string; readonly name: string; readonly email: string | null; + readonly avatar: string | null; + readonly tenantName: string | null; + readonly tenantLogo: string | null; readonly role: string | null; readonly date: string; readonly status: 'complete' | 'pending'; @@ -94,6 +97,11 @@ const REPORT_STAFF_ROLES = Object.freeze([ ROLE_NAMES.SUPPORT_STAFF, ]); +interface StaffTenantInfo { + readonly tenantName: string | null; + readonly tenantLogo: string | null; +} + async function resolveCampusTimezone(currentUser?: CurrentUser): Promise { const campusId = getCampusId(currentUser); if (!campusId) { @@ -166,6 +174,18 @@ function displayNameOf(user: Users): string { || 'Staff Member'; } +function avatarOf(user: Users): string | null { + return user.avatar?.[0]?.privateUrl ?? null; +} + +function tenantOf(user: Users): StaffTenantInfo { + const tenant = user.class ?? user.campus ?? user.school ?? user.organizations ?? null; + return { + tenantName: tenant?.name ?? null, + tenantLogo: tenant?.logo ?? null, + }; +} + function latestProgressByUserAndDate( rows: readonly UserProgress[], ): ReadonlyMap { @@ -277,6 +297,35 @@ class ZoneCheckinService { required: true, where: { name: REPORT_STAFF_ROLES }, }, + { + model: db.file, + as: 'avatar', + required: false, + }, + { + model: db.organizations, + as: 'organizations', + required: false, + attributes: ['id', 'name', 'logo'], + }, + { + model: db.schools, + as: 'school', + required: false, + attributes: ['id', 'name', 'logo'], + }, + { + model: db.campuses, + as: 'campus', + required: false, + attributes: ['id', 'name', 'logo'], + }, + { + model: db.classes, + as: 'class', + required: false, + attributes: ['id', 'name', 'logo'], + }, ], order: [ ['lastName', 'asc'], @@ -319,11 +368,15 @@ class ZoneCheckinService { const zone = progress?.value ?? null; const status = zone ? 'complete' : 'pending'; const riskLevel = !zone ? 'pending' : zone === 'green' ? 'none' : 'medium'; + const tenant = tenantOf(user); return { userId: user.id, name: displayNameOf(user), email: user.email, + avatar: avatarOf(user), + tenantName: tenant.tenantName, + tenantLogo: tenant.tenantLogo, role: user.app_role?.name ?? null, date, status, diff --git a/frontend/docs/campus-attendance-integration.md b/frontend/docs/campus-attendance-integration.md index a756a96..af49aab 100644 --- a/frontend/docs/campus-attendance-integration.md +++ b/frontend/docs/campus-attendance-integration.md @@ -44,6 +44,9 @@ API/data access layer: - Attendance links save through `PUT /api/campus_attendance/configs/:campusKey`. - Daily campus summaries load from `GET /api/campus_attendance/summaries`. - Daily campus summaries save through `PUT /api/campus_attendance/summaries/:campusKey/:date`. +- Staff attendance records load from `GET /api/staff_attendance/records` when the user has + `READ_STAFF_ATTENDANCE_REPORTS`. Campus views group these records by campus and date so student + attendance and staff attendance remain separate in history tables and reporting totals. - The page derives its mode from the effective scope, not from the signed-in role label: - campus/class effective scope shows campus-only attendance. - school effective scope aggregates all scoped campus summaries and school staff attendance. @@ -61,6 +64,9 @@ API/data access layer: child data show empty inputs. Saving writes valid edited rows back to `campus_attendance_summaries` per campus. Organization and school totals are computed from those campus rows plus staff attendance reports. +- Organization and school percentage cards include every scoped child row in the denominator. + A child scope without a saved report counts as incomplete instead of being ignored, and staff + attendance percentages use the scoped staff count as the minimum denominator. - Aggregate cards follow the tenant hierarchy: organization scope shows school cards, and school scope shows campus cards. Clicking a child card opens `/attendance/details/:level/:tenantId`. The details page shows separate tables for that child @@ -74,6 +80,9 @@ API/data access layer: - Campus screens expose the same staff attendance table for campus-bound and class-scoped staff inside the campus. - Staff summary loads from `GET /api/staff_attendance/summary?startDate=today&endDate=today` only when the user has `READ_STAFF_ATTENDANCE_REPORTS`. +- Campus screens show combined summary cards with student/staff breakdowns, a single recent + attendance history table with Students/Staff/Total rows per date, and print reports with the + same combined report structure. - Aggregate views render only campus cards represented by scoped attendance/config rows, because the campus catalog endpoint is not the source of scoped reporting data. - The backend calculates the attendance percentage. - `CampusAttendance.tsx` is a thin composition wrapper. @@ -83,7 +92,7 @@ API/data access layer: ## Verification -- `frontend/src/business/campus-attendance/selectors.test.ts` covers attendance calculations, scope titles, and combined student/staff summary selectors. -- `frontend/src/business/campus-attendance/printReport.test.ts` covers printable report generation. +- `frontend/src/business/campus-attendance/selectors.test.ts` covers attendance calculations, scope titles, staff daily summaries, and combined student/staff summary selectors. +- `frontend/src/business/campus-attendance/printReport.test.ts` covers printable report generation with separate student, staff, and combined attendance totals. - `frontend/src/business/campus-attendance/printReport.test.ts` covers blocked-popup handling for attendance report printing. - `frontend/src/business/campus-attendance/mappers.test.ts` covers API DTO mapping. diff --git a/frontend/docs/frontend-architecture.md b/frontend/docs/frontend-architecture.md index d09e958..bd2c94e 100644 --- a/frontend/docs/frontend-architecture.md +++ b/frontend/docs/frontend-architecture.md @@ -201,6 +201,7 @@ The active frontend already has: - An `AuthGuard` (`frontend/src/app/AuthGuard.tsx`) gates the shell — unauthenticated users redirect to `/login`; `ModuleRouteGuard` renders the 404 page for a forbidden direct URL; `IndexRedirect` lands each user on the first permission- and scope-accessible module. Scope changes normalize the current module route to the first module available in the new effective scope. The previous in-shell guest-preview experience was removed. - App shell state, access selection, campus display lookup, mobile overlay visibility, shell outlet context, and prepared Sidebar/TopBar/Footer props live under `frontend/src/business/app-shell/`. The shared shell layout remains a thin view composition in `frontend/src/components/AppLayout.tsx`. - Top bar shell state under `frontend/src/business/top-bar/`, with search, badges, notifications, and profile menu composition split under `frontend/src/components/top-bar/`. +- Tenant branding logos for organizations, schools, campuses, and classes are uploaded through the shared file subsystem and rendered through `frontend/src/components/common/TenantLogo.tsx` in scope selectors, tenant lists, user lists, and leader dashboard staff reports. Views should not render stored tenant logo values with raw `` tags because private file URLs need the shared file URL resolver. - FRAME entries under `frontend/src/business/frame/`, with typed API calls in `frontend/src/shared/api/frame.ts` and explicit empty/error states in the view. - Current-user progress under `frontend/src/business/user-progress/`, with typed API calls in `frontend/src/shared/api/userProgress.ts` for learned signs and zone check-ins. - Safety quiz results under `frontend/src/business/safety-quiz/`, with typed API calls in `frontend/src/shared/api/safetyQuizResults.ts`. diff --git a/frontend/src/business/app-shell/hooks.ts b/frontend/src/business/app-shell/hooks.ts index 54d5a75..f0e48ec 100644 --- a/frontend/src/business/app-shell/hooks.ts +++ b/frontend/src/business/app-shell/hooks.ts @@ -200,6 +200,7 @@ export function useAppShell(options: UseAppShellOptions): AppShellState { user: options.user, userRole, userName, + avatar: options.user?.avatar ?? null, campusInfo, toggleSidebar, setCurrentModule, diff --git a/frontend/src/business/campus-attendance/hooks.ts b/frontend/src/business/campus-attendance/hooks.ts index 58fa860..2fc9ce3 100644 --- a/frontend/src/business/campus-attendance/hooks.ts +++ b/frontend/src/business/campus-attendance/hooks.ts @@ -31,7 +31,10 @@ import { getWeekEnd, getWeekStart, } from '@/business/campus-attendance/selectors'; -import { useStaffAttendanceSummary } from '@/business/staff-attendance/hooks'; +import { + useStaffAttendanceRecords, + useStaffAttendanceSummary, +} from '@/business/staff-attendance/hooks'; import { saveStaffAttendanceRecord } from '@/shared/api/staffAttendance'; import { CAMPUS_ATTENDANCE_PRINT_POPUP_BLOCKED_MESSAGE, @@ -57,9 +60,11 @@ import type { TenantChild, TenantLevel } from '@/shared/types/scope'; import { listUsers, type AdminUserRow } from '@/shared/api/users'; import { STAFF_ATTENDANCE_QUERY_KEYS } from '@/shared/constants/staffAttendance'; import { getClass } from '@/shared/api/classes'; +import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types'; const EMPTY_CONFIGS: ReturnType[] = []; const EMPTY_SUMMARIES: ReturnType[] = []; +const EMPTY_STAFF_RECORDS: readonly StaffAttendanceRecordViewModel[] = []; const EMPTY_CAMPUSES: CampusInfo[] = []; const EMPTY_TENANT_CHILDREN: TenantChild[] = []; const EMPTY_CAMPUS_CHILDREN_BY_PARENT: Readonly> = {}; @@ -297,6 +302,14 @@ function percentageFromRecords( return enrolled > 0 ? Number(((present / enrolled) * 100).toFixed(2)) : null; } +function percentageFromStaffDailySummaries( + records: readonly { readonly total_staff: number; readonly total_present: number }[], +): number | null { + const staff = records.reduce((sum, record) => sum + record.total_staff, 0); + const present = records.reduce((sum, record) => sum + record.total_present, 0); + return staff > 0 ? Number(((present / staff) * 100).toFixed(2)) : null; +} + export function useCampusAttendancePage({ userRole, userCampus, @@ -366,6 +379,10 @@ export function useCampusAttendancePage({ { startDate: today, endDate: today }, roleAccess.canReadStaffReports && hasAttendanceScope, ); + const staffRecordsQuery = useStaffAttendanceRecords( + { limit: 500 }, + roleAccess.canReadStaffReports && hasAttendanceScope, + ); const officeStaffQuery = useQuery({ queryKey: ['attendance-office-staff-users', effectiveTier, effectiveTenant?.id ?? null], enabled: roleAccess.canEnterData && hasAttendanceScope, @@ -378,6 +395,7 @@ export function useCampusAttendancePage({ const configs = configsQuery.data ?? EMPTY_CONFIGS; const attendanceData = summariesQuery.data ?? EMPTY_SUMMARIES; const staffSummary = staffSummaryQuery.data ?? null; + const staffRecords = staffRecordsQuery.data ?? EMPTY_STAFF_RECORDS; const officeStaffUsers = useMemo(() => ( (officeStaffQuery.data?.rows ?? []) .filter((user) => isOfficeStaffUser(user, scopeModel.mode) && isStaffRosterUser(user)) @@ -402,6 +420,7 @@ export function useCampusAttendancePage({ || configsQuery.isLoading || summariesQuery.isLoading || (roleAccess.canReadStaffReports && staffSummaryQuery.isLoading) + || (roleAccess.canReadStaffReports && staffRecordsQuery.isLoading) || (roleAccess.canEnterData && officeStaffQuery.isLoading) || (roleAccess.canSeeAllCampuses && scopedAttendanceChildrenQuery.isLoading); const saving = saveConfigMutation.isPending || saveSummaryMutation.isPending || saveStaffAttendanceMutation.isPending; @@ -410,6 +429,7 @@ export function useCampusAttendancePage({ ?? configsQuery.error ?? summariesQuery.error ?? (roleAccess.canReadStaffReports ? staffSummaryQuery.error : null) + ?? (roleAccess.canReadStaffReports ? staffRecordsQuery.error : null) ?? (roleAccess.canEnterData ? officeStaffQuery.error : null) ?? (roleAccess.canSeeAllCampuses ? scopedAttendanceChildrenQuery.error : null); const saveError = saveConfigMutation.error ?? saveSummaryMutation.error ?? saveStaffAttendanceMutation.error; @@ -429,8 +449,8 @@ export function useCampusAttendancePage({ const [studentRollupOverrides, setStudentRollupOverrides] = useState({}); const campusStats = useMemo( - () => buildCampusAttendanceStats(campusCatalog.campuses, configs, attendanceData, today, weekStart, weekEnd), - [attendanceData, campusCatalog.campuses, configs, today, weekEnd, weekStart], + () => buildCampusAttendanceStats(campusCatalog.campuses, configs, attendanceData, today, weekStart, weekEnd, staffRecords), + [attendanceData, campusCatalog.campuses, configs, staffRecords, today, weekEnd, weekStart], ); const visibleCampusStats = useMemo(() => { if (scopeModel.mode === 'campus') { @@ -461,6 +481,8 @@ export function useCampusAttendancePage({ recentData: campus.recentData, todayRecord: campus.todayRecord, childCampusIds: [campus.id], + recentStaffData: campus.recentStaffData, + todayStaffRecord: campus.todayStaffRecord, })); } @@ -482,6 +504,8 @@ export function useCampusAttendancePage({ recentData: campus?.recentData ?? [], todayRecord: campus?.todayRecord ?? null, childCampusIds: campus ? [campus.id] : [], + recentStaffData: campus?.recentStaffData ?? [], + todayStaffRecord: campus?.todayStaffRecord ?? null, }; }); } @@ -501,6 +525,23 @@ export function useCampusAttendancePage({ && record.date >= weekStart && record.date <= weekEnd )); + const childStaffRecords = campusStats + .filter((campus) => childCampusIds.includes(campus.id)) + .flatMap((campus) => campus.recentStaffData); + const childTodayStaffRecords = childStaffRecords.filter((record) => record.date === today); + const todayStaffRecord = childTodayStaffRecords.length > 0 + ? { + id: `school-staff:${child.id}:${today}`, + campus_id: null, + date: today, + total_staff: childTodayStaffRecords.reduce((sum, record) => sum + record.total_staff, 0), + total_present: childTodayStaffRecords.reduce((sum, record) => sum + record.total_present, 0), + total_absent: childTodayStaffRecords.reduce((sum, record) => sum + record.total_absent, 0), + total_late: childTodayStaffRecords.reduce((sum, record) => sum + record.total_late, 0), + attendance_percentage: percentageFromStaffDailySummaries(childTodayStaffRecords) ?? 0, + notes: null, + } + : null; const weekDays = Array.from(new Set(childWeekRecords.map((record) => record.date))); const weekAvg = weekDays.length > 0 ? Number((weekDays.reduce((sum, day) => ( @@ -519,6 +560,8 @@ export function useCampusAttendancePage({ recentData: childWeekRecords.slice(0, 10), todayRecord: null, childCampusIds, + recentStaffData: childStaffRecords.slice(0, 10), + todayStaffRecord, }; }); }, [ @@ -538,11 +581,17 @@ export function useCampusAttendancePage({ [attendanceData, today, weekEnd, weekStart], ); const combinedStats = useMemo( - () => buildCombinedAttendanceStats(overallStats, staffSummary), - [overallStats, staffSummary], + () => buildCombinedAttendanceStats( + overallStats, + staffSummary, + scopeModel.mode === 'campus' ? undefined : attendanceChildStats, + ), + [attendanceChildStats, overallStats, scopeModel.mode, staffSummary], ); const myCampusConfig = attendanceCampusId ? configs.find((config) => config.campus_id === attendanceCampusId) : undefined; const myCampusData = attendanceCampusId ? attendanceData.filter((record) => record.campus_id === attendanceCampusId) : []; + const myCampusStats = attendanceCampusId ? visibleCampusStats.find((campus) => campus.id === attendanceCampusId) : undefined; + const myStaffData = myCampusStats?.recentStaffData ?? []; const myTodayPct = attendanceCampusId ? getTodayPercentage(attendanceData, attendanceCampusId, today) : null; const myWeekAvg = attendanceCampusId ? getWeeklyAverage(attendanceData, attendanceCampusId, weekStart, weekEnd) : null; const selectedEntryCampus = scopedCampusOptions.find((campus) => campus.id === entryDraft.campusId); @@ -742,7 +791,10 @@ export function useCampusAttendancePage({ return true; }; - const saveStaffBatchAttendance = async (requireStaff: boolean): Promise => { + const saveStaffBatchAttendance = async ( + requireStaff: boolean, + input: Pick = staffEntryDraft, + ): Promise => { if (officeStaffUsers.length === 0) { if (requireStaff) { setStaffEntryError('No office staff are available for this scope.'); @@ -752,10 +804,10 @@ export function useCampusAttendancePage({ for (const staffUser of officeStaffUsers) { await saveStaffAttendanceMutation.mutateAsync({ - date: staffEntryDraft.date, + date: input.date, userId: staffUser.id, status: staffAttendanceStatuses[staffUser.id] ?? 'present', - note: staffEntryDraft.note, + note: input.note, }); } @@ -807,7 +859,10 @@ export function useCampusAttendancePage({ return; } - const staffSaved = await saveStaffBatchAttendance(false); + const staffSaved = await saveStaffBatchAttendance(false, { + date: entryDraft.date, + note: staffEntryDraft.note, + }); if (!staffSaved) { return; } @@ -840,6 +895,7 @@ export function useCampusAttendancePage({ campusesToPrint, printTodayRecords, printWeekRecords, + staffSummary, }, }); @@ -862,6 +918,7 @@ export function useCampusAttendancePage({ weekEnd, configs, attendanceData, + staffRecords, loading, saving, errorMessage, @@ -892,6 +949,7 @@ export function useCampusAttendancePage({ }, myCampusConfig, myCampusData, + myStaffData, myTodayPct, myWeekAvg, userCampus, diff --git a/frontend/src/business/campus-attendance/printReport.test.ts b/frontend/src/business/campus-attendance/printReport.test.ts index 87c5cc3..08ea361 100644 --- a/frontend/src/business/campus-attendance/printReport.test.ts +++ b/frontend/src/business/campus-attendance/printReport.test.ts @@ -31,6 +31,15 @@ describe('campus attendance print report', () => { expect(html).toContain(CAMPUS_ATTENDANCE_TEST_SEED.todayNotesEscaped); expect(html).toContain('
90%
'); expect(html).toContain('
85%
'); + expect(html).toContain('Students 90% · Staff 90%'); + expect(html).toContain('
People
'); + expect(html).toContain('
60
'); + expect(html).toContain("Today's report"); + expect(html).toContain('Attendance history'); + expect(html).toContain('Students'); + expect(html).toContain('Staff'); + expect(html).toContain('Total'); + expect(html).toContain('90.0%'); expect(html).toContain('80.0%'); }); @@ -44,10 +53,13 @@ describe('campus attendance print report', () => { weekAvg: null, recentData: [], todayRecord: null, + recentStaffData: [], + todayStaffRecord: null, }, ], printTodayRecords: [], printWeekRecords: [], + staffSummary: null, }); expect(html).toContain('
No data
'); diff --git a/frontend/src/business/campus-attendance/printReport.ts b/frontend/src/business/campus-attendance/printReport.ts index 43a771b..7274189 100644 --- a/frontend/src/business/campus-attendance/printReport.ts +++ b/frontend/src/business/campus-attendance/printReport.ts @@ -1,6 +1,14 @@ -import { buildPrintAttendanceStats, formatAttendanceDate } from '@/business/campus-attendance/selectors'; +import { + buildCombinedAttendanceHistoryRows, + buildPrintAttendanceStats, + formatAttendanceDate, +} from '@/business/campus-attendance/selectors'; import { PRINT_DIALOG_OPEN_DELAY_MS } from '@/shared/constants/ui'; -import type { CampusAttendancePrintInput } from '@/business/campus-attendance/types'; +import type { + CampusAttendanceStats, + CampusAttendancePrintInput, + CombinedAttendanceHistoryRow, +} from '@/business/campus-attendance/types'; const escapeHtml = (value: string): string => ( value @@ -43,6 +51,102 @@ const inlinePercentageClass = (percentage: number | null): string => { return 'pct-bad'; }; +function buildTodayReportRows(campus: CampusAttendanceStats): readonly CombinedAttendanceHistoryRow[] { + const date = campus.todayRecord?.date ?? campus.todayStaffRecord?.date ?? ''; + const studentRecord = campus.todayRecord; + const staffRecord = campus.todayStaffRecord; + const studentTotal = studentRecord?.total_enrolled ?? 0; + const staffTotal = staffRecord?.total_staff ?? 0; + const studentPresent = studentRecord?.total_present ?? 0; + const staffPresent = staffRecord?.total_present ?? 0; + const studentAbsent = studentRecord?.total_absent ?? 0; + const staffAbsent = staffRecord?.total_absent ?? 0; + const studentLate = studentRecord?.total_tardy ?? 0; + const staffLate = staffRecord?.total_late ?? 0; + const combinedTotal = studentTotal + staffTotal; + const combinedPresent = studentPresent + staffPresent; + const combinedPercentage = combinedTotal > 0 + ? Number(((combinedPresent / combinedTotal) * 100).toFixed(2)) + : null; + const notes = [studentRecord?.notes, staffRecord?.notes].filter((note): note is string => Boolean(note)); + + return [ + { + id: `${campus.id}:today:students`, + date, + group: 'students', + label: 'Students', + total: studentTotal, + present: studentPresent, + absent: studentAbsent, + late: studentLate, + attendancePercentage: studentRecord?.attendance_percentage ?? null, + notes: studentRecord?.notes ?? null, + }, + { + id: `${campus.id}:today:staff`, + date, + group: 'staff', + label: 'Staff', + total: staffTotal, + present: staffPresent, + absent: staffAbsent, + late: staffLate, + attendancePercentage: staffRecord?.attendance_percentage ?? null, + notes: staffRecord?.notes ?? null, + }, + { + id: `${campus.id}:today:total`, + date, + group: 'total', + label: 'Total', + total: combinedTotal, + present: combinedPresent, + absent: studentAbsent + staffAbsent, + late: studentLate + staffLate, + attendancePercentage: combinedPercentage, + notes: notes.length > 0 ? notes.join('; ') : null, + }, + ]; +} + +function renderAttendanceRows(rows: readonly CombinedAttendanceHistoryRow[]): string { + return rows.map((record) => ` + + ${record.group === 'students' ? formatAttendanceDate(record.date) : ''} + ${record.label} + ${record.total} + ${record.present} + ${record.absent} + ${record.late} + ${record.attendancePercentage !== null ? `${record.attendancePercentage.toFixed(1)}%` : 'N/A'} + ${record.notes ? escapeHtml(record.notes) : '-'} + + `).join(''); +} + +function renderAttendanceHistoryTable(campus: CampusAttendanceStats): string { + const historyRows = buildCombinedAttendanceHistoryRows(campus.recentData, campus.recentStaffData); + + if (historyRows.length === 0) { + return ''; + } + + return ` + + + + + + + + + ${renderAttendanceRows(historyRows)} + +
DateGroupTotalPresentAbsentTardy / LateAttendance %Notes
+ `; +} + export type CampusAttendancePrintResult = | { readonly ok: true } | { readonly ok: false; readonly reason: 'popup-blocked' }; @@ -87,6 +191,9 @@ export function openCampusAttendancePrintReport({ export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput): string { const printStats = buildPrintAttendanceStats(input); + const staffTotal = input.staffSummary + ? input.staffSummary.present + input.staffSummary.late + input.staffSummary.absent + : 0; const generatedAtDate = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', @@ -109,7 +216,7 @@ export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput .header { text-align: center; margin-bottom: 32px; border-bottom: 3px solid #7c3aed; padding-bottom: 16px; } .header h1 { font-size: 24px; color: #7c3aed; margin-bottom: 4px; } .header p { font-size: 13px; color: #64748b; } - .summary-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 32px; } + .summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 32px; } .summary-box { border: 2px solid #e2e8f0; border-radius: 12px; padding: 16px; text-align: center; } .summary-box .label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; } .summary-box .value { font-size: 28px; font-weight: 700; } @@ -131,6 +238,7 @@ export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput .history-table { margin-top: 16px; } .history-table th { font-size: 11px; } .history-table td { font-size: 11px; } + .section-label { font-size: 12px; font-weight: 700; color: #334155; margin: 12px 0 6px; } .footer { margin-top: 32px; padding-top: 16px; border-top: 2px solid #e2e8f0; text-align: center; font-size: 11px; color: #94a3b8; } @media print { body { padding: 16px; } .no-print { display: none; } } @@ -144,11 +252,16 @@ export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput
Today's Attendance
-
${printStats.todayPct !== null ? `${printStats.todayPct}%` : 'No data'}
-
${formatAttendanceDate(input.today)}
+
${printStats.combinedPct !== null ? `${printStats.combinedPct}%` : 'No data'}
+
Students ${printStats.todayPct !== null ? `${printStats.todayPct}%` : 'N/A'} · Staff ${printStats.staffPct !== null ? `${printStats.staffPct}%` : 'N/A'}
-
This Week's Average Attendance
+
People
+
${input.printTodayRecords.reduce((sum, record) => sum + record.total_enrolled, 0) + staffTotal}
+
Students ${input.printTodayRecords.reduce((sum, record) => sum + record.total_enrolled, 0)} · Staff ${staffTotal}
+
+
+
This Week's Student Average
${printStats.weekPct !== null ? `${printStats.weekPct}%` : 'No data'}
Week of ${formatAttendanceDate(input.weekStart)}
@@ -162,35 +275,20 @@ export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput Week Avg: ${campus.weekAvg !== null ? `${campus.weekAvg}%` : 'N/A'}
- ${campus.todayRecord ? ` - - - - ${campus.todayRecord.notes ? `` : ''} -
Enrolled${campus.todayRecord.total_enrolled}Present${campus.todayRecord.total_present}
Absent${campus.todayRecord.total_absent}Tardy${campus.todayRecord.total_tardy}
Notes${escapeHtml(campus.todayRecord.notes)}
- ` : '

No attendance data recorded for today.

'} - ${campus.recentData.length > 0 ? ` + ${campus.todayRecord || campus.todayStaffRecord ? ` + - + - ${campus.recentData.map((record) => ` - - - - - - - - - - `).join('')} + ${renderAttendanceRows(buildTodayReportRows(campus))}
DateEnrolledPresentAbsentTardyAttendance %NotesDateGroupTotalPresentAbsentTardy / LateAttendance %Notes
${formatAttendanceDate(record.date)}${record.total_enrolled}${record.total_present}${record.total_absent}${record.total_tardy}${record.attendance_percentage.toFixed(1)}%${record.notes ? escapeHtml(record.notes) : '-'}
- ` : ''} + ` : '

No attendance data recorded for today.

'} + ${renderAttendanceHistoryTable(campus)} `).join('')} ))} diff --git a/frontend/src/components/director-dashboard/DirectorQuizResultsPanel.tsx b/frontend/src/components/director-dashboard/DirectorQuizResultsPanel.tsx index 3043d51..f2615ad 100644 --- a/frontend/src/components/director-dashboard/DirectorQuizResultsPanel.tsx +++ b/frontend/src/components/director-dashboard/DirectorQuizResultsPanel.tsx @@ -2,6 +2,8 @@ import { useState } from 'react'; import { ChevronDown, Users } from 'lucide-react'; import { StatePanel } from '@/components/ui/state-panel'; +import { UserAvatar } from '@/components/common/UserAvatar'; +import { TenantLogo } from '@/components/common/TenantLogo'; import type { DirectorQuizResultRow } from '@/business/director-dashboard/types'; import { cn } from '@/lib/utils'; @@ -80,8 +82,22 @@ export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelPr aria-expanded={isExpanded} aria-controls={`quiz-results-${result.id}`} > - {result.staffName} - {result.tenant} + + + {result.staffName} + + + + {result.tenant} + {result.role} = { @@ -42,14 +42,16 @@ export function TenantSwitcher() { const levelLabel = effectiveTenant ? (LEVEL_LABEL[effectiveTenant.level] ?? 'Tenant') : 'Platform'; - const mark = effectiveTenant?.logo ? ( - - ) : effectiveTenant ? ( - - {getTenantInitials(effectiveTenant.name)} - + const mark = effectiveTenant ? ( + ) : ( - + + + ); return ( @@ -59,9 +61,7 @@ export function TenantSwitcher() { onClick={() => setOpen((o) => !o)} className="flex items-center gap-2 px-3 py-1.5 rounded-xl text-xs font-semibold border border-slate-700/50 bg-slate-800/40 text-slate-200 hover:bg-slate-800/70 transition-colors" > - - {mark} - + {mark} {label} · {levelLabel} {canDrill && } @@ -93,9 +93,16 @@ export function TenantSwitcher() { drillInto(c); setOpen(false); }} - className="w-full flex items-center justify-between px-3 py-2 text-xs text-slate-200 hover:bg-slate-800/70" + className="w-full flex items-center justify-between gap-3 px-3 py-2 text-xs text-slate-200 hover:bg-slate-800/70" > - {c.name ?? '—'} + + + {c.name ?? '—'} + {LEVEL_LABEL[c.level]} )) diff --git a/frontend/src/components/top-bar/TopBarProfileMenu.tsx b/frontend/src/components/top-bar/TopBarProfileMenu.tsx index 684e7ff..25c5f22 100644 --- a/frontend/src/components/top-bar/TopBarProfileMenu.tsx +++ b/frontend/src/components/top-bar/TopBarProfileMenu.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { Building2, ChevronDown, LogOut, Settings, User, Wifi } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { UserAvatar } from '@/components/common/UserAvatar'; import { useOnClickOutside } from '@/hooks/useOnClickOutside'; import { TOP_BAR_PROFILE_MENU_ITEMS } from '@/shared/constants/topBar'; import { APP_ROUTE_PATHS } from '@/shared/constants/routes'; @@ -12,6 +13,7 @@ import { cn } from '@/lib/utils'; interface TopBarProfileMenuProps { readonly userName: string; readonly initials: string; + readonly avatar: string | null; readonly campusInfo?: CampusInfo; readonly campusLabel: string; readonly profileRoleLabel: string; @@ -24,6 +26,7 @@ interface TopBarProfileMenuProps { export function TopBarProfileMenu({ userName, initials, + avatar, campusInfo, campusLabel, profileRoleLabel, @@ -49,9 +52,12 @@ export function TopBarProfileMenu({ aria-expanded={isOpen} className="flex items-center gap-2 pl-2 border-l border-slate-700/50 hover:bg-slate-800/50 rounded-xl pr-2 py-1 transition-colors h-auto" > - - {initials} - + {userName} {campusLabel} @@ -63,9 +69,12 @@ export function TopBarProfileMenu({
-
- {initials} -
+

{userName}

{profileRoleLabel}

diff --git a/frontend/src/components/top-bar/TopBarView.tsx b/frontend/src/components/top-bar/TopBarView.tsx index 45b9f72..7110d3b 100644 --- a/frontend/src/components/top-bar/TopBarView.tsx +++ b/frontend/src/components/top-bar/TopBarView.tsx @@ -53,6 +53,7 @@ export function TopBarView({ page }: TopBarViewProps) { part[0]?.toUpperCase()) - .join('') || 'L'; -} - function uniqueRows(rows: readonly TenantChild[]): TenantChild[] { const seen = new Set(); const result: TenantChild[] = []; @@ -265,6 +257,7 @@ export default function CreateTenantPage() { const [editContact, setEditContact] = useState(() => emptyContactFields()); const [deleteCandidate, setDeleteCandidate] = useState(null); const [expandedLocations, setExpandedLocations] = useState>(() => new Set()); + const [isLocationFormOpen, setIsLocationFormOpen] = useState(false); const [locationsPage, setLocationsPage] = useState(0); const [saving, setSaving] = useState(false); const [savingEdit, setSavingEdit] = useState(false); @@ -429,9 +422,11 @@ export default function CreateTenantPage() { setPickedParentId(null); setOrgId(''); setLogo(null); + setIsLocationFormOpen(false); } function startEdit(row: TenantChild) { + setIsLocationFormOpen(true); setEditingLocation(row); setEditName(displayName(row)); setEditLogo(row.logo ?? null); @@ -446,6 +441,7 @@ export default function CreateTenantPage() { setEditLogo(null); setEditDescription(''); setEditContact(emptyContactFields()); + setIsLocationFormOpen(false); } function locationKey(row: TenantChild): string { @@ -613,13 +609,11 @@ export default function CreateTenantPage() { > {expanded ? : } -
- {row.logo ? ( - - ) : ( - initials(rowName) - )} -
+

{rowName}

{TENANT_TYPE_LABELS[row.level]}

@@ -692,10 +686,26 @@ export default function CreateTenantPage() { - {editingLocation ? : null} - {editingLocation - ? `Edit ${TENANT_TYPE_LABELS[editingLocation.level]}` - : 'New organization or location'} + {editingLocation && (
+ )} )} diff --git a/frontend/src/pages/modules/UserAdminPage.tsx b/frontend/src/pages/modules/UserAdminPage.tsx index ab9206f..5528e66 100644 --- a/frontend/src/pages/modules/UserAdminPage.tsx +++ b/frontend/src/pages/modules/UserAdminPage.tsx @@ -1,7 +1,7 @@ import type { FormEvent } from 'react'; import { useCallback, useMemo, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { ArrowDown, ArrowUp, ArrowUpDown, Loader2, Pencil, Search, Trash2, UserPlus, Users, X } from 'lucide-react'; +import { ArrowDown, ArrowUp, ArrowUpDown, ChevronDown, Loader2, Pencil, Search, Trash2, UserPlus, Users, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -12,6 +12,7 @@ import { NativeSelect } from '@/components/ui/native-select'; import { PageSkeleton } from '@/components/ui/page-skeleton'; import { TenantParentPicker } from '@/components/tenant-create/TenantParentPicker'; import { ImageUpload } from '@/components/common/ImageUpload'; +import { TenantLogo } from '@/components/common/TenantLogo'; import { useScopeContext } from '@/contexts/scope-context'; import { useAuth } from '@/contexts/useAuth'; import { useTenantChildren } from '@/business/scope/queries'; @@ -76,6 +77,23 @@ function locationName(value?: { name?: string | null } | null): string { return value?.name?.trim() || '—'; } +function locationCell(value?: { name?: string | null; logo?: string | null } | null) { + const name = value?.name?.trim(); + if (!name) { + return '—'; + } + return ( + + + {name} + + ); +} + function sortText(value: string | null | undefined): string { return value?.trim().toLocaleLowerCase() || ''; } @@ -97,6 +115,7 @@ export default function UserAdminPage() { const [usersSearch, setUsersSearch] = useState(''); const [usersSortField, setUsersSortField] = useState('name'); const [usersSortDirection, setUsersSortDirection] = useState('asc'); + const [isUserFormOpen, setIsUserFormOpen] = useState(false); const usersQuery = useQuery({ queryKey: ['admin-users', usersSearch], queryFn: () => @@ -322,9 +341,11 @@ export default function UserAdminPage() { setStudentIds([]); setGrantPerms([]); setExcludePerms([]); + setIsUserFormOpen(false); } function startEdit(row: AdminUserRow) { + setIsUserFormOpen(true); setEditingId(row.id); setNamePrefix(row.name_prefix ?? ''); setFirstName(row.firstName ?? ''); @@ -456,8 +477,23 @@ export default function UserAdminPage() { - {editingId ? : } - {editingId ? 'Edit user' : 'New user'} + {editingId && (