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, id,
name: asStringOrNull(plain.name), name: asStringOrNull(plain.name),
code: asStringOrNull(plain.code), code: asStringOrNull(plain.code),
logo: asStringOrNull(plain.logo),
}; };
} }

View File

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

View File

@ -64,6 +64,11 @@ const REPORT_STAFF_ROLES = Object.freeze([
ROLE_NAMES.SUPPORT_STAFF, ROLE_NAMES.SUPPORT_STAFF,
]); ]);
interface StaffTenantInfo {
readonly tenantName: string | null;
readonly tenantLogo: string | null;
}
function normalizeQuizKind(value: unknown): string { function normalizeQuizKind(value: unknown): string {
if (typeof value !== 'string' || value.trim().length === 0) { if (typeof value !== 'string' || value.trim().length === 0) {
return PERSONALITY_QUIZ_KIND; return PERSONALITY_QUIZ_KIND;
@ -207,6 +212,18 @@ function displayNameOf(user: Users): string {
|| 'Staff Member'; || '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( function latestResultsByUserAndKind(
results: readonly PersonalityQuizResults[], results: readonly PersonalityQuizResults[],
): ReadonlyMap<string, PersonalityQuizResults> { ): ReadonlyMap<string, PersonalityQuizResults> {
@ -433,6 +450,35 @@ class PersonalityQuizResultsService {
required: true, required: true,
where: { name: REPORT_STAFF_ROLES }, 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: [ order: [
['lastName', 'asc'], ['lastName', 'asc'],
@ -474,6 +520,7 @@ class PersonalityQuizResultsService {
const rows = staffUsers.map((user) => { const rows = staffUsers.map((user) => {
const selfAssessment = resultByUserAndKind.get(`${user.id}:${EI_SELF_ASSESSMENT_KIND}`) ?? null; const selfAssessment = resultByUserAndKind.get(`${user.id}:${EI_SELF_ASSESSMENT_KIND}`) ?? null;
const personality = resultByUserAndKind.get(`${user.id}:${PERSONALITY_QUIZ_KIND}`) ?? null; const personality = resultByUserAndKind.get(`${user.id}:${PERSONALITY_QUIZ_KIND}`) ?? null;
const tenant = tenantOf(user);
const completedKinds: string[] = []; const completedKinds: string[] = [];
if (selfAssessment) { if (selfAssessment) {
completedKinds.push(EI_SELF_ASSESSMENT_KIND); completedKinds.push(EI_SELF_ASSESSMENT_KIND);
@ -486,6 +533,9 @@ class PersonalityQuizResultsService {
userId: user.id, userId: user.id,
name: displayNameOf(user), name: displayNameOf(user),
email: user.email, email: user.email,
avatar: avatarOf(user),
tenantName: tenant.tenantName,
tenantLogo: tenant.tenantLogo,
role: user.app_role?.name ?? null, role: user.app_role?.name ?? null,
status: completedKinds.length >= quizKinds.length ? 'complete' : 'pending', status: completedKinds.length >= quizKinds.length ? 'complete' : 'pending',
completedKinds, completedKinds,

View File

@ -36,6 +36,11 @@ const ACKNOWLEDGMENT_REPORT_STAFF_ROLES = Object.freeze([
ROLE_NAMES.SUPPORT_STAFF, ROLE_NAMES.SUPPORT_STAFF,
]); ]);
interface TenantReference {
readonly name?: string | null;
readonly logo?: string | null;
}
function assertCanAcknowledge(currentUser?: CurrentUser): void { function assertCanAcknowledge(currentUser?: CurrentUser): void {
if (hasFeaturePermission(currentUser, FEATURE_PERMISSIONS.ACK_POLICY)) { if (hasFeaturePermission(currentUser, FEATURE_PERMISSIONS.ACK_POLICY)) {
return; return;
@ -110,6 +115,23 @@ function displayNameOf(user: {
|| 'Staff Member'; || '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 { class PolicyAcknowledgmentsService {
/** A campus staff member's own acknowledgments (optionally for one document). */ /** A campus staff member's own acknowledgments (optionally for one document). */
static async list( static async list(
@ -220,6 +242,35 @@ class PolicyAcknowledgmentsService {
required: true, required: true,
where: { name: ACKNOWLEDGMENT_REPORT_STAFF_ROLES }, 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: [ order: [
['lastName', 'asc'], ['lastName', 'asc'],
@ -272,6 +323,7 @@ class PolicyAcknowledgmentsService {
}); });
const staffRows = staffUsers.map((user) => { const staffRows = staffUsers.map((user) => {
const tenant = tenantOf(user);
const documentStatuses = documents.map((document) => { const documentStatuses = documents.map((document) => {
const acknowledgedAt = acknowledgedAtByKey.get( const acknowledgedAt = acknowledgedAtByKey.get(
`${user.id}:${document.id}:${document.version}`, `${user.id}:${document.id}:${document.version}`,
@ -291,6 +343,9 @@ class PolicyAcknowledgmentsService {
userId: user.id, userId: user.id,
name: displayNameOf(user), name: displayNameOf(user),
email: user.email, email: user.email,
avatar: avatarOf(user),
tenantName: tenant.tenantName,
tenantLogo: tenant.tenantLogo,
role: user.app_role?.name ?? null, role: user.app_role?.name ?? null,
campusId: user.campusId ?? null, campusId: user.campusId ?? null,
schoolId: user.schoolId ?? null, schoolId: user.schoolId ?? null,

View File

@ -51,6 +51,11 @@ const REPORT_STAFF_ROLES = Object.freeze([
ROLE_NAMES.SUPPORT_STAFF, ROLE_NAMES.SUPPORT_STAFF,
]); ]);
interface StaffTenantInfo {
readonly tenantName: string | null;
readonly tenantLogo: string | null;
}
function getProductRole(currentUser?: CurrentUser): string { function getProductRole(currentUser?: CurrentUser): string {
return currentUser?.app_role?.name ?? ROLE_NAMES.TEACHER; return currentUser?.app_role?.name ?? ROLE_NAMES.TEACHER;
} }
@ -181,6 +186,18 @@ function displayNameOf(user: Users): string {
|| 'Staff Member'; || '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( function latestResultByUser(
results: readonly SafetyQuizResults[], results: readonly SafetyQuizResults[],
): ReadonlyMap<string, SafetyQuizResults> { ): ReadonlyMap<string, SafetyQuizResults> {
@ -294,6 +311,35 @@ class SafetyQuizResultsService {
required: true, required: true,
where: { name: REPORT_STAFF_ROLES }, 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: [ order: [
['lastName', 'asc'], ['lastName', 'asc'],
@ -315,10 +361,14 @@ class SafetyQuizResultsService {
const resultByUser = latestResultByUser(results); const resultByUser = latestResultByUser(results);
const rows = staffUsers.map((user) => { const rows = staffUsers.map((user) => {
const result = resultByUser.get(user.id); const result = resultByUser.get(user.id);
const tenant = tenantOf(user);
return { return {
userId: user.id, userId: user.id,
name: displayNameOf(user), name: displayNameOf(user),
email: user.email, email: user.email,
avatar: avatarOf(user),
tenantName: tenant.tenantName,
tenantLogo: tenant.tenantLogo,
role: user.app_role?.name ?? null, role: user.app_role?.name ?? null,
status: result ? 'complete' : 'pending', status: result ? 'complete' : 'pending',
result: result ? toDto(result) : null, result: result ? toDto(result) : null,

View File

@ -116,4 +116,76 @@ describe('StaffAttendanceService', () => {
assert.equal(userWhere.schoolId, schoolId); assert.equal(userWhere.schoolId, schoolId);
assert.equal(userWhere.campusId, null); 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 * 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 * their scope allows: org-wide for owner/superintendent, the school's campuses
@ -213,7 +223,14 @@ function staffUserScope(currentUser?: CurrentUser): WhereOptions {
if (!campusId) { if (!campusId) {
throw new ForbiddenError(); 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 }; return { ...base, schoolId: null, campusId: null };
@ -328,6 +345,11 @@ class StaffAttendanceService {
as: 'app_role', as: 'app_role',
attributes: ['name'], attributes: ['name'],
}, },
{
model: db.classes,
as: 'class',
attributes: ['campusId'],
},
], ],
}); });
@ -341,8 +363,10 @@ class StaffAttendanceService {
lastName?: string | null; lastName?: string | null;
email?: string | null; email?: string | null;
campusId?: string | null; campusId?: string | null;
class?: { campusId?: string | null } | null;
app_role?: { name?: string | null } | null; app_role?: { name?: string | null } | null;
}; };
const recordCampusId = plain.campusId ?? plain.class?.campusId ?? null;
return withTransaction(async (transaction) => { return withTransaction(async (transaction) => {
const existing = await db.staff_attendance_records.findOne({ const existing = await db.staff_attendance_records.findOne({
@ -360,7 +384,7 @@ class StaffAttendanceService {
user_name: staffUserName(plain), user_name: staffUserName(plain),
user_role: plain.app_role?.name ?? null, user_role: plain.app_role?.name ?? null,
organizationId: requireOrganizationId(currentUser), organizationId: requireOrganizationId(currentUser),
campusId: plain.campusId ?? null, campusId: recordCampusId,
userId, userId,
updatedById: requireUserId(currentUser), updatedById: requireUserId(currentUser),
}; };

View File

@ -49,6 +49,9 @@ export interface ZoneCheckinCompletionRow {
readonly userId: string; readonly userId: string;
readonly name: string; readonly name: string;
readonly email: string | null; readonly email: string | null;
readonly avatar: string | null;
readonly tenantName: string | null;
readonly tenantLogo: string | null;
readonly role: string | null; readonly role: string | null;
readonly date: string; readonly date: string;
readonly status: 'complete' | 'pending'; readonly status: 'complete' | 'pending';
@ -94,6 +97,11 @@ const REPORT_STAFF_ROLES = Object.freeze([
ROLE_NAMES.SUPPORT_STAFF, ROLE_NAMES.SUPPORT_STAFF,
]); ]);
interface StaffTenantInfo {
readonly tenantName: string | null;
readonly tenantLogo: string | null;
}
async function resolveCampusTimezone(currentUser?: CurrentUser): Promise<string> { async function resolveCampusTimezone(currentUser?: CurrentUser): Promise<string> {
const campusId = getCampusId(currentUser); const campusId = getCampusId(currentUser);
if (!campusId) { if (!campusId) {
@ -166,6 +174,18 @@ function displayNameOf(user: Users): string {
|| 'Staff Member'; || '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( function latestProgressByUserAndDate(
rows: readonly UserProgress[], rows: readonly UserProgress[],
): ReadonlyMap<string, UserProgress> { ): ReadonlyMap<string, UserProgress> {
@ -277,6 +297,35 @@ class ZoneCheckinService {
required: true, required: true,
where: { name: REPORT_STAFF_ROLES }, 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: [ order: [
['lastName', 'asc'], ['lastName', 'asc'],
@ -319,11 +368,15 @@ class ZoneCheckinService {
const zone = progress?.value ?? null; const zone = progress?.value ?? null;
const status = zone ? 'complete' : 'pending'; const status = zone ? 'complete' : 'pending';
const riskLevel = !zone ? 'pending' : zone === 'green' ? 'none' : 'medium'; const riskLevel = !zone ? 'pending' : zone === 'green' ? 'none' : 'medium';
const tenant = tenantOf(user);
return { return {
userId: user.id, userId: user.id,
name: displayNameOf(user), name: displayNameOf(user),
email: user.email, email: user.email,
avatar: avatarOf(user),
tenantName: tenant.tenantName,
tenantLogo: tenant.tenantLogo,
role: user.app_role?.name ?? null, role: user.app_role?.name ?? null,
date, date,
status, status,

View File

@ -44,6 +44,9 @@ API/data access layer:
- Attendance links save through `PUT /api/campus_attendance/configs/:campusKey`. - Attendance links save through `PUT /api/campus_attendance/configs/:campusKey`.
- Daily campus summaries load from `GET /api/campus_attendance/summaries`. - Daily campus summaries load from `GET /api/campus_attendance/summaries`.
- Daily campus summaries save through `PUT /api/campus_attendance/summaries/:campusKey/:date`. - 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: - The page derives its mode from the effective scope, not from the signed-in role label:
- campus/class effective scope shows campus-only attendance. - campus/class effective scope shows campus-only attendance.
- school effective scope aggregates all scoped campus summaries and school staff 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 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_attendance_summaries` per campus. Organization and school totals are computed from those
campus rows plus staff attendance reports. 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 - Aggregate cards follow the tenant hierarchy: organization scope shows school cards, and school
scope shows campus cards. Clicking a child card opens scope shows campus cards. Clicking a child card opens
`/attendance/details/:level/:tenantId`. The details page shows separate tables for that child `/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 - Campus screens expose the same staff attendance table for campus-bound and class-scoped staff
inside the campus. 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`. - 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. - 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. - The backend calculates the attendance percentage.
- `CampusAttendance.tsx` is a thin composition wrapper. - `CampusAttendance.tsx` is a thin composition wrapper.
@ -83,7 +92,7 @@ API/data access layer:
## Verification ## 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/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. - `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/printReport.test.ts` covers blocked-popup handling for attendance report printing.
- `frontend/src/business/campus-attendance/mappers.test.ts` covers API DTO mapping. - `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. - 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`. - 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/`. - 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. - 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. - 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`. - 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, user: options.user,
userRole, userRole,
userName, userName,
avatar: options.user?.avatar ?? null,
campusInfo, campusInfo,
toggleSidebar, toggleSidebar,
setCurrentModule, setCurrentModule,

View File

@ -31,7 +31,10 @@ import {
getWeekEnd, getWeekEnd,
getWeekStart, getWeekStart,
} from '@/business/campus-attendance/selectors'; } 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 { saveStaffAttendanceRecord } from '@/shared/api/staffAttendance';
import { import {
CAMPUS_ATTENDANCE_PRINT_POPUP_BLOCKED_MESSAGE, 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 { listUsers, type AdminUserRow } from '@/shared/api/users';
import { STAFF_ATTENDANCE_QUERY_KEYS } from '@/shared/constants/staffAttendance'; import { STAFF_ATTENDANCE_QUERY_KEYS } from '@/shared/constants/staffAttendance';
import { getClass } from '@/shared/api/classes'; import { getClass } from '@/shared/api/classes';
import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types';
const EMPTY_CONFIGS: ReturnType<typeof toCampusAttendanceConfigViewModel>[] = []; const EMPTY_CONFIGS: ReturnType<typeof toCampusAttendanceConfigViewModel>[] = [];
const EMPTY_SUMMARIES: ReturnType<typeof toCampusAttendanceSummaryViewModel>[] = []; const EMPTY_SUMMARIES: ReturnType<typeof toCampusAttendanceSummaryViewModel>[] = [];
const EMPTY_STAFF_RECORDS: readonly StaffAttendanceRecordViewModel[] = [];
const EMPTY_CAMPUSES: CampusInfo[] = []; const EMPTY_CAMPUSES: CampusInfo[] = [];
const EMPTY_TENANT_CHILDREN: TenantChild[] = []; const EMPTY_TENANT_CHILDREN: TenantChild[] = [];
const EMPTY_CAMPUS_CHILDREN_BY_PARENT: Readonly<Record<string, readonly 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; 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({ export function useCampusAttendancePage({
userRole, userRole,
userCampus, userCampus,
@ -366,6 +379,10 @@ export function useCampusAttendancePage({
{ startDate: today, endDate: today }, { startDate: today, endDate: today },
roleAccess.canReadStaffReports && hasAttendanceScope, roleAccess.canReadStaffReports && hasAttendanceScope,
); );
const staffRecordsQuery = useStaffAttendanceRecords(
{ limit: 500 },
roleAccess.canReadStaffReports && hasAttendanceScope,
);
const officeStaffQuery = useQuery({ const officeStaffQuery = useQuery({
queryKey: ['attendance-office-staff-users', effectiveTier, effectiveTenant?.id ?? null], queryKey: ['attendance-office-staff-users', effectiveTier, effectiveTenant?.id ?? null],
enabled: roleAccess.canEnterData && hasAttendanceScope, enabled: roleAccess.canEnterData && hasAttendanceScope,
@ -378,6 +395,7 @@ export function useCampusAttendancePage({
const configs = configsQuery.data ?? EMPTY_CONFIGS; const configs = configsQuery.data ?? EMPTY_CONFIGS;
const attendanceData = summariesQuery.data ?? EMPTY_SUMMARIES; const attendanceData = summariesQuery.data ?? EMPTY_SUMMARIES;
const staffSummary = staffSummaryQuery.data ?? null; const staffSummary = staffSummaryQuery.data ?? null;
const staffRecords = staffRecordsQuery.data ?? EMPTY_STAFF_RECORDS;
const officeStaffUsers = useMemo(() => ( const officeStaffUsers = useMemo(() => (
(officeStaffQuery.data?.rows ?? []) (officeStaffQuery.data?.rows ?? [])
.filter((user) => isOfficeStaffUser(user, scopeModel.mode) && isStaffRosterUser(user)) .filter((user) => isOfficeStaffUser(user, scopeModel.mode) && isStaffRosterUser(user))
@ -402,6 +420,7 @@ export function useCampusAttendancePage({
|| configsQuery.isLoading || configsQuery.isLoading
|| summariesQuery.isLoading || summariesQuery.isLoading
|| (roleAccess.canReadStaffReports && staffSummaryQuery.isLoading) || (roleAccess.canReadStaffReports && staffSummaryQuery.isLoading)
|| (roleAccess.canReadStaffReports && staffRecordsQuery.isLoading)
|| (roleAccess.canEnterData && officeStaffQuery.isLoading) || (roleAccess.canEnterData && officeStaffQuery.isLoading)
|| (roleAccess.canSeeAllCampuses && scopedAttendanceChildrenQuery.isLoading); || (roleAccess.canSeeAllCampuses && scopedAttendanceChildrenQuery.isLoading);
const saving = saveConfigMutation.isPending || saveSummaryMutation.isPending || saveStaffAttendanceMutation.isPending; const saving = saveConfigMutation.isPending || saveSummaryMutation.isPending || saveStaffAttendanceMutation.isPending;
@ -410,6 +429,7 @@ export function useCampusAttendancePage({
?? configsQuery.error ?? configsQuery.error
?? summariesQuery.error ?? summariesQuery.error
?? (roleAccess.canReadStaffReports ? staffSummaryQuery.error : null) ?? (roleAccess.canReadStaffReports ? staffSummaryQuery.error : null)
?? (roleAccess.canReadStaffReports ? staffRecordsQuery.error : null)
?? (roleAccess.canEnterData ? officeStaffQuery.error : null) ?? (roleAccess.canEnterData ? officeStaffQuery.error : null)
?? (roleAccess.canSeeAllCampuses ? scopedAttendanceChildrenQuery.error : null); ?? (roleAccess.canSeeAllCampuses ? scopedAttendanceChildrenQuery.error : null);
const saveError = saveConfigMutation.error ?? saveSummaryMutation.error ?? saveStaffAttendanceMutation.error; const saveError = saveConfigMutation.error ?? saveSummaryMutation.error ?? saveStaffAttendanceMutation.error;
@ -429,8 +449,8 @@ export function useCampusAttendancePage({
const [studentRollupOverrides, setStudentRollupOverrides] = useState<StudentRollupOverrideMap>({}); const [studentRollupOverrides, setStudentRollupOverrides] = useState<StudentRollupOverrideMap>({});
const campusStats = useMemo( const campusStats = useMemo(
() => buildCampusAttendanceStats(campusCatalog.campuses, configs, attendanceData, today, weekStart, weekEnd), () => buildCampusAttendanceStats(campusCatalog.campuses, configs, attendanceData, today, weekStart, weekEnd, staffRecords),
[attendanceData, campusCatalog.campuses, configs, today, weekEnd, weekStart], [attendanceData, campusCatalog.campuses, configs, staffRecords, today, weekEnd, weekStart],
); );
const visibleCampusStats = useMemo(() => { const visibleCampusStats = useMemo(() => {
if (scopeModel.mode === 'campus') { if (scopeModel.mode === 'campus') {
@ -461,6 +481,8 @@ export function useCampusAttendancePage({
recentData: campus.recentData, recentData: campus.recentData,
todayRecord: campus.todayRecord, todayRecord: campus.todayRecord,
childCampusIds: [campus.id], childCampusIds: [campus.id],
recentStaffData: campus.recentStaffData,
todayStaffRecord: campus.todayStaffRecord,
})); }));
} }
@ -482,6 +504,8 @@ export function useCampusAttendancePage({
recentData: campus?.recentData ?? [], recentData: campus?.recentData ?? [],
todayRecord: campus?.todayRecord ?? null, todayRecord: campus?.todayRecord ?? null,
childCampusIds: campus ? [campus.id] : [], childCampusIds: campus ? [campus.id] : [],
recentStaffData: campus?.recentStaffData ?? [],
todayStaffRecord: campus?.todayStaffRecord ?? null,
}; };
}); });
} }
@ -501,6 +525,23 @@ export function useCampusAttendancePage({
&& record.date >= weekStart && record.date >= weekStart
&& record.date <= weekEnd && 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 weekDays = Array.from(new Set(childWeekRecords.map((record) => record.date)));
const weekAvg = weekDays.length > 0 const weekAvg = weekDays.length > 0
? Number((weekDays.reduce((sum, day) => ( ? Number((weekDays.reduce((sum, day) => (
@ -519,6 +560,8 @@ export function useCampusAttendancePage({
recentData: childWeekRecords.slice(0, 10), recentData: childWeekRecords.slice(0, 10),
todayRecord: null, todayRecord: null,
childCampusIds, childCampusIds,
recentStaffData: childStaffRecords.slice(0, 10),
todayStaffRecord,
}; };
}); });
}, [ }, [
@ -538,11 +581,17 @@ export function useCampusAttendancePage({
[attendanceData, today, weekEnd, weekStart], [attendanceData, today, weekEnd, weekStart],
); );
const combinedStats = useMemo( const combinedStats = useMemo(
() => buildCombinedAttendanceStats(overallStats, staffSummary), () => buildCombinedAttendanceStats(
[overallStats, staffSummary], overallStats,
staffSummary,
scopeModel.mode === 'campus' ? undefined : attendanceChildStats,
),
[attendanceChildStats, overallStats, scopeModel.mode, staffSummary],
); );
const myCampusConfig = attendanceCampusId ? configs.find((config) => config.campus_id === attendanceCampusId) : undefined; const myCampusConfig = attendanceCampusId ? configs.find((config) => config.campus_id === attendanceCampusId) : undefined;
const myCampusData = attendanceCampusId ? attendanceData.filter((record) => record.campus_id === attendanceCampusId) : []; 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 myTodayPct = attendanceCampusId ? getTodayPercentage(attendanceData, attendanceCampusId, today) : null;
const myWeekAvg = attendanceCampusId ? getWeeklyAverage(attendanceData, attendanceCampusId, weekStart, weekEnd) : null; const myWeekAvg = attendanceCampusId ? getWeeklyAverage(attendanceData, attendanceCampusId, weekStart, weekEnd) : null;
const selectedEntryCampus = scopedCampusOptions.find((campus) => campus.id === entryDraft.campusId); const selectedEntryCampus = scopedCampusOptions.find((campus) => campus.id === entryDraft.campusId);
@ -742,7 +791,10 @@ export function useCampusAttendancePage({
return true; 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 (officeStaffUsers.length === 0) {
if (requireStaff) { if (requireStaff) {
setStaffEntryError('No office staff are available for this scope.'); setStaffEntryError('No office staff are available for this scope.');
@ -752,10 +804,10 @@ export function useCampusAttendancePage({
for (const staffUser of officeStaffUsers) { for (const staffUser of officeStaffUsers) {
await saveStaffAttendanceMutation.mutateAsync({ await saveStaffAttendanceMutation.mutateAsync({
date: staffEntryDraft.date, date: input.date,
userId: staffUser.id, userId: staffUser.id,
status: staffAttendanceStatuses[staffUser.id] ?? 'present', status: staffAttendanceStatuses[staffUser.id] ?? 'present',
note: staffEntryDraft.note, note: input.note,
}); });
} }
@ -807,7 +859,10 @@ export function useCampusAttendancePage({
return; return;
} }
const staffSaved = await saveStaffBatchAttendance(false); const staffSaved = await saveStaffBatchAttendance(false, {
date: entryDraft.date,
note: staffEntryDraft.note,
});
if (!staffSaved) { if (!staffSaved) {
return; return;
} }
@ -840,6 +895,7 @@ export function useCampusAttendancePage({
campusesToPrint, campusesToPrint,
printTodayRecords, printTodayRecords,
printWeekRecords, printWeekRecords,
staffSummary,
}, },
}); });
@ -862,6 +918,7 @@ export function useCampusAttendancePage({
weekEnd, weekEnd,
configs, configs,
attendanceData, attendanceData,
staffRecords,
loading, loading,
saving, saving,
errorMessage, errorMessage,
@ -892,6 +949,7 @@ export function useCampusAttendancePage({
}, },
myCampusConfig, myCampusConfig,
myCampusData, myCampusData,
myStaffData,
myTodayPct, myTodayPct,
myWeekAvg, myWeekAvg,
userCampus, userCampus,

View File

@ -31,6 +31,15 @@ describe('campus attendance print report', () => {
expect(html).toContain(CAMPUS_ATTENDANCE_TEST_SEED.todayNotesEscaped); expect(html).toContain(CAMPUS_ATTENDANCE_TEST_SEED.todayNotesEscaped);
expect(html).toContain('<div class="value green">90%</div>'); expect(html).toContain('<div class="value green">90%</div>');
expect(html).toContain('<div class="value amber">85%</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>'); expect(html).toContain('<td class="pct-warn">80.0%</td>');
}); });
@ -44,10 +53,13 @@ describe('campus attendance print report', () => {
weekAvg: null, weekAvg: null,
recentData: [], recentData: [],
todayRecord: null, todayRecord: null,
recentStaffData: [],
todayStaffRecord: null,
}, },
], ],
printTodayRecords: [], printTodayRecords: [],
printWeekRecords: [], printWeekRecords: [],
staffSummary: null,
}); });
expect(html).toContain('<div class="value ">No data</div>'); 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 { 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 => ( const escapeHtml = (value: string): string => (
value value
@ -43,6 +51,102 @@ const inlinePercentageClass = (percentage: number | null): string => {
return 'pct-bad'; 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 = export type CampusAttendancePrintResult =
| { readonly ok: true } | { readonly ok: true }
| { readonly ok: false; readonly reason: 'popup-blocked' }; | { readonly ok: false; readonly reason: 'popup-blocked' };
@ -87,6 +191,9 @@ export function openCampusAttendancePrintReport({
export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput): string { export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput): string {
const printStats = buildPrintAttendanceStats(input); 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', { const generatedAtDate = new Date().toLocaleDateString('en-US', {
weekday: 'long', weekday: 'long',
year: 'numeric', 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 { 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 h1 { font-size: 24px; color: #7c3aed; margin-bottom: 4px; }
.header p { font-size: 13px; color: #64748b; } .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 { 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 .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; } .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 { margin-top: 16px; }
.history-table th { font-size: 11px; } .history-table th { font-size: 11px; }
.history-table td { 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; } .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; } } @media print { body { padding: 16px; } .no-print { display: none; } }
</style> </style>
@ -144,11 +252,16 @@ export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput
<div class="summary-grid"> <div class="summary-grid">
<div class="summary-box"> <div class="summary-box">
<div class="label">Today's Attendance</div> <div class="label">Today's Attendance</div>
<div class="value ${percentageClass(printStats.todayPct)}">${printStats.todayPct !== null ? `${printStats.todayPct}%` : 'No data'}</div> <div class="value ${percentageClass(printStats.combinedPct)}">${printStats.combinedPct !== null ? `${printStats.combinedPct}%` : 'No data'}</div>
<div class="label" style="margin-top:4px">${formatAttendanceDate(input.today)}</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>
<div class="summary-box"> <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="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 class="label" style="margin-top:4px">Week of ${formatAttendanceDate(input.weekStart)}</div>
</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> <span>Week Avg: <span class="${inlinePercentageClass(campus.weekAvg)}">${campus.weekAvg !== null ? `${campus.weekAvg}%` : 'N/A'}</span></span>
</div> </div>
</div> </div>
${campus.todayRecord ? ` ${campus.todayRecord || campus.todayStaffRecord ? `
<table> <div class="section-label">Today's report</div>
<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 ? `
<table class="history-table"> <table class="history-table">
<thead> <thead>
<tr> <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> </tr>
</thead> </thead>
<tbody> <tbody>
${campus.recentData.map((record) => ` ${renderAttendanceRows(buildTodayReportRows(campus))}
<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('')}
</tbody> </tbody>
</table> </table>
` : ''} ` : '<p style="font-size:12px;color:#94a3b8;padding:8px 0;">No attendance data recorded for today.</p>'}
${renderAttendanceHistoryTable(campus)}
</div> </div>
`).join('')} `).join('')}
<div class="footer"> <div class="footer">

View File

@ -2,15 +2,21 @@ import { describe, expect, it } from 'vitest';
import { import {
buildAttendanceEntryInput, buildAttendanceEntryInput,
buildCampusAttendanceScopeModel, buildCampusAttendanceScopeModel,
buildCampusAttendanceStats,
buildCombinedAttendanceHistoryRows,
buildCombinedAttendanceStats, buildCombinedAttendanceStats,
buildOverallAttendanceStats, buildOverallAttendanceStats,
buildStaffAttendanceDailySummaries,
getWeekEnd, getWeekEnd,
getWeekStart, getWeekStart,
} from '@/business/campus-attendance/selectors'; } from '@/business/campus-attendance/selectors';
import type { import type {
CampusAttendanceChildStats,
CampusAttendanceEntryDraft, CampusAttendanceEntryDraft,
CampusAttendanceSummaryViewModel, CampusAttendanceSummaryViewModel,
} from '@/business/campus-attendance/types'; } from '@/business/campus-attendance/types';
import type { CampusInfo } from '@/shared/types/app';
import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types';
const summaries: readonly CampusAttendanceSummaryViewModel[] = [ 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', () => { describe('campus attendance selectors', () => {
it('calculates Monday and Friday boundaries for a midweek date', () => { it('calculates Monday and Friday boundaries for a midweek date', () => {
const date = new Date('2026-06-10T12:00:00Z'); const date = new Date('2026-06-10T12:00:00Z');
@ -171,8 +226,127 @@ describe('campus attendance selectors', () => {
).toEqual({ ).toEqual({
studentTodayPct: 86.67, studentTodayPct: 86.67,
studentWeekPct: 90.83, studentWeekPct: 90.83,
staffTodayPct: 90, staffTodayPct: 75,
combinedTodayPct: 86.88, 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 { import type {
AttendanceScopeMode, AttendanceScopeMode,
CampusAttendanceCombinedStats, CampusAttendanceCombinedStats,
CampusAttendanceChildStats,
CampusAttendanceEntryDraft, CampusAttendanceEntryDraft,
CampusAttendanceConfigViewModel, CampusAttendanceConfigViewModel,
CombinedAttendanceHistoryRow,
CampusAttendanceOverallStats, CampusAttendanceOverallStats,
CampusAttendancePrintInput, CampusAttendancePrintInput,
CampusAttendanceScopeModel, CampusAttendanceScopeModel,
CampusAttendanceStats, CampusAttendanceStats,
CampusAttendanceSummaryViewModel, CampusAttendanceSummaryViewModel,
StaffAttendanceDailySummaryViewModel,
} from '@/business/campus-attendance/types'; } 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 { export function getWeekStart(date: Date): string {
const weekStart = new Date(date); const weekStart = new Date(date);
@ -132,13 +138,20 @@ export function buildCampusAttendanceStats(
today: string, today: string,
weekStart: string, weekStart: string,
weekEnd: string, weekEnd: string,
staffRecords: readonly StaffAttendanceRecordViewModel[] = [],
): readonly CampusAttendanceStats[] { ): readonly CampusAttendanceStats[] {
const staffDailySummaries = buildStaffAttendanceDailySummaries(staffRecords);
return campuses.map((campus) => { return campuses.map((campus) => {
const todayPct = getTodayPercentage(summaries, campus.id, today); const todayPct = getTodayPercentage(summaries, campus.id, today);
const weekAvg = getWeeklyAverage(summaries, campus.id, weekStart, weekEnd); const weekAvg = getWeeklyAverage(summaries, campus.id, weekStart, weekEnd);
const config = configs.find((item) => item.campus_id === campus.id) ?? null; 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 recentData = summaries.filter((record) => record.campus_id === campus.id).slice(0, 10);
const todayRecord = getTodayData(summaries, campus.id, today)[0] ?? null; 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 { return {
...campus, ...campus,
@ -147,6 +160,8 @@ export function buildCampusAttendanceStats(
config, config,
recentData, recentData,
todayRecord, 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; 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( export function buildCombinedAttendanceStats(
overallStats: CampusAttendanceOverallStats, overallStats: CampusAttendanceOverallStats,
staffSummary: StaffAttendanceSummaryViewModel | null, staffSummary: StaffAttendanceSummaryViewModel | null,
childStats?: readonly CampusAttendanceChildStats[],
): CampusAttendanceCombinedStats { ): CampusAttendanceCombinedStats {
const staffTotal = staffSummary const scopedChildren = childStats ?? [];
const recordedStaffTotal = staffSummary
? staffSummary.present + staffSummary.late + staffSummary.absent ? staffSummary.present + staffSummary.late + staffSummary.absent
: 0; : 0;
const staffTotal = staffSummary ? Math.max(staffSummary.staffCount, recordedStaffTotal) : 0;
const staffPresent = staffSummary ? staffSummary.present + staffSummary.late : 0; const staffPresent = staffSummary ? staffSummary.present + staffSummary.late : 0;
const studentPresent = overallStats.todayPresent; const scopedStudentTodayPct = percentageFromScopedChildren(scopedChildren, (child) => child.todayPct);
const studentTotal = overallStats.todayEnrolled; 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 { return {
studentTodayPct: overallStats.todayPct, studentTodayPct,
studentWeekPct: overallStats.weekPct, studentWeekPct,
staffTodayPct: percentageFromCounts(staffPresent, staffTotal), staffTodayPct: percentageFromCounts(staffPresent, staffTotal),
combinedTodayPct: percentageFromCounts(studentPresent + staffPresent, studentTotal + 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) { export function buildPrintAttendanceStats(input: CampusAttendancePrintInput) {
const printEnrolled = input.printTodayRecords.reduce((sum, record) => sum + record.total_enrolled, 0); 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 printPresent = input.printTodayRecords.reduce((sum, record) => sum + record.total_present, 0);
const todayPct = printEnrolled > 0 ? Number(((printPresent / printEnrolled) * 100).toFixed(2)) : null; 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 weekDays = Array.from(new Set(input.printWeekRecords.map((record) => record.date)));
const weekPct = weekDays.length > 0 const weekPct = weekDays.length > 0
? Number((weekDays.reduce((sum, day) => { ? Number((weekDays.reduce((sum, day) => {
@ -296,6 +480,8 @@ export function buildPrintAttendanceStats(input: CampusAttendancePrintInput) {
return { return {
todayPct, todayPct,
staffPct,
combinedPct,
weekPct, weekPct,
}; };
} }

View File

@ -43,6 +43,8 @@ export interface CampusAttendanceStats extends CampusInfo {
readonly config: CampusAttendanceConfigViewModel | null; readonly config: CampusAttendanceConfigViewModel | null;
readonly recentData: readonly CampusAttendanceSummaryViewModel[]; readonly recentData: readonly CampusAttendanceSummaryViewModel[];
readonly todayRecord: CampusAttendanceSummaryViewModel | null; readonly todayRecord: CampusAttendanceSummaryViewModel | null;
readonly recentStaffData: readonly StaffAttendanceDailySummaryViewModel[];
readonly todayStaffRecord: StaffAttendanceDailySummaryViewModel | null;
} }
export interface CampusAttendanceChildStats { export interface CampusAttendanceChildStats {
@ -57,6 +59,8 @@ export interface CampusAttendanceChildStats {
readonly recentData: readonly CampusAttendanceSummaryViewModel[]; readonly recentData: readonly CampusAttendanceSummaryViewModel[];
readonly todayRecord: CampusAttendanceSummaryViewModel | null; readonly todayRecord: CampusAttendanceSummaryViewModel | null;
readonly childCampusIds: readonly CampusId[]; readonly childCampusIds: readonly CampusId[];
readonly recentStaffData: readonly StaffAttendanceDailySummaryViewModel[];
readonly todayStaffRecord: StaffAttendanceDailySummaryViewModel | null;
} }
export interface CampusAttendanceOverallStats { export interface CampusAttendanceOverallStats {
@ -133,6 +137,33 @@ export interface CampusAttendanceCombinedStats {
readonly combinedTodayPct: number | null; 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 { export interface CampusAttendanceStaffSummaryState {
readonly summary: StaffAttendanceSummaryViewModel | null; readonly summary: StaffAttendanceSummaryViewModel | null;
readonly loading: boolean; readonly loading: boolean;
@ -148,4 +179,5 @@ export interface CampusAttendancePrintInput {
readonly campusesToPrint: readonly CampusAttendanceStats[]; readonly campusesToPrint: readonly CampusAttendanceStats[];
readonly printTodayRecords: readonly CampusAttendanceSummaryViewModel[]; readonly printTodayRecords: readonly CampusAttendanceSummaryViewModel[];
readonly printWeekRecords: readonly CampusAttendanceSummaryViewModel[]; readonly printWeekRecords: readonly CampusAttendanceSummaryViewModel[];
readonly staffSummary: StaffAttendanceSummaryViewModel | null;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -42,10 +42,7 @@ export function getScopeTierLabel(tier: ScopeTier): string {
return SCOPE_TIER_LABELS[tier]; return SCOPE_TIER_LABELS[tier];
} }
/** /** The user's own active tenant — the tenant at their scope 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.
*/
export function getActiveTenant( export function getActiveTenant(
user: CurrentUser | null | undefined, user: CurrentUser | null | undefined,
): ActiveTenant | null { ): 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 }; return { level: 'class', id: user.classRoom.id, name: user.classRoom.name ?? null, logo: user.classRoom.logo ?? null };
} }
if (tier === 'campus' && user.campus?.id) { 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) { if (tier === 'school' && user.school?.id) {
return { level: 'school', id: user.school.id, name: user.school.name ?? null, logo: user.school.logo ?? null }; return { level: 'school', id: user.school.id, name: user.school.name ?? null, logo: user.school.logo ?? null };
} }
if (user.organizations?.id) { 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; return null;
} }

View File

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

View File

@ -11,6 +11,7 @@ export interface TopBarProps {
readonly user: CurrentUser | null; readonly user: CurrentUser | null;
readonly userRole: UserRole; readonly userRole: UserRole;
readonly userName: string; readonly userName: string;
readonly avatar: string | null;
readonly campusInfo?: CampusInfo; readonly campusInfo?: CampusInfo;
readonly toggleSidebar: () => void; readonly toggleSidebar: () => void;
readonly setCurrentModule: (moduleId: ModuleId) => void; readonly setCurrentModule: (moduleId: ModuleId) => void;
@ -33,6 +34,7 @@ export interface UseTopBarPageOptions extends TopBarProps {
export interface TopBarPage { export interface TopBarPage {
readonly userRole: UserRole; readonly userRole: UserRole;
readonly userName: string; readonly userName: string;
readonly avatar: string | null;
readonly campusInfo?: CampusInfo; readonly campusInfo?: CampusInfo;
readonly profileRoleLabel: string; readonly profileRoleLabel: string;
readonly roleLabel: 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 { AttendanceSummaryCard } from '@/components/campus-attendance/AttendanceSummaryCard';
import { percentageTextClass } from '@/components/campus-attendance/styles'; import { percentageTextClass } from '@/components/campus-attendance/styles';
import { import {
@ -25,52 +28,74 @@ export function IndividualCampusAttendanceView({ state, actions }: IndividualCam
const { const {
today, today,
weekStart, weekStart,
myTodayPct, weekEnd,
myWeekAvg, myWeekAvg,
myCampusData, myCampusData,
myStaffData,
myCampusConfig, myCampusConfig,
roleAccess, roleAccess,
campusInfo, campusInfo,
scopeModel, scopeModel,
} = state; } = state;
const todayRecord = myCampusData.find((record) => record.date === today); 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 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 ( 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 <AttendanceSummaryCard
label="Today's Attendance" label="Today's Attendance"
value={myTodayPct !== null ? `${myTodayPct}%` : 'N/A'} value={combinedTodayPct !== null ? `${combinedTodayPct}%` : 'N/A'}
helper={formatAttendanceDate(today)} helper={`${studentTodayLabel} · ${staffTodayLabel}`}
icon={Calendar} icon={Calendar}
percentage={myTodayPct} percentage={combinedTodayPct}
/> />
<AttendanceSummaryCard <AttendanceSummaryCard
label="Weekly Average" label="Weekly Average"
value={myWeekAvg !== null ? `${myWeekAvg}%` : 'N/A'} value={combinedWeekPct !== null ? `${combinedWeekPct}%` : 'N/A'}
helper={`Week of ${formatAttendanceDate(weekStart)}`} helper={`${weekStudentLabel} · ${weekStaffLabel}`}
icon={BarChart3} 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> </div>
{myCampusConfig?.attendance_link && !roleAccess.isOfficeManager && ( {myCampusConfig?.attendance_link && !roleAccess.isOfficeManager && (
@ -99,41 +124,59 @@ export function IndividualCampusAttendanceView({ state, actions }: IndividualCam
Recent Attendance History - {historyTitle} Recent Attendance History - {historyTitle}
</h3> </h3>
</div> </div>
{myCampusData.length > 0 ? ( {combinedHistoryRows.length > 0 ? (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-slate-700/30 hover:bg-slate-700/30"> <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-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-left text-xs text-slate-400">Group</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">Total</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">Present</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">Absent</TableHead>
<TableHead className="h-auto p-3 text-center text-xs text-slate-400">Attendance %</TableHead> <TableHead className="h-auto p-3 text-center text-xs text-slate-400">Tardy / Late</TableHead>
<TableHead className="h-auto p-3 text-left text-xs text-slate-400">Notes</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> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{myCampusData.slice(0, 15).map((record) => ( {combinedHistoryRows
<TableRow key={record.id} className="border-t border-slate-700/20 hover:bg-slate-700/20"> .filter((record) => record.date === today || record.group === 'total')
<TableCell className="p-3 text-slate-300 font-medium">{formatAttendanceDate(record.date)}</TableCell> .slice(0, 45)
<TableCell className="p-3 text-center text-slate-300">{record.total_enrolled}</TableCell> .map((record) => {
<TableCell className="p-3 text-center"> const shouldShowDate = record.date !== today || record.group === 'students';
<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> return (
<TableCell className="p-3 text-center"> <TableRow
<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> key={record.id}
</TableCell> className={`border-t border-slate-700/20 hover:bg-slate-700/20 ${
<TableCell className="p-3 text-center"> record.group === 'total' ? 'bg-slate-900/30' : ''
<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"> <TableCell className="p-3 text-slate-300 font-medium">
<span className={`font-bold ${percentageTextClass(record.attendance_percentage)}`}> {shouldShowDate ? formatAttendanceDate(record.date) : ''}
{record.attendance_percentage.toFixed(1)}% </TableCell>
</span> <TableCell className={`p-3 ${record.group === 'total' ? 'text-white font-semibold' : 'text-slate-300'}`}>
</TableCell> {record.label}
<TableCell className="p-3 text-xs text-slate-500 max-w-[200px] truncate">{record.notes || '-'}</TableCell> </TableCell>
</TableRow> <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> </TableBody>
</Table> </Table>
) : ( ) : (

View File

@ -44,7 +44,10 @@ export function SuperintendentAttendanceView({ state, actions }: SuperintendentA
scopeModel, scopeModel,
} = state; } = state;
const staffRecordTotal = staffSummary.summary 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; : null;
const staffPresentOrLate = staffSummary.summary const staffPresentOrLate = staffSummary.summary
? staffSummary.summary.present + staffSummary.summary.late ? 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 { ChevronDown, ClipboardCheck } from 'lucide-react';
import type { DirectorAcknowledgmentDocumentRow } from '@/business/director-dashboard/types'; 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'; import { cn } from '@/lib/utils';
interface DirectorAcknowledgmentTrackingPanelProps { interface DirectorAcknowledgmentTrackingPanelProps {
@ -123,9 +125,24 @@ export function DirectorAcknowledgmentTrackingPanel({
key={`${document.id}-${staff.userId}`} 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" 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="flex min-w-0 items-center gap-2">
<span className="text-xs capitalize text-gray-500"> <UserAvatar
{staff.role} 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> </span>
</div> </div>
))} ))}

View File

@ -2,6 +2,8 @@ import { useState } from 'react';
import { ChevronDown, Users } from 'lucide-react'; import { ChevronDown, Users } from 'lucide-react';
import { StatePanel } from '@/components/ui/state-panel'; 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 type { DirectorQuizResultRow } from '@/business/director-dashboard/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -80,8 +82,22 @@ export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelPr
aria-expanded={isExpanded} aria-expanded={isExpanded}
aria-controls={`quiz-results-${result.id}`} aria-controls={`quiz-results-${result.id}`}
> >
<span className="truncate font-medium text-gray-700">{result.staffName}</span> <span className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm text-gray-500">{result.tenant}</span> <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 text-xs capitalize text-gray-500">{result.role}</span>
<span className="text-center"> <span className="text-center">
<span className={cn( <span className={cn(

View File

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

View File

@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { Building2, ChevronDown, LogOut, Settings, User, Wifi } from 'lucide-react'; import { Building2, ChevronDown, LogOut, Settings, User, Wifi } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { UserAvatar } from '@/components/common/UserAvatar';
import { useOnClickOutside } from '@/hooks/useOnClickOutside'; import { useOnClickOutside } from '@/hooks/useOnClickOutside';
import { TOP_BAR_PROFILE_MENU_ITEMS } from '@/shared/constants/topBar'; import { TOP_BAR_PROFILE_MENU_ITEMS } from '@/shared/constants/topBar';
import { APP_ROUTE_PATHS } from '@/shared/constants/routes'; import { APP_ROUTE_PATHS } from '@/shared/constants/routes';
@ -12,6 +13,7 @@ import { cn } from '@/lib/utils';
interface TopBarProfileMenuProps { interface TopBarProfileMenuProps {
readonly userName: string; readonly userName: string;
readonly initials: string; readonly initials: string;
readonly avatar: string | null;
readonly campusInfo?: CampusInfo; readonly campusInfo?: CampusInfo;
readonly campusLabel: string; readonly campusLabel: string;
readonly profileRoleLabel: string; readonly profileRoleLabel: string;
@ -24,6 +26,7 @@ interface TopBarProfileMenuProps {
export function TopBarProfileMenu({ export function TopBarProfileMenu({
userName, userName,
initials, initials,
avatar,
campusInfo, campusInfo,
campusLabel, campusLabel,
profileRoleLabel, profileRoleLabel,
@ -49,9 +52,12 @@ export function TopBarProfileMenu({
aria-expanded={isOpen} 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" 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)}> <UserAvatar
{initials} name={userName || initials}
</span> 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="hidden md:block text-left">
<span className="text-sm font-medium text-slate-200 leading-tight block">{userName}</span> <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> <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="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="px-4 py-3 border-b border-slate-700/50">
<div className="flex items-center gap-3"> <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)}> <UserAvatar
{initials} name={userName || initials}
</div> avatarUrl={avatar}
className={cn('h-10 w-10 rounded-xl border-0 text-white shadow-lg', avatarClassName)}
fallbackClassName="text-sm font-bold"
/>
<div> <div>
<p className="text-sm font-semibold text-white">{userName}</p> <p className="text-sm font-semibold text-white">{userName}</p>
<p className="text-[10px] text-slate-400">{profileRoleLabel}</p> <p className="text-[10px] text-slate-400">{profileRoleLabel}</p>

View File

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

View File

@ -13,6 +13,7 @@ import { NativeSelect } from '@/components/ui/native-select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { TenantParentPicker } from '@/components/tenant-create/TenantParentPicker'; import { TenantParentPicker } from '@/components/tenant-create/TenantParentPicker';
import { ImageUpload } from '@/components/common/ImageUpload'; import { ImageUpload } from '@/components/common/ImageUpload';
import { TenantLogo } from '@/components/common/TenantLogo';
import { useScopeContext } from '@/contexts/scope-context'; import { useScopeContext } from '@/contexts/scope-context';
import { useTenantChildren } from '@/business/scope/queries'; import { useTenantChildren } from '@/business/scope/queries';
import { useIamCapabilities } from '@/business/iam-capabilities/hooks'; import { useIamCapabilities } from '@/business/iam-capabilities/hooks';
@ -88,15 +89,6 @@ function displayName(row: TenantChild): string {
return row.name?.trim() || row.id; 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[] { function uniqueRows(rows: readonly TenantChild[]): TenantChild[] {
const seen = new Set<string>(); const seen = new Set<string>();
const result: TenantChild[] = []; const result: TenantChild[] = [];
@ -265,6 +257,7 @@ export default function CreateTenantPage() {
const [editContact, setEditContact] = useState<ContactFields>(() => emptyContactFields()); const [editContact, setEditContact] = useState<ContactFields>(() => emptyContactFields());
const [deleteCandidate, setDeleteCandidate] = useState<TenantChild | null>(null); const [deleteCandidate, setDeleteCandidate] = useState<TenantChild | null>(null);
const [expandedLocations, setExpandedLocations] = useState<ReadonlySet<string>>(() => new Set()); const [expandedLocations, setExpandedLocations] = useState<ReadonlySet<string>>(() => new Set());
const [isLocationFormOpen, setIsLocationFormOpen] = useState(false);
const [locationsPage, setLocationsPage] = useState(0); const [locationsPage, setLocationsPage] = useState(0);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [savingEdit, setSavingEdit] = useState(false); const [savingEdit, setSavingEdit] = useState(false);
@ -429,9 +422,11 @@ export default function CreateTenantPage() {
setPickedParentId(null); setPickedParentId(null);
setOrgId(''); setOrgId('');
setLogo(null); setLogo(null);
setIsLocationFormOpen(false);
} }
function startEdit(row: TenantChild) { function startEdit(row: TenantChild) {
setIsLocationFormOpen(true);
setEditingLocation(row); setEditingLocation(row);
setEditName(displayName(row)); setEditName(displayName(row));
setEditLogo(row.logo ?? null); setEditLogo(row.logo ?? null);
@ -446,6 +441,7 @@ export default function CreateTenantPage() {
setEditLogo(null); setEditLogo(null);
setEditDescription(''); setEditDescription('');
setEditContact(emptyContactFields()); setEditContact(emptyContactFields());
setIsLocationFormOpen(false);
} }
function locationKey(row: TenantChild): string { function locationKey(row: TenantChild): string {
@ -613,13 +609,11 @@ export default function CreateTenantPage() {
> >
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />} {expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</Button> </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"> <TenantLogo
{row.logo ? ( name={rowName}
<img src={row.logo} alt="" className="h-full w-full object-cover" /> logoUrl={row.logo}
) : ( className="h-10 w-10 bg-slate-800 text-sm text-slate-200"
initials(rowName) />
)}
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-200">{rowName}</p> <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> <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"> <Card className="border-slate-600/70 bg-slate-900/80 shadow-lg shadow-black/20">
<CardHeader className="border-b border-slate-700/70"> <CardHeader className="border-b border-slate-700/70">
<CardTitle className="flex items-center gap-2 text-base text-slate-50"> <CardTitle className="flex items-center gap-2 text-base text-slate-50">
{editingLocation ? <Pencil size={16} /> : null} <button
{editingLocation type="button"
? `Edit ${TENANT_TYPE_LABELS[editingLocation.level]}` className="flex min-w-0 flex-1 items-center gap-2 text-left"
: 'New organization or location'} 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 && ( {editingLocation && (
<Button <Button
type="button" type="button"
@ -708,7 +718,8 @@ export default function CreateTenantPage() {
)} )}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> {isLocationFormOpen && (
<CardContent id="tenant-location-form">
<form className="space-y-5 pt-6" onSubmit={editingLocation ? handleEditSubmit : handleSubmit}> <form className="space-y-5 pt-6" onSubmit={editingLocation ? handleEditSubmit : handleSubmit}>
{editingLocation ? ( {editingLocation ? (
<> <>
@ -1000,6 +1011,7 @@ export default function CreateTenantPage() {
</div> </div>
</form> </form>
</CardContent> </CardContent>
)}
</Card> </Card>
)} )}

View File

@ -1,7 +1,7 @@
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; 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 { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 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 { PageSkeleton } from '@/components/ui/page-skeleton';
import { TenantParentPicker } from '@/components/tenant-create/TenantParentPicker'; import { TenantParentPicker } from '@/components/tenant-create/TenantParentPicker';
import { ImageUpload } from '@/components/common/ImageUpload'; import { ImageUpload } from '@/components/common/ImageUpload';
import { TenantLogo } from '@/components/common/TenantLogo';
import { useScopeContext } from '@/contexts/scope-context'; import { useScopeContext } from '@/contexts/scope-context';
import { useAuth } from '@/contexts/useAuth'; import { useAuth } from '@/contexts/useAuth';
import { useTenantChildren } from '@/business/scope/queries'; import { useTenantChildren } from '@/business/scope/queries';
@ -76,6 +77,23 @@ function locationName(value?: { name?: string | null } | null): string {
return value?.name?.trim() || '—'; 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 { function sortText(value: string | null | undefined): string {
return value?.trim().toLocaleLowerCase() || ''; return value?.trim().toLocaleLowerCase() || '';
} }
@ -97,6 +115,7 @@ export default function UserAdminPage() {
const [usersSearch, setUsersSearch] = useState(''); const [usersSearch, setUsersSearch] = useState('');
const [usersSortField, setUsersSortField] = useState<UserListSortField>('name'); const [usersSortField, setUsersSortField] = useState<UserListSortField>('name');
const [usersSortDirection, setUsersSortDirection] = useState<UserListSortDirection>('asc'); const [usersSortDirection, setUsersSortDirection] = useState<UserListSortDirection>('asc');
const [isUserFormOpen, setIsUserFormOpen] = useState(false);
const usersQuery = useQuery({ const usersQuery = useQuery({
queryKey: ['admin-users', usersSearch], queryKey: ['admin-users', usersSearch],
queryFn: () => queryFn: () =>
@ -322,9 +341,11 @@ export default function UserAdminPage() {
setStudentIds([]); setStudentIds([]);
setGrantPerms([]); setGrantPerms([]);
setExcludePerms([]); setExcludePerms([]);
setIsUserFormOpen(false);
} }
function startEdit(row: AdminUserRow) { function startEdit(row: AdminUserRow) {
setIsUserFormOpen(true);
setEditingId(row.id); setEditingId(row.id);
setNamePrefix(row.name_prefix ?? ''); setNamePrefix(row.name_prefix ?? '');
setFirstName(row.firstName ?? ''); 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"> <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"> <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"> <CardTitle className="flex items-center gap-2 text-base text-slate-50">
{editingId ? <Pencil size={16} /> : <UserPlus size={16} />} <button
{editingId ? 'Edit user' : 'New user'} 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 && ( {editingId && (
<Button <Button
type="button" type="button"
@ -470,7 +506,8 @@ export default function UserAdminPage() {
)} )}
</CardTitle> </CardTitle>
</CardHeader> </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}> <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 className="rounded-lg border border-slate-600/80 bg-slate-950/45 p-4 space-y-4">
<div> <div>
@ -699,18 +736,6 @@ export default function UserAdminPage() {
</details> </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"> <div className="flex justify-end">
<Button <Button
type="submit" type="submit"
@ -722,9 +747,22 @@ export default function UserAdminPage() {
</div> </div>
</form> </form>
</CardContent> </CardContent>
)}
</Card> </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> <Card>
<CardHeader className="px-4 py-4 md:px-5"> <CardHeader className="px-4 py-4 md:px-5">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
@ -830,9 +868,9 @@ export default function UserAdminPage() {
</td> </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 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">{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">{locationCell(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">{locationCell(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.campus)}</td>
<td className="px-3 py-2.5 text-slate-300"> <td className="px-3 py-2.5 text-slate-300">
{roleName ? getAuthRoleLabel(roleName as UserRole) : '—'} {roleName ? getAuthRoleLabel(roleName as UserRole) : '—'}
</td> </td>

View File

@ -72,6 +72,20 @@ describe('http client', () => {
expect(requests[0].input).toBe(`${API_BASE_URL}/auth/me`); expect(requests[0].input).toBe(`${API_BASE_URL}/auth/me`);
expect(requests[0].init?.credentials).toBe('include'); expect(requests[0].init?.credentials).toBe('include');
expect(requests[0].init?.method).toBe('GET'); 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 () => { 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> { async function sendRequest(path: string, options: RequestOptions): Promise<Response> {
const headers = new Headers(options.headers); const headers = new Headers(options.headers);
const method = options.method || 'GET';
headers.set(API_HEADERS.contentType, API_CONTENT_TYPES.json); headers.set(API_HEADERS.contentType, API_CONTENT_TYPES.json);
if (activeTenant) { if (activeTenant) {
@ -154,9 +155,10 @@ async function sendRequest(path: string, options: RequestOptions): Promise<Respo
return fetch(createApiUrl(path), { return fetch(createApiUrl(path), {
credentials: 'include', credentials: 'include',
method: options.method || 'GET', method,
headers, headers,
signal: options.signal, signal: options.signal,
cache: method === 'GET' ? 'no-store' : undefined,
body: options.body ? JSON.stringify(options.body) : undefined, body: options.body ? JSON.stringify(options.body) : undefined,
}); });
} }

View File

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

View File

@ -86,7 +86,7 @@ export const DASHBOARD_QUICK_ACTIONS: readonly DashboardQuickAction[] = [
shadow: 'shadow-cyan-500/30', shadow: 'shadow-cyan-500/30',
}, },
{ {
label: 'De-escalation', label: 'Behavior management',
iconId: 'shield', iconId: 'shield',
module: 'qbs', module: 'qbs',
color: 'from-blue-500 to-blue-600', 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_TEXT_PREVIEW_LENGTH = 60;
export const DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD = 50; export const DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD = 50;
export const DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD = 3; export const DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD = 3;
export const DIRECTOR_DASHBOARD_HIGH_ABSENCE_THRESHOLD = 3;
export type DirectorQuickActionTone = export type DirectorQuickActionTone =
| 'indigo' | 'indigo'

View File

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

View File

@ -61,6 +61,9 @@ export interface PersonalityCompletionRowDto {
readonly userId: string; readonly userId: string;
readonly name: string; readonly name: string;
readonly email: string | null; readonly email: string | null;
readonly avatar: string | null;
readonly tenantName: string | null;
readonly tenantLogo: string | null;
readonly role: string | null; readonly role: string | null;
readonly status: 'complete' | 'pending'; readonly status: 'complete' | 'pending';
readonly completedKinds: readonly PersonalityQuizKind[]; readonly completedKinds: readonly PersonalityQuizKind[];

View File

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

View File

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

View File

@ -29,6 +29,9 @@ export interface ZoneCheckinCompletionRowDto {
readonly userId: string; readonly userId: string;
readonly name: string; readonly name: string;
readonly email: string | null; readonly email: string | null;
readonly avatar: string | null;
readonly tenantName: string | null;
readonly tenantLogo: string | null;
readonly role: string | null; readonly role: string | null;
readonly date: string; readonly date: string;
readonly status: 'complete' | 'pending'; readonly status: 'complete' | 'pending';

View File

@ -2,7 +2,9 @@ import type {
CampusAttendancePrintInput, CampusAttendancePrintInput,
CampusAttendanceStats, CampusAttendanceStats,
CampusAttendanceSummaryViewModel, CampusAttendanceSummaryViewModel,
StaffAttendanceDailySummaryViewModel,
} from '@/business/campus-attendance/types'; } from '@/business/campus-attendance/types';
import type { StaffAttendanceSummaryViewModel } from '@/business/staff-attendance/types';
export const CAMPUS_ATTENDANCE_TEST_SEED = { export const CAMPUS_ATTENDANCE_TEST_SEED = {
campusId: 'tigers', campusId: 'tigers',
@ -42,6 +44,38 @@ export const campusAttendanceWeekRecord: CampusAttendanceSummaryViewModel = {
notes: null, 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 = { export const campusAttendanceStatsSeed: CampusAttendanceStats = {
id: CAMPUS_ATTENDANCE_TEST_SEED.campusId, id: CAMPUS_ATTENDANCE_TEST_SEED.campusId,
mascot: CAMPUS_ATTENDANCE_TEST_SEED.campusMascot, mascot: CAMPUS_ATTENDANCE_TEST_SEED.campusMascot,
@ -57,6 +91,8 @@ export const campusAttendanceStatsSeed: CampusAttendanceStats = {
config: null, config: null,
recentData: [campusAttendanceTodayRecord, campusAttendanceWeekRecord], recentData: [campusAttendanceTodayRecord, campusAttendanceWeekRecord],
todayRecord: campusAttendanceTodayRecord, todayRecord: campusAttendanceTodayRecord,
recentStaffData: [campusStaffAttendanceTodayRecord, campusStaffAttendanceWeekRecord],
todayStaffRecord: campusStaffAttendanceTodayRecord,
}; };
export const campusAttendancePrintInputSeed: CampusAttendancePrintInput = { export const campusAttendancePrintInputSeed: CampusAttendancePrintInput = {
@ -68,4 +104,5 @@ export const campusAttendancePrintInputSeed: CampusAttendancePrintInput = {
campusesToPrint: [campusAttendanceStatsSeed], campusesToPrint: [campusAttendanceStatsSeed],
printTodayRecords: [campusAttendanceTodayRecord], printTodayRecords: [campusAttendanceTodayRecord],
printWeekRecords: [campusAttendanceTodayRecord, campusAttendanceWeekRecord], printWeekRecords: [campusAttendanceTodayRecord, campusAttendanceWeekRecord],
staffSummary: campusStaffAttendanceSummary,
}; };