976 lines
35 KiB
TypeScript

import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import {
listCampusAttendanceConfigs,
listCampusAttendanceSummaries,
saveCampusAttendanceConfig,
saveCampusAttendanceSummary,
} from '@/shared/api/campusAttendance';
import { useCampusCatalog } from '@/business/campuses/hooks';
import { CAMPUS_ATTENDANCE_QUERY_KEYS } from '@/shared/constants/campusAttendance';
import { findCampusByNameOrCode } from '@/shared/constants/campusDisplay';
import { UI_FEEDBACK_CLEAR_DELAY_MS } from '@/shared/constants/ui';
import type {
CampusAttendanceCampusKey,
CampusAttendanceListFilter,
} from '@/shared/types/campusAttendance';
import {
toCampusAttendanceConfigViewModel,
toCampusAttendanceSummaryMutationDto,
toCampusAttendanceSummaryViewModel,
} from '@/business/campus-attendance/mappers';
import {
buildAttendanceEntryInput,
buildCampusAttendanceScopeModel,
buildCampusAttendanceStats,
buildCombinedAttendanceStats,
buildOverallAttendanceStats,
getToday,
getTodayPercentage,
getWeeklyAverage,
getWeekEnd,
getWeekStart,
} from '@/business/campus-attendance/selectors';
import {
useStaffAttendanceRecords,
useStaffAttendanceSummary,
} from '@/business/staff-attendance/hooks';
import { saveStaffAttendanceRecord } from '@/shared/api/staffAttendance';
import {
CAMPUS_ATTENDANCE_PRINT_POPUP_BLOCKED_MESSAGE,
openCampusAttendancePrintReport,
} from '@/business/campus-attendance/printReport';
import type {
CampusAttendanceChildStats,
CampusAttendanceEntryDraft,
CampusAttendanceEntryInput,
CampusAttendanceRollupDraft,
AttendanceRosterStatus,
StaffAttendanceEntryDraft,
} from '@/business/campus-attendance/types';
import type { CampusId, UserRole } from '@/shared/types/app';
import { getOptionalErrorMessage } from '@/shared/errors/errorMessages';
import { mapApiListRows } from '@/shared/business/apiListRows';
import { useInvalidatingMutation } from '@/shared/business/queryMutations';
import { usePermissions } from '@/shared/app/usePermissions';
import { useScopeContext } from '@/shared/app/scope-context';
import { getScopeChildren } from '@/shared/api/scope';
import type { CampusInfo } from '@/shared/types/app';
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[]>> = {};
const SCHOOL_TILE_GRADIENTS = [
'from-emerald-500 to-green-500',
'from-orange-600 to-amber-500',
'from-rose-500 to-pink-500',
'from-violet-500 to-purple-500',
'from-blue-500 to-cyan-500',
] as const;
export function useCampusAttendanceConfigs(campusKey?: CampusAttendanceCampusKey, enabled = true) {
return useQuery({
queryKey: [CAMPUS_ATTENDANCE_QUERY_KEYS.configs, campusKey],
enabled,
queryFn: () => mapApiListRows(
listCampusAttendanceConfigs(campusKey ? { campusKey } : undefined),
toCampusAttendanceConfigViewModel,
),
});
}
export function useSaveCampusAttendanceConfig() {
return useInvalidatingMutation({
mutationFn: (input: { campusKey: CampusAttendanceCampusKey; attendanceLink: string | null }) => (
saveCampusAttendanceConfig(input.campusKey, { attendance_link: input.attendanceLink })
),
invalidateQueryKey: [CAMPUS_ATTENDANCE_QUERY_KEYS.configs],
});
}
export function useCampusAttendanceSummaries(filter?: CampusAttendanceListFilter, enabled = true) {
return useQuery({
queryKey: [CAMPUS_ATTENDANCE_QUERY_KEYS.summaries, filter],
enabled,
queryFn: () => mapApiListRows(
listCampusAttendanceSummaries(filter),
toCampusAttendanceSummaryViewModel,
),
});
}
export function useSaveCampusAttendanceSummary() {
return useInvalidatingMutation({
mutationFn: (input: CampusAttendanceEntryInput) => saveCampusAttendanceSummary(
input.campusId,
input.date,
toCampusAttendanceSummaryMutationDto(input),
),
invalidateQueryKey: [CAMPUS_ATTENDANCE_QUERY_KEYS.summaries],
});
}
export function useAttendanceDetailsChildCampuses(
level: TenantLevel | undefined,
tenantId: string | undefined,
) {
return useQuery({
queryKey: ['attendance-details-child-campuses', level, tenantId],
enabled: level === 'school' && Boolean(tenantId),
queryFn: () => getScopeChildren('school', tenantId ?? '', { limit: 500 }),
});
}
export function useSaveStaffAttendanceRecord() {
return useInvalidatingMutation({
mutationFn: (input: StaffAttendanceEntryDraft) => saveStaffAttendanceRecord(
input.userId,
input.date,
{ status: input.status, note: input.note.trim() || null },
),
invalidateQueryKeys: [
[STAFF_ATTENDANCE_QUERY_KEYS.records],
[STAFF_ATTENDANCE_QUERY_KEYS.summary],
],
});
}
type UseCampusAttendancePageInput = {
readonly userRole: UserRole;
readonly userCampus: string;
readonly userName: string;
};
const emptyEntryDraft = (date: string, campusId: CampusId = ''): CampusAttendanceEntryDraft => ({
date,
campusId,
enrolled: '',
present: '',
absent: '',
tardy: '',
notes: '',
});
const emptyStaffEntryDraft = (date: string): StaffAttendanceEntryDraft => ({
date,
userId: '',
status: 'present',
note: '',
});
type AttendanceStatusMap = Record<string, AttendanceRosterStatus>;
type StudentRollupOverrideMap = Record<CampusId, Partial<Pick<
CampusAttendanceRollupDraft,
'enrolled' | 'present' | 'absent' | 'tardy' | 'notes'
>>>;
function userDisplayName(user: AdminUserRow): string {
const fullName = `${user.firstName ?? ''} ${user.lastName ?? ''}`.trim();
return fullName || user.email;
}
function reconcileAttendanceStatuses(
current: AttendanceStatusMap,
userIds: readonly string[],
): AttendanceStatusMap {
const next: AttendanceStatusMap = {};
for (const userId of userIds) {
next[userId] = current[userId] ?? 'present';
}
return next;
}
function isBlankRollupRow(row: CampusAttendanceRollupDraft): boolean {
return !row.enrolled && !row.present && !row.absent && !row.tardy && !row.notes.trim();
}
function toRollupEntryInput(
row: CampusAttendanceRollupDraft,
date: string,
): CampusAttendanceEntryInput | null {
return buildAttendanceEntryInput(
{
date,
campusId: row.campusId,
enrolled: row.enrolled,
present: row.present,
absent: row.absent,
tardy: row.tardy,
notes: row.notes,
},
row.campusId,
);
}
function isOfficeStaffUser(user: AdminUserRow, mode: 'organization' | 'school' | 'campus'): boolean {
const role = user.app_role?.name;
if (role === 'student' || role === 'guardian') {
return false;
}
if (mode === 'organization') {
return !user.schoolId && !user.campusId;
}
if (mode === 'school') {
return Boolean(user.schoolId) && !user.campusId;
}
return Boolean(user.campusId || user.classId);
}
function isStaffRosterUser(user: AdminUserRow): boolean {
const role = user.app_role?.name;
return role !== 'student' && role !== 'guardian';
}
function mapAttendanceRosterUser(user: AdminUserRow) {
return {
id: user.id,
name: userDisplayName(user),
role: user.app_role?.name ?? null,
};
}
interface ScopedAttendanceChildren {
readonly directChildren: readonly TenantChild[];
readonly campusChildrenByParentId: Readonly<Record<string, readonly TenantChild[]>>;
readonly campusChildren: readonly TenantChild[];
}
async function listScopedAttendanceChildren(
level: TenantLevel,
tenantId: string,
): Promise<ScopedAttendanceChildren> {
if (level === 'campus') {
return {
directChildren: [],
campusChildrenByParentId: {},
campusChildren: [],
};
}
if (level === 'school') {
const response = await getScopeChildren(level, tenantId, { limit: 500 });
const campusChildren = response.rows.filter((child) => child.level === 'campus');
return {
directChildren: campusChildren,
campusChildrenByParentId: Object.fromEntries(campusChildren.map((campus) => [campus.id, [campus]])),
campusChildren,
};
}
if (level === 'organization') {
const schoolsResponse = await getScopeChildren(level, tenantId, { limit: 500 });
const schools = schoolsResponse.rows.filter((child) => child.level === 'school');
const campusGroups = await Promise.all(
schools.map((school) => getScopeChildren('school', school.id, { limit: 500 })),
);
const campusChildrenByParentId = Object.fromEntries(
schools.map((school, index) => [
school.id,
campusGroups[index]?.rows.filter((child) => child.level === 'campus') ?? [],
]),
);
return {
directChildren: schools,
campusChildrenByParentId,
campusChildren: Object.values(campusChildrenByParentId).flat(),
};
}
return {
directChildren: [],
campusChildrenByParentId: {},
campusChildren: [],
};
}
function percentageFromRecords(
records: readonly ReturnType<typeof toCampusAttendanceSummaryViewModel>[],
): number | null {
const enrolled = records.reduce((sum, record) => sum + record.total_enrolled, 0);
const present = records.reduce((sum, record) => sum + record.total_present, 0);
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,
userName,
}: UseCampusAttendancePageInput) {
const permissions = usePermissions();
const { tier, effectiveTenant, selectedTenant } = useScopeContext();
const effectiveTier = selectedTenant ? selectedTenant.level : tier;
const selectedCampusId = effectiveTier === 'campus' ? effectiveTenant?.id ?? null : null;
const selectedClassId = effectiveTier === 'class' ? effectiveTenant?.id ?? null : null;
const roleAccess = {
isSuperintendent: userRole === 'superintendent',
isDirector: userRole === 'director',
isOfficeManager: userRole === 'office_manager',
canSeeAllCampuses: effectiveTier === 'organization' || effectiveTier === 'school',
canEnterData: permissions.has('FILL_ATTENDANCE') && (
effectiveTier === 'organization'
|| effectiveTier === 'school'
|| effectiveTier === 'campus'
|| effectiveTier === 'class'
),
canPrint: userRole === 'superintendent' || userRole === 'director' || userRole === 'office_manager' || permissions.has('READ_STAFF_ATTENDANCE_REPORTS'),
canReadStaffReports: permissions.has('READ_STAFF_ATTENDANCE_REPORTS'),
};
const campusCatalog = useCampusCatalog();
const classQuery = useQuery({
queryKey: ['attendance-class-scope', selectedClassId],
enabled: Boolean(selectedClassId),
queryFn: () => getClass(selectedClassId ?? ''),
});
const classCampusTenantId = classQuery.data?.campusId ?? null;
const scopedCampusTenantId = selectedCampusId ?? classCampusTenantId;
const campusInfo = scopedCampusTenantId
? campusCatalog.campuses.find((campus) => campus.tenantId === scopedCampusTenantId || campus.id === scopedCampusTenantId)
: findCampusByNameOrCode(campusCatalog.campuses, userCampus);
const campusId = campusInfo?.id ?? null;
const today = getToday();
const weekStart = getWeekStart(new Date());
const weekEnd = getWeekEnd(new Date());
const scopeModel = buildCampusAttendanceScopeModel(
effectiveTier,
effectiveTenant,
campusId,
campusInfo?.fullName || userCampus,
);
const attendanceCampusId = scopeModel.campusId;
const attendanceSummaryFilter = attendanceCampusId ? { campusKey: attendanceCampusId } : undefined;
const scopedAttendanceChildrenQuery = useQuery({
queryKey: ['attendance-scoped-children', effectiveTier, effectiveTenant?.id ?? null],
enabled: Boolean(
effectiveTenant?.id
&& (effectiveTier === 'organization' || effectiveTier === 'school'),
),
queryFn: () => listScopedAttendanceChildren(effectiveTier as TenantLevel, effectiveTenant?.id ?? ''),
});
const hasAttendanceScope = scopeModel.mode !== 'campus' || Boolean(attendanceCampusId);
const configsQuery = useCampusAttendanceConfigs(
attendanceCampusId ?? undefined,
hasAttendanceScope,
);
const summariesQuery = useCampusAttendanceSummaries(
attendanceSummaryFilter,
hasAttendanceScope,
);
const staffSummaryQuery = useStaffAttendanceSummary(
{ 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,
queryFn: ({ signal }) => listUsers({ limit: 500, field: 'name', sort: 'asc' }, { signal }),
});
const saveConfigMutation = useSaveCampusAttendanceConfig();
const saveSummaryMutation = useSaveCampusAttendanceSummary();
const saveStaffAttendanceMutation = useSaveStaffAttendanceRecord();
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))
.map(mapAttendanceRosterUser)
), [officeStaffQuery.data?.rows, scopeModel.mode]);
const scopedAttendanceChildren = scopedAttendanceChildrenQuery.data;
const scopedCampusTenants = scopedAttendanceChildren?.campusChildren;
const scopedDirectChildren = scopedAttendanceChildren?.directChildren ?? EMPTY_TENANT_CHILDREN;
const campusChildrenByParentId = scopedAttendanceChildren?.campusChildrenByParentId ?? EMPTY_CAMPUS_CHILDREN_BY_PARENT;
const scopedCampusOptions = useMemo(() => {
if (scopeModel.mode === 'campus') {
return campusInfo ? [campusInfo] : EMPTY_CAMPUSES;
}
const scopedTenantIds = new Set((scopedCampusTenants ?? EMPTY_TENANT_CHILDREN).map((campus) => campus.id));
return campusCatalog.campuses.filter((campus) => (
campus.tenantId ? scopedTenantIds.has(campus.tenantId) : false
));
}, [campusCatalog.campuses, campusInfo, scopeModel.mode, scopedCampusTenants]);
const loading = campusCatalog.isLoading
|| (effectiveTier === 'class' && classQuery.isLoading)
|| 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;
const loadError = campusCatalog.error
?? (effectiveTier === 'class' ? classQuery.error : null)
?? 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;
const [successMessage, setSuccessMessage] = useState('');
const [printError, setPrintError] = useState<string | null>(null);
const errorMessage = printError ?? getOptionalErrorMessage(loadError ?? saveError);
const [editingLink, setEditingLink] = useState<CampusId | null>(null);
const [linkValue, setLinkValue] = useState('');
const [showEntryForm, setShowEntryForm] = useState(false);
const [expandedCampus, setExpandedCampus] = useState<CampusId | null>(null);
const [entryDraft, setEntryDraft] = useState<CampusAttendanceEntryDraft>(() => emptyEntryDraft(today, attendanceCampusId ?? ''));
const [staffEntryDraft, setStaffEntryDraft] = useState<StaffAttendanceEntryDraft>(() => emptyStaffEntryDraft(today));
const [entryError, setEntryError] = useState<string | null>(null);
const [staffEntryError, setStaffEntryError] = useState<string | null>(null);
const [studentAttendanceStatusOverrides, setStudentAttendanceStatusOverrides] = useState<AttendanceStatusMap>({});
const [staffAttendanceStatusOverrides, setStaffAttendanceStatusOverrides] = useState<AttendanceStatusMap>({});
const [studentRollupOverrides, setStudentRollupOverrides] = useState<StudentRollupOverrideMap>({});
const campusStats = useMemo(
() => buildCampusAttendanceStats(campusCatalog.campuses, configs, attendanceData, today, weekStart, weekEnd, staffRecords),
[attendanceData, campusCatalog.campuses, configs, staffRecords, today, weekEnd, weekStart],
);
const visibleCampusStats = useMemo(() => {
if (scopeModel.mode === 'campus') {
return attendanceCampusId
? campusStats.filter((campus) => campus.id === attendanceCampusId)
: [];
}
const scopedCampusIds = new Set([
...scopedCampusOptions.map((campus) => campus.id),
...configs.map((config) => config.campus_id),
...attendanceData.map((record) => record.campus_id),
]);
return campusStats.filter((campus) => scopedCampusIds.has(campus.id));
}, [attendanceCampusId, attendanceData, campusStats, configs, scopeModel.mode, scopedCampusOptions]);
const attendanceChildStats = useMemo((): readonly CampusAttendanceChildStats[] => {
if (scopeModel.mode === 'campus') {
return visibleCampusStats.map((campus) => ({
id: campus.tenantId ?? campus.id,
level: 'campus',
mascot: campus.mascot,
fullName: campus.fullName,
bgGradient: campus.bgGradient,
isOnline: campus.isOnline,
todayPct: campus.todayPct,
weekAvg: campus.weekAvg,
recentData: campus.recentData,
todayRecord: campus.todayRecord,
childCampusIds: [campus.id],
recentStaffData: campus.recentStaffData,
todayStaffRecord: campus.todayStaffRecord,
}));
}
if (scopeModel.mode === 'school') {
return scopedDirectChildren
.filter((child) => child.level === 'campus')
.map((child, index) => {
const campus = visibleCampusStats.find((item) => item.tenantId === child.id)
?? campusStats.find((item) => item.tenantId === child.id);
return {
id: child.id,
level: child.level,
mascot: campus?.mascot ?? child.name ?? 'Campus',
fullName: campus?.fullName ?? child.name ?? 'Campus',
bgGradient: campus?.bgGradient ?? SCHOOL_TILE_GRADIENTS[index % SCHOOL_TILE_GRADIENTS.length],
isOnline: campus?.isOnline,
todayPct: campus?.todayPct ?? null,
weekAvg: campus?.weekAvg ?? null,
recentData: campus?.recentData ?? [],
todayRecord: campus?.todayRecord ?? null,
childCampusIds: campus ? [campus.id] : [],
recentStaffData: campus?.recentStaffData ?? [],
todayStaffRecord: campus?.todayStaffRecord ?? null,
};
});
}
return scopedDirectChildren
.filter((child) => child.level === 'school')
.map((child, index) => {
const childCampuses = (campusChildrenByParentId[child.id] ?? [])
.map((campusChild) => campusCatalog.campuses.find((campus) => campus.tenantId === campusChild.id))
.filter((campus): campus is CampusInfo => Boolean(campus));
const childCampusIds = childCampuses.map((campus) => campus.id);
const childTodayRecords = attendanceData.filter((record) => (
childCampusIds.includes(record.campus_id) && record.date === today
));
const childWeekRecords = attendanceData.filter((record) => (
childCampusIds.includes(record.campus_id)
&& 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) => (
sum + (percentageFromRecords(childWeekRecords.filter((record) => record.date === day)) ?? 0)
), 0) / weekDays.length).toFixed(2))
: null;
return {
id: child.id,
level: child.level,
mascot: child.name ?? 'School',
fullName: child.name ?? 'School',
bgGradient: SCHOOL_TILE_GRADIENTS[index % SCHOOL_TILE_GRADIENTS.length],
todayPct: percentageFromRecords(childTodayRecords),
weekAvg,
recentData: childWeekRecords.slice(0, 10),
todayRecord: null,
childCampusIds,
recentStaffData: childStaffRecords.slice(0, 10),
todayStaffRecord,
};
});
}, [
attendanceData,
campusCatalog.campuses,
campusChildrenByParentId,
campusStats,
scopeModel.mode,
scopedDirectChildren,
today,
visibleCampusStats,
weekEnd,
weekStart,
]);
const overallStats = useMemo(
() => buildOverallAttendanceStats(attendanceData, today, weekStart, weekEnd),
[attendanceData, today, weekEnd, weekStart],
);
const combinedStats = useMemo(
() => 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);
const selectedEntryCampusTenantId = scopeModel.mode === 'campus'
? scopedCampusTenantId
: selectedEntryCampus?.tenantId ?? null;
const selectedEntryClassId = scopeModel.mode === 'campus' ? selectedClassId : null;
const attendanceStudentsQuery = useQuery({
queryKey: ['attendance-student-users', selectedEntryCampusTenantId, selectedEntryClassId],
enabled: roleAccess.canEnterData
&& showEntryForm
&& Boolean(selectedEntryClassId || selectedEntryCampusTenantId),
queryFn: ({ signal }) => listUsers({
app_role: 'student',
classId: selectedEntryClassId ?? undefined,
campusId: selectedEntryClassId ? undefined : selectedEntryCampusTenantId ?? undefined,
limit: 1000,
field: 'name',
sort: 'asc',
}, { signal }),
});
const attendanceStudents = useMemo(() => (
(attendanceStudentsQuery.data?.rows ?? []).map((user) => ({
id: user.id,
name: userDisplayName(user),
role: user.app_role?.name ?? null,
}))
), [attendanceStudentsQuery.data?.rows]);
const studentAttendanceStatuses = useMemo(
() => reconcileAttendanceStatuses(
studentAttendanceStatusOverrides,
attendanceStudents.map((student) => student.id),
),
[attendanceStudents, studentAttendanceStatusOverrides],
);
const staffAttendanceStatuses = useMemo(
() => reconcileAttendanceStatuses(
staffAttendanceStatusOverrides,
officeStaffUsers.map((staff) => staff.id),
),
[officeStaffUsers, staffAttendanceStatusOverrides],
);
const studentRollupRows = useMemo((): readonly CampusAttendanceRollupDraft[] => {
if (scopeModel.mode === 'campus') {
return [];
}
return scopedCampusOptions.map((campus) => {
const todayRecord = attendanceData.find((record) => (
record.campus_id === campus.id && record.date === entryDraft.date
));
const override = studentRollupOverrides[campus.id] ?? {};
return {
campusId: campus.id,
campusName: campus.fullName,
enrolled: override.enrolled ?? (todayRecord ? String(todayRecord.total_enrolled) : ''),
present: override.present ?? (todayRecord ? String(todayRecord.total_present) : ''),
absent: override.absent ?? (todayRecord ? String(todayRecord.total_absent) : ''),
tardy: override.tardy ?? (todayRecord ? String(todayRecord.total_tardy) : ''),
notes: override.notes ?? todayRecord?.notes ?? '',
hasRecordedData: Boolean(todayRecord),
};
});
}, [attendanceData, entryDraft.date, scopeModel.mode, scopedCampusOptions, studentRollupOverrides]);
const showSuccess = (message: string) => {
setPrintError(null);
setSuccessMessage(message);
window.setTimeout(() => setSuccessMessage(''), UI_FEEDBACK_CLEAR_DELAY_MS);
};
const updateEntryDraft = (patch: Partial<CampusAttendanceEntryDraft>) => {
setEntryDraft((currentDraft) => ({ ...currentDraft, ...patch }));
setEntryError(null);
};
const updateStaffEntryDraft = (patch: Partial<StaffAttendanceEntryDraft>) => {
setStaffEntryDraft((currentDraft) => ({ ...currentDraft, ...patch }));
setStaffEntryError(null);
};
const updateStudentAttendanceStatus = (userId: string, status: AttendanceRosterStatus) => {
setStudentAttendanceStatusOverrides((currentStatuses) => ({ ...currentStatuses, [userId]: status }));
setEntryError(null);
};
const updateStudentRollupDraft = (
campusIdToUpdate: CampusId,
patch: Partial<Pick<CampusAttendanceRollupDraft, 'enrolled' | 'present' | 'absent' | 'tardy' | 'notes'>>,
) => {
setStudentRollupOverrides((currentOverrides) => ({
...currentOverrides,
[campusIdToUpdate]: {
...currentOverrides[campusIdToUpdate],
...patch,
},
}));
setEntryError(null);
};
const updateStaffAttendanceStatus = (userId: string, status: AttendanceRosterStatus) => {
setStaffAttendanceStatusOverrides((currentStatuses) => ({ ...currentStatuses, [userId]: status }));
setStaffEntryError(null);
};
const setEntryFormVisibility = (nextShowEntryForm: boolean) => {
if (nextShowEntryForm) {
setEntryDraft((currentDraft) => ({
...currentDraft,
campusId: attendanceCampusId ?? currentDraft.campusId,
}));
}
setEntryError(null);
setStaffEntryError(null);
setShowEntryForm(nextShowEntryForm);
};
const handleSaveLink = async (targetCampusId: CampusId) => {
setPrintError(null);
await saveConfigMutation.mutateAsync({
campusKey: targetCampusId,
attendanceLink: linkValue.trim() || null,
});
showSuccess('Link saved successfully!');
setEditingLink(null);
};
const saveStudentAttendance = async (): Promise<boolean> => {
if (scopeModel.mode !== 'campus') {
const rowsToSave: CampusAttendanceEntryInput[] = [];
let hasInvalidRow = false;
for (const row of studentRollupRows) {
if (isBlankRollupRow(row)) {
continue;
}
const input = toRollupEntryInput(row, entryDraft.date);
if (!input) {
hasInvalidRow = true;
break;
}
rowsToSave.push(input);
}
if (hasInvalidRow) {
setEntryError('Each campus row needs enrolled, present, and absent counts before saving.');
return false;
}
if (rowsToSave.length === 0) {
setEntryError('Enter at least one campus attendance row before saving.');
return false;
}
for (const input of rowsToSave) {
await saveSummaryMutation.mutateAsync(input);
}
setStudentRollupOverrides({});
return true;
}
const targetCampusId = attendanceCampusId ?? entryDraft.campusId;
if (!targetCampusId) {
setEntryError('Select a campus before saving attendance.');
return false;
}
const input = attendanceStudents.length > 0
? {
campusId: targetCampusId,
date: entryDraft.date,
totalEnrolled: attendanceStudents.length,
totalPresent: attendanceStudents.filter((student) => (
(studentAttendanceStatuses[student.id] ?? 'present') !== 'absent'
)).length,
totalAbsent: attendanceStudents.filter((student) => (
(studentAttendanceStatuses[student.id] ?? 'present') === 'absent'
)).length,
totalTardy: attendanceStudents.filter((student) => (
(studentAttendanceStatuses[student.id] ?? 'present') === 'late'
)).length,
notes: entryDraft.notes.trim() || null,
}
: buildAttendanceEntryInput(entryDraft, targetCampusId);
if (!input) {
setEntryError('Enter enrolled, present, and absent counts before saving.');
return false;
}
await saveSummaryMutation.mutateAsync(input);
setEntryDraft(emptyEntryDraft(today, attendanceCampusId ?? targetCampusId));
return true;
};
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.');
}
return !requireStaff;
}
for (const staffUser of officeStaffUsers) {
await saveStaffAttendanceMutation.mutateAsync({
date: input.date,
userId: staffUser.id,
status: staffAttendanceStatuses[staffUser.id] ?? 'present',
note: input.note,
});
}
setStaffEntryDraft(emptyStaffEntryDraft(today));
return true;
};
const handleSubmitEntry = async () => {
setPrintError(null);
const saved = await saveStudentAttendance();
if (!saved) {
return;
}
showSuccess('Student attendance saved!');
setShowEntryForm(false);
};
const handleSubmitStaffEntry = async () => {
setPrintError(null);
if (!staffEntryDraft.userId) {
setStaffEntryError('Select an office staff member before saving attendance.');
return;
}
await saveStaffAttendanceMutation.mutateAsync(staffEntryDraft);
showSuccess('Office staff attendance saved!');
setStaffEntryDraft(emptyStaffEntryDraft(today));
};
const handleSubmitStaffBatch = async () => {
setPrintError(null);
const saved = await saveStaffBatchAttendance(true);
if (!saved) {
return;
}
showSuccess('Staff attendance saved!');
};
const handleSubmitAttendanceForm = async () => {
setPrintError(null);
const studentSaved = await saveStudentAttendance();
if (!studentSaved) {
return;
}
const staffSaved = await saveStaffBatchAttendance(false, {
date: entryDraft.date,
note: staffEntryDraft.note,
});
if (!staffSaved) {
return;
}
showSuccess('Attendance saved!');
setShowEntryForm(false);
};
const handlePrint = () => {
const campusesToPrint = roleAccess.canSeeAllCampuses
? visibleCampusStats
: campusStats.filter((campus) => campus.id === attendanceCampusId);
const reportTitle = roleAccess.canSeeAllCampuses
? `${scopeModel.reportLabel} Attendance Report`
: `${campusInfo?.fullName || userCampus} Attendance Report`;
const printTodayRecords = roleAccess.canSeeAllCampuses
? overallStats.todayRecords
: attendanceData.filter((record) => record.campus_id === attendanceCampusId && record.date === today);
const printWeekRecords = roleAccess.canSeeAllCampuses
? overallStats.weekRecords
: attendanceData.filter((record) => record.campus_id === attendanceCampusId && record.date >= weekStart && record.date <= weekEnd);
const printResult = openCampusAttendancePrintReport({
input: {
reportTitle,
generatedByName: userName,
generatedByRole: `${userRole.charAt(0).toUpperCase()}${userRole.slice(1)}`,
today,
weekStart,
campusesToPrint,
printTodayRecords,
printWeekRecords,
staffSummary,
},
});
if (!printResult.ok) {
setPrintError(CAMPUS_ATTENDANCE_PRINT_POPUP_BLOCKED_MESSAGE);
return;
}
setPrintError(null);
};
return {
state: {
roleAccess,
scopeModel,
campusInfo,
campusId: attendanceCampusId,
today,
weekStart,
weekEnd,
configs,
attendanceData,
staffRecords,
loading,
saving,
errorMessage,
successMessage,
editingLink,
linkValue,
showEntryForm,
expandedCampus,
entryDraft,
entryError,
staffEntryDraft,
staffEntryError,
officeStaffUsers,
staffAttendanceStatuses,
studentRollupRows,
attendanceStudents,
studentAttendanceStatuses,
attendanceStudentsLoading: attendanceStudentsQuery.isLoading,
scopedCampusOptions,
campusStats: visibleCampusStats,
attendanceChildStats,
overallStats,
combinedStats,
staffSummary: {
summary: staffSummary,
loading: staffSummaryQuery.isLoading,
error: staffSummaryQuery.error,
},
myCampusConfig,
myCampusData,
myStaffData,
myTodayPct,
myWeekAvg,
userCampus,
},
actions: {
setEditingLink,
setLinkValue,
setShowEntryForm: setEntryFormVisibility,
setExpandedCampus,
updateEntryDraft,
updateStaffEntryDraft,
updateStudentAttendanceStatus,
updateStudentRollupDraft,
updateStaffAttendanceStatus,
handleSaveLink,
handleSubmitEntry,
handleSubmitStaffEntry,
handleSubmitStaffBatch,
handleSubmitAttendanceForm,
handlePrint,
},
};
}