976 lines
35 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
}
|