improved attendance reports, avatar and logos processing,

This commit is contained in:
Dmitri 2026-06-21 14:42:22 +02:00
parent d12d2b0a10
commit 569c577beb
48 changed files with 1517 additions and 205 deletions

View File

@ -89,6 +89,7 @@ function toCampusDto(campus: unknown): CampusDto | null {
id,
name: asStringOrNull(plain.name),
code: asStringOrNull(plain.code),
logo: asStringOrNull(plain.logo),
};
}

View File

@ -23,4 +23,5 @@ export interface CampusDto {
id: string;
name: string | null;
code: string | null;
logo?: string | null;
}

View File

@ -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<string, PersonalityQuizResults> {
@ -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,

View File

@ -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,

View File

@ -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<string, SafetyQuizResults> {
@ -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,

View File

@ -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);
}
});
});

View File

@ -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),
};

View File

@ -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<string> {
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<string, UserProgress> {
@ -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,

View File

@ -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.

View File

@ -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 `<img>` 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`.

View File

@ -200,6 +200,7 @@ export function useAppShell(options: UseAppShellOptions): AppShellState {
user: options.user,
userRole,
userName,
avatar: options.user?.avatar ?? null,
campusInfo,
toggleSidebar,
setCurrentModule,

View File

@ -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<typeof toCampusAttendanceConfigViewModel>[] = [];
const EMPTY_SUMMARIES: ReturnType<typeof toCampusAttendanceSummaryViewModel>[] = [];
const EMPTY_STAFF_RECORDS: readonly StaffAttendanceRecordViewModel[] = [];
const EMPTY_CAMPUSES: CampusInfo[] = [];
const EMPTY_TENANT_CHILDREN: TenantChild[] = [];
const EMPTY_CAMPUS_CHILDREN_BY_PARENT: Readonly<Record<string, readonly TenantChild[]>> = {};
@ -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<StudentRollupOverrideMap>({});
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<boolean> => {
const saveStaffBatchAttendance = async (
requireStaff: boolean,
input: Pick<StaffAttendanceEntryDraft, 'date' | 'note'> = staffEntryDraft,
): Promise<boolean> => {
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,

View File

@ -31,6 +31,15 @@ describe('campus attendance print report', () => {
expect(html).toContain(CAMPUS_ATTENDANCE_TEST_SEED.todayNotesEscaped);
expect(html).toContain('<div class="value green">90%</div>');
expect(html).toContain('<div class="value amber">85%</div>');
expect(html).toContain('Students 90% · Staff 90%');
expect(html).toContain('<div class="label">People</div>');
expect(html).toContain('<div class="value">60</div>');
expect(html).toContain("Today's report");
expect(html).toContain('Attendance history');
expect(html).toContain('<td>Students</td>');
expect(html).toContain('<td>Staff</td>');
expect(html).toContain('<td>Total</td>');
expect(html).toContain('<td class="pct-good">90.0%</td>');
expect(html).toContain('<td class="pct-warn">80.0%</td>');
});
@ -44,10 +53,13 @@ describe('campus attendance print report', () => {
weekAvg: null,
recentData: [],
todayRecord: null,
recentStaffData: [],
todayStaffRecord: null,
},
],
printTodayRecords: [],
printWeekRecords: [],
staffSummary: null,
});
expect(html).toContain('<div class="value ">No data</div>');

View File

@ -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) => `
<tr>
<td>${record.group === 'students' ? formatAttendanceDate(record.date) : ''}</td>
<td>${record.label}</td>
<td>${record.total}</td>
<td>${record.present}</td>
<td>${record.absent}</td>
<td>${record.late}</td>
<td class="${inlinePercentageClass(record.attendancePercentage)}">${record.attendancePercentage !== null ? `${record.attendancePercentage.toFixed(1)}%` : 'N/A'}</td>
<td>${record.notes ? escapeHtml(record.notes) : '-'}</td>
</tr>
`).join('');
}
function renderAttendanceHistoryTable(campus: CampusAttendanceStats): string {
const historyRows = buildCombinedAttendanceHistoryRows(campus.recentData, campus.recentStaffData);
if (historyRows.length === 0) {
return '';
}
return `
<div class="section-label">Attendance history</div>
<table class="history-table">
<thead>
<tr>
<th>Date</th><th>Group</th><th>Total</th><th>Present</th><th>Absent</th><th>Tardy / Late</th><th>Attendance %</th><th>Notes</th>
</tr>
</thead>
<tbody>
${renderAttendanceRows(historyRows)}
</tbody>
</table>
`;
}
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; } }
</style>
@ -144,11 +252,16 @@ export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput
<div class="summary-grid">
<div class="summary-box">
<div class="label">Today's Attendance</div>
<div class="value ${percentageClass(printStats.todayPct)}">${printStats.todayPct !== null ? `${printStats.todayPct}%` : 'No data'}</div>
<div class="label" style="margin-top:4px">${formatAttendanceDate(input.today)}</div>
<div class="value ${percentageClass(printStats.combinedPct)}">${printStats.combinedPct !== null ? `${printStats.combinedPct}%` : 'No data'}</div>
<div class="label" style="margin-top:4px">Students ${printStats.todayPct !== null ? `${printStats.todayPct}%` : 'N/A'} · Staff ${printStats.staffPct !== null ? `${printStats.staffPct}%` : 'N/A'}</div>
</div>
<div class="summary-box">
<div class="label">This Week's Average Attendance</div>
<div class="label">People</div>
<div class="value">${input.printTodayRecords.reduce((sum, record) => sum + record.total_enrolled, 0) + staffTotal}</div>
<div class="label" style="margin-top:4px">Students ${input.printTodayRecords.reduce((sum, record) => sum + record.total_enrolled, 0)} · Staff ${staffTotal}</div>
</div>
<div class="summary-box">
<div class="label">This Week's Student Average</div>
<div class="value ${percentageClass(printStats.weekPct)}">${printStats.weekPct !== null ? `${printStats.weekPct}%` : 'No data'}</div>
<div class="label" style="margin-top:4px">Week of ${formatAttendanceDate(input.weekStart)}</div>
</div>
@ -162,35 +275,20 @@ export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput
<span>Week Avg: <span class="${inlinePercentageClass(campus.weekAvg)}">${campus.weekAvg !== null ? `${campus.weekAvg}%` : 'N/A'}</span></span>
</div>
</div>
${campus.todayRecord ? `
<table>
<tr><td style="width:25%;font-weight:600">Enrolled</td><td>${campus.todayRecord.total_enrolled}</td><td style="width:25%;font-weight:600">Present</td><td>${campus.todayRecord.total_present}</td></tr>
<tr><td style="font-weight:600">Absent</td><td>${campus.todayRecord.total_absent}</td><td style="font-weight:600">Tardy</td><td>${campus.todayRecord.total_tardy}</td></tr>
${campus.todayRecord.notes ? `<tr><td style="font-weight:600">Notes</td><td colspan="3">${escapeHtml(campus.todayRecord.notes)}</td></tr>` : ''}
</table>
` : '<p style="font-size:12px;color:#94a3b8;padding:8px 0;">No attendance data recorded for today.</p>'}
${campus.recentData.length > 0 ? `
${campus.todayRecord || campus.todayStaffRecord ? `
<div class="section-label">Today's report</div>
<table class="history-table">
<thead>
<tr>
<th>Date</th><th>Enrolled</th><th>Present</th><th>Absent</th><th>Tardy</th><th>Attendance %</th><th>Notes</th>
<th>Date</th><th>Group</th><th>Total</th><th>Present</th><th>Absent</th><th>Tardy / Late</th><th>Attendance %</th><th>Notes</th>
</tr>
</thead>
<tbody>
${campus.recentData.map((record) => `
<tr>
<td>${formatAttendanceDate(record.date)}</td>
<td>${record.total_enrolled}</td>
<td>${record.total_present}</td>
<td>${record.total_absent}</td>
<td>${record.total_tardy}</td>
<td class="${inlinePercentageClass(record.attendance_percentage)}">${record.attendance_percentage.toFixed(1)}%</td>
<td>${record.notes ? escapeHtml(record.notes) : '-'}</td>
</tr>
`).join('')}
${renderAttendanceRows(buildTodayReportRows(campus))}
</tbody>
</table>
` : ''}
` : '<p style="font-size:12px;color:#94a3b8;padding:8px 0;">No attendance data recorded for today.</p>'}
${renderAttendanceHistoryTable(campus)}
</div>
`).join('')}
<div class="footer">

View File

@ -2,15 +2,21 @@ import { describe, expect, it } from 'vitest';
import {
buildAttendanceEntryInput,
buildCampusAttendanceScopeModel,
buildCampusAttendanceStats,
buildCombinedAttendanceHistoryRows,
buildCombinedAttendanceStats,
buildOverallAttendanceStats,
buildStaffAttendanceDailySummaries,
getWeekEnd,
getWeekStart,
} from '@/business/campus-attendance/selectors';
import type {
CampusAttendanceChildStats,
CampusAttendanceEntryDraft,
CampusAttendanceSummaryViewModel,
} from '@/business/campus-attendance/types';
import type { CampusInfo } from '@/shared/types/app';
import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types';
const summaries: readonly CampusAttendanceSummaryViewModel[] = [
{
@ -51,6 +57,55 @@ const summaries: readonly CampusAttendanceSummaryViewModel[] = [
},
];
const staffRecords: readonly StaffAttendanceRecordViewModel[] = [
{
id: 'staff-1',
userId: 'user-1',
date: '2026-06-08',
status: 'present',
note: null,
userName: 'Sarah Williams',
userRole: 'Director',
campusId: 'tenant-tigers',
},
{
id: 'staff-2',
userId: 'user-2',
date: '2026-06-08',
status: 'late',
note: 'Traffic',
userName: 'Lisa Park',
userRole: 'Office Manager',
campusId: 'tenant-tigers',
},
{
id: 'staff-3',
userId: 'user-3',
date: '2026-06-08',
status: 'absent',
note: null,
userName: 'Marcus Davis',
userRole: 'Support Staff',
campusId: 'tenant-tigers',
},
];
const campusInfo: readonly CampusInfo[] = [
{
id: 'tigers',
tenantId: 'tenant-tigers',
mascot: 'Tigers',
fullName: 'Tigers Campus',
color: 'bg-orange-500',
bgGradient: 'from-orange-500 to-amber-500',
borderColor: 'border-orange-500/30',
textColor: 'text-orange-400',
bgLight: 'bg-orange-500/10',
description: 'Tigers campus',
isOnline: true,
},
];
describe('campus attendance selectors', () => {
it('calculates Monday and Friday boundaries for a midweek date', () => {
const date = new Date('2026-06-10T12:00:00Z');
@ -171,8 +226,127 @@ describe('campus attendance selectors', () => {
).toEqual({
studentTodayPct: 86.67,
studentWeekPct: 90.83,
staffTodayPct: 90,
combinedTodayPct: 86.88,
staffTodayPct: 75,
combinedTodayPct: 85.8,
});
});
it('counts missing child attendance reports as incomplete in aggregate percentages', () => {
const overallStats = buildOverallAttendanceStats(summaries, '2026-06-08', '2026-06-08', '2026-06-12');
const childStats: readonly CampusAttendanceChildStats[] = [
{
id: 'school-1',
level: 'school',
mascot: 'North',
fullName: 'Demo Academy North',
bgGradient: 'from-emerald-500 to-green-500',
todayPct: 100,
weekAvg: 100,
recentData: [],
todayRecord: null,
childCampusIds: ['tigers'],
recentStaffData: [],
todayStaffRecord: null,
},
{
id: 'school-2',
level: 'school',
mascot: 'South',
fullName: 'Demo Academy South',
bgGradient: 'from-orange-600 to-amber-500',
todayPct: null,
weekAvg: null,
recentData: [],
todayRecord: null,
childCampusIds: ['gators'],
recentStaffData: [],
todayStaffRecord: null,
},
];
expect(buildCombinedAttendanceStats(overallStats, null, childStats)).toEqual({
studentTodayPct: 50,
studentWeekPct: 50,
staffTodayPct: null,
combinedTodayPct: 50,
});
});
it('builds daily staff attendance summaries by campus and date', () => {
expect(buildStaffAttendanceDailySummaries(staffRecords)).toEqual([
{
id: 'staff:tenant-tigers:2026-06-08',
campus_id: 'tenant-tigers',
date: '2026-06-08',
total_staff: 3,
total_present: 2,
total_absent: 1,
total_late: 1,
attendance_percentage: 66.67,
notes: 'Traffic',
},
]);
});
it('attaches staff attendance history to campus attendance stats', () => {
const [campus] = buildCampusAttendanceStats(
campusInfo,
[],
summaries,
'2026-06-08',
'2026-06-08',
'2026-06-12',
staffRecords,
);
expect(campus?.todayRecord?.total_enrolled).toBe(100);
expect(campus?.todayStaffRecord).toMatchObject({
total_staff: 3,
total_present: 2,
total_absent: 1,
attendance_percentage: 66.67,
});
expect(campus?.recentStaffData).toHaveLength(1);
});
it('builds combined attendance history with students, staff, and total rows per date', () => {
expect(buildCombinedAttendanceHistoryRows([summaries[0]], buildStaffAttendanceDailySummaries(staffRecords))).toEqual([
{
id: '2026-06-08:students',
date: '2026-06-08',
group: 'students',
label: 'Students',
total: 100,
present: 90,
absent: 10,
late: 3,
attendancePercentage: 90,
notes: null,
},
{
id: '2026-06-08:staff',
date: '2026-06-08',
group: 'staff',
label: 'Staff',
total: 3,
present: 2,
absent: 1,
late: 1,
attendancePercentage: 66.67,
notes: 'Traffic',
},
{
id: '2026-06-08:total',
date: '2026-06-08',
group: 'total',
label: 'Total',
total: 103,
present: 92,
absent: 11,
late: 4,
attendancePercentage: 89.32,
notes: 'Traffic',
},
]);
});
});

View File

@ -4,15 +4,21 @@ import type { ActiveTenant } from '@/shared/types/scope';
import type {
AttendanceScopeMode,
CampusAttendanceCombinedStats,
CampusAttendanceChildStats,
CampusAttendanceEntryDraft,
CampusAttendanceConfigViewModel,
CombinedAttendanceHistoryRow,
CampusAttendanceOverallStats,
CampusAttendancePrintInput,
CampusAttendanceScopeModel,
CampusAttendanceStats,
CampusAttendanceSummaryViewModel,
StaffAttendanceDailySummaryViewModel,
} from '@/business/campus-attendance/types';
import type { StaffAttendanceSummaryViewModel } from '@/business/staff-attendance/types';
import type {
StaffAttendanceRecordViewModel,
StaffAttendanceSummaryViewModel,
} from '@/business/staff-attendance/types';
export function getWeekStart(date: Date): string {
const weekStart = new Date(date);
@ -132,13 +138,20 @@ export function buildCampusAttendanceStats(
today: string,
weekStart: string,
weekEnd: string,
staffRecords: readonly StaffAttendanceRecordViewModel[] = [],
): readonly CampusAttendanceStats[] {
const staffDailySummaries = buildStaffAttendanceDailySummaries(staffRecords);
return campuses.map((campus) => {
const todayPct = getTodayPercentage(summaries, campus.id, today);
const weekAvg = getWeeklyAverage(summaries, campus.id, weekStart, weekEnd);
const config = configs.find((item) => item.campus_id === campus.id) ?? null;
const recentData = summaries.filter((record) => record.campus_id === campus.id).slice(0, 10);
const todayRecord = getTodayData(summaries, campus.id, today)[0] ?? null;
const recentStaffData = staffDailySummaries
.filter((record) => isStaffSummaryForCampus(record, campus))
.slice(0, 10);
const todayStaffRecord = recentStaffData.find((record) => record.date === today) ?? null;
return {
...campus,
@ -147,6 +160,8 @@ export function buildCampusAttendanceStats(
config,
recentData,
todayRecord,
recentStaffData,
todayStaffRecord,
};
});
}
@ -261,29 +276,198 @@ function percentageFromCounts(present: number, total: number): number | null {
return total > 0 ? Number(((present / total) * 100).toFixed(2)) : null;
}
function percentageFromScopedChildren(
childStats: readonly CampusAttendanceChildStats[] | undefined,
getPercentage: (child: CampusAttendanceChildStats) => number | null,
): number | null {
if (!childStats || childStats.length === 0) {
return null;
}
const total = childStats.length;
const presentEquivalent = childStats.reduce((sum, child) => sum + ((getPercentage(child) ?? 0) / 100), 0);
return percentageFromCounts(presentEquivalent, total);
}
function staffAttendancePercentage(present: number, late: number, total: number): number {
return percentageFromCounts(present + late, total) ?? 0;
}
function staffDailySummaryId(campusId: CampusId | null, date: string): string {
return `staff:${campusId ?? 'office'}:${date}`;
}
function isStaffSummaryForCampus(
record: StaffAttendanceDailySummaryViewModel,
campus: CampusInfo,
): boolean {
return record.campus_id === campus.id || record.campus_id === campus.tenantId;
}
export function buildStaffAttendanceDailySummaries(
records: readonly StaffAttendanceRecordViewModel[],
): readonly StaffAttendanceDailySummaryViewModel[] {
const grouped = new Map<string, StaffAttendanceRecordViewModel[]>();
for (const record of records) {
const key = staffDailySummaryId(record.campusId ?? null, record.date);
const group = grouped.get(key);
if (group) {
group.push(record);
} else {
grouped.set(key, [record]);
}
}
return Array.from(grouped.values())
.map((group) => {
const firstRecord = group[0];
const campusId = firstRecord?.campusId ?? null;
const date = firstRecord?.date ?? '';
const totalPresent = group.filter((record) => record.status === 'present').length;
const totalLate = group.filter((record) => record.status === 'late').length;
const totalAbsent = group.filter((record) => record.status === 'absent').length;
const notes = group
.map((record) => record.note)
.filter((note): note is string => Boolean(note))
.join('; ');
return {
id: staffDailySummaryId(campusId, date),
campus_id: campusId,
date,
total_staff: group.length,
total_present: totalPresent + totalLate,
total_absent: totalAbsent,
total_late: totalLate,
attendance_percentage: staffAttendancePercentage(totalPresent, totalLate, group.length),
notes: notes || null,
};
})
.sort((left, right) => right.date.localeCompare(left.date) || left.id.localeCompare(right.id));
}
export function buildCombinedAttendanceStats(
overallStats: CampusAttendanceOverallStats,
staffSummary: StaffAttendanceSummaryViewModel | null,
childStats?: readonly CampusAttendanceChildStats[],
): CampusAttendanceCombinedStats {
const staffTotal = staffSummary
const scopedChildren = childStats ?? [];
const recordedStaffTotal = staffSummary
? staffSummary.present + staffSummary.late + staffSummary.absent
: 0;
const staffTotal = staffSummary ? Math.max(staffSummary.staffCount, recordedStaffTotal) : 0;
const staffPresent = staffSummary ? staffSummary.present + staffSummary.late : 0;
const studentPresent = overallStats.todayPresent;
const studentTotal = overallStats.todayEnrolled;
const scopedStudentTodayPct = percentageFromScopedChildren(scopedChildren, (child) => child.todayPct);
const scopedStudentWeekPct = percentageFromScopedChildren(scopedChildren, (child) => child.weekAvg);
const hasScopedChildren = scopedChildren.length > 0;
const studentTodayPct = scopedStudentTodayPct ?? overallStats.todayPct;
const studentWeekPct = scopedStudentWeekPct ?? overallStats.weekPct;
const studentTotal = hasScopedChildren ? scopedChildren.length : overallStats.todayEnrolled;
const studentPresent = hasScopedChildren
? scopedChildren.reduce((sum, child) => sum + ((child.todayPct ?? 0) / 100), 0)
: overallStats.todayPresent;
return {
studentTodayPct: overallStats.todayPct,
studentWeekPct: overallStats.weekPct,
studentTodayPct,
studentWeekPct,
staffTodayPct: percentageFromCounts(staffPresent, staffTotal),
combinedTodayPct: percentageFromCounts(studentPresent + staffPresent, studentTotal + staffTotal),
};
}
function notesFromParts(...parts: readonly (string | null | undefined)[]): string | null {
const notes = parts.filter((part): part is string => Boolean(part));
return notes.length > 0 ? notes.join('; ') : null;
}
function combinedAttendancePercentage(
studentRecord: CampusAttendanceSummaryViewModel | null,
staffRecord: StaffAttendanceDailySummaryViewModel | null,
): number | null {
const studentTotal = studentRecord?.total_enrolled ?? 0;
const staffTotal = staffRecord?.total_staff ?? 0;
const studentPresent = studentRecord?.total_present ?? 0;
const staffPresent = staffRecord?.total_present ?? 0;
return percentageFromCounts(studentPresent + staffPresent, studentTotal + staffTotal);
}
export function buildCombinedAttendanceHistoryRows(
studentRecords: readonly CampusAttendanceSummaryViewModel[],
staffRecords: readonly StaffAttendanceDailySummaryViewModel[],
): readonly CombinedAttendanceHistoryRow[] {
const dates = Array.from(new Set([
...studentRecords.map((record) => record.date),
...staffRecords.map((record) => record.date),
])).sort((left, right) => right.localeCompare(left));
return dates.flatMap((date) => {
const studentRecord = studentRecords.find((record) => record.date === date) ?? null;
const staffRecord = staffRecords.find((record) => record.date === date) ?? null;
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;
return [
{
id: `${date}:students`,
date,
group: 'students',
label: 'Students',
total: studentTotal,
present: studentPresent,
absent: studentAbsent,
late: studentLate,
attendancePercentage: studentRecord?.attendance_percentage ?? null,
notes: studentRecord?.notes ?? null,
},
{
id: `${date}:staff`,
date,
group: 'staff',
label: 'Staff',
total: staffTotal,
present: staffPresent,
absent: staffAbsent,
late: staffLate,
attendancePercentage: staffRecord?.attendance_percentage ?? null,
notes: staffRecord?.notes ?? null,
},
{
id: `${date}:total`,
date,
group: 'total',
label: 'Total',
total: studentTotal + staffTotal,
present: studentPresent + staffPresent,
absent: studentAbsent + staffAbsent,
late: studentLate + staffLate,
attendancePercentage: combinedAttendancePercentage(studentRecord, staffRecord),
notes: notesFromParts(studentRecord?.notes, staffRecord?.notes),
},
] satisfies readonly CombinedAttendanceHistoryRow[];
});
}
export function buildPrintAttendanceStats(input: CampusAttendancePrintInput) {
const printEnrolled = input.printTodayRecords.reduce((sum, record) => sum + record.total_enrolled, 0);
const printPresent = input.printTodayRecords.reduce((sum, record) => sum + record.total_present, 0);
const todayPct = printEnrolled > 0 ? Number(((printPresent / printEnrolled) * 100).toFixed(2)) : null;
const staffTotal = input.staffSummary
? Math.max(input.staffSummary.staffCount, input.staffSummary.present + input.staffSummary.late + input.staffSummary.absent)
: 0;
const staffPresent = input.staffSummary
? input.staffSummary.present + input.staffSummary.late
: 0;
const staffPct = percentageFromCounts(staffPresent, staffTotal);
const combinedPct = percentageFromCounts(printPresent + staffPresent, printEnrolled + staffTotal);
const weekDays = Array.from(new Set(input.printWeekRecords.map((record) => record.date)));
const weekPct = weekDays.length > 0
? Number((weekDays.reduce((sum, day) => {
@ -296,6 +480,8 @@ export function buildPrintAttendanceStats(input: CampusAttendancePrintInput) {
return {
todayPct,
staffPct,
combinedPct,
weekPct,
};
}

View File

@ -43,6 +43,8 @@ export interface CampusAttendanceStats extends CampusInfo {
readonly config: CampusAttendanceConfigViewModel | null;
readonly recentData: readonly CampusAttendanceSummaryViewModel[];
readonly todayRecord: CampusAttendanceSummaryViewModel | null;
readonly recentStaffData: readonly StaffAttendanceDailySummaryViewModel[];
readonly todayStaffRecord: StaffAttendanceDailySummaryViewModel | null;
}
export interface CampusAttendanceChildStats {
@ -57,6 +59,8 @@ export interface CampusAttendanceChildStats {
readonly recentData: readonly CampusAttendanceSummaryViewModel[];
readonly todayRecord: CampusAttendanceSummaryViewModel | null;
readonly childCampusIds: readonly CampusId[];
readonly recentStaffData: readonly StaffAttendanceDailySummaryViewModel[];
readonly todayStaffRecord: StaffAttendanceDailySummaryViewModel | null;
}
export interface CampusAttendanceOverallStats {
@ -133,6 +137,33 @@ export interface CampusAttendanceCombinedStats {
readonly combinedTodayPct: number | null;
}
export interface StaffAttendanceDailySummaryViewModel {
readonly id: string;
readonly campus_id: CampusId | null;
readonly date: string;
readonly total_staff: number;
readonly total_present: number;
readonly total_absent: number;
readonly total_late: number;
readonly attendance_percentage: number;
readonly notes: string | null;
}
export type CombinedAttendanceGroup = 'students' | 'staff' | 'total';
export interface CombinedAttendanceHistoryRow {
readonly id: string;
readonly date: string;
readonly group: CombinedAttendanceGroup;
readonly label: string;
readonly total: number;
readonly present: number;
readonly absent: number;
readonly late: number;
readonly attendancePercentage: number | null;
readonly notes: string | null;
}
export interface CampusAttendanceStaffSummaryState {
readonly summary: StaffAttendanceSummaryViewModel | null;
readonly loading: boolean;
@ -148,4 +179,5 @@ export interface CampusAttendancePrintInput {
readonly campusesToPrint: readonly CampusAttendanceStats[];
readonly printTodayRecords: readonly CampusAttendanceSummaryViewModel[];
readonly printWeekRecords: readonly CampusAttendanceSummaryViewModel[];
readonly staffSummary: StaffAttendanceSummaryViewModel | null;
}

View File

@ -76,6 +76,9 @@ function createPersonalityCompletion(
userId: 'user-1',
name: 'Ava Lee',
email: 'ava@example.test',
avatar: 'users/avatar/ava.png',
tenantName: 'Tigers Campus',
tenantLogo: 'campuses/logo/tigers.png',
role: 'Teacher',
status: 'complete',
completedKinds: ['ei_self_assessment', 'personality_type'],
@ -148,6 +151,9 @@ function createZoneCheckinCompletion(
userId: 'user-1',
name: 'Ava Lee',
email: 'ava@example.test',
avatar: 'users/avatar/ava.png',
tenantName: 'Tigers Campus',
tenantLogo: 'campuses/logo/tigers.png',
role: 'Teacher',
date: '2026-06-18',
status: 'complete',
@ -190,6 +196,9 @@ function createAcknowledgmentReport(
userId: 'user-1',
name: 'Ava Lee',
email: 'ava@example.test',
avatar: 'users/avatar/ava.png',
tenantName: 'Tigers Campus',
tenantLogo: 'campuses/logo/tigers.png',
role: 'teacher',
campusId: 'campus-1',
schoolId: 'school-1',
@ -212,6 +221,9 @@ function createAcknowledgmentReport(
userId: 'user-2',
name: 'Ben Cruz',
email: 'ben@example.test',
avatar: 'users/avatar/ben.png',
tenantName: 'Tigers Campus',
tenantLogo: 'campuses/logo/tigers.png',
role: 'support_staff',
campusId: 'campus-1',
schoolId: 'school-1',
@ -272,13 +284,13 @@ describe('director dashboard selectors', () => {
expect(cards[cards.length - 1]?.action).toBe('openAcknowledgments');
});
it('flags dashboard risk levels from quiz completion and absences', () => {
it('flags dashboard risk levels from quiz completion and staff attendance exceptions', () => {
const risks = buildDirectorRiskAreas(
[
createAttendanceRecord({ id: '1', status: 'absent' }),
createAttendanceRecord({ id: '2', status: 'absent' }),
createAttendanceRecord({ id: '3', status: 'absent' }),
createAttendanceRecord({ id: '4', status: 'absent' }),
createAttendanceRecord({ id: '4', status: 'late' }),
],
createQuizSummary({ completedCount: 1, pendingCount: 5, totalStaff: 6, completionRate: 17 }),
null,
@ -288,7 +300,7 @@ describe('director dashboard selectors', () => {
expect(risks).toEqual([
{
issue: "5 staff haven't completed de-escalation quiz",
issue: "5 staff haven't completed Behavior Management quiz",
severity: 'high',
module: 'qbs',
action: 'openQuizResults',
@ -311,7 +323,7 @@ describe('director dashboard selectors', () => {
action: 'openAcknowledgments',
},
{
issue: '4 absences recorded this period',
issue: '4 staff attendance exceptions this period (1 late, 3 absent)',
severity: 'high',
module: 'attendance',
},
@ -346,6 +358,9 @@ describe('director dashboard selectors', () => {
userId: 'user-1',
name: 'Ava Lee',
email: 'ava@example.test',
avatar: 'users/avatar/ava.png',
tenantName: 'Tigers Campus',
tenantLogo: 'campuses/logo/tigers.png',
role: 'Teacher',
date: '2026-06-18',
status: 'complete',
@ -401,10 +416,42 @@ describe('director dashboard selectors', () => {
missingCount: 4,
completionRate: 0,
missingStaff: [
{ userId: 'user-1', name: 'Ava Lee', role: 'teacher', email: 'ava@example.test' },
{ userId: 'user-2', name: 'Ben Cruz', role: 'support staff', email: 'ben@example.test' },
{ userId: 'user-3', name: 'Cara Fox', role: 'office manager', email: 'cara@example.test' },
{ userId: 'user-4', name: 'Drew Kim', role: 'director', email: 'drew@example.test' },
{
userId: 'user-1',
name: 'Ava Lee',
avatar: null,
tenantName: 'Tigers Campus',
tenantLogo: null,
role: 'teacher',
email: 'ava@example.test',
},
{
userId: 'user-2',
name: 'Ben Cruz',
avatar: null,
tenantName: 'Tigers Campus',
tenantLogo: null,
role: 'support staff',
email: 'ben@example.test',
},
{
userId: 'user-3',
name: 'Cara Fox',
avatar: null,
tenantName: 'Tigers Campus',
tenantLogo: null,
role: 'office manager',
email: 'cara@example.test',
},
{
userId: 'user-4',
name: 'Drew Kim',
avatar: null,
tenantName: 'Tigers Campus',
tenantLogo: null,
role: 'director',
email: 'drew@example.test',
},
],
})),
);
@ -437,6 +484,9 @@ describe('director dashboard selectors', () => {
{
userId: 'user-2',
name: 'Ben Cruz',
avatar: 'users/avatar/ben.png',
tenantName: 'Tigers Campus',
tenantLogo: 'campuses/logo/tigers.png',
role: 'support staff',
email: 'ben@example.test',
},
@ -451,6 +501,9 @@ describe('director dashboard selectors', () => {
{
userId: 'user-1',
name: 'Ava Lee',
avatar: 'users/avatar/ava.png',
tenantName: 'Tigers Campus',
tenantLogo: 'campuses/logo/tigers.png',
role: 'Teacher',
status: 'complete',
score: '3/5',
@ -467,6 +520,7 @@ describe('director dashboard selectors', () => {
id: 'user-1',
staffName: 'Ava Lee',
tenant: 'Tigers Campus',
tenantLogo: 'campuses/logo/tigers.png',
role: 'Teacher',
completedCount: 4,
totalCount: 4,
@ -491,6 +545,9 @@ describe('director dashboard selectors', () => {
{
userId: 'user-1',
name: 'Ava Lee',
avatar: 'users/avatar/ava.png',
tenantName: 'Tigers Campus',
tenantLogo: 'campuses/logo/tigers.png',
role: 'Teacher',
status: 'complete',
score: '3/5',
@ -503,6 +560,9 @@ describe('director dashboard selectors', () => {
userId: 'user-2',
name: 'Ben Cruz',
email: 'ben@example.test',
avatar: null,
tenantName: 'Tigers Campus',
tenantLogo: null,
role: 'Support Staff',
status: 'pending',
completedKinds: [],
@ -517,6 +577,9 @@ describe('director dashboard selectors', () => {
userId: 'user-1',
name: 'Ava Lee',
email: 'ava@example.test',
avatar: 'users/avatar/ava.png',
tenantName: 'Tigers Campus',
tenantLogo: 'campuses/logo/tigers.png',
role: 'Teacher',
date: '2026-06-18',
status: 'pending',

View File

@ -1,7 +1,6 @@
import {
DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD,
DIRECTOR_DASHBOARD_FRAME_PREVIEW_LIMIT,
DIRECTOR_DASHBOARD_HIGH_ABSENCE_THRESHOLD,
DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD,
DIRECTOR_DASHBOARD_TEXT_PREVIEW_LENGTH,
} from '@/shared/constants/directorDashboard';
@ -109,6 +108,8 @@ export function buildDirectorRiskAreas(
): readonly DirectorRiskArea[] {
const incompleteStaffCount = quizSummary.pendingCount;
const absenceCount = countStaffAttendanceStatus(attendanceRecords, 'absent');
const lateCount = countStaffAttendanceStatus(attendanceRecords, 'late');
const staffAttendanceExceptionCount = absenceCount + lateCount;
const selfAssessmentPendingCount = emotionalIntelligenceCompletion?.summary
? emotionalIntelligenceCompletion.summary.totalStaff
- emotionalIntelligenceCompletion.summary.selfAssessmentCompletedCount
@ -124,7 +125,7 @@ export function buildDirectorRiskAreas(
if (incompleteStaffCount > 0) {
risks.push({
issue: `${incompleteStaffCount} staff haven't completed de-escalation quiz`,
issue: `${incompleteStaffCount} staff haven't completed Behavior Management quiz`,
severity: incompleteStaffCount > DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD ? 'high' : 'medium',
module: 'qbs',
action: 'openQuizResults',
@ -176,10 +177,10 @@ export function buildDirectorRiskAreas(
});
}
if (absenceCount > 0) {
if (staffAttendanceExceptionCount > 0) {
risks.push({
issue: `${absenceCount} absences recorded this period`,
severity: absenceCount > DIRECTOR_DASHBOARD_HIGH_ABSENCE_THRESHOLD ? 'high' : 'low',
issue: `${staffAttendanceExceptionCount} ${pluralize('staff attendance exception', staffAttendanceExceptionCount)} this period (${lateCount} late, ${absenceCount} absent)`,
severity: 'high',
module: 'attendance',
});
}
@ -237,6 +238,9 @@ export function buildDirectorAcknowledgmentDocuments(
.map((staff) => ({
userId: staff.userId,
name: staff.name,
avatar: staff.avatar,
tenantName: staff.tenantName,
tenantLogo: staff.tenantLogo,
role: formatRoleLabel(staff.role),
email: staff.email,
}));
@ -303,21 +307,46 @@ export function buildDirectorQuizResults(
): readonly DirectorQuizResultRow[] {
const rowsByUserId = new Map<string, {
staffName: string;
avatar: string | null;
tenantName: string | null;
tenantLogo: string | null;
role: string;
details: DirectorQuizResultDetail[];
}>();
const ensureRow = (userId: string, staffName: string, role: string | null): {
const ensureRow = (
userId: string,
staffName: string,
avatar: string | null,
tenantName: string | null,
tenantLogo: string | null,
role: string | null,
): {
staffName: string;
avatar: string | null;
tenantName: string | null;
tenantLogo: string | null;
role: string;
details: DirectorQuizResultDetail[];
} => {
const existing = rowsByUserId.get(userId);
if (existing) {
if (!existing.avatar && avatar) {
existing.avatar = avatar;
}
if (!existing.tenantName && tenantName) {
existing.tenantName = tenantName;
}
if (!existing.tenantLogo && tenantLogo) {
existing.tenantLogo = tenantLogo;
}
return existing;
}
const next = {
staffName,
avatar,
tenantName,
tenantLogo,
role: role ?? 'Staff',
details: [],
};
@ -326,7 +355,7 @@ export function buildDirectorQuizResults(
};
for (const row of safetyRows) {
ensureRow(row.userId, row.name, row.role).details.push({
ensureRow(row.userId, row.name, row.avatar, row.tenantName, row.tenantLogo, row.role).details.push({
id: 'behavior-management',
quiz: 'Behavior Management',
result: row.score,
@ -336,7 +365,7 @@ export function buildDirectorQuizResults(
}
for (const row of emotionalIntelligenceCompletion?.rows ?? []) {
const target = ensureRow(row.userId, row.name, row.role);
const target = ensureRow(row.userId, row.name, row.avatar, row.tenantName, row.tenantLogo, row.role);
target.details.push(
{
id: 'ei-self-assessment',
@ -356,7 +385,7 @@ export function buildDirectorQuizResults(
}
for (const row of zoneCheckinCompletion?.rows ?? []) {
ensureRow(row.userId, row.name, row.role).details.push({
ensureRow(row.userId, row.name, row.avatar, row.tenantName, row.tenantLogo, row.role).details.push({
id: 'daily-zone-check-in',
quiz: 'Daily Zone Check-In',
result: row.result,
@ -370,7 +399,9 @@ export function buildDirectorQuizResults(
return [...rowsByUserId.entries()].map(([userId, row]) => ({
id: userId,
staffName: row.staffName,
tenant: tenantLabel,
avatar: row.avatar,
tenant: row.tenantName ?? tenantLabel,
tenantLogo: row.tenantLogo,
role: row.role,
completedCount: row.details.filter((detail) => detail.status === 'complete').length,
totalCount: row.details.length,

View File

@ -52,7 +52,9 @@ export interface DirectorQuizResultDetail {
export interface DirectorQuizResultRow {
readonly id: string;
readonly staffName: string;
readonly avatar: string | null;
readonly tenant: string;
readonly tenantLogo: string | null;
readonly role: string;
readonly completedCount: number;
readonly totalCount: number;
@ -62,6 +64,9 @@ export interface DirectorQuizResultRow {
export interface DirectorAcknowledgmentMissingStaff {
readonly userId: string;
readonly name: string;
readonly avatar: string | null;
readonly tenantName: string | null;
readonly tenantLogo: string | null;
readonly role: string;
readonly email: string;
}

View File

@ -39,6 +39,9 @@ describe('safety quiz mappers', () => {
expect(toSafetyQuizComplianceRow(createResult())).toEqual({
userId: 'user-1',
name: 'Ava Lee',
avatar: null,
tenantName: null,
tenantLogo: null,
role: 'Support Staff',
status: 'complete',
score: '4/5',
@ -111,8 +114,28 @@ describe('safety quiz selectors', () => {
it('summarizes compliance rows', () => {
expect(
calculateSafetyQuizCompletionSummary([
{ userId: 'user-1', name: 'Ava', role: 'Teacher', status: 'complete', score: '5/5', date: 'Jun 8' },
{ userId: 'user-2', name: 'Ben', role: 'Para', status: 'complete', score: '4/5', date: 'Jun 8' },
{
userId: 'user-1',
name: 'Ava',
avatar: null,
tenantName: null,
tenantLogo: null,
role: 'Teacher',
status: 'complete',
score: '5/5',
date: 'Jun 8',
},
{
userId: 'user-2',
name: 'Ben',
avatar: null,
tenantName: null,
tenantLogo: null,
role: 'Para',
status: 'complete',
score: '4/5',
date: 'Jun 8',
},
]),
).toEqual({
completedCount: 2,

View File

@ -41,6 +41,9 @@ export function toSafetyQuizComplianceRow(dto: SafetyQuizResultDto): SafetyQuizC
return {
userId: dto.userId,
name: dto.user_name,
avatar: null,
tenantName: null,
tenantLogo: null,
role: toRoleLabel(dto.user_role),
status: 'complete',
score: `${dto.score}/${dto.total_questions}`,
@ -55,6 +58,9 @@ export function toSafetyQuizCompletionRow(dto: SafetyQuizCompletionRowDto): Safe
return {
userId: dto.userId,
name: dto.name,
avatar: dto.avatar,
tenantName: dto.tenantName,
tenantLogo: dto.tenantLogo,
role: dto.role ? toRoleLabel(dto.role) : 'Staff',
status: dto.status,
score: dto.result ? `${dto.result.score}/${dto.result.total_questions}` : 'Pending',

View File

@ -1,6 +1,9 @@
export interface SafetyQuizComplianceRow {
readonly userId: string;
readonly name: string;
readonly avatar: string | null;
readonly tenantName: string | null;
readonly tenantLogo: string | null;
readonly role: string;
readonly status: 'complete' | 'pending';
readonly score: string;

View File

@ -42,10 +42,7 @@ export function getScopeTierLabel(tier: ScopeTier): string {
return SCOPE_TIER_LABELS[tier];
}
/**
* The user's own active tenant the tenant at their scope tier. Campuses carry
* no logo (name only); schools/classes/orgs may carry one.
*/
/** The user's own active tenant — the tenant at their scope tier. */
export function getActiveTenant(
user: CurrentUser | null | undefined,
): ActiveTenant | null {
@ -56,13 +53,13 @@ export function getActiveTenant(
return { level: 'class', id: user.classRoom.id, name: user.classRoom.name ?? null, logo: user.classRoom.logo ?? null };
}
if (tier === 'campus' && user.campus?.id) {
return { level: 'campus', id: user.campus.id, name: user.campus.name ?? null, logo: null };
return { level: 'campus', id: user.campus.id, name: user.campus.name ?? null, logo: user.campus.logo ?? null };
}
if (tier === 'school' && user.school?.id) {
return { level: 'school', id: user.school.id, name: user.school.name ?? null, logo: user.school.logo ?? null };
}
if (user.organizations?.id) {
return { level: 'organization', id: user.organizations.id, name: user.organizations.name ?? null, logo: null };
return { level: 'organization', id: user.organizations.id, name: user.organizations.name ?? null, logo: user.organizations.logo ?? null };
}
return null;
}

View File

@ -188,6 +188,7 @@ export function useTopBarPage({
return {
userRole,
userName,
avatar: user?.avatar ?? null,
campusInfo,
profileRoleLabel: profile ? getTopBarRoleLabel(profile.role) : getTopBarRoleLabel(userRole),
roleLabel: getTopBarRoleLabel(userRole),

View File

@ -11,6 +11,7 @@ export interface TopBarProps {
readonly user: CurrentUser | null;
readonly userRole: UserRole;
readonly userName: string;
readonly avatar: string | null;
readonly campusInfo?: CampusInfo;
readonly toggleSidebar: () => void;
readonly setCurrentModule: (moduleId: ModuleId) => void;
@ -33,6 +34,7 @@ export interface UseTopBarPageOptions extends TopBarProps {
export interface TopBarPage {
readonly userRole: UserRole;
readonly userName: string;
readonly avatar: string | null;
readonly campusInfo?: CampusInfo;
readonly profileRoleLabel: string;
readonly roleLabel: string;

View File

@ -1,6 +1,9 @@
import { BarChart3, Calendar, ClipboardList, ExternalLink, FileText, Globe, Link2, Users, UserX } from 'lucide-react';
import { BarChart3, Calendar, ClipboardList, ExternalLink, FileText, Globe, Link2, Users } from 'lucide-react';
import { formatAttendanceDate } from '@/business/campus-attendance/selectors';
import {
buildCombinedAttendanceHistoryRows,
formatAttendanceDate,
} from '@/business/campus-attendance/selectors';
import { AttendanceSummaryCard } from '@/components/campus-attendance/AttendanceSummaryCard';
import { percentageTextClass } from '@/components/campus-attendance/styles';
import {
@ -25,52 +28,74 @@ export function IndividualCampusAttendanceView({ state, actions }: IndividualCam
const {
today,
weekStart,
myTodayPct,
weekEnd,
myWeekAvg,
myCampusData,
myStaffData,
myCampusConfig,
roleAccess,
campusInfo,
scopeModel,
} = state;
const todayRecord = myCampusData.find((record) => record.date === today);
const todayStaffRecord = myStaffData.find((record) => record.date === today);
const historyTitle = campusInfo?.fullName || scopeModel.tenantName || 'Current Campus';
const combinedHistoryRows = buildCombinedAttendanceHistoryRows(myCampusData, myStaffData);
const todayStudentTotal = todayRecord?.total_enrolled ?? 0;
const todayStaffTotal = todayStaffRecord?.total_staff ?? 0;
const todayStudentPresent = todayRecord?.total_present ?? 0;
const todayStaffPresent = todayStaffRecord?.total_present ?? 0;
const combinedTodayTotal = todayStudentTotal + todayStaffTotal;
const combinedTodayPresent = todayStudentPresent + todayStaffPresent;
const combinedTodayPct = combinedTodayTotal > 0
? Number(((combinedTodayPresent / combinedTodayTotal) * 100).toFixed(2))
: null;
const weekStudentDates = new Set(myCampusData.map((record) => record.date));
const weekStaffDates = new Set(myStaffData.map((record) => record.date));
const weekDates = Array.from(new Set([...weekStudentDates, ...weekStaffDates]))
.filter((date) => date >= weekStart && date <= weekEnd);
const combinedWeekPct = weekDates.length > 0
? Number((weekDates.reduce((sum, date) => {
const studentRecord = myCampusData.find((record) => record.date === date) ?? null;
const staffRecord = myStaffData.find((record) => record.date === date) ?? null;
const total = (studentRecord?.total_enrolled ?? 0) + (staffRecord?.total_staff ?? 0);
const present = (studentRecord?.total_present ?? 0) + (staffRecord?.total_present ?? 0);
return sum + (total > 0 ? (present / total) * 100 : 0);
}, 0) / weekDates.length).toFixed(2))
: null;
const studentTodayLabel = todayRecord ? `${todayRecord.total_present}/${todayRecord.total_enrolled} students` : 'No student data';
const staffTodayLabel = todayStaffRecord ? `${todayStaffRecord.total_present}/${todayStaffRecord.total_staff} staff` : 'No staff data';
const weekStudentLabel = myWeekAvg !== null ? `Students ${myWeekAvg}%` : 'Students N/A';
const weekStaffRecords = myStaffData.filter((record) => record.date >= weekStart && record.date <= weekEnd);
const weekStaffAvg = weekStaffRecords.length > 0
? Number((weekStaffRecords.reduce((sum, record) => sum + record.attendance_percentage, 0) / weekStaffRecords.length).toFixed(2))
: null;
const weekStaffLabel = weekStaffAvg !== null ? `Staff ${weekStaffAvg}%` : 'Staff N/A';
return (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<AttendanceSummaryCard
label="Today's Attendance"
value={myTodayPct !== null ? `${myTodayPct}%` : 'N/A'}
helper={formatAttendanceDate(today)}
value={combinedTodayPct !== null ? `${combinedTodayPct}%` : 'N/A'}
helper={`${studentTodayLabel} · ${staffTodayLabel}`}
icon={Calendar}
percentage={myTodayPct}
percentage={combinedTodayPct}
/>
<AttendanceSummaryCard
label="Weekly Average"
value={myWeekAvg !== null ? `${myWeekAvg}%` : 'N/A'}
helper={`Week of ${formatAttendanceDate(weekStart)}`}
value={combinedWeekPct !== null ? `${combinedWeekPct}%` : 'N/A'}
helper={`${weekStudentLabel} · ${weekStaffLabel}`}
icon={BarChart3}
percentage={myWeekAvg}
percentage={combinedWeekPct}
/>
<AttendanceSummaryCard
label="People"
value={combinedTodayTotal || 'N/A'}
helper={`${todayStudentTotal} students · ${todayStaffTotal} staff`}
icon={Users}
color="blue"
/>
{todayRecord && (
<>
<AttendanceSummaryCard
label="Enrolled"
value={todayRecord.total_enrolled}
helper={`${todayRecord.total_present} present`}
icon={Users}
color="blue"
/>
<AttendanceSummaryCard
label="Absent Today"
value={todayRecord.total_absent}
helper={`${todayRecord.total_tardy} tardy`}
icon={UserX}
color="red"
/>
</>
)}
</div>
{myCampusConfig?.attendance_link && !roleAccess.isOfficeManager && (
@ -99,41 +124,59 @@ export function IndividualCampusAttendanceView({ state, actions }: IndividualCam
Recent Attendance History - {historyTitle}
</h3>
</div>
{myCampusData.length > 0 ? (
{combinedHistoryRows.length > 0 ? (
<Table>
<TableHeader>
<TableRow className="bg-slate-700/30 hover:bg-slate-700/30">
<TableHead className="h-auto p-3 text-left text-xs text-slate-400">Date</TableHead>
<TableHead className="h-auto p-3 text-center text-xs text-slate-400">Enrolled</TableHead>
<TableHead className="h-auto p-3 text-center text-xs text-slate-400">Present</TableHead>
<TableHead className="h-auto p-3 text-center text-xs text-slate-400">Absent</TableHead>
<TableHead className="h-auto p-3 text-center text-xs text-slate-400">Tardy</TableHead>
<TableHead className="h-auto p-3 text-center text-xs text-slate-400">Attendance %</TableHead>
<TableHead className="h-auto p-3 text-left text-xs text-slate-400">Notes</TableHead>
<TableHead className="h-auto p-3 text-left text-xs text-slate-400">Date</TableHead>
<TableHead className="h-auto p-3 text-left text-xs text-slate-400">Group</TableHead>
<TableHead className="h-auto p-3 text-center text-xs text-slate-400">Total</TableHead>
<TableHead className="h-auto p-3 text-center text-xs text-slate-400">Present</TableHead>
<TableHead className="h-auto p-3 text-center text-xs text-slate-400">Absent</TableHead>
<TableHead className="h-auto p-3 text-center text-xs text-slate-400">Tardy / Late</TableHead>
<TableHead className="h-auto p-3 text-center text-xs text-slate-400">Attendance %</TableHead>
<TableHead className="h-auto p-3 text-left text-xs text-slate-400">Notes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{myCampusData.slice(0, 15).map((record) => (
<TableRow key={record.id} className="border-t border-slate-700/20 hover:bg-slate-700/20">
<TableCell className="p-3 text-slate-300 font-medium">{formatAttendanceDate(record.date)}</TableCell>
<TableCell className="p-3 text-center text-slate-300">{record.total_enrolled}</TableCell>
<TableCell className="p-3 text-center">
<span className="px-2 py-0.5 rounded-lg text-xs font-semibold bg-emerald-500/10 text-emerald-400">{record.total_present}</span>
</TableCell>
<TableCell className="p-3 text-center">
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${record.total_absent > 3 ? 'bg-red-500/10 text-red-400' : 'bg-slate-700/30 text-slate-400'}`}>{record.total_absent}</span>
</TableCell>
<TableCell className="p-3 text-center">
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${record.total_tardy > 2 ? 'bg-amber-500/10 text-amber-400' : 'bg-slate-700/30 text-slate-400'}`}>{record.total_tardy}</span>
</TableCell>
<TableCell className="p-3 text-center">
<span className={`font-bold ${percentageTextClass(record.attendance_percentage)}`}>
{record.attendance_percentage.toFixed(1)}%
</span>
</TableCell>
<TableCell className="p-3 text-xs text-slate-500 max-w-[200px] truncate">{record.notes || '-'}</TableCell>
</TableRow>
))}
{combinedHistoryRows
.filter((record) => record.date === today || record.group === 'total')
.slice(0, 45)
.map((record) => {
const shouldShowDate = record.date !== today || record.group === 'students';
return (
<TableRow
key={record.id}
className={`border-t border-slate-700/20 hover:bg-slate-700/20 ${
record.group === 'total' ? 'bg-slate-900/30' : ''
}`}
>
<TableCell className="p-3 text-slate-300 font-medium">
{shouldShowDate ? formatAttendanceDate(record.date) : ''}
</TableCell>
<TableCell className={`p-3 ${record.group === 'total' ? 'text-white font-semibold' : 'text-slate-300'}`}>
{record.label}
</TableCell>
<TableCell className="p-3 text-center text-slate-300">{record.total}</TableCell>
<TableCell className="p-3 text-center">
<span className="px-2 py-0.5 rounded-lg text-xs font-semibold bg-emerald-500/10 text-emerald-400">{record.present}</span>
</TableCell>
<TableCell className="p-3 text-center">
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${record.absent > 0 ? 'bg-red-500/10 text-red-400' : 'bg-slate-700/30 text-slate-400'}`}>{record.absent}</span>
</TableCell>
<TableCell className="p-3 text-center">
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${record.late > 0 ? 'bg-amber-500/10 text-amber-400' : 'bg-slate-700/30 text-slate-400'}`}>{record.late}</span>
</TableCell>
<TableCell className="p-3 text-center">
<span className={`font-bold ${percentageTextClass(record.attendancePercentage)}`}>
{record.attendancePercentage !== null ? `${record.attendancePercentage.toFixed(1)}%` : 'N/A'}
</span>
</TableCell>
<TableCell className="p-3 text-xs text-slate-500 max-w-[200px] truncate">{record.notes || '-'}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
) : (

View File

@ -44,7 +44,10 @@ export function SuperintendentAttendanceView({ state, actions }: SuperintendentA
scopeModel,
} = state;
const staffRecordTotal = staffSummary.summary
? staffSummary.summary.present + staffSummary.summary.late + staffSummary.summary.absent
? Math.max(
staffSummary.summary.staffCount,
staffSummary.summary.present + staffSummary.summary.late + staffSummary.summary.absent,
)
: null;
const staffPresentOrLate = staffSummary.summary
? staffSummary.summary.present + staffSummary.summary.late

View File

@ -0,0 +1,48 @@
import { fileAssetUrl } from '@/shared/api/files';
import { cn } from '@/lib/utils';
interface TenantLogoProps {
readonly name?: string | null;
readonly logoUrl?: string | null;
readonly className?: string;
readonly imageClassName?: string;
}
export function TenantLogo({
name,
logoUrl,
className,
imageClassName,
}: TenantLogoProps) {
const label = name?.trim() || 'Tenant';
return (
<span
className={cn(
'inline-flex shrink-0 items-center justify-center overflow-hidden rounded-lg bg-gradient-to-br from-violet-500 to-indigo-600 text-xs font-bold text-white',
className,
)}
aria-hidden="true"
>
{logoUrl ? (
<img
src={fileAssetUrl(logoUrl)}
alt=""
className={cn('h-full w-full object-cover', imageClassName)}
/>
) : (
tenantInitials(label)
)}
</span>
);
}
function tenantInitials(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) {
return '-';
}
if (words.length === 1) {
return words[0].slice(0, 2).toUpperCase();
}
return `${words[0][0]}${words[words.length - 1][0]}`.toUpperCase();
}

View File

@ -0,0 +1,45 @@
import { fileDownloadUrl } from '@/shared/api/files';
import { cn } from '@/lib/utils';
interface UserAvatarProps {
readonly name: string;
readonly avatarUrl?: string | null;
readonly className?: string;
readonly fallbackClassName?: string;
readonly imageClassName?: string;
}
function initialsForName(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
return (parts[0]?.slice(0, 2) || 'U').toUpperCase();
}
export function UserAvatar({
name,
avatarUrl,
className,
fallbackClassName,
imageClassName,
}: UserAvatarProps) {
return (
<span
className={cn(
'inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full border border-slate-700 bg-slate-800 text-xs font-semibold text-slate-300',
className,
)}
>
{avatarUrl ? (
<img
src={fileDownloadUrl(avatarUrl)}
alt=""
className={cn('h-full w-full object-cover', imageClassName)}
/>
) : (
<span className={fallbackClassName}>{initialsForName(name)}</span>
)}
</span>
);
}

View File

@ -2,6 +2,8 @@ import { useMemo, useState } from 'react';
import { ChevronDown, ClipboardCheck } from 'lucide-react';
import type { DirectorAcknowledgmentDocumentRow } from '@/business/director-dashboard/types';
import { UserAvatar } from '@/components/common/UserAvatar';
import { TenantLogo } from '@/components/common/TenantLogo';
import { cn } from '@/lib/utils';
interface DirectorAcknowledgmentTrackingPanelProps {
@ -123,9 +125,24 @@ export function DirectorAcknowledgmentTrackingPanel({
key={`${document.id}-${staff.userId}`}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-white px-3 py-2 text-sm"
>
<span className="font-medium text-gray-800">{staff.name}</span>
<span className="text-xs capitalize text-gray-500">
{staff.role}
<span className="flex min-w-0 items-center gap-2">
<UserAvatar
name={staff.name}
avatarUrl={staff.avatar}
className="h-8 w-8 border-gray-200 bg-gray-100 text-[10px] text-gray-600"
/>
<span className="truncate font-medium text-gray-800">{staff.name}</span>
</span>
<span className="flex min-w-0 items-center gap-2 text-xs text-gray-500">
<TenantLogo
name={staff.tenantName}
logoUrl={staff.tenantLogo}
className="h-6 w-6 rounded-md text-[9px]"
/>
<span className="flex min-w-0 flex-col text-right">
<span className="truncate">{staff.tenantName ?? 'Scope not set'}</span>
<span className="truncate capitalize text-gray-400">{staff.role}</span>
</span>
</span>
</div>
))}

View File

@ -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}`}
>
<span className="truncate font-medium text-gray-700">{result.staffName}</span>
<span className="truncate text-sm text-gray-500">{result.tenant}</span>
<span className="flex min-w-0 items-center gap-2">
<UserAvatar
name={result.staffName}
avatarUrl={result.avatar}
className="h-8 w-8 border-gray-200 bg-gray-100 text-[10px] text-gray-600"
/>
<span className="truncate font-medium text-gray-700">{result.staffName}</span>
</span>
<span className="flex min-w-0 items-center gap-2">
<TenantLogo
name={result.tenant}
logoUrl={result.tenantLogo}
className="h-7 w-7 rounded-md text-[10px]"
/>
<span className="truncate text-sm text-gray-500">{result.tenant}</span>
</span>
<span className="text-center text-xs capitalize text-gray-500">{result.role}</span>
<span className="text-center">
<span className={cn(

View File

@ -3,7 +3,7 @@ import { ChevronDown, CornerUpLeft, Globe } from 'lucide-react';
import { useScopeContext } from '@/contexts/scope-context';
import { useTenantChildren } from '@/business/scope/queries';
import { getTenantInitials } from '@/business/scope/selectors';
import { TenantLogo } from '@/components/common/TenantLogo';
import { useOnClickOutside } from '@/hooks/useOnClickOutside';
const LEVEL_LABEL: Record<string, string> = {
@ -42,14 +42,16 @@ export function TenantSwitcher() {
const levelLabel = effectiveTenant
? (LEVEL_LABEL[effectiveTenant.level] ?? 'Tenant')
: 'Platform';
const mark = effectiveTenant?.logo ? (
<img src={effectiveTenant.logo} alt="" className="w-full h-full object-cover" />
) : effectiveTenant ? (
<span className="text-white text-[8px] font-bold">
{getTenantInitials(effectiveTenant.name)}
</span>
const mark = effectiveTenant ? (
<TenantLogo
name={effectiveTenant.name}
logoUrl={effectiveTenant.logo}
className="h-4 w-4 rounded bg-gradient-to-br from-violet-500 to-indigo-600 text-[8px]"
/>
) : (
<Globe size={11} className="text-white" />
<span className="flex h-4 w-4 items-center justify-center rounded bg-gradient-to-br from-violet-500 to-indigo-600">
<Globe size={11} className="text-white" />
</span>
);
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"
>
<span className="w-4 h-4 rounded bg-gradient-to-br from-violet-500 to-indigo-600 flex items-center justify-center overflow-hidden">
{mark}
</span>
{mark}
<span className="truncate max-w-[10rem]">{label}</span>
<span className="text-slate-500">· {levelLabel}</span>
{canDrill && <ChevronDown size={14} className="text-slate-500" />}
@ -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"
>
<span className="truncate">{c.name ?? '—'}</span>
<span className="flex min-w-0 items-center gap-2">
<TenantLogo
name={c.name}
logoUrl={c.logo}
className="h-6 w-6 rounded-md text-[9px]"
/>
<span className="truncate">{c.name ?? '—'}</span>
</span>
<span className="text-slate-500">{LEVEL_LABEL[c.level]}</span>
</button>
))

View File

@ -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"
>
<span className={cn('w-8 h-8 rounded-xl flex items-center justify-center text-white text-xs font-bold shadow-lg', avatarClassName)}>
{initials}
</span>
<UserAvatar
name={userName || initials}
avatarUrl={avatar}
className={cn('h-8 w-8 rounded-xl border-0 text-white shadow-lg', avatarClassName)}
fallbackClassName="text-xs font-bold"
/>
<span className="hidden md:block text-left">
<span className="text-sm font-medium text-slate-200 leading-tight block">{userName}</span>
<span className="text-[10px] text-slate-500 leading-tight block">{campusLabel}</span>
@ -63,9 +69,12 @@ export function TopBarProfileMenu({
<div className="absolute right-0 top-full mt-2 w-64 bg-slate-800 rounded-xl shadow-2xl shadow-black/40 border border-slate-700/50 py-2 z-40">
<div className="px-4 py-3 border-b border-slate-700/50">
<div className="flex items-center gap-3">
<div className={cn('w-10 h-10 rounded-xl flex items-center justify-center text-white text-sm font-bold shadow-lg', avatarClassName)}>
{initials}
</div>
<UserAvatar
name={userName || initials}
avatarUrl={avatar}
className={cn('h-10 w-10 rounded-xl border-0 text-white shadow-lg', avatarClassName)}
fallbackClassName="text-sm font-bold"
/>
<div>
<p className="text-sm font-semibold text-white">{userName}</p>
<p className="text-[10px] text-slate-400">{profileRoleLabel}</p>

View File

@ -53,6 +53,7 @@ export function TopBarView({ page }: TopBarViewProps) {
<TopBarProfileMenu
userName={page.userName}
initials={page.initials}
avatar={page.avatar}
campusInfo={page.campusInfo}
campusLabel={page.campusLabel}
profileRoleLabel={page.profileRoleLabel}

View File

@ -13,6 +13,7 @@ import { NativeSelect } from '@/components/ui/native-select';
import { Textarea } from '@/components/ui/textarea';
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 { useTenantChildren } from '@/business/scope/queries';
import { useIamCapabilities } from '@/business/iam-capabilities/hooks';
@ -88,15 +89,6 @@ function displayName(row: TenantChild): string {
return row.name?.trim() || row.id;
}
function initials(name: string): string {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join('') || 'L';
}
function uniqueRows(rows: readonly TenantChild[]): TenantChild[] {
const seen = new Set<string>();
const result: TenantChild[] = [];
@ -265,6 +257,7 @@ export default function CreateTenantPage() {
const [editContact, setEditContact] = useState<ContactFields>(() => emptyContactFields());
const [deleteCandidate, setDeleteCandidate] = useState<TenantChild | null>(null);
const [expandedLocations, setExpandedLocations] = useState<ReadonlySet<string>>(() => 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 ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</Button>
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-slate-800 text-sm font-semibold text-slate-200">
{row.logo ? (
<img src={row.logo} alt="" className="h-full w-full object-cover" />
) : (
initials(rowName)
)}
</div>
<TenantLogo
name={rowName}
logoUrl={row.logo}
className="h-10 w-10 bg-slate-800 text-sm text-slate-200"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-200">{rowName}</p>
<p className="text-xs text-slate-500">{TENANT_TYPE_LABELS[row.level]}</p>
@ -692,10 +686,26 @@ export default function CreateTenantPage() {
<Card className="border-slate-600/70 bg-slate-900/80 shadow-lg shadow-black/20">
<CardHeader className="border-b border-slate-700/70">
<CardTitle className="flex items-center gap-2 text-base text-slate-50">
{editingLocation ? <Pencil size={16} /> : null}
{editingLocation
? `Edit ${TENANT_TYPE_LABELS[editingLocation.level]}`
: 'New organization or location'}
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
aria-expanded={isLocationFormOpen}
aria-controls="tenant-location-form"
onClick={() => setIsLocationFormOpen((open) => !open)}
>
{editingLocation ? <Pencil size={16} /> : null}
<span>
{editingLocation
? `Edit ${TENANT_TYPE_LABELS[editingLocation.level]}`
: 'Add new organization'}
</span>
<ChevronDown
size={16}
className={`ml-auto shrink-0 text-slate-400 transition-transform ${
isLocationFormOpen ? 'rotate-180' : ''
}`}
/>
</button>
{editingLocation && (
<Button
type="button"
@ -708,7 +718,8 @@ export default function CreateTenantPage() {
)}
</CardTitle>
</CardHeader>
<CardContent>
{isLocationFormOpen && (
<CardContent id="tenant-location-form">
<form className="space-y-5 pt-6" onSubmit={editingLocation ? handleEditSubmit : handleSubmit}>
{editingLocation ? (
<>
@ -1000,6 +1011,7 @@ export default function CreateTenantPage() {
</div>
</form>
</CardContent>
)}
</Card>
)}

View File

@ -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 (
<span className="flex min-w-[140px] items-center gap-2">
<TenantLogo
name={name}
logoUrl={value?.logo ?? null}
className="h-7 w-7 rounded-md text-[9px]"
/>
<span className="truncate">{name}</span>
</span>
);
}
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<UserListSortField>('name');
const [usersSortDirection, setUsersSortDirection] = useState<UserListSortDirection>('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() {
<Card className="border-slate-600/70 bg-slate-900/80 shadow-lg shadow-black/20">
<CardHeader className="border-b border-slate-700/70 px-4 py-4 md:px-5">
<CardTitle className="flex items-center gap-2 text-base text-slate-50">
{editingId ? <Pencil size={16} /> : <UserPlus size={16} />}
{editingId ? 'Edit user' : 'New user'}
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
aria-expanded={isUserFormOpen}
aria-controls="user-admin-form"
onClick={() => setIsUserFormOpen((open) => !open)}
>
{editingId ? <Pencil size={16} /> : <UserPlus size={16} />}
<span>{editingId ? 'Edit user' : 'Add new user'}</span>
<ChevronDown
size={16}
className={cn(
'ml-auto shrink-0 text-slate-400 transition-transform',
isUserFormOpen && 'rotate-180',
)}
/>
</button>
{editingId && (
<Button
type="button"
@ -470,7 +506,8 @@ export default function UserAdminPage() {
)}
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 md:px-5">
{isUserFormOpen && (
<CardContent id="user-admin-form" className="px-4 pb-4 md:px-5">
<form className="space-y-5 pt-6" onSubmit={handleSubmit}>
<div className="rounded-lg border border-slate-600/80 bg-slate-950/45 p-4 space-y-4">
<div>
@ -699,18 +736,6 @@ export default function UserAdminPage() {
</details>
)}
{status && (
<div
className={`rounded-lg border px-3 py-2 text-sm ${
status.type === 'success'
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200'
: 'border-red-500/30 bg-red-500/10 text-red-200'
}`}
>
{status.text}
</div>
)}
<div className="flex justify-end">
<Button
type="submit"
@ -722,9 +747,22 @@ export default function UserAdminPage() {
</div>
</form>
</CardContent>
)}
</Card>
)}
{status && (
<div
className={`rounded-lg border px-3 py-2 text-sm ${
status.type === 'success'
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200'
: 'border-red-500/30 bg-red-500/10 text-red-200'
}`}
>
{status.text}
</div>
)}
<Card>
<CardHeader className="px-4 py-4 md:px-5">
<div className="flex flex-wrap items-center justify-between gap-3">
@ -830,9 +868,9 @@ export default function UserAdminPage() {
</td>
<td className="px-3 py-2.5 text-slate-300 break-all">{row.email || '—'}</td>
<td className="px-3 py-2.5 text-slate-300">{row.phoneNumber || '—'}</td>
<td className="px-3 py-2.5 text-slate-300">{locationName(row.organizations)}</td>
<td className="px-3 py-2.5 text-slate-300">{locationName(row.school)}</td>
<td className="px-3 py-2.5 text-slate-300">{locationName(row.campus)}</td>
<td className="px-3 py-2.5 text-slate-300">{locationCell(row.organizations)}</td>
<td className="px-3 py-2.5 text-slate-300">{locationCell(row.school)}</td>
<td className="px-3 py-2.5 text-slate-300">{locationCell(row.campus)}</td>
<td className="px-3 py-2.5 text-slate-300">
{roleName ? getAuthRoleLabel(roleName as UserRole) : '—'}
</td>

View File

@ -72,6 +72,20 @@ describe('http client', () => {
expect(requests[0].input).toBe(`${API_BASE_URL}/auth/me`);
expect(requests[0].init?.credentials).toBe('include');
expect(requests[0].init?.method).toBe('GET');
expect(requests[0].init?.cache).toBe('no-store');
});
it('does not force no-store cache mode for write requests', async () => {
const requests = stubFetch(createJsonResponse({ id: 'created' }));
await expect(
apiRequest('/frame_entries', {
method: 'POST',
body: { formal: 'Plan' },
}),
).resolves.toEqual({ id: 'created' });
expect(requests[0].init?.cache).toBeUndefined();
});
it('returns undefined for successful empty responses', async () => {

View File

@ -145,6 +145,7 @@ export function setActiveTenant(
async function sendRequest(path: string, options: RequestOptions): Promise<Response> {
const headers = new Headers(options.headers);
const method = options.method || 'GET';
headers.set(API_HEADERS.contentType, API_CONTENT_TYPES.json);
if (activeTenant) {
@ -154,9 +155,10 @@ async function sendRequest(path: string, options: RequestOptions): Promise<Respo
return fetch(createApiUrl(path), {
credentials: 'include',
method: options.method || 'GET',
method,
headers,
signal: options.signal,
cache: method === 'GET' ? 'no-store' : undefined,
body: options.body ? JSON.stringify(options.body) : undefined,
});
}

View File

@ -15,10 +15,10 @@ export interface AdminUserRow {
readonly email: string;
readonly avatar?: readonly { readonly privateUrl?: string | null }[];
readonly app_role?: { id: string; name: string | null } | null;
readonly organizations?: { id: string; name?: string | null } | null;
readonly school?: { id: string; name?: string | null } | null;
readonly campus?: { id: string; name?: string | null } | null;
readonly class?: { id: string; name?: string | null } | null;
readonly organizations?: { id: string; name?: string | null; logo?: string | null } | null;
readonly school?: { id: string; name?: string | null; logo?: string | null } | null;
readonly campus?: { id: string; name?: string | null; logo?: string | null } | null;
readonly class?: { id: string; name?: string | null; logo?: string | null } | null;
readonly campusId?: string | null;
readonly schoolId?: string | null;
readonly classId?: string | null;

View File

@ -86,7 +86,7 @@ export const DASHBOARD_QUICK_ACTIONS: readonly DashboardQuickAction[] = [
shadow: 'shadow-cyan-500/30',
},
{
label: 'De-escalation',
label: 'Behavior management',
iconId: 'shield',
module: 'qbs',
color: 'from-blue-500 to-blue-600',

View File

@ -9,7 +9,6 @@ export const DIRECTOR_DASHBOARD_FRAME_PREVIEW_LIMIT = 3;
export const DIRECTOR_DASHBOARD_TEXT_PREVIEW_LENGTH = 60;
export const DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD = 50;
export const DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD = 3;
export const DIRECTOR_DASHBOARD_HIGH_ABSENCE_THRESHOLD = 3;
export type DirectorQuickActionTone =
| 'indigo'

View File

@ -22,8 +22,9 @@ export interface BackendTenant {
export interface BackendCampus {
readonly id: string;
readonly name?: string;
readonly code?: string;
readonly name?: string | null;
readonly code?: string | null;
readonly logo?: string | null;
}
export interface CurrentUser {

View File

@ -61,6 +61,9 @@ export interface PersonalityCompletionRowDto {
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 status: 'complete' | 'pending';
readonly completedKinds: readonly PersonalityQuizKind[];

View File

@ -86,6 +86,9 @@ export interface PolicyAcknowledgmentReportStaffDto {
readonly userId: string;
readonly name: string;
readonly email: string;
readonly avatar: string | null;
readonly tenantName: string | null;
readonly tenantLogo: string | null;
readonly role: string | null;
readonly campusId: string | null;
readonly schoolId: string | null;

View File

@ -36,6 +36,9 @@ export interface SafetyQuizCompletionRowDto {
readonly userId: string;
readonly name: string;
readonly email: string;
readonly avatar: string | null;
readonly tenantName: string | null;
readonly tenantLogo: string | null;
readonly role: UserRole | null;
readonly status: 'complete' | 'pending';
readonly result: SafetyQuizResultDto | null;

View File

@ -29,6 +29,9 @@ export interface ZoneCheckinCompletionRowDto {
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';

View File

@ -2,7 +2,9 @@ import type {
CampusAttendancePrintInput,
CampusAttendanceStats,
CampusAttendanceSummaryViewModel,
StaffAttendanceDailySummaryViewModel,
} from '@/business/campus-attendance/types';
import type { StaffAttendanceSummaryViewModel } from '@/business/staff-attendance/types';
export const CAMPUS_ATTENDANCE_TEST_SEED = {
campusId: 'tigers',
@ -42,6 +44,38 @@ export const campusAttendanceWeekRecord: CampusAttendanceSummaryViewModel = {
notes: null,
};
export const campusStaffAttendanceTodayRecord: StaffAttendanceDailySummaryViewModel = {
id: 'staff:tigers:2026-06-08',
campus_id: CAMPUS_ATTENDANCE_TEST_SEED.campusId,
date: '2026-06-08',
total_staff: 10,
total_present: 9,
total_absent: 1,
total_late: 1,
attendance_percentage: 90,
notes: 'One staff late',
};
export const campusStaffAttendanceWeekRecord: StaffAttendanceDailySummaryViewModel = {
id: 'staff:tigers:2026-06-09',
campus_id: CAMPUS_ATTENDANCE_TEST_SEED.campusId,
date: '2026-06-09',
total_staff: 10,
total_present: 8,
total_absent: 2,
total_late: 0,
attendance_percentage: 80,
notes: null,
};
export const campusStaffAttendanceSummary: StaffAttendanceSummaryViewModel = {
staffCount: 10,
recordsCount: 10,
present: 8,
late: 1,
absent: 1,
};
export const campusAttendanceStatsSeed: CampusAttendanceStats = {
id: CAMPUS_ATTENDANCE_TEST_SEED.campusId,
mascot: CAMPUS_ATTENDANCE_TEST_SEED.campusMascot,
@ -57,6 +91,8 @@ export const campusAttendanceStatsSeed: CampusAttendanceStats = {
config: null,
recentData: [campusAttendanceTodayRecord, campusAttendanceWeekRecord],
todayRecord: campusAttendanceTodayRecord,
recentStaffData: [campusStaffAttendanceTodayRecord, campusStaffAttendanceWeekRecord],
todayStaffRecord: campusStaffAttendanceTodayRecord,
};
export const campusAttendancePrintInputSeed: CampusAttendancePrintInput = {
@ -68,4 +104,5 @@ export const campusAttendancePrintInputSeed: CampusAttendancePrintInput = {
campusesToPrint: [campusAttendanceStatsSeed],
printTodayRecords: [campusAttendanceTodayRecord],
printWeekRecords: [campusAttendanceTodayRecord, campusAttendanceWeekRecord],
staffSummary: campusStaffAttendanceSummary,
};