improved attendance reports, avatar and logos processing,
This commit is contained in:
parent
d12d2b0a10
commit
569c577beb
@ -89,6 +89,7 @@ function toCampusDto(campus: unknown): CampusDto | null {
|
||||
id,
|
||||
name: asStringOrNull(plain.name),
|
||||
code: asStringOrNull(plain.code),
|
||||
logo: asStringOrNull(plain.logo),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -23,4 +23,5 @@ export interface CampusDto {
|
||||
id: string;
|
||||
name: string | null;
|
||||
code: string | null;
|
||||
logo?: string | null;
|
||||
}
|
||||
|
||||
@ -64,6 +64,11 @@ const REPORT_STAFF_ROLES = Object.freeze([
|
||||
ROLE_NAMES.SUPPORT_STAFF,
|
||||
]);
|
||||
|
||||
interface StaffTenantInfo {
|
||||
readonly tenantName: string | null;
|
||||
readonly tenantLogo: string | null;
|
||||
}
|
||||
|
||||
function normalizeQuizKind(value: unknown): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
return PERSONALITY_QUIZ_KIND;
|
||||
@ -207,6 +212,18 @@ function displayNameOf(user: Users): string {
|
||||
|| 'Staff Member';
|
||||
}
|
||||
|
||||
function avatarOf(user: Users): string | null {
|
||||
return user.avatar?.[0]?.privateUrl ?? null;
|
||||
}
|
||||
|
||||
function tenantOf(user: Users): StaffTenantInfo {
|
||||
const tenant = user.class ?? user.campus ?? user.school ?? user.organizations ?? null;
|
||||
return {
|
||||
tenantName: tenant?.name ?? null,
|
||||
tenantLogo: tenant?.logo ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function latestResultsByUserAndKind(
|
||||
results: readonly PersonalityQuizResults[],
|
||||
): ReadonlyMap<string, PersonalityQuizResults> {
|
||||
@ -433,6 +450,35 @@ class PersonalityQuizResultsService {
|
||||
required: true,
|
||||
where: { name: REPORT_STAFF_ROLES },
|
||||
},
|
||||
{
|
||||
model: db.file,
|
||||
as: 'avatar',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: db.organizations,
|
||||
as: 'organizations',
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'logo'],
|
||||
},
|
||||
{
|
||||
model: db.schools,
|
||||
as: 'school',
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'logo'],
|
||||
},
|
||||
{
|
||||
model: db.campuses,
|
||||
as: 'campus',
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'logo'],
|
||||
},
|
||||
{
|
||||
model: db.classes,
|
||||
as: 'class',
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'logo'],
|
||||
},
|
||||
],
|
||||
order: [
|
||||
['lastName', 'asc'],
|
||||
@ -474,6 +520,7 @@ class PersonalityQuizResultsService {
|
||||
const rows = staffUsers.map((user) => {
|
||||
const selfAssessment = resultByUserAndKind.get(`${user.id}:${EI_SELF_ASSESSMENT_KIND}`) ?? null;
|
||||
const personality = resultByUserAndKind.get(`${user.id}:${PERSONALITY_QUIZ_KIND}`) ?? null;
|
||||
const tenant = tenantOf(user);
|
||||
const completedKinds: string[] = [];
|
||||
if (selfAssessment) {
|
||||
completedKinds.push(EI_SELF_ASSESSMENT_KIND);
|
||||
@ -486,6 +533,9 @@ class PersonalityQuizResultsService {
|
||||
userId: user.id,
|
||||
name: displayNameOf(user),
|
||||
email: user.email,
|
||||
avatar: avatarOf(user),
|
||||
tenantName: tenant.tenantName,
|
||||
tenantLogo: tenant.tenantLogo,
|
||||
role: user.app_role?.name ?? null,
|
||||
status: completedKinds.length >= quizKinds.length ? 'complete' : 'pending',
|
||||
completedKinds,
|
||||
|
||||
@ -36,6 +36,11 @@ const ACKNOWLEDGMENT_REPORT_STAFF_ROLES = Object.freeze([
|
||||
ROLE_NAMES.SUPPORT_STAFF,
|
||||
]);
|
||||
|
||||
interface TenantReference {
|
||||
readonly name?: string | null;
|
||||
readonly logo?: string | null;
|
||||
}
|
||||
|
||||
function assertCanAcknowledge(currentUser?: CurrentUser): void {
|
||||
if (hasFeaturePermission(currentUser, FEATURE_PERMISSIONS.ACK_POLICY)) {
|
||||
return;
|
||||
@ -110,6 +115,23 @@ function displayNameOf(user: {
|
||||
|| 'Staff Member';
|
||||
}
|
||||
|
||||
function avatarOf(user: { avatar?: readonly { privateUrl?: string | null }[] }): string | null {
|
||||
return user.avatar?.[0]?.privateUrl ?? null;
|
||||
}
|
||||
|
||||
function tenantOf(user: {
|
||||
readonly class?: TenantReference | null;
|
||||
readonly campus?: TenantReference | null;
|
||||
readonly school?: TenantReference | null;
|
||||
readonly organizations?: TenantReference | null;
|
||||
}): { tenantName: string | null; tenantLogo: string | null } {
|
||||
const tenant = user.class ?? user.campus ?? user.school ?? user.organizations ?? null;
|
||||
return {
|
||||
tenantName: tenant?.name ?? null,
|
||||
tenantLogo: tenant?.logo ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
class PolicyAcknowledgmentsService {
|
||||
/** A campus staff member's own acknowledgments (optionally for one document). */
|
||||
static async list(
|
||||
@ -220,6 +242,35 @@ class PolicyAcknowledgmentsService {
|
||||
required: true,
|
||||
where: { name: ACKNOWLEDGMENT_REPORT_STAFF_ROLES },
|
||||
},
|
||||
{
|
||||
model: db.file,
|
||||
as: 'avatar',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: db.organizations,
|
||||
as: 'organizations',
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'logo'],
|
||||
},
|
||||
{
|
||||
model: db.schools,
|
||||
as: 'school',
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'logo'],
|
||||
},
|
||||
{
|
||||
model: db.campuses,
|
||||
as: 'campus',
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'logo'],
|
||||
},
|
||||
{
|
||||
model: db.classes,
|
||||
as: 'class',
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'logo'],
|
||||
},
|
||||
],
|
||||
order: [
|
||||
['lastName', 'asc'],
|
||||
@ -272,6 +323,7 @@ class PolicyAcknowledgmentsService {
|
||||
});
|
||||
|
||||
const staffRows = staffUsers.map((user) => {
|
||||
const tenant = tenantOf(user);
|
||||
const documentStatuses = documents.map((document) => {
|
||||
const acknowledgedAt = acknowledgedAtByKey.get(
|
||||
`${user.id}:${document.id}:${document.version}`,
|
||||
@ -291,6 +343,9 @@ class PolicyAcknowledgmentsService {
|
||||
userId: user.id,
|
||||
name: displayNameOf(user),
|
||||
email: user.email,
|
||||
avatar: avatarOf(user),
|
||||
tenantName: tenant.tenantName,
|
||||
tenantLogo: tenant.tenantLogo,
|
||||
role: user.app_role?.name ?? null,
|
||||
campusId: user.campusId ?? null,
|
||||
schoolId: user.schoolId ?? null,
|
||||
|
||||
@ -51,6 +51,11 @@ const REPORT_STAFF_ROLES = Object.freeze([
|
||||
ROLE_NAMES.SUPPORT_STAFF,
|
||||
]);
|
||||
|
||||
interface StaffTenantInfo {
|
||||
readonly tenantName: string | null;
|
||||
readonly tenantLogo: string | null;
|
||||
}
|
||||
|
||||
function getProductRole(currentUser?: CurrentUser): string {
|
||||
return currentUser?.app_role?.name ?? ROLE_NAMES.TEACHER;
|
||||
}
|
||||
@ -181,6 +186,18 @@ function displayNameOf(user: Users): string {
|
||||
|| 'Staff Member';
|
||||
}
|
||||
|
||||
function avatarOf(user: Users): string | null {
|
||||
return user.avatar?.[0]?.privateUrl ?? null;
|
||||
}
|
||||
|
||||
function tenantOf(user: Users): StaffTenantInfo {
|
||||
const tenant = user.class ?? user.campus ?? user.school ?? user.organizations ?? null;
|
||||
return {
|
||||
tenantName: tenant?.name ?? null,
|
||||
tenantLogo: tenant?.logo ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function latestResultByUser(
|
||||
results: readonly SafetyQuizResults[],
|
||||
): ReadonlyMap<string, SafetyQuizResults> {
|
||||
@ -294,6 +311,35 @@ class SafetyQuizResultsService {
|
||||
required: true,
|
||||
where: { name: REPORT_STAFF_ROLES },
|
||||
},
|
||||
{
|
||||
model: db.file,
|
||||
as: 'avatar',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: db.organizations,
|
||||
as: 'organizations',
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'logo'],
|
||||
},
|
||||
{
|
||||
model: db.schools,
|
||||
as: 'school',
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'logo'],
|
||||
},
|
||||
{
|
||||
model: db.campuses,
|
||||
as: 'campus',
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'logo'],
|
||||
},
|
||||
{
|
||||
model: db.classes,
|
||||
as: 'class',
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'logo'],
|
||||
},
|
||||
],
|
||||
order: [
|
||||
['lastName', 'asc'],
|
||||
@ -315,10 +361,14 @@ class SafetyQuizResultsService {
|
||||
const resultByUser = latestResultByUser(results);
|
||||
const rows = staffUsers.map((user) => {
|
||||
const result = resultByUser.get(user.id);
|
||||
const tenant = tenantOf(user);
|
||||
return {
|
||||
userId: user.id,
|
||||
name: displayNameOf(user),
|
||||
email: user.email,
|
||||
avatar: avatarOf(user),
|
||||
tenantName: tenant.tenantName,
|
||||
tenantLogo: tenant.tenantLogo,
|
||||
role: user.app_role?.name ?? null,
|
||||
status: result ? 'complete' : 'pending',
|
||||
result: result ? toDto(result) : null,
|
||||
|
||||
@ -116,4 +116,76 @@ describe('StaffAttendanceService', () => {
|
||||
assert.equal(userWhere.schoolId, schoolId);
|
||||
assert.equal(userWhere.campusId, null);
|
||||
});
|
||||
|
||||
test('upserts class staff attendance inside campus scope and stores class campus', async () => {
|
||||
const organizationId = '11111111-1111-4111-8111-111111111111';
|
||||
const campusId = '33333333-3333-4333-8333-333333333333';
|
||||
const actor = createTestUser({
|
||||
id: '44444444-4444-4444-8444-444444444444',
|
||||
organizationId,
|
||||
organizations: { id: organizationId },
|
||||
campusId,
|
||||
app_role: {
|
||||
name: ROLE_NAMES.DIRECTOR,
|
||||
scope: ROLE_SCOPES.CAMPUS,
|
||||
globalAccess: false,
|
||||
permissions: [permission(FEATURE_PERMISSIONS.FILL_ATTENDANCE)],
|
||||
},
|
||||
});
|
||||
let userWhere: unknown = null;
|
||||
let createdPayload: unknown = null;
|
||||
|
||||
mock.method(db.users, 'findOne', async (options: unknown) => {
|
||||
if (isRecord(options)) {
|
||||
userWhere = options.where;
|
||||
}
|
||||
return {
|
||||
get: () => ({
|
||||
id: '55555555-5555-4555-8555-555555555555',
|
||||
firstName: 'Emily',
|
||||
lastName: 'Johnson',
|
||||
email: 'teacher@flatlogic.com',
|
||||
campusId: null,
|
||||
class: { campusId },
|
||||
app_role: { name: ROLE_NAMES.TEACHER },
|
||||
}),
|
||||
};
|
||||
});
|
||||
mock.method(db.sequelize, 'transaction', async () => ({
|
||||
commit: async () => undefined,
|
||||
rollback: async () => undefined,
|
||||
}));
|
||||
mock.method(db.staff_attendance_records, 'findOne', async () => null);
|
||||
mock.method(db.staff_attendance_records, 'create', async (payload: unknown) => {
|
||||
createdPayload = payload;
|
||||
return {
|
||||
get: () => ({
|
||||
id: '66666666-6666-4666-8666-666666666666',
|
||||
...(isRecord(payload) ? payload : {}),
|
||||
createdAt: new Date('2026-06-17T12:00:00Z'),
|
||||
updatedAt: new Date('2026-06-17T12:00:00Z'),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
await StaffAttendanceService.upsertRecord(
|
||||
{
|
||||
userId: '55555555-5555-4555-8555-555555555555',
|
||||
date: '2026-06-17',
|
||||
status: 'present',
|
||||
note: '',
|
||||
},
|
||||
actor,
|
||||
);
|
||||
|
||||
assert.equal(isRecord(userWhere), true);
|
||||
if (isRecord(userWhere)) {
|
||||
assert.equal(userWhere.organizationId, organizationId);
|
||||
assert.equal(Object.getOwnPropertySymbols(userWhere).includes(Op.or), true);
|
||||
}
|
||||
assert.equal(isRecord(createdPayload), true);
|
||||
if (isRecord(createdPayload)) {
|
||||
assert.equal(createdPayload.campusId, campusId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -72,6 +72,16 @@ function schoolUserIdSubquery(schoolId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function campusClassIdSubquery(campusId: string) {
|
||||
if (!STAFF_ATTENDANCE_UUID_RE.test(campusId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return literal(
|
||||
`(SELECT "id" FROM "classes" WHERE "campusId" = '${campusId}' AND "deletedAt" IS NULL)`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restricts records to the staff member, or (for report roles) to the records
|
||||
* their scope allows: org-wide for owner/superintendent, the school's campuses
|
||||
@ -213,7 +223,14 @@ function staffUserScope(currentUser?: CurrentUser): WhereOptions {
|
||||
if (!campusId) {
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
return { ...base, campusId };
|
||||
const classSubquery = campusClassIdSubquery(campusId);
|
||||
return {
|
||||
...base,
|
||||
[Op.or]: [
|
||||
{ campusId },
|
||||
...(classSubquery ? [{ classId: { [Op.in]: classSubquery } }] : []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return { ...base, schoolId: null, campusId: null };
|
||||
@ -328,6 +345,11 @@ class StaffAttendanceService {
|
||||
as: 'app_role',
|
||||
attributes: ['name'],
|
||||
},
|
||||
{
|
||||
model: db.classes,
|
||||
as: 'class',
|
||||
attributes: ['campusId'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@ -341,8 +363,10 @@ class StaffAttendanceService {
|
||||
lastName?: string | null;
|
||||
email?: string | null;
|
||||
campusId?: string | null;
|
||||
class?: { campusId?: string | null } | null;
|
||||
app_role?: { name?: string | null } | null;
|
||||
};
|
||||
const recordCampusId = plain.campusId ?? plain.class?.campusId ?? null;
|
||||
|
||||
return withTransaction(async (transaction) => {
|
||||
const existing = await db.staff_attendance_records.findOne({
|
||||
@ -360,7 +384,7 @@ class StaffAttendanceService {
|
||||
user_name: staffUserName(plain),
|
||||
user_role: plain.app_role?.name ?? null,
|
||||
organizationId: requireOrganizationId(currentUser),
|
||||
campusId: plain.campusId ?? null,
|
||||
campusId: recordCampusId,
|
||||
userId,
|
||||
updatedById: requireUserId(currentUser),
|
||||
};
|
||||
|
||||
@ -49,6 +49,9 @@ export interface ZoneCheckinCompletionRow {
|
||||
readonly userId: string;
|
||||
readonly name: string;
|
||||
readonly email: string | null;
|
||||
readonly avatar: string | null;
|
||||
readonly tenantName: string | null;
|
||||
readonly tenantLogo: string | null;
|
||||
readonly role: string | null;
|
||||
readonly date: string;
|
||||
readonly status: 'complete' | 'pending';
|
||||
@ -94,6 +97,11 @@ const REPORT_STAFF_ROLES = Object.freeze([
|
||||
ROLE_NAMES.SUPPORT_STAFF,
|
||||
]);
|
||||
|
||||
interface StaffTenantInfo {
|
||||
readonly tenantName: string | null;
|
||||
readonly tenantLogo: string | null;
|
||||
}
|
||||
|
||||
async function resolveCampusTimezone(currentUser?: CurrentUser): Promise<string> {
|
||||
const campusId = getCampusId(currentUser);
|
||||
if (!campusId) {
|
||||
@ -166,6 +174,18 @@ function displayNameOf(user: Users): string {
|
||||
|| 'Staff Member';
|
||||
}
|
||||
|
||||
function avatarOf(user: Users): string | null {
|
||||
return user.avatar?.[0]?.privateUrl ?? null;
|
||||
}
|
||||
|
||||
function tenantOf(user: Users): StaffTenantInfo {
|
||||
const tenant = user.class ?? user.campus ?? user.school ?? user.organizations ?? null;
|
||||
return {
|
||||
tenantName: tenant?.name ?? null,
|
||||
tenantLogo: tenant?.logo ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function latestProgressByUserAndDate(
|
||||
rows: readonly UserProgress[],
|
||||
): ReadonlyMap<string, UserProgress> {
|
||||
@ -277,6 +297,35 @@ class ZoneCheckinService {
|
||||
required: true,
|
||||
where: { name: REPORT_STAFF_ROLES },
|
||||
},
|
||||
{
|
||||
model: db.file,
|
||||
as: 'avatar',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: db.organizations,
|
||||
as: 'organizations',
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'logo'],
|
||||
},
|
||||
{
|
||||
model: db.schools,
|
||||
as: 'school',
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'logo'],
|
||||
},
|
||||
{
|
||||
model: db.campuses,
|
||||
as: 'campus',
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'logo'],
|
||||
},
|
||||
{
|
||||
model: db.classes,
|
||||
as: 'class',
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'logo'],
|
||||
},
|
||||
],
|
||||
order: [
|
||||
['lastName', 'asc'],
|
||||
@ -319,11 +368,15 @@ class ZoneCheckinService {
|
||||
const zone = progress?.value ?? null;
|
||||
const status = zone ? 'complete' : 'pending';
|
||||
const riskLevel = !zone ? 'pending' : zone === 'green' ? 'none' : 'medium';
|
||||
const tenant = tenantOf(user);
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
name: displayNameOf(user),
|
||||
email: user.email,
|
||||
avatar: avatarOf(user),
|
||||
tenantName: tenant.tenantName,
|
||||
tenantLogo: tenant.tenantLogo,
|
||||
role: user.app_role?.name ?? null,
|
||||
date,
|
||||
status,
|
||||
|
||||
@ -44,6 +44,9 @@ API/data access layer:
|
||||
- Attendance links save through `PUT /api/campus_attendance/configs/:campusKey`.
|
||||
- Daily campus summaries load from `GET /api/campus_attendance/summaries`.
|
||||
- Daily campus summaries save through `PUT /api/campus_attendance/summaries/:campusKey/:date`.
|
||||
- Staff attendance records load from `GET /api/staff_attendance/records` when the user has
|
||||
`READ_STAFF_ATTENDANCE_REPORTS`. Campus views group these records by campus and date so student
|
||||
attendance and staff attendance remain separate in history tables and reporting totals.
|
||||
- The page derives its mode from the effective scope, not from the signed-in role label:
|
||||
- campus/class effective scope shows campus-only attendance.
|
||||
- school effective scope aggregates all scoped campus summaries and school staff attendance.
|
||||
@ -61,6 +64,9 @@ API/data access layer:
|
||||
child data show empty inputs. Saving writes valid edited rows back to
|
||||
`campus_attendance_summaries` per campus. Organization and school totals are computed from those
|
||||
campus rows plus staff attendance reports.
|
||||
- Organization and school percentage cards include every scoped child row in the denominator.
|
||||
A child scope without a saved report counts as incomplete instead of being ignored, and staff
|
||||
attendance percentages use the scoped staff count as the minimum denominator.
|
||||
- Aggregate cards follow the tenant hierarchy: organization scope shows school cards, and school
|
||||
scope shows campus cards. Clicking a child card opens
|
||||
`/attendance/details/:level/:tenantId`. The details page shows separate tables for that child
|
||||
@ -74,6 +80,9 @@ API/data access layer:
|
||||
- Campus screens expose the same staff attendance table for campus-bound and class-scoped staff
|
||||
inside the campus.
|
||||
- Staff summary loads from `GET /api/staff_attendance/summary?startDate=today&endDate=today` only when the user has `READ_STAFF_ATTENDANCE_REPORTS`.
|
||||
- Campus screens show combined summary cards with student/staff breakdowns, a single recent
|
||||
attendance history table with Students/Staff/Total rows per date, and print reports with the
|
||||
same combined report structure.
|
||||
- Aggregate views render only campus cards represented by scoped attendance/config rows, because the campus catalog endpoint is not the source of scoped reporting data.
|
||||
- The backend calculates the attendance percentage.
|
||||
- `CampusAttendance.tsx` is a thin composition wrapper.
|
||||
@ -83,7 +92,7 @@ API/data access layer:
|
||||
|
||||
## Verification
|
||||
|
||||
- `frontend/src/business/campus-attendance/selectors.test.ts` covers attendance calculations, scope titles, and combined student/staff summary selectors.
|
||||
- `frontend/src/business/campus-attendance/printReport.test.ts` covers printable report generation.
|
||||
- `frontend/src/business/campus-attendance/selectors.test.ts` covers attendance calculations, scope titles, staff daily summaries, and combined student/staff summary selectors.
|
||||
- `frontend/src/business/campus-attendance/printReport.test.ts` covers printable report generation with separate student, staff, and combined attendance totals.
|
||||
- `frontend/src/business/campus-attendance/printReport.test.ts` covers blocked-popup handling for attendance report printing.
|
||||
- `frontend/src/business/campus-attendance/mappers.test.ts` covers API DTO mapping.
|
||||
|
||||
@ -201,6 +201,7 @@ The active frontend already has:
|
||||
- An `AuthGuard` (`frontend/src/app/AuthGuard.tsx`) gates the shell — unauthenticated users redirect to `/login`; `ModuleRouteGuard` renders the 404 page for a forbidden direct URL; `IndexRedirect` lands each user on the first permission- and scope-accessible module. Scope changes normalize the current module route to the first module available in the new effective scope. The previous in-shell guest-preview experience was removed.
|
||||
- App shell state, access selection, campus display lookup, mobile overlay visibility, shell outlet context, and prepared Sidebar/TopBar/Footer props live under `frontend/src/business/app-shell/`. The shared shell layout remains a thin view composition in `frontend/src/components/AppLayout.tsx`.
|
||||
- Top bar shell state under `frontend/src/business/top-bar/`, with search, badges, notifications, and profile menu composition split under `frontend/src/components/top-bar/`.
|
||||
- Tenant branding logos for organizations, schools, campuses, and classes are uploaded through the shared file subsystem and rendered through `frontend/src/components/common/TenantLogo.tsx` in scope selectors, tenant lists, user lists, and leader dashboard staff reports. Views should not render stored tenant logo values with raw `<img>` tags because private file URLs need the shared file URL resolver.
|
||||
- FRAME entries under `frontend/src/business/frame/`, with typed API calls in `frontend/src/shared/api/frame.ts` and explicit empty/error states in the view.
|
||||
- Current-user progress under `frontend/src/business/user-progress/`, with typed API calls in `frontend/src/shared/api/userProgress.ts` for learned signs and zone check-ins.
|
||||
- Safety quiz results under `frontend/src/business/safety-quiz/`, with typed API calls in `frontend/src/shared/api/safetyQuizResults.ts`.
|
||||
|
||||
@ -200,6 +200,7 @@ export function useAppShell(options: UseAppShellOptions): AppShellState {
|
||||
user: options.user,
|
||||
userRole,
|
||||
userName,
|
||||
avatar: options.user?.avatar ?? null,
|
||||
campusInfo,
|
||||
toggleSidebar,
|
||||
setCurrentModule,
|
||||
|
||||
@ -31,7 +31,10 @@ import {
|
||||
getWeekEnd,
|
||||
getWeekStart,
|
||||
} from '@/business/campus-attendance/selectors';
|
||||
import { useStaffAttendanceSummary } from '@/business/staff-attendance/hooks';
|
||||
import {
|
||||
useStaffAttendanceRecords,
|
||||
useStaffAttendanceSummary,
|
||||
} from '@/business/staff-attendance/hooks';
|
||||
import { saveStaffAttendanceRecord } from '@/shared/api/staffAttendance';
|
||||
import {
|
||||
CAMPUS_ATTENDANCE_PRINT_POPUP_BLOCKED_MESSAGE,
|
||||
@ -57,9 +60,11 @@ import type { TenantChild, TenantLevel } from '@/shared/types/scope';
|
||||
import { listUsers, type AdminUserRow } from '@/shared/api/users';
|
||||
import { STAFF_ATTENDANCE_QUERY_KEYS } from '@/shared/constants/staffAttendance';
|
||||
import { getClass } from '@/shared/api/classes';
|
||||
import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types';
|
||||
|
||||
const EMPTY_CONFIGS: ReturnType<typeof toCampusAttendanceConfigViewModel>[] = [];
|
||||
const EMPTY_SUMMARIES: ReturnType<typeof toCampusAttendanceSummaryViewModel>[] = [];
|
||||
const EMPTY_STAFF_RECORDS: readonly StaffAttendanceRecordViewModel[] = [];
|
||||
const EMPTY_CAMPUSES: CampusInfo[] = [];
|
||||
const EMPTY_TENANT_CHILDREN: TenantChild[] = [];
|
||||
const EMPTY_CAMPUS_CHILDREN_BY_PARENT: Readonly<Record<string, readonly TenantChild[]>> = {};
|
||||
@ -297,6 +302,14 @@ function percentageFromRecords(
|
||||
return enrolled > 0 ? Number(((present / enrolled) * 100).toFixed(2)) : null;
|
||||
}
|
||||
|
||||
function percentageFromStaffDailySummaries(
|
||||
records: readonly { readonly total_staff: number; readonly total_present: number }[],
|
||||
): number | null {
|
||||
const staff = records.reduce((sum, record) => sum + record.total_staff, 0);
|
||||
const present = records.reduce((sum, record) => sum + record.total_present, 0);
|
||||
return staff > 0 ? Number(((present / staff) * 100).toFixed(2)) : null;
|
||||
}
|
||||
|
||||
export function useCampusAttendancePage({
|
||||
userRole,
|
||||
userCampus,
|
||||
@ -366,6 +379,10 @@ export function useCampusAttendancePage({
|
||||
{ startDate: today, endDate: today },
|
||||
roleAccess.canReadStaffReports && hasAttendanceScope,
|
||||
);
|
||||
const staffRecordsQuery = useStaffAttendanceRecords(
|
||||
{ limit: 500 },
|
||||
roleAccess.canReadStaffReports && hasAttendanceScope,
|
||||
);
|
||||
const officeStaffQuery = useQuery({
|
||||
queryKey: ['attendance-office-staff-users', effectiveTier, effectiveTenant?.id ?? null],
|
||||
enabled: roleAccess.canEnterData && hasAttendanceScope,
|
||||
@ -378,6 +395,7 @@ export function useCampusAttendancePage({
|
||||
const configs = configsQuery.data ?? EMPTY_CONFIGS;
|
||||
const attendanceData = summariesQuery.data ?? EMPTY_SUMMARIES;
|
||||
const staffSummary = staffSummaryQuery.data ?? null;
|
||||
const staffRecords = staffRecordsQuery.data ?? EMPTY_STAFF_RECORDS;
|
||||
const officeStaffUsers = useMemo(() => (
|
||||
(officeStaffQuery.data?.rows ?? [])
|
||||
.filter((user) => isOfficeStaffUser(user, scopeModel.mode) && isStaffRosterUser(user))
|
||||
@ -402,6 +420,7 @@ export function useCampusAttendancePage({
|
||||
|| configsQuery.isLoading
|
||||
|| summariesQuery.isLoading
|
||||
|| (roleAccess.canReadStaffReports && staffSummaryQuery.isLoading)
|
||||
|| (roleAccess.canReadStaffReports && staffRecordsQuery.isLoading)
|
||||
|| (roleAccess.canEnterData && officeStaffQuery.isLoading)
|
||||
|| (roleAccess.canSeeAllCampuses && scopedAttendanceChildrenQuery.isLoading);
|
||||
const saving = saveConfigMutation.isPending || saveSummaryMutation.isPending || saveStaffAttendanceMutation.isPending;
|
||||
@ -410,6 +429,7 @@ export function useCampusAttendancePage({
|
||||
?? configsQuery.error
|
||||
?? summariesQuery.error
|
||||
?? (roleAccess.canReadStaffReports ? staffSummaryQuery.error : null)
|
||||
?? (roleAccess.canReadStaffReports ? staffRecordsQuery.error : null)
|
||||
?? (roleAccess.canEnterData ? officeStaffQuery.error : null)
|
||||
?? (roleAccess.canSeeAllCampuses ? scopedAttendanceChildrenQuery.error : null);
|
||||
const saveError = saveConfigMutation.error ?? saveSummaryMutation.error ?? saveStaffAttendanceMutation.error;
|
||||
@ -429,8 +449,8 @@ export function useCampusAttendancePage({
|
||||
const [studentRollupOverrides, setStudentRollupOverrides] = useState<StudentRollupOverrideMap>({});
|
||||
|
||||
const campusStats = useMemo(
|
||||
() => buildCampusAttendanceStats(campusCatalog.campuses, configs, attendanceData, today, weekStart, weekEnd),
|
||||
[attendanceData, campusCatalog.campuses, configs, today, weekEnd, weekStart],
|
||||
() => buildCampusAttendanceStats(campusCatalog.campuses, configs, attendanceData, today, weekStart, weekEnd, staffRecords),
|
||||
[attendanceData, campusCatalog.campuses, configs, staffRecords, today, weekEnd, weekStart],
|
||||
);
|
||||
const visibleCampusStats = useMemo(() => {
|
||||
if (scopeModel.mode === 'campus') {
|
||||
@ -461,6 +481,8 @@ export function useCampusAttendancePage({
|
||||
recentData: campus.recentData,
|
||||
todayRecord: campus.todayRecord,
|
||||
childCampusIds: [campus.id],
|
||||
recentStaffData: campus.recentStaffData,
|
||||
todayStaffRecord: campus.todayStaffRecord,
|
||||
}));
|
||||
}
|
||||
|
||||
@ -482,6 +504,8 @@ export function useCampusAttendancePage({
|
||||
recentData: campus?.recentData ?? [],
|
||||
todayRecord: campus?.todayRecord ?? null,
|
||||
childCampusIds: campus ? [campus.id] : [],
|
||||
recentStaffData: campus?.recentStaffData ?? [],
|
||||
todayStaffRecord: campus?.todayStaffRecord ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -501,6 +525,23 @@ export function useCampusAttendancePage({
|
||||
&& record.date >= weekStart
|
||||
&& record.date <= weekEnd
|
||||
));
|
||||
const childStaffRecords = campusStats
|
||||
.filter((campus) => childCampusIds.includes(campus.id))
|
||||
.flatMap((campus) => campus.recentStaffData);
|
||||
const childTodayStaffRecords = childStaffRecords.filter((record) => record.date === today);
|
||||
const todayStaffRecord = childTodayStaffRecords.length > 0
|
||||
? {
|
||||
id: `school-staff:${child.id}:${today}`,
|
||||
campus_id: null,
|
||||
date: today,
|
||||
total_staff: childTodayStaffRecords.reduce((sum, record) => sum + record.total_staff, 0),
|
||||
total_present: childTodayStaffRecords.reduce((sum, record) => sum + record.total_present, 0),
|
||||
total_absent: childTodayStaffRecords.reduce((sum, record) => sum + record.total_absent, 0),
|
||||
total_late: childTodayStaffRecords.reduce((sum, record) => sum + record.total_late, 0),
|
||||
attendance_percentage: percentageFromStaffDailySummaries(childTodayStaffRecords) ?? 0,
|
||||
notes: null,
|
||||
}
|
||||
: null;
|
||||
const weekDays = Array.from(new Set(childWeekRecords.map((record) => record.date)));
|
||||
const weekAvg = weekDays.length > 0
|
||||
? Number((weekDays.reduce((sum, day) => (
|
||||
@ -519,6 +560,8 @@ export function useCampusAttendancePage({
|
||||
recentData: childWeekRecords.slice(0, 10),
|
||||
todayRecord: null,
|
||||
childCampusIds,
|
||||
recentStaffData: childStaffRecords.slice(0, 10),
|
||||
todayStaffRecord,
|
||||
};
|
||||
});
|
||||
}, [
|
||||
@ -538,11 +581,17 @@ export function useCampusAttendancePage({
|
||||
[attendanceData, today, weekEnd, weekStart],
|
||||
);
|
||||
const combinedStats = useMemo(
|
||||
() => buildCombinedAttendanceStats(overallStats, staffSummary),
|
||||
[overallStats, staffSummary],
|
||||
() => buildCombinedAttendanceStats(
|
||||
overallStats,
|
||||
staffSummary,
|
||||
scopeModel.mode === 'campus' ? undefined : attendanceChildStats,
|
||||
),
|
||||
[attendanceChildStats, overallStats, scopeModel.mode, staffSummary],
|
||||
);
|
||||
const myCampusConfig = attendanceCampusId ? configs.find((config) => config.campus_id === attendanceCampusId) : undefined;
|
||||
const myCampusData = attendanceCampusId ? attendanceData.filter((record) => record.campus_id === attendanceCampusId) : [];
|
||||
const myCampusStats = attendanceCampusId ? visibleCampusStats.find((campus) => campus.id === attendanceCampusId) : undefined;
|
||||
const myStaffData = myCampusStats?.recentStaffData ?? [];
|
||||
const myTodayPct = attendanceCampusId ? getTodayPercentage(attendanceData, attendanceCampusId, today) : null;
|
||||
const myWeekAvg = attendanceCampusId ? getWeeklyAverage(attendanceData, attendanceCampusId, weekStart, weekEnd) : null;
|
||||
const selectedEntryCampus = scopedCampusOptions.find((campus) => campus.id === entryDraft.campusId);
|
||||
@ -742,7 +791,10 @@ export function useCampusAttendancePage({
|
||||
return true;
|
||||
};
|
||||
|
||||
const saveStaffBatchAttendance = async (requireStaff: boolean): Promise<boolean> => {
|
||||
const saveStaffBatchAttendance = async (
|
||||
requireStaff: boolean,
|
||||
input: Pick<StaffAttendanceEntryDraft, 'date' | 'note'> = staffEntryDraft,
|
||||
): Promise<boolean> => {
|
||||
if (officeStaffUsers.length === 0) {
|
||||
if (requireStaff) {
|
||||
setStaffEntryError('No office staff are available for this scope.');
|
||||
@ -752,10 +804,10 @@ export function useCampusAttendancePage({
|
||||
|
||||
for (const staffUser of officeStaffUsers) {
|
||||
await saveStaffAttendanceMutation.mutateAsync({
|
||||
date: staffEntryDraft.date,
|
||||
date: input.date,
|
||||
userId: staffUser.id,
|
||||
status: staffAttendanceStatuses[staffUser.id] ?? 'present',
|
||||
note: staffEntryDraft.note,
|
||||
note: input.note,
|
||||
});
|
||||
}
|
||||
|
||||
@ -807,7 +859,10 @@ export function useCampusAttendancePage({
|
||||
return;
|
||||
}
|
||||
|
||||
const staffSaved = await saveStaffBatchAttendance(false);
|
||||
const staffSaved = await saveStaffBatchAttendance(false, {
|
||||
date: entryDraft.date,
|
||||
note: staffEntryDraft.note,
|
||||
});
|
||||
if (!staffSaved) {
|
||||
return;
|
||||
}
|
||||
@ -840,6 +895,7 @@ export function useCampusAttendancePage({
|
||||
campusesToPrint,
|
||||
printTodayRecords,
|
||||
printWeekRecords,
|
||||
staffSummary,
|
||||
},
|
||||
});
|
||||
|
||||
@ -862,6 +918,7 @@ export function useCampusAttendancePage({
|
||||
weekEnd,
|
||||
configs,
|
||||
attendanceData,
|
||||
staffRecords,
|
||||
loading,
|
||||
saving,
|
||||
errorMessage,
|
||||
@ -892,6 +949,7 @@ export function useCampusAttendancePage({
|
||||
},
|
||||
myCampusConfig,
|
||||
myCampusData,
|
||||
myStaffData,
|
||||
myTodayPct,
|
||||
myWeekAvg,
|
||||
userCampus,
|
||||
|
||||
@ -31,6 +31,15 @@ describe('campus attendance print report', () => {
|
||||
expect(html).toContain(CAMPUS_ATTENDANCE_TEST_SEED.todayNotesEscaped);
|
||||
expect(html).toContain('<div class="value green">90%</div>');
|
||||
expect(html).toContain('<div class="value amber">85%</div>');
|
||||
expect(html).toContain('Students 90% · Staff 90%');
|
||||
expect(html).toContain('<div class="label">People</div>');
|
||||
expect(html).toContain('<div class="value">60</div>');
|
||||
expect(html).toContain("Today's report");
|
||||
expect(html).toContain('Attendance history');
|
||||
expect(html).toContain('<td>Students</td>');
|
||||
expect(html).toContain('<td>Staff</td>');
|
||||
expect(html).toContain('<td>Total</td>');
|
||||
expect(html).toContain('<td class="pct-good">90.0%</td>');
|
||||
expect(html).toContain('<td class="pct-warn">80.0%</td>');
|
||||
});
|
||||
|
||||
@ -44,10 +53,13 @@ describe('campus attendance print report', () => {
|
||||
weekAvg: null,
|
||||
recentData: [],
|
||||
todayRecord: null,
|
||||
recentStaffData: [],
|
||||
todayStaffRecord: null,
|
||||
},
|
||||
],
|
||||
printTodayRecords: [],
|
||||
printWeekRecords: [],
|
||||
staffSummary: null,
|
||||
});
|
||||
|
||||
expect(html).toContain('<div class="value ">No data</div>');
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
import { buildPrintAttendanceStats, formatAttendanceDate } from '@/business/campus-attendance/selectors';
|
||||
import {
|
||||
buildCombinedAttendanceHistoryRows,
|
||||
buildPrintAttendanceStats,
|
||||
formatAttendanceDate,
|
||||
} from '@/business/campus-attendance/selectors';
|
||||
import { PRINT_DIALOG_OPEN_DELAY_MS } from '@/shared/constants/ui';
|
||||
import type { CampusAttendancePrintInput } from '@/business/campus-attendance/types';
|
||||
import type {
|
||||
CampusAttendanceStats,
|
||||
CampusAttendancePrintInput,
|
||||
CombinedAttendanceHistoryRow,
|
||||
} from '@/business/campus-attendance/types';
|
||||
|
||||
const escapeHtml = (value: string): string => (
|
||||
value
|
||||
@ -43,6 +51,102 @@ const inlinePercentageClass = (percentage: number | null): string => {
|
||||
return 'pct-bad';
|
||||
};
|
||||
|
||||
function buildTodayReportRows(campus: CampusAttendanceStats): readonly CombinedAttendanceHistoryRow[] {
|
||||
const date = campus.todayRecord?.date ?? campus.todayStaffRecord?.date ?? '';
|
||||
const studentRecord = campus.todayRecord;
|
||||
const staffRecord = campus.todayStaffRecord;
|
||||
const studentTotal = studentRecord?.total_enrolled ?? 0;
|
||||
const staffTotal = staffRecord?.total_staff ?? 0;
|
||||
const studentPresent = studentRecord?.total_present ?? 0;
|
||||
const staffPresent = staffRecord?.total_present ?? 0;
|
||||
const studentAbsent = studentRecord?.total_absent ?? 0;
|
||||
const staffAbsent = staffRecord?.total_absent ?? 0;
|
||||
const studentLate = studentRecord?.total_tardy ?? 0;
|
||||
const staffLate = staffRecord?.total_late ?? 0;
|
||||
const combinedTotal = studentTotal + staffTotal;
|
||||
const combinedPresent = studentPresent + staffPresent;
|
||||
const combinedPercentage = combinedTotal > 0
|
||||
? Number(((combinedPresent / combinedTotal) * 100).toFixed(2))
|
||||
: null;
|
||||
const notes = [studentRecord?.notes, staffRecord?.notes].filter((note): note is string => Boolean(note));
|
||||
|
||||
return [
|
||||
{
|
||||
id: `${campus.id}:today:students`,
|
||||
date,
|
||||
group: 'students',
|
||||
label: 'Students',
|
||||
total: studentTotal,
|
||||
present: studentPresent,
|
||||
absent: studentAbsent,
|
||||
late: studentLate,
|
||||
attendancePercentage: studentRecord?.attendance_percentage ?? null,
|
||||
notes: studentRecord?.notes ?? null,
|
||||
},
|
||||
{
|
||||
id: `${campus.id}:today:staff`,
|
||||
date,
|
||||
group: 'staff',
|
||||
label: 'Staff',
|
||||
total: staffTotal,
|
||||
present: staffPresent,
|
||||
absent: staffAbsent,
|
||||
late: staffLate,
|
||||
attendancePercentage: staffRecord?.attendance_percentage ?? null,
|
||||
notes: staffRecord?.notes ?? null,
|
||||
},
|
||||
{
|
||||
id: `${campus.id}:today:total`,
|
||||
date,
|
||||
group: 'total',
|
||||
label: 'Total',
|
||||
total: combinedTotal,
|
||||
present: combinedPresent,
|
||||
absent: studentAbsent + staffAbsent,
|
||||
late: studentLate + staffLate,
|
||||
attendancePercentage: combinedPercentage,
|
||||
notes: notes.length > 0 ? notes.join('; ') : null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function renderAttendanceRows(rows: readonly CombinedAttendanceHistoryRow[]): string {
|
||||
return rows.map((record) => `
|
||||
<tr>
|
||||
<td>${record.group === 'students' ? formatAttendanceDate(record.date) : ''}</td>
|
||||
<td>${record.label}</td>
|
||||
<td>${record.total}</td>
|
||||
<td>${record.present}</td>
|
||||
<td>${record.absent}</td>
|
||||
<td>${record.late}</td>
|
||||
<td class="${inlinePercentageClass(record.attendancePercentage)}">${record.attendancePercentage !== null ? `${record.attendancePercentage.toFixed(1)}%` : 'N/A'}</td>
|
||||
<td>${record.notes ? escapeHtml(record.notes) : '-'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderAttendanceHistoryTable(campus: CampusAttendanceStats): string {
|
||||
const historyRows = buildCombinedAttendanceHistoryRows(campus.recentData, campus.recentStaffData);
|
||||
|
||||
if (historyRows.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="section-label">Attendance history</div>
|
||||
<table class="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th><th>Group</th><th>Total</th><th>Present</th><th>Absent</th><th>Tardy / Late</th><th>Attendance %</th><th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${renderAttendanceRows(historyRows)}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
export type CampusAttendancePrintResult =
|
||||
| { readonly ok: true }
|
||||
| { readonly ok: false; readonly reason: 'popup-blocked' };
|
||||
@ -87,6 +191,9 @@ export function openCampusAttendancePrintReport({
|
||||
|
||||
export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput): string {
|
||||
const printStats = buildPrintAttendanceStats(input);
|
||||
const staffTotal = input.staffSummary
|
||||
? input.staffSummary.present + input.staffSummary.late + input.staffSummary.absent
|
||||
: 0;
|
||||
const generatedAtDate = new Date().toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
@ -109,7 +216,7 @@ export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput
|
||||
.header { text-align: center; margin-bottom: 32px; border-bottom: 3px solid #7c3aed; padding-bottom: 16px; }
|
||||
.header h1 { font-size: 24px; color: #7c3aed; margin-bottom: 4px; }
|
||||
.header p { font-size: 13px; color: #64748b; }
|
||||
.summary-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 32px; }
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 32px; }
|
||||
.summary-box { border: 2px solid #e2e8f0; border-radius: 12px; padding: 16px; text-align: center; }
|
||||
.summary-box .label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
|
||||
.summary-box .value { font-size: 28px; font-weight: 700; }
|
||||
@ -131,6 +238,7 @@ export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput
|
||||
.history-table { margin-top: 16px; }
|
||||
.history-table th { font-size: 11px; }
|
||||
.history-table td { font-size: 11px; }
|
||||
.section-label { font-size: 12px; font-weight: 700; color: #334155; margin: 12px 0 6px; }
|
||||
.footer { margin-top: 32px; padding-top: 16px; border-top: 2px solid #e2e8f0; text-align: center; font-size: 11px; color: #94a3b8; }
|
||||
@media print { body { padding: 16px; } .no-print { display: none; } }
|
||||
</style>
|
||||
@ -144,11 +252,16 @@ export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput
|
||||
<div class="summary-grid">
|
||||
<div class="summary-box">
|
||||
<div class="label">Today's Attendance</div>
|
||||
<div class="value ${percentageClass(printStats.todayPct)}">${printStats.todayPct !== null ? `${printStats.todayPct}%` : 'No data'}</div>
|
||||
<div class="label" style="margin-top:4px">${formatAttendanceDate(input.today)}</div>
|
||||
<div class="value ${percentageClass(printStats.combinedPct)}">${printStats.combinedPct !== null ? `${printStats.combinedPct}%` : 'No data'}</div>
|
||||
<div class="label" style="margin-top:4px">Students ${printStats.todayPct !== null ? `${printStats.todayPct}%` : 'N/A'} · Staff ${printStats.staffPct !== null ? `${printStats.staffPct}%` : 'N/A'}</div>
|
||||
</div>
|
||||
<div class="summary-box">
|
||||
<div class="label">This Week's Average Attendance</div>
|
||||
<div class="label">People</div>
|
||||
<div class="value">${input.printTodayRecords.reduce((sum, record) => sum + record.total_enrolled, 0) + staffTotal}</div>
|
||||
<div class="label" style="margin-top:4px">Students ${input.printTodayRecords.reduce((sum, record) => sum + record.total_enrolled, 0)} · Staff ${staffTotal}</div>
|
||||
</div>
|
||||
<div class="summary-box">
|
||||
<div class="label">This Week's Student Average</div>
|
||||
<div class="value ${percentageClass(printStats.weekPct)}">${printStats.weekPct !== null ? `${printStats.weekPct}%` : 'No data'}</div>
|
||||
<div class="label" style="margin-top:4px">Week of ${formatAttendanceDate(input.weekStart)}</div>
|
||||
</div>
|
||||
@ -162,35 +275,20 @@ export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput
|
||||
<span>Week Avg: <span class="${inlinePercentageClass(campus.weekAvg)}">${campus.weekAvg !== null ? `${campus.weekAvg}%` : 'N/A'}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
${campus.todayRecord ? `
|
||||
<table>
|
||||
<tr><td style="width:25%;font-weight:600">Enrolled</td><td>${campus.todayRecord.total_enrolled}</td><td style="width:25%;font-weight:600">Present</td><td>${campus.todayRecord.total_present}</td></tr>
|
||||
<tr><td style="font-weight:600">Absent</td><td>${campus.todayRecord.total_absent}</td><td style="font-weight:600">Tardy</td><td>${campus.todayRecord.total_tardy}</td></tr>
|
||||
${campus.todayRecord.notes ? `<tr><td style="font-weight:600">Notes</td><td colspan="3">${escapeHtml(campus.todayRecord.notes)}</td></tr>` : ''}
|
||||
</table>
|
||||
` : '<p style="font-size:12px;color:#94a3b8;padding:8px 0;">No attendance data recorded for today.</p>'}
|
||||
${campus.recentData.length > 0 ? `
|
||||
${campus.todayRecord || campus.todayStaffRecord ? `
|
||||
<div class="section-label">Today's report</div>
|
||||
<table class="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th><th>Enrolled</th><th>Present</th><th>Absent</th><th>Tardy</th><th>Attendance %</th><th>Notes</th>
|
||||
<th>Date</th><th>Group</th><th>Total</th><th>Present</th><th>Absent</th><th>Tardy / Late</th><th>Attendance %</th><th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${campus.recentData.map((record) => `
|
||||
<tr>
|
||||
<td>${formatAttendanceDate(record.date)}</td>
|
||||
<td>${record.total_enrolled}</td>
|
||||
<td>${record.total_present}</td>
|
||||
<td>${record.total_absent}</td>
|
||||
<td>${record.total_tardy}</td>
|
||||
<td class="${inlinePercentageClass(record.attendance_percentage)}">${record.attendance_percentage.toFixed(1)}%</td>
|
||||
<td>${record.notes ? escapeHtml(record.notes) : '-'}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
${renderAttendanceRows(buildTodayReportRows(campus))}
|
||||
</tbody>
|
||||
</table>
|
||||
` : ''}
|
||||
` : '<p style="font-size:12px;color:#94a3b8;padding:8px 0;">No attendance data recorded for today.</p>'}
|
||||
${renderAttendanceHistoryTable(campus)}
|
||||
</div>
|
||||
`).join('')}
|
||||
<div class="footer">
|
||||
|
||||
@ -2,15 +2,21 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildAttendanceEntryInput,
|
||||
buildCampusAttendanceScopeModel,
|
||||
buildCampusAttendanceStats,
|
||||
buildCombinedAttendanceHistoryRows,
|
||||
buildCombinedAttendanceStats,
|
||||
buildOverallAttendanceStats,
|
||||
buildStaffAttendanceDailySummaries,
|
||||
getWeekEnd,
|
||||
getWeekStart,
|
||||
} from '@/business/campus-attendance/selectors';
|
||||
import type {
|
||||
CampusAttendanceChildStats,
|
||||
CampusAttendanceEntryDraft,
|
||||
CampusAttendanceSummaryViewModel,
|
||||
} from '@/business/campus-attendance/types';
|
||||
import type { CampusInfo } from '@/shared/types/app';
|
||||
import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types';
|
||||
|
||||
const summaries: readonly CampusAttendanceSummaryViewModel[] = [
|
||||
{
|
||||
@ -51,6 +57,55 @@ const summaries: readonly CampusAttendanceSummaryViewModel[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const staffRecords: readonly StaffAttendanceRecordViewModel[] = [
|
||||
{
|
||||
id: 'staff-1',
|
||||
userId: 'user-1',
|
||||
date: '2026-06-08',
|
||||
status: 'present',
|
||||
note: null,
|
||||
userName: 'Sarah Williams',
|
||||
userRole: 'Director',
|
||||
campusId: 'tenant-tigers',
|
||||
},
|
||||
{
|
||||
id: 'staff-2',
|
||||
userId: 'user-2',
|
||||
date: '2026-06-08',
|
||||
status: 'late',
|
||||
note: 'Traffic',
|
||||
userName: 'Lisa Park',
|
||||
userRole: 'Office Manager',
|
||||
campusId: 'tenant-tigers',
|
||||
},
|
||||
{
|
||||
id: 'staff-3',
|
||||
userId: 'user-3',
|
||||
date: '2026-06-08',
|
||||
status: 'absent',
|
||||
note: null,
|
||||
userName: 'Marcus Davis',
|
||||
userRole: 'Support Staff',
|
||||
campusId: 'tenant-tigers',
|
||||
},
|
||||
];
|
||||
|
||||
const campusInfo: readonly CampusInfo[] = [
|
||||
{
|
||||
id: 'tigers',
|
||||
tenantId: 'tenant-tigers',
|
||||
mascot: 'Tigers',
|
||||
fullName: 'Tigers Campus',
|
||||
color: 'bg-orange-500',
|
||||
bgGradient: 'from-orange-500 to-amber-500',
|
||||
borderColor: 'border-orange-500/30',
|
||||
textColor: 'text-orange-400',
|
||||
bgLight: 'bg-orange-500/10',
|
||||
description: 'Tigers campus',
|
||||
isOnline: true,
|
||||
},
|
||||
];
|
||||
|
||||
describe('campus attendance selectors', () => {
|
||||
it('calculates Monday and Friday boundaries for a midweek date', () => {
|
||||
const date = new Date('2026-06-10T12:00:00Z');
|
||||
@ -171,8 +226,127 @@ describe('campus attendance selectors', () => {
|
||||
).toEqual({
|
||||
studentTodayPct: 86.67,
|
||||
studentWeekPct: 90.83,
|
||||
staffTodayPct: 90,
|
||||
combinedTodayPct: 86.88,
|
||||
staffTodayPct: 75,
|
||||
combinedTodayPct: 85.8,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts missing child attendance reports as incomplete in aggregate percentages', () => {
|
||||
const overallStats = buildOverallAttendanceStats(summaries, '2026-06-08', '2026-06-08', '2026-06-12');
|
||||
const childStats: readonly CampusAttendanceChildStats[] = [
|
||||
{
|
||||
id: 'school-1',
|
||||
level: 'school',
|
||||
mascot: 'North',
|
||||
fullName: 'Demo Academy North',
|
||||
bgGradient: 'from-emerald-500 to-green-500',
|
||||
todayPct: 100,
|
||||
weekAvg: 100,
|
||||
recentData: [],
|
||||
todayRecord: null,
|
||||
childCampusIds: ['tigers'],
|
||||
recentStaffData: [],
|
||||
todayStaffRecord: null,
|
||||
},
|
||||
{
|
||||
id: 'school-2',
|
||||
level: 'school',
|
||||
mascot: 'South',
|
||||
fullName: 'Demo Academy South',
|
||||
bgGradient: 'from-orange-600 to-amber-500',
|
||||
todayPct: null,
|
||||
weekAvg: null,
|
||||
recentData: [],
|
||||
todayRecord: null,
|
||||
childCampusIds: ['gators'],
|
||||
recentStaffData: [],
|
||||
todayStaffRecord: null,
|
||||
},
|
||||
];
|
||||
|
||||
expect(buildCombinedAttendanceStats(overallStats, null, childStats)).toEqual({
|
||||
studentTodayPct: 50,
|
||||
studentWeekPct: 50,
|
||||
staffTodayPct: null,
|
||||
combinedTodayPct: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds daily staff attendance summaries by campus and date', () => {
|
||||
expect(buildStaffAttendanceDailySummaries(staffRecords)).toEqual([
|
||||
{
|
||||
id: 'staff:tenant-tigers:2026-06-08',
|
||||
campus_id: 'tenant-tigers',
|
||||
date: '2026-06-08',
|
||||
total_staff: 3,
|
||||
total_present: 2,
|
||||
total_absent: 1,
|
||||
total_late: 1,
|
||||
attendance_percentage: 66.67,
|
||||
notes: 'Traffic',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('attaches staff attendance history to campus attendance stats', () => {
|
||||
const [campus] = buildCampusAttendanceStats(
|
||||
campusInfo,
|
||||
[],
|
||||
summaries,
|
||||
'2026-06-08',
|
||||
'2026-06-08',
|
||||
'2026-06-12',
|
||||
staffRecords,
|
||||
);
|
||||
|
||||
expect(campus?.todayRecord?.total_enrolled).toBe(100);
|
||||
expect(campus?.todayStaffRecord).toMatchObject({
|
||||
total_staff: 3,
|
||||
total_present: 2,
|
||||
total_absent: 1,
|
||||
attendance_percentage: 66.67,
|
||||
});
|
||||
expect(campus?.recentStaffData).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('builds combined attendance history with students, staff, and total rows per date', () => {
|
||||
expect(buildCombinedAttendanceHistoryRows([summaries[0]], buildStaffAttendanceDailySummaries(staffRecords))).toEqual([
|
||||
{
|
||||
id: '2026-06-08:students',
|
||||
date: '2026-06-08',
|
||||
group: 'students',
|
||||
label: 'Students',
|
||||
total: 100,
|
||||
present: 90,
|
||||
absent: 10,
|
||||
late: 3,
|
||||
attendancePercentage: 90,
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: '2026-06-08:staff',
|
||||
date: '2026-06-08',
|
||||
group: 'staff',
|
||||
label: 'Staff',
|
||||
total: 3,
|
||||
present: 2,
|
||||
absent: 1,
|
||||
late: 1,
|
||||
attendancePercentage: 66.67,
|
||||
notes: 'Traffic',
|
||||
},
|
||||
{
|
||||
id: '2026-06-08:total',
|
||||
date: '2026-06-08',
|
||||
group: 'total',
|
||||
label: 'Total',
|
||||
total: 103,
|
||||
present: 92,
|
||||
absent: 11,
|
||||
late: 4,
|
||||
attendancePercentage: 89.32,
|
||||
notes: 'Traffic',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,15 +4,21 @@ import type { ActiveTenant } from '@/shared/types/scope';
|
||||
import type {
|
||||
AttendanceScopeMode,
|
||||
CampusAttendanceCombinedStats,
|
||||
CampusAttendanceChildStats,
|
||||
CampusAttendanceEntryDraft,
|
||||
CampusAttendanceConfigViewModel,
|
||||
CombinedAttendanceHistoryRow,
|
||||
CampusAttendanceOverallStats,
|
||||
CampusAttendancePrintInput,
|
||||
CampusAttendanceScopeModel,
|
||||
CampusAttendanceStats,
|
||||
CampusAttendanceSummaryViewModel,
|
||||
StaffAttendanceDailySummaryViewModel,
|
||||
} from '@/business/campus-attendance/types';
|
||||
import type { StaffAttendanceSummaryViewModel } from '@/business/staff-attendance/types';
|
||||
import type {
|
||||
StaffAttendanceRecordViewModel,
|
||||
StaffAttendanceSummaryViewModel,
|
||||
} from '@/business/staff-attendance/types';
|
||||
|
||||
export function getWeekStart(date: Date): string {
|
||||
const weekStart = new Date(date);
|
||||
@ -132,13 +138,20 @@ export function buildCampusAttendanceStats(
|
||||
today: string,
|
||||
weekStart: string,
|
||||
weekEnd: string,
|
||||
staffRecords: readonly StaffAttendanceRecordViewModel[] = [],
|
||||
): readonly CampusAttendanceStats[] {
|
||||
const staffDailySummaries = buildStaffAttendanceDailySummaries(staffRecords);
|
||||
|
||||
return campuses.map((campus) => {
|
||||
const todayPct = getTodayPercentage(summaries, campus.id, today);
|
||||
const weekAvg = getWeeklyAverage(summaries, campus.id, weekStart, weekEnd);
|
||||
const config = configs.find((item) => item.campus_id === campus.id) ?? null;
|
||||
const recentData = summaries.filter((record) => record.campus_id === campus.id).slice(0, 10);
|
||||
const todayRecord = getTodayData(summaries, campus.id, today)[0] ?? null;
|
||||
const recentStaffData = staffDailySummaries
|
||||
.filter((record) => isStaffSummaryForCampus(record, campus))
|
||||
.slice(0, 10);
|
||||
const todayStaffRecord = recentStaffData.find((record) => record.date === today) ?? null;
|
||||
|
||||
return {
|
||||
...campus,
|
||||
@ -147,6 +160,8 @@ export function buildCampusAttendanceStats(
|
||||
config,
|
||||
recentData,
|
||||
todayRecord,
|
||||
recentStaffData,
|
||||
todayStaffRecord,
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -261,29 +276,198 @@ function percentageFromCounts(present: number, total: number): number | null {
|
||||
return total > 0 ? Number(((present / total) * 100).toFixed(2)) : null;
|
||||
}
|
||||
|
||||
function percentageFromScopedChildren(
|
||||
childStats: readonly CampusAttendanceChildStats[] | undefined,
|
||||
getPercentage: (child: CampusAttendanceChildStats) => number | null,
|
||||
): number | null {
|
||||
if (!childStats || childStats.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const total = childStats.length;
|
||||
const presentEquivalent = childStats.reduce((sum, child) => sum + ((getPercentage(child) ?? 0) / 100), 0);
|
||||
return percentageFromCounts(presentEquivalent, total);
|
||||
}
|
||||
|
||||
function staffAttendancePercentage(present: number, late: number, total: number): number {
|
||||
return percentageFromCounts(present + late, total) ?? 0;
|
||||
}
|
||||
|
||||
function staffDailySummaryId(campusId: CampusId | null, date: string): string {
|
||||
return `staff:${campusId ?? 'office'}:${date}`;
|
||||
}
|
||||
|
||||
function isStaffSummaryForCampus(
|
||||
record: StaffAttendanceDailySummaryViewModel,
|
||||
campus: CampusInfo,
|
||||
): boolean {
|
||||
return record.campus_id === campus.id || record.campus_id === campus.tenantId;
|
||||
}
|
||||
|
||||
export function buildStaffAttendanceDailySummaries(
|
||||
records: readonly StaffAttendanceRecordViewModel[],
|
||||
): readonly StaffAttendanceDailySummaryViewModel[] {
|
||||
const grouped = new Map<string, StaffAttendanceRecordViewModel[]>();
|
||||
|
||||
for (const record of records) {
|
||||
const key = staffDailySummaryId(record.campusId ?? null, record.date);
|
||||
const group = grouped.get(key);
|
||||
|
||||
if (group) {
|
||||
group.push(record);
|
||||
} else {
|
||||
grouped.set(key, [record]);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(grouped.values())
|
||||
.map((group) => {
|
||||
const firstRecord = group[0];
|
||||
const campusId = firstRecord?.campusId ?? null;
|
||||
const date = firstRecord?.date ?? '';
|
||||
const totalPresent = group.filter((record) => record.status === 'present').length;
|
||||
const totalLate = group.filter((record) => record.status === 'late').length;
|
||||
const totalAbsent = group.filter((record) => record.status === 'absent').length;
|
||||
const notes = group
|
||||
.map((record) => record.note)
|
||||
.filter((note): note is string => Boolean(note))
|
||||
.join('; ');
|
||||
|
||||
return {
|
||||
id: staffDailySummaryId(campusId, date),
|
||||
campus_id: campusId,
|
||||
date,
|
||||
total_staff: group.length,
|
||||
total_present: totalPresent + totalLate,
|
||||
total_absent: totalAbsent,
|
||||
total_late: totalLate,
|
||||
attendance_percentage: staffAttendancePercentage(totalPresent, totalLate, group.length),
|
||||
notes: notes || null,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => right.date.localeCompare(left.date) || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
export function buildCombinedAttendanceStats(
|
||||
overallStats: CampusAttendanceOverallStats,
|
||||
staffSummary: StaffAttendanceSummaryViewModel | null,
|
||||
childStats?: readonly CampusAttendanceChildStats[],
|
||||
): CampusAttendanceCombinedStats {
|
||||
const staffTotal = staffSummary
|
||||
const scopedChildren = childStats ?? [];
|
||||
const recordedStaffTotal = staffSummary
|
||||
? staffSummary.present + staffSummary.late + staffSummary.absent
|
||||
: 0;
|
||||
const staffTotal = staffSummary ? Math.max(staffSummary.staffCount, recordedStaffTotal) : 0;
|
||||
const staffPresent = staffSummary ? staffSummary.present + staffSummary.late : 0;
|
||||
const studentPresent = overallStats.todayPresent;
|
||||
const studentTotal = overallStats.todayEnrolled;
|
||||
const scopedStudentTodayPct = percentageFromScopedChildren(scopedChildren, (child) => child.todayPct);
|
||||
const scopedStudentWeekPct = percentageFromScopedChildren(scopedChildren, (child) => child.weekAvg);
|
||||
const hasScopedChildren = scopedChildren.length > 0;
|
||||
const studentTodayPct = scopedStudentTodayPct ?? overallStats.todayPct;
|
||||
const studentWeekPct = scopedStudentWeekPct ?? overallStats.weekPct;
|
||||
const studentTotal = hasScopedChildren ? scopedChildren.length : overallStats.todayEnrolled;
|
||||
const studentPresent = hasScopedChildren
|
||||
? scopedChildren.reduce((sum, child) => sum + ((child.todayPct ?? 0) / 100), 0)
|
||||
: overallStats.todayPresent;
|
||||
|
||||
return {
|
||||
studentTodayPct: overallStats.todayPct,
|
||||
studentWeekPct: overallStats.weekPct,
|
||||
studentTodayPct,
|
||||
studentWeekPct,
|
||||
staffTodayPct: percentageFromCounts(staffPresent, staffTotal),
|
||||
combinedTodayPct: percentageFromCounts(studentPresent + staffPresent, studentTotal + staffTotal),
|
||||
};
|
||||
}
|
||||
|
||||
function notesFromParts(...parts: readonly (string | null | undefined)[]): string | null {
|
||||
const notes = parts.filter((part): part is string => Boolean(part));
|
||||
return notes.length > 0 ? notes.join('; ') : null;
|
||||
}
|
||||
|
||||
function combinedAttendancePercentage(
|
||||
studentRecord: CampusAttendanceSummaryViewModel | null,
|
||||
staffRecord: StaffAttendanceDailySummaryViewModel | null,
|
||||
): number | null {
|
||||
const studentTotal = studentRecord?.total_enrolled ?? 0;
|
||||
const staffTotal = staffRecord?.total_staff ?? 0;
|
||||
const studentPresent = studentRecord?.total_present ?? 0;
|
||||
const staffPresent = staffRecord?.total_present ?? 0;
|
||||
|
||||
return percentageFromCounts(studentPresent + staffPresent, studentTotal + staffTotal);
|
||||
}
|
||||
|
||||
export function buildCombinedAttendanceHistoryRows(
|
||||
studentRecords: readonly CampusAttendanceSummaryViewModel[],
|
||||
staffRecords: readonly StaffAttendanceDailySummaryViewModel[],
|
||||
): readonly CombinedAttendanceHistoryRow[] {
|
||||
const dates = Array.from(new Set([
|
||||
...studentRecords.map((record) => record.date),
|
||||
...staffRecords.map((record) => record.date),
|
||||
])).sort((left, right) => right.localeCompare(left));
|
||||
|
||||
return dates.flatMap((date) => {
|
||||
const studentRecord = studentRecords.find((record) => record.date === date) ?? null;
|
||||
const staffRecord = staffRecords.find((record) => record.date === date) ?? null;
|
||||
const studentTotal = studentRecord?.total_enrolled ?? 0;
|
||||
const staffTotal = staffRecord?.total_staff ?? 0;
|
||||
const studentPresent = studentRecord?.total_present ?? 0;
|
||||
const staffPresent = staffRecord?.total_present ?? 0;
|
||||
const studentAbsent = studentRecord?.total_absent ?? 0;
|
||||
const staffAbsent = staffRecord?.total_absent ?? 0;
|
||||
const studentLate = studentRecord?.total_tardy ?? 0;
|
||||
const staffLate = staffRecord?.total_late ?? 0;
|
||||
|
||||
return [
|
||||
{
|
||||
id: `${date}:students`,
|
||||
date,
|
||||
group: 'students',
|
||||
label: 'Students',
|
||||
total: studentTotal,
|
||||
present: studentPresent,
|
||||
absent: studentAbsent,
|
||||
late: studentLate,
|
||||
attendancePercentage: studentRecord?.attendance_percentage ?? null,
|
||||
notes: studentRecord?.notes ?? null,
|
||||
},
|
||||
{
|
||||
id: `${date}:staff`,
|
||||
date,
|
||||
group: 'staff',
|
||||
label: 'Staff',
|
||||
total: staffTotal,
|
||||
present: staffPresent,
|
||||
absent: staffAbsent,
|
||||
late: staffLate,
|
||||
attendancePercentage: staffRecord?.attendance_percentage ?? null,
|
||||
notes: staffRecord?.notes ?? null,
|
||||
},
|
||||
{
|
||||
id: `${date}:total`,
|
||||
date,
|
||||
group: 'total',
|
||||
label: 'Total',
|
||||
total: studentTotal + staffTotal,
|
||||
present: studentPresent + staffPresent,
|
||||
absent: studentAbsent + staffAbsent,
|
||||
late: studentLate + staffLate,
|
||||
attendancePercentage: combinedAttendancePercentage(studentRecord, staffRecord),
|
||||
notes: notesFromParts(studentRecord?.notes, staffRecord?.notes),
|
||||
},
|
||||
] satisfies readonly CombinedAttendanceHistoryRow[];
|
||||
});
|
||||
}
|
||||
|
||||
export function buildPrintAttendanceStats(input: CampusAttendancePrintInput) {
|
||||
const printEnrolled = input.printTodayRecords.reduce((sum, record) => sum + record.total_enrolled, 0);
|
||||
const printPresent = input.printTodayRecords.reduce((sum, record) => sum + record.total_present, 0);
|
||||
const todayPct = printEnrolled > 0 ? Number(((printPresent / printEnrolled) * 100).toFixed(2)) : null;
|
||||
const staffTotal = input.staffSummary
|
||||
? Math.max(input.staffSummary.staffCount, input.staffSummary.present + input.staffSummary.late + input.staffSummary.absent)
|
||||
: 0;
|
||||
const staffPresent = input.staffSummary
|
||||
? input.staffSummary.present + input.staffSummary.late
|
||||
: 0;
|
||||
const staffPct = percentageFromCounts(staffPresent, staffTotal);
|
||||
const combinedPct = percentageFromCounts(printPresent + staffPresent, printEnrolled + staffTotal);
|
||||
const weekDays = Array.from(new Set(input.printWeekRecords.map((record) => record.date)));
|
||||
const weekPct = weekDays.length > 0
|
||||
? Number((weekDays.reduce((sum, day) => {
|
||||
@ -296,6 +480,8 @@ export function buildPrintAttendanceStats(input: CampusAttendancePrintInput) {
|
||||
|
||||
return {
|
||||
todayPct,
|
||||
staffPct,
|
||||
combinedPct,
|
||||
weekPct,
|
||||
};
|
||||
}
|
||||
|
||||
@ -43,6 +43,8 @@ export interface CampusAttendanceStats extends CampusInfo {
|
||||
readonly config: CampusAttendanceConfigViewModel | null;
|
||||
readonly recentData: readonly CampusAttendanceSummaryViewModel[];
|
||||
readonly todayRecord: CampusAttendanceSummaryViewModel | null;
|
||||
readonly recentStaffData: readonly StaffAttendanceDailySummaryViewModel[];
|
||||
readonly todayStaffRecord: StaffAttendanceDailySummaryViewModel | null;
|
||||
}
|
||||
|
||||
export interface CampusAttendanceChildStats {
|
||||
@ -57,6 +59,8 @@ export interface CampusAttendanceChildStats {
|
||||
readonly recentData: readonly CampusAttendanceSummaryViewModel[];
|
||||
readonly todayRecord: CampusAttendanceSummaryViewModel | null;
|
||||
readonly childCampusIds: readonly CampusId[];
|
||||
readonly recentStaffData: readonly StaffAttendanceDailySummaryViewModel[];
|
||||
readonly todayStaffRecord: StaffAttendanceDailySummaryViewModel | null;
|
||||
}
|
||||
|
||||
export interface CampusAttendanceOverallStats {
|
||||
@ -133,6 +137,33 @@ export interface CampusAttendanceCombinedStats {
|
||||
readonly combinedTodayPct: number | null;
|
||||
}
|
||||
|
||||
export interface StaffAttendanceDailySummaryViewModel {
|
||||
readonly id: string;
|
||||
readonly campus_id: CampusId | null;
|
||||
readonly date: string;
|
||||
readonly total_staff: number;
|
||||
readonly total_present: number;
|
||||
readonly total_absent: number;
|
||||
readonly total_late: number;
|
||||
readonly attendance_percentage: number;
|
||||
readonly notes: string | null;
|
||||
}
|
||||
|
||||
export type CombinedAttendanceGroup = 'students' | 'staff' | 'total';
|
||||
|
||||
export interface CombinedAttendanceHistoryRow {
|
||||
readonly id: string;
|
||||
readonly date: string;
|
||||
readonly group: CombinedAttendanceGroup;
|
||||
readonly label: string;
|
||||
readonly total: number;
|
||||
readonly present: number;
|
||||
readonly absent: number;
|
||||
readonly late: number;
|
||||
readonly attendancePercentage: number | null;
|
||||
readonly notes: string | null;
|
||||
}
|
||||
|
||||
export interface CampusAttendanceStaffSummaryState {
|
||||
readonly summary: StaffAttendanceSummaryViewModel | null;
|
||||
readonly loading: boolean;
|
||||
@ -148,4 +179,5 @@ export interface CampusAttendancePrintInput {
|
||||
readonly campusesToPrint: readonly CampusAttendanceStats[];
|
||||
readonly printTodayRecords: readonly CampusAttendanceSummaryViewModel[];
|
||||
readonly printWeekRecords: readonly CampusAttendanceSummaryViewModel[];
|
||||
readonly staffSummary: StaffAttendanceSummaryViewModel | null;
|
||||
}
|
||||
|
||||
@ -76,6 +76,9 @@ function createPersonalityCompletion(
|
||||
userId: 'user-1',
|
||||
name: 'Ava Lee',
|
||||
email: 'ava@example.test',
|
||||
avatar: 'users/avatar/ava.png',
|
||||
tenantName: 'Tigers Campus',
|
||||
tenantLogo: 'campuses/logo/tigers.png',
|
||||
role: 'Teacher',
|
||||
status: 'complete',
|
||||
completedKinds: ['ei_self_assessment', 'personality_type'],
|
||||
@ -148,6 +151,9 @@ function createZoneCheckinCompletion(
|
||||
userId: 'user-1',
|
||||
name: 'Ava Lee',
|
||||
email: 'ava@example.test',
|
||||
avatar: 'users/avatar/ava.png',
|
||||
tenantName: 'Tigers Campus',
|
||||
tenantLogo: 'campuses/logo/tigers.png',
|
||||
role: 'Teacher',
|
||||
date: '2026-06-18',
|
||||
status: 'complete',
|
||||
@ -190,6 +196,9 @@ function createAcknowledgmentReport(
|
||||
userId: 'user-1',
|
||||
name: 'Ava Lee',
|
||||
email: 'ava@example.test',
|
||||
avatar: 'users/avatar/ava.png',
|
||||
tenantName: 'Tigers Campus',
|
||||
tenantLogo: 'campuses/logo/tigers.png',
|
||||
role: 'teacher',
|
||||
campusId: 'campus-1',
|
||||
schoolId: 'school-1',
|
||||
@ -212,6 +221,9 @@ function createAcknowledgmentReport(
|
||||
userId: 'user-2',
|
||||
name: 'Ben Cruz',
|
||||
email: 'ben@example.test',
|
||||
avatar: 'users/avatar/ben.png',
|
||||
tenantName: 'Tigers Campus',
|
||||
tenantLogo: 'campuses/logo/tigers.png',
|
||||
role: 'support_staff',
|
||||
campusId: 'campus-1',
|
||||
schoolId: 'school-1',
|
||||
@ -272,13 +284,13 @@ describe('director dashboard selectors', () => {
|
||||
expect(cards[cards.length - 1]?.action).toBe('openAcknowledgments');
|
||||
});
|
||||
|
||||
it('flags dashboard risk levels from quiz completion and absences', () => {
|
||||
it('flags dashboard risk levels from quiz completion and staff attendance exceptions', () => {
|
||||
const risks = buildDirectorRiskAreas(
|
||||
[
|
||||
createAttendanceRecord({ id: '1', status: 'absent' }),
|
||||
createAttendanceRecord({ id: '2', status: 'absent' }),
|
||||
createAttendanceRecord({ id: '3', status: 'absent' }),
|
||||
createAttendanceRecord({ id: '4', status: 'absent' }),
|
||||
createAttendanceRecord({ id: '4', status: 'late' }),
|
||||
],
|
||||
createQuizSummary({ completedCount: 1, pendingCount: 5, totalStaff: 6, completionRate: 17 }),
|
||||
null,
|
||||
@ -288,7 +300,7 @@ describe('director dashboard selectors', () => {
|
||||
|
||||
expect(risks).toEqual([
|
||||
{
|
||||
issue: "5 staff haven't completed de-escalation quiz",
|
||||
issue: "5 staff haven't completed Behavior Management quiz",
|
||||
severity: 'high',
|
||||
module: 'qbs',
|
||||
action: 'openQuizResults',
|
||||
@ -311,7 +323,7 @@ describe('director dashboard selectors', () => {
|
||||
action: 'openAcknowledgments',
|
||||
},
|
||||
{
|
||||
issue: '4 absences recorded this period',
|
||||
issue: '4 staff attendance exceptions this period (1 late, 3 absent)',
|
||||
severity: 'high',
|
||||
module: 'attendance',
|
||||
},
|
||||
@ -346,6 +358,9 @@ describe('director dashboard selectors', () => {
|
||||
userId: 'user-1',
|
||||
name: 'Ava Lee',
|
||||
email: 'ava@example.test',
|
||||
avatar: 'users/avatar/ava.png',
|
||||
tenantName: 'Tigers Campus',
|
||||
tenantLogo: 'campuses/logo/tigers.png',
|
||||
role: 'Teacher',
|
||||
date: '2026-06-18',
|
||||
status: 'complete',
|
||||
@ -401,10 +416,42 @@ describe('director dashboard selectors', () => {
|
||||
missingCount: 4,
|
||||
completionRate: 0,
|
||||
missingStaff: [
|
||||
{ userId: 'user-1', name: 'Ava Lee', role: 'teacher', email: 'ava@example.test' },
|
||||
{ userId: 'user-2', name: 'Ben Cruz', role: 'support staff', email: 'ben@example.test' },
|
||||
{ userId: 'user-3', name: 'Cara Fox', role: 'office manager', email: 'cara@example.test' },
|
||||
{ userId: 'user-4', name: 'Drew Kim', role: 'director', email: 'drew@example.test' },
|
||||
{
|
||||
userId: 'user-1',
|
||||
name: 'Ava Lee',
|
||||
avatar: null,
|
||||
tenantName: 'Tigers Campus',
|
||||
tenantLogo: null,
|
||||
role: 'teacher',
|
||||
email: 'ava@example.test',
|
||||
},
|
||||
{
|
||||
userId: 'user-2',
|
||||
name: 'Ben Cruz',
|
||||
avatar: null,
|
||||
tenantName: 'Tigers Campus',
|
||||
tenantLogo: null,
|
||||
role: 'support staff',
|
||||
email: 'ben@example.test',
|
||||
},
|
||||
{
|
||||
userId: 'user-3',
|
||||
name: 'Cara Fox',
|
||||
avatar: null,
|
||||
tenantName: 'Tigers Campus',
|
||||
tenantLogo: null,
|
||||
role: 'office manager',
|
||||
email: 'cara@example.test',
|
||||
},
|
||||
{
|
||||
userId: 'user-4',
|
||||
name: 'Drew Kim',
|
||||
avatar: null,
|
||||
tenantName: 'Tigers Campus',
|
||||
tenantLogo: null,
|
||||
role: 'director',
|
||||
email: 'drew@example.test',
|
||||
},
|
||||
],
|
||||
})),
|
||||
);
|
||||
@ -437,6 +484,9 @@ describe('director dashboard selectors', () => {
|
||||
{
|
||||
userId: 'user-2',
|
||||
name: 'Ben Cruz',
|
||||
avatar: 'users/avatar/ben.png',
|
||||
tenantName: 'Tigers Campus',
|
||||
tenantLogo: 'campuses/logo/tigers.png',
|
||||
role: 'support staff',
|
||||
email: 'ben@example.test',
|
||||
},
|
||||
@ -451,6 +501,9 @@ describe('director dashboard selectors', () => {
|
||||
{
|
||||
userId: 'user-1',
|
||||
name: 'Ava Lee',
|
||||
avatar: 'users/avatar/ava.png',
|
||||
tenantName: 'Tigers Campus',
|
||||
tenantLogo: 'campuses/logo/tigers.png',
|
||||
role: 'Teacher',
|
||||
status: 'complete',
|
||||
score: '3/5',
|
||||
@ -467,6 +520,7 @@ describe('director dashboard selectors', () => {
|
||||
id: 'user-1',
|
||||
staffName: 'Ava Lee',
|
||||
tenant: 'Tigers Campus',
|
||||
tenantLogo: 'campuses/logo/tigers.png',
|
||||
role: 'Teacher',
|
||||
completedCount: 4,
|
||||
totalCount: 4,
|
||||
@ -491,6 +545,9 @@ describe('director dashboard selectors', () => {
|
||||
{
|
||||
userId: 'user-1',
|
||||
name: 'Ava Lee',
|
||||
avatar: 'users/avatar/ava.png',
|
||||
tenantName: 'Tigers Campus',
|
||||
tenantLogo: 'campuses/logo/tigers.png',
|
||||
role: 'Teacher',
|
||||
status: 'complete',
|
||||
score: '3/5',
|
||||
@ -503,6 +560,9 @@ describe('director dashboard selectors', () => {
|
||||
userId: 'user-2',
|
||||
name: 'Ben Cruz',
|
||||
email: 'ben@example.test',
|
||||
avatar: null,
|
||||
tenantName: 'Tigers Campus',
|
||||
tenantLogo: null,
|
||||
role: 'Support Staff',
|
||||
status: 'pending',
|
||||
completedKinds: [],
|
||||
@ -517,6 +577,9 @@ describe('director dashboard selectors', () => {
|
||||
userId: 'user-1',
|
||||
name: 'Ava Lee',
|
||||
email: 'ava@example.test',
|
||||
avatar: 'users/avatar/ava.png',
|
||||
tenantName: 'Tigers Campus',
|
||||
tenantLogo: 'campuses/logo/tigers.png',
|
||||
role: 'Teacher',
|
||||
date: '2026-06-18',
|
||||
status: 'pending',
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import {
|
||||
DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD,
|
||||
DIRECTOR_DASHBOARD_FRAME_PREVIEW_LIMIT,
|
||||
DIRECTOR_DASHBOARD_HIGH_ABSENCE_THRESHOLD,
|
||||
DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD,
|
||||
DIRECTOR_DASHBOARD_TEXT_PREVIEW_LENGTH,
|
||||
} from '@/shared/constants/directorDashboard';
|
||||
@ -109,6 +108,8 @@ export function buildDirectorRiskAreas(
|
||||
): readonly DirectorRiskArea[] {
|
||||
const incompleteStaffCount = quizSummary.pendingCount;
|
||||
const absenceCount = countStaffAttendanceStatus(attendanceRecords, 'absent');
|
||||
const lateCount = countStaffAttendanceStatus(attendanceRecords, 'late');
|
||||
const staffAttendanceExceptionCount = absenceCount + lateCount;
|
||||
const selfAssessmentPendingCount = emotionalIntelligenceCompletion?.summary
|
||||
? emotionalIntelligenceCompletion.summary.totalStaff
|
||||
- emotionalIntelligenceCompletion.summary.selfAssessmentCompletedCount
|
||||
@ -124,7 +125,7 @@ export function buildDirectorRiskAreas(
|
||||
|
||||
if (incompleteStaffCount > 0) {
|
||||
risks.push({
|
||||
issue: `${incompleteStaffCount} staff haven't completed de-escalation quiz`,
|
||||
issue: `${incompleteStaffCount} staff haven't completed Behavior Management quiz`,
|
||||
severity: incompleteStaffCount > DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD ? 'high' : 'medium',
|
||||
module: 'qbs',
|
||||
action: 'openQuizResults',
|
||||
@ -176,10 +177,10 @@ export function buildDirectorRiskAreas(
|
||||
});
|
||||
}
|
||||
|
||||
if (absenceCount > 0) {
|
||||
if (staffAttendanceExceptionCount > 0) {
|
||||
risks.push({
|
||||
issue: `${absenceCount} absences recorded this period`,
|
||||
severity: absenceCount > DIRECTOR_DASHBOARD_HIGH_ABSENCE_THRESHOLD ? 'high' : 'low',
|
||||
issue: `${staffAttendanceExceptionCount} ${pluralize('staff attendance exception', staffAttendanceExceptionCount)} this period (${lateCount} late, ${absenceCount} absent)`,
|
||||
severity: 'high',
|
||||
module: 'attendance',
|
||||
});
|
||||
}
|
||||
@ -237,6 +238,9 @@ export function buildDirectorAcknowledgmentDocuments(
|
||||
.map((staff) => ({
|
||||
userId: staff.userId,
|
||||
name: staff.name,
|
||||
avatar: staff.avatar,
|
||||
tenantName: staff.tenantName,
|
||||
tenantLogo: staff.tenantLogo,
|
||||
role: formatRoleLabel(staff.role),
|
||||
email: staff.email,
|
||||
}));
|
||||
@ -303,21 +307,46 @@ export function buildDirectorQuizResults(
|
||||
): readonly DirectorQuizResultRow[] {
|
||||
const rowsByUserId = new Map<string, {
|
||||
staffName: string;
|
||||
avatar: string | null;
|
||||
tenantName: string | null;
|
||||
tenantLogo: string | null;
|
||||
role: string;
|
||||
details: DirectorQuizResultDetail[];
|
||||
}>();
|
||||
|
||||
const ensureRow = (userId: string, staffName: string, role: string | null): {
|
||||
const ensureRow = (
|
||||
userId: string,
|
||||
staffName: string,
|
||||
avatar: string | null,
|
||||
tenantName: string | null,
|
||||
tenantLogo: string | null,
|
||||
role: string | null,
|
||||
): {
|
||||
staffName: string;
|
||||
avatar: string | null;
|
||||
tenantName: string | null;
|
||||
tenantLogo: string | null;
|
||||
role: string;
|
||||
details: DirectorQuizResultDetail[];
|
||||
} => {
|
||||
const existing = rowsByUserId.get(userId);
|
||||
if (existing) {
|
||||
if (!existing.avatar && avatar) {
|
||||
existing.avatar = avatar;
|
||||
}
|
||||
if (!existing.tenantName && tenantName) {
|
||||
existing.tenantName = tenantName;
|
||||
}
|
||||
if (!existing.tenantLogo && tenantLogo) {
|
||||
existing.tenantLogo = tenantLogo;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
const next = {
|
||||
staffName,
|
||||
avatar,
|
||||
tenantName,
|
||||
tenantLogo,
|
||||
role: role ?? 'Staff',
|
||||
details: [],
|
||||
};
|
||||
@ -326,7 +355,7 @@ export function buildDirectorQuizResults(
|
||||
};
|
||||
|
||||
for (const row of safetyRows) {
|
||||
ensureRow(row.userId, row.name, row.role).details.push({
|
||||
ensureRow(row.userId, row.name, row.avatar, row.tenantName, row.tenantLogo, row.role).details.push({
|
||||
id: 'behavior-management',
|
||||
quiz: 'Behavior Management',
|
||||
result: row.score,
|
||||
@ -336,7 +365,7 @@ export function buildDirectorQuizResults(
|
||||
}
|
||||
|
||||
for (const row of emotionalIntelligenceCompletion?.rows ?? []) {
|
||||
const target = ensureRow(row.userId, row.name, row.role);
|
||||
const target = ensureRow(row.userId, row.name, row.avatar, row.tenantName, row.tenantLogo, row.role);
|
||||
target.details.push(
|
||||
{
|
||||
id: 'ei-self-assessment',
|
||||
@ -356,7 +385,7 @@ export function buildDirectorQuizResults(
|
||||
}
|
||||
|
||||
for (const row of zoneCheckinCompletion?.rows ?? []) {
|
||||
ensureRow(row.userId, row.name, row.role).details.push({
|
||||
ensureRow(row.userId, row.name, row.avatar, row.tenantName, row.tenantLogo, row.role).details.push({
|
||||
id: 'daily-zone-check-in',
|
||||
quiz: 'Daily Zone Check-In',
|
||||
result: row.result,
|
||||
@ -370,7 +399,9 @@ export function buildDirectorQuizResults(
|
||||
return [...rowsByUserId.entries()].map(([userId, row]) => ({
|
||||
id: userId,
|
||||
staffName: row.staffName,
|
||||
tenant: tenantLabel,
|
||||
avatar: row.avatar,
|
||||
tenant: row.tenantName ?? tenantLabel,
|
||||
tenantLogo: row.tenantLogo,
|
||||
role: row.role,
|
||||
completedCount: row.details.filter((detail) => detail.status === 'complete').length,
|
||||
totalCount: row.details.length,
|
||||
|
||||
@ -52,7 +52,9 @@ export interface DirectorQuizResultDetail {
|
||||
export interface DirectorQuizResultRow {
|
||||
readonly id: string;
|
||||
readonly staffName: string;
|
||||
readonly avatar: string | null;
|
||||
readonly tenant: string;
|
||||
readonly tenantLogo: string | null;
|
||||
readonly role: string;
|
||||
readonly completedCount: number;
|
||||
readonly totalCount: number;
|
||||
@ -62,6 +64,9 @@ export interface DirectorQuizResultRow {
|
||||
export interface DirectorAcknowledgmentMissingStaff {
|
||||
readonly userId: string;
|
||||
readonly name: string;
|
||||
readonly avatar: string | null;
|
||||
readonly tenantName: string | null;
|
||||
readonly tenantLogo: string | null;
|
||||
readonly role: string;
|
||||
readonly email: string;
|
||||
}
|
||||
|
||||
@ -39,6 +39,9 @@ describe('safety quiz mappers', () => {
|
||||
expect(toSafetyQuizComplianceRow(createResult())).toEqual({
|
||||
userId: 'user-1',
|
||||
name: 'Ava Lee',
|
||||
avatar: null,
|
||||
tenantName: null,
|
||||
tenantLogo: null,
|
||||
role: 'Support Staff',
|
||||
status: 'complete',
|
||||
score: '4/5',
|
||||
@ -111,8 +114,28 @@ describe('safety quiz selectors', () => {
|
||||
it('summarizes compliance rows', () => {
|
||||
expect(
|
||||
calculateSafetyQuizCompletionSummary([
|
||||
{ userId: 'user-1', name: 'Ava', role: 'Teacher', status: 'complete', score: '5/5', date: 'Jun 8' },
|
||||
{ userId: 'user-2', name: 'Ben', role: 'Para', status: 'complete', score: '4/5', date: 'Jun 8' },
|
||||
{
|
||||
userId: 'user-1',
|
||||
name: 'Ava',
|
||||
avatar: null,
|
||||
tenantName: null,
|
||||
tenantLogo: null,
|
||||
role: 'Teacher',
|
||||
status: 'complete',
|
||||
score: '5/5',
|
||||
date: 'Jun 8',
|
||||
},
|
||||
{
|
||||
userId: 'user-2',
|
||||
name: 'Ben',
|
||||
avatar: null,
|
||||
tenantName: null,
|
||||
tenantLogo: null,
|
||||
role: 'Para',
|
||||
status: 'complete',
|
||||
score: '4/5',
|
||||
date: 'Jun 8',
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
completedCount: 2,
|
||||
|
||||
@ -41,6 +41,9 @@ export function toSafetyQuizComplianceRow(dto: SafetyQuizResultDto): SafetyQuizC
|
||||
return {
|
||||
userId: dto.userId,
|
||||
name: dto.user_name,
|
||||
avatar: null,
|
||||
tenantName: null,
|
||||
tenantLogo: null,
|
||||
role: toRoleLabel(dto.user_role),
|
||||
status: 'complete',
|
||||
score: `${dto.score}/${dto.total_questions}`,
|
||||
@ -55,6 +58,9 @@ export function toSafetyQuizCompletionRow(dto: SafetyQuizCompletionRowDto): Safe
|
||||
return {
|
||||
userId: dto.userId,
|
||||
name: dto.name,
|
||||
avatar: dto.avatar,
|
||||
tenantName: dto.tenantName,
|
||||
tenantLogo: dto.tenantLogo,
|
||||
role: dto.role ? toRoleLabel(dto.role) : 'Staff',
|
||||
status: dto.status,
|
||||
score: dto.result ? `${dto.result.score}/${dto.result.total_questions}` : 'Pending',
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
export interface SafetyQuizComplianceRow {
|
||||
readonly userId: string;
|
||||
readonly name: string;
|
||||
readonly avatar: string | null;
|
||||
readonly tenantName: string | null;
|
||||
readonly tenantLogo: string | null;
|
||||
readonly role: string;
|
||||
readonly status: 'complete' | 'pending';
|
||||
readonly score: string;
|
||||
|
||||
@ -42,10 +42,7 @@ export function getScopeTierLabel(tier: ScopeTier): string {
|
||||
return SCOPE_TIER_LABELS[tier];
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's own active tenant — the tenant at their scope tier. Campuses carry
|
||||
* no logo (name only); schools/classes/orgs may carry one.
|
||||
*/
|
||||
/** The user's own active tenant — the tenant at their scope tier. */
|
||||
export function getActiveTenant(
|
||||
user: CurrentUser | null | undefined,
|
||||
): ActiveTenant | null {
|
||||
@ -56,13 +53,13 @@ export function getActiveTenant(
|
||||
return { level: 'class', id: user.classRoom.id, name: user.classRoom.name ?? null, logo: user.classRoom.logo ?? null };
|
||||
}
|
||||
if (tier === 'campus' && user.campus?.id) {
|
||||
return { level: 'campus', id: user.campus.id, name: user.campus.name ?? null, logo: null };
|
||||
return { level: 'campus', id: user.campus.id, name: user.campus.name ?? null, logo: user.campus.logo ?? null };
|
||||
}
|
||||
if (tier === 'school' && user.school?.id) {
|
||||
return { level: 'school', id: user.school.id, name: user.school.name ?? null, logo: user.school.logo ?? null };
|
||||
}
|
||||
if (user.organizations?.id) {
|
||||
return { level: 'organization', id: user.organizations.id, name: user.organizations.name ?? null, logo: null };
|
||||
return { level: 'organization', id: user.organizations.id, name: user.organizations.name ?? null, logo: user.organizations.logo ?? null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -188,6 +188,7 @@ export function useTopBarPage({
|
||||
return {
|
||||
userRole,
|
||||
userName,
|
||||
avatar: user?.avatar ?? null,
|
||||
campusInfo,
|
||||
profileRoleLabel: profile ? getTopBarRoleLabel(profile.role) : getTopBarRoleLabel(userRole),
|
||||
roleLabel: getTopBarRoleLabel(userRole),
|
||||
|
||||
@ -11,6 +11,7 @@ export interface TopBarProps {
|
||||
readonly user: CurrentUser | null;
|
||||
readonly userRole: UserRole;
|
||||
readonly userName: string;
|
||||
readonly avatar: string | null;
|
||||
readonly campusInfo?: CampusInfo;
|
||||
readonly toggleSidebar: () => void;
|
||||
readonly setCurrentModule: (moduleId: ModuleId) => void;
|
||||
@ -33,6 +34,7 @@ export interface UseTopBarPageOptions extends TopBarProps {
|
||||
export interface TopBarPage {
|
||||
readonly userRole: UserRole;
|
||||
readonly userName: string;
|
||||
readonly avatar: string | null;
|
||||
readonly campusInfo?: CampusInfo;
|
||||
readonly profileRoleLabel: string;
|
||||
readonly roleLabel: string;
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { BarChart3, Calendar, ClipboardList, ExternalLink, FileText, Globe, Link2, Users, UserX } from 'lucide-react';
|
||||
import { BarChart3, Calendar, ClipboardList, ExternalLink, FileText, Globe, Link2, Users } from 'lucide-react';
|
||||
|
||||
import { formatAttendanceDate } from '@/business/campus-attendance/selectors';
|
||||
import {
|
||||
buildCombinedAttendanceHistoryRows,
|
||||
formatAttendanceDate,
|
||||
} from '@/business/campus-attendance/selectors';
|
||||
import { AttendanceSummaryCard } from '@/components/campus-attendance/AttendanceSummaryCard';
|
||||
import { percentageTextClass } from '@/components/campus-attendance/styles';
|
||||
import {
|
||||
@ -25,52 +28,74 @@ export function IndividualCampusAttendanceView({ state, actions }: IndividualCam
|
||||
const {
|
||||
today,
|
||||
weekStart,
|
||||
myTodayPct,
|
||||
weekEnd,
|
||||
myWeekAvg,
|
||||
myCampusData,
|
||||
myStaffData,
|
||||
myCampusConfig,
|
||||
roleAccess,
|
||||
campusInfo,
|
||||
scopeModel,
|
||||
} = state;
|
||||
const todayRecord = myCampusData.find((record) => record.date === today);
|
||||
const todayStaffRecord = myStaffData.find((record) => record.date === today);
|
||||
const historyTitle = campusInfo?.fullName || scopeModel.tenantName || 'Current Campus';
|
||||
const combinedHistoryRows = buildCombinedAttendanceHistoryRows(myCampusData, myStaffData);
|
||||
const todayStudentTotal = todayRecord?.total_enrolled ?? 0;
|
||||
const todayStaffTotal = todayStaffRecord?.total_staff ?? 0;
|
||||
const todayStudentPresent = todayRecord?.total_present ?? 0;
|
||||
const todayStaffPresent = todayStaffRecord?.total_present ?? 0;
|
||||
const combinedTodayTotal = todayStudentTotal + todayStaffTotal;
|
||||
const combinedTodayPresent = todayStudentPresent + todayStaffPresent;
|
||||
const combinedTodayPct = combinedTodayTotal > 0
|
||||
? Number(((combinedTodayPresent / combinedTodayTotal) * 100).toFixed(2))
|
||||
: null;
|
||||
const weekStudentDates = new Set(myCampusData.map((record) => record.date));
|
||||
const weekStaffDates = new Set(myStaffData.map((record) => record.date));
|
||||
const weekDates = Array.from(new Set([...weekStudentDates, ...weekStaffDates]))
|
||||
.filter((date) => date >= weekStart && date <= weekEnd);
|
||||
const combinedWeekPct = weekDates.length > 0
|
||||
? Number((weekDates.reduce((sum, date) => {
|
||||
const studentRecord = myCampusData.find((record) => record.date === date) ?? null;
|
||||
const staffRecord = myStaffData.find((record) => record.date === date) ?? null;
|
||||
const total = (studentRecord?.total_enrolled ?? 0) + (staffRecord?.total_staff ?? 0);
|
||||
const present = (studentRecord?.total_present ?? 0) + (staffRecord?.total_present ?? 0);
|
||||
return sum + (total > 0 ? (present / total) * 100 : 0);
|
||||
}, 0) / weekDates.length).toFixed(2))
|
||||
: null;
|
||||
const studentTodayLabel = todayRecord ? `${todayRecord.total_present}/${todayRecord.total_enrolled} students` : 'No student data';
|
||||
const staffTodayLabel = todayStaffRecord ? `${todayStaffRecord.total_present}/${todayStaffRecord.total_staff} staff` : 'No staff data';
|
||||
const weekStudentLabel = myWeekAvg !== null ? `Students ${myWeekAvg}%` : 'Students N/A';
|
||||
const weekStaffRecords = myStaffData.filter((record) => record.date >= weekStart && record.date <= weekEnd);
|
||||
const weekStaffAvg = weekStaffRecords.length > 0
|
||||
? Number((weekStaffRecords.reduce((sum, record) => sum + record.attendance_percentage, 0) / weekStaffRecords.length).toFixed(2))
|
||||
: null;
|
||||
const weekStaffLabel = weekStaffAvg !== null ? `Staff ${weekStaffAvg}%` : 'Staff N/A';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<AttendanceSummaryCard
|
||||
label="Today's Attendance"
|
||||
value={myTodayPct !== null ? `${myTodayPct}%` : 'N/A'}
|
||||
helper={formatAttendanceDate(today)}
|
||||
value={combinedTodayPct !== null ? `${combinedTodayPct}%` : 'N/A'}
|
||||
helper={`${studentTodayLabel} · ${staffTodayLabel}`}
|
||||
icon={Calendar}
|
||||
percentage={myTodayPct}
|
||||
percentage={combinedTodayPct}
|
||||
/>
|
||||
<AttendanceSummaryCard
|
||||
label="Weekly Average"
|
||||
value={myWeekAvg !== null ? `${myWeekAvg}%` : 'N/A'}
|
||||
helper={`Week of ${formatAttendanceDate(weekStart)}`}
|
||||
value={combinedWeekPct !== null ? `${combinedWeekPct}%` : 'N/A'}
|
||||
helper={`${weekStudentLabel} · ${weekStaffLabel}`}
|
||||
icon={BarChart3}
|
||||
percentage={myWeekAvg}
|
||||
percentage={combinedWeekPct}
|
||||
/>
|
||||
<AttendanceSummaryCard
|
||||
label="People"
|
||||
value={combinedTodayTotal || 'N/A'}
|
||||
helper={`${todayStudentTotal} students · ${todayStaffTotal} staff`}
|
||||
icon={Users}
|
||||
color="blue"
|
||||
/>
|
||||
{todayRecord && (
|
||||
<>
|
||||
<AttendanceSummaryCard
|
||||
label="Enrolled"
|
||||
value={todayRecord.total_enrolled}
|
||||
helper={`${todayRecord.total_present} present`}
|
||||
icon={Users}
|
||||
color="blue"
|
||||
/>
|
||||
<AttendanceSummaryCard
|
||||
label="Absent Today"
|
||||
value={todayRecord.total_absent}
|
||||
helper={`${todayRecord.total_tardy} tardy`}
|
||||
icon={UserX}
|
||||
color="red"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{myCampusConfig?.attendance_link && !roleAccess.isOfficeManager && (
|
||||
@ -99,41 +124,59 @@ export function IndividualCampusAttendanceView({ state, actions }: IndividualCam
|
||||
Recent Attendance History - {historyTitle}
|
||||
</h3>
|
||||
</div>
|
||||
{myCampusData.length > 0 ? (
|
||||
{combinedHistoryRows.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-700/30 hover:bg-slate-700/30">
|
||||
<TableHead className="h-auto p-3 text-left text-xs text-slate-400">Date</TableHead>
|
||||
<TableHead className="h-auto p-3 text-center text-xs text-slate-400">Enrolled</TableHead>
|
||||
<TableHead className="h-auto p-3 text-center text-xs text-slate-400">Present</TableHead>
|
||||
<TableHead className="h-auto p-3 text-center text-xs text-slate-400">Absent</TableHead>
|
||||
<TableHead className="h-auto p-3 text-center text-xs text-slate-400">Tardy</TableHead>
|
||||
<TableHead className="h-auto p-3 text-center text-xs text-slate-400">Attendance %</TableHead>
|
||||
<TableHead className="h-auto p-3 text-left text-xs text-slate-400">Notes</TableHead>
|
||||
<TableHead className="h-auto p-3 text-left text-xs text-slate-400">Date</TableHead>
|
||||
<TableHead className="h-auto p-3 text-left text-xs text-slate-400">Group</TableHead>
|
||||
<TableHead className="h-auto p-3 text-center text-xs text-slate-400">Total</TableHead>
|
||||
<TableHead className="h-auto p-3 text-center text-xs text-slate-400">Present</TableHead>
|
||||
<TableHead className="h-auto p-3 text-center text-xs text-slate-400">Absent</TableHead>
|
||||
<TableHead className="h-auto p-3 text-center text-xs text-slate-400">Tardy / Late</TableHead>
|
||||
<TableHead className="h-auto p-3 text-center text-xs text-slate-400">Attendance %</TableHead>
|
||||
<TableHead className="h-auto p-3 text-left text-xs text-slate-400">Notes</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{myCampusData.slice(0, 15).map((record) => (
|
||||
<TableRow key={record.id} className="border-t border-slate-700/20 hover:bg-slate-700/20">
|
||||
<TableCell className="p-3 text-slate-300 font-medium">{formatAttendanceDate(record.date)}</TableCell>
|
||||
<TableCell className="p-3 text-center text-slate-300">{record.total_enrolled}</TableCell>
|
||||
<TableCell className="p-3 text-center">
|
||||
<span className="px-2 py-0.5 rounded-lg text-xs font-semibold bg-emerald-500/10 text-emerald-400">{record.total_present}</span>
|
||||
</TableCell>
|
||||
<TableCell className="p-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${record.total_absent > 3 ? 'bg-red-500/10 text-red-400' : 'bg-slate-700/30 text-slate-400'}`}>{record.total_absent}</span>
|
||||
</TableCell>
|
||||
<TableCell className="p-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${record.total_tardy > 2 ? 'bg-amber-500/10 text-amber-400' : 'bg-slate-700/30 text-slate-400'}`}>{record.total_tardy}</span>
|
||||
</TableCell>
|
||||
<TableCell className="p-3 text-center">
|
||||
<span className={`font-bold ${percentageTextClass(record.attendance_percentage)}`}>
|
||||
{record.attendance_percentage.toFixed(1)}%
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="p-3 text-xs text-slate-500 max-w-[200px] truncate">{record.notes || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{combinedHistoryRows
|
||||
.filter((record) => record.date === today || record.group === 'total')
|
||||
.slice(0, 45)
|
||||
.map((record) => {
|
||||
const shouldShowDate = record.date !== today || record.group === 'students';
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={record.id}
|
||||
className={`border-t border-slate-700/20 hover:bg-slate-700/20 ${
|
||||
record.group === 'total' ? 'bg-slate-900/30' : ''
|
||||
}`}
|
||||
>
|
||||
<TableCell className="p-3 text-slate-300 font-medium">
|
||||
{shouldShowDate ? formatAttendanceDate(record.date) : ''}
|
||||
</TableCell>
|
||||
<TableCell className={`p-3 ${record.group === 'total' ? 'text-white font-semibold' : 'text-slate-300'}`}>
|
||||
{record.label}
|
||||
</TableCell>
|
||||
<TableCell className="p-3 text-center text-slate-300">{record.total}</TableCell>
|
||||
<TableCell className="p-3 text-center">
|
||||
<span className="px-2 py-0.5 rounded-lg text-xs font-semibold bg-emerald-500/10 text-emerald-400">{record.present}</span>
|
||||
</TableCell>
|
||||
<TableCell className="p-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${record.absent > 0 ? 'bg-red-500/10 text-red-400' : 'bg-slate-700/30 text-slate-400'}`}>{record.absent}</span>
|
||||
</TableCell>
|
||||
<TableCell className="p-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${record.late > 0 ? 'bg-amber-500/10 text-amber-400' : 'bg-slate-700/30 text-slate-400'}`}>{record.late}</span>
|
||||
</TableCell>
|
||||
<TableCell className="p-3 text-center">
|
||||
<span className={`font-bold ${percentageTextClass(record.attendancePercentage)}`}>
|
||||
{record.attendancePercentage !== null ? `${record.attendancePercentage.toFixed(1)}%` : 'N/A'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="p-3 text-xs text-slate-500 max-w-[200px] truncate">{record.notes || '-'}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
|
||||
@ -44,7 +44,10 @@ export function SuperintendentAttendanceView({ state, actions }: SuperintendentA
|
||||
scopeModel,
|
||||
} = state;
|
||||
const staffRecordTotal = staffSummary.summary
|
||||
? staffSummary.summary.present + staffSummary.summary.late + staffSummary.summary.absent
|
||||
? Math.max(
|
||||
staffSummary.summary.staffCount,
|
||||
staffSummary.summary.present + staffSummary.summary.late + staffSummary.summary.absent,
|
||||
)
|
||||
: null;
|
||||
const staffPresentOrLate = staffSummary.summary
|
||||
? staffSummary.summary.present + staffSummary.summary.late
|
||||
|
||||
48
frontend/src/components/common/TenantLogo.tsx
Normal file
48
frontend/src/components/common/TenantLogo.tsx
Normal 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();
|
||||
}
|
||||
45
frontend/src/components/common/UserAvatar.tsx
Normal file
45
frontend/src/components/common/UserAvatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,8 @@ import { useMemo, useState } from 'react';
|
||||
import { ChevronDown, ClipboardCheck } from 'lucide-react';
|
||||
|
||||
import type { DirectorAcknowledgmentDocumentRow } from '@/business/director-dashboard/types';
|
||||
import { UserAvatar } from '@/components/common/UserAvatar';
|
||||
import { TenantLogo } from '@/components/common/TenantLogo';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DirectorAcknowledgmentTrackingPanelProps {
|
||||
@ -123,9 +125,24 @@ export function DirectorAcknowledgmentTrackingPanel({
|
||||
key={`${document.id}-${staff.userId}`}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-medium text-gray-800">{staff.name}</span>
|
||||
<span className="text-xs capitalize text-gray-500">
|
||||
{staff.role}
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<UserAvatar
|
||||
name={staff.name}
|
||||
avatarUrl={staff.avatar}
|
||||
className="h-8 w-8 border-gray-200 bg-gray-100 text-[10px] text-gray-600"
|
||||
/>
|
||||
<span className="truncate font-medium text-gray-800">{staff.name}</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-2 text-xs text-gray-500">
|
||||
<TenantLogo
|
||||
name={staff.tenantName}
|
||||
logoUrl={staff.tenantLogo}
|
||||
className="h-6 w-6 rounded-md text-[9px]"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col text-right">
|
||||
<span className="truncate">{staff.tenantName ?? 'Scope not set'}</span>
|
||||
<span className="truncate capitalize text-gray-400">{staff.role}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -2,6 +2,8 @@ import { useState } from 'react';
|
||||
import { ChevronDown, Users } from 'lucide-react';
|
||||
|
||||
import { StatePanel } from '@/components/ui/state-panel';
|
||||
import { UserAvatar } from '@/components/common/UserAvatar';
|
||||
import { TenantLogo } from '@/components/common/TenantLogo';
|
||||
import type { DirectorQuizResultRow } from '@/business/director-dashboard/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@ -80,8 +82,22 @@ export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelPr
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={`quiz-results-${result.id}`}
|
||||
>
|
||||
<span className="truncate font-medium text-gray-700">{result.staffName}</span>
|
||||
<span className="truncate text-sm text-gray-500">{result.tenant}</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<UserAvatar
|
||||
name={result.staffName}
|
||||
avatarUrl={result.avatar}
|
||||
className="h-8 w-8 border-gray-200 bg-gray-100 text-[10px] text-gray-600"
|
||||
/>
|
||||
<span className="truncate font-medium text-gray-700">{result.staffName}</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<TenantLogo
|
||||
name={result.tenant}
|
||||
logoUrl={result.tenantLogo}
|
||||
className="h-7 w-7 rounded-md text-[10px]"
|
||||
/>
|
||||
<span className="truncate text-sm text-gray-500">{result.tenant}</span>
|
||||
</span>
|
||||
<span className="text-center text-xs capitalize text-gray-500">{result.role}</span>
|
||||
<span className="text-center">
|
||||
<span className={cn(
|
||||
|
||||
@ -3,7 +3,7 @@ import { ChevronDown, CornerUpLeft, Globe } from 'lucide-react';
|
||||
|
||||
import { useScopeContext } from '@/contexts/scope-context';
|
||||
import { useTenantChildren } from '@/business/scope/queries';
|
||||
import { getTenantInitials } from '@/business/scope/selectors';
|
||||
import { TenantLogo } from '@/components/common/TenantLogo';
|
||||
import { useOnClickOutside } from '@/hooks/useOnClickOutside';
|
||||
|
||||
const LEVEL_LABEL: Record<string, string> = {
|
||||
@ -42,14 +42,16 @@ export function TenantSwitcher() {
|
||||
const levelLabel = effectiveTenant
|
||||
? (LEVEL_LABEL[effectiveTenant.level] ?? 'Tenant')
|
||||
: 'Platform';
|
||||
const mark = effectiveTenant?.logo ? (
|
||||
<img src={effectiveTenant.logo} alt="" className="w-full h-full object-cover" />
|
||||
) : effectiveTenant ? (
|
||||
<span className="text-white text-[8px] font-bold">
|
||||
{getTenantInitials(effectiveTenant.name)}
|
||||
</span>
|
||||
const mark = effectiveTenant ? (
|
||||
<TenantLogo
|
||||
name={effectiveTenant.name}
|
||||
logoUrl={effectiveTenant.logo}
|
||||
className="h-4 w-4 rounded bg-gradient-to-br from-violet-500 to-indigo-600 text-[8px]"
|
||||
/>
|
||||
) : (
|
||||
<Globe size={11} className="text-white" />
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded bg-gradient-to-br from-violet-500 to-indigo-600">
|
||||
<Globe size={11} className="text-white" />
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
@ -59,9 +61,7 @@ export function TenantSwitcher() {
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-xl text-xs font-semibold border border-slate-700/50 bg-slate-800/40 text-slate-200 hover:bg-slate-800/70 transition-colors"
|
||||
>
|
||||
<span className="w-4 h-4 rounded bg-gradient-to-br from-violet-500 to-indigo-600 flex items-center justify-center overflow-hidden">
|
||||
{mark}
|
||||
</span>
|
||||
{mark}
|
||||
<span className="truncate max-w-[10rem]">{label}</span>
|
||||
<span className="text-slate-500">· {levelLabel}</span>
|
||||
{canDrill && <ChevronDown size={14} className="text-slate-500" />}
|
||||
@ -93,9 +93,16 @@ export function TenantSwitcher() {
|
||||
drillInto(c);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-xs text-slate-200 hover:bg-slate-800/70"
|
||||
className="w-full flex items-center justify-between gap-3 px-3 py-2 text-xs text-slate-200 hover:bg-slate-800/70"
|
||||
>
|
||||
<span className="truncate">{c.name ?? '—'}</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<TenantLogo
|
||||
name={c.name}
|
||||
logoUrl={c.logo}
|
||||
className="h-6 w-6 rounded-md text-[9px]"
|
||||
/>
|
||||
<span className="truncate">{c.name ?? '—'}</span>
|
||||
</span>
|
||||
<span className="text-slate-500">{LEVEL_LABEL[c.level]}</span>
|
||||
</button>
|
||||
))
|
||||
|
||||
@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { Building2, ChevronDown, LogOut, Settings, User, Wifi } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { UserAvatar } from '@/components/common/UserAvatar';
|
||||
import { useOnClickOutside } from '@/hooks/useOnClickOutside';
|
||||
import { TOP_BAR_PROFILE_MENU_ITEMS } from '@/shared/constants/topBar';
|
||||
import { APP_ROUTE_PATHS } from '@/shared/constants/routes';
|
||||
@ -12,6 +13,7 @@ import { cn } from '@/lib/utils';
|
||||
interface TopBarProfileMenuProps {
|
||||
readonly userName: string;
|
||||
readonly initials: string;
|
||||
readonly avatar: string | null;
|
||||
readonly campusInfo?: CampusInfo;
|
||||
readonly campusLabel: string;
|
||||
readonly profileRoleLabel: string;
|
||||
@ -24,6 +26,7 @@ interface TopBarProfileMenuProps {
|
||||
export function TopBarProfileMenu({
|
||||
userName,
|
||||
initials,
|
||||
avatar,
|
||||
campusInfo,
|
||||
campusLabel,
|
||||
profileRoleLabel,
|
||||
@ -49,9 +52,12 @@ export function TopBarProfileMenu({
|
||||
aria-expanded={isOpen}
|
||||
className="flex items-center gap-2 pl-2 border-l border-slate-700/50 hover:bg-slate-800/50 rounded-xl pr-2 py-1 transition-colors h-auto"
|
||||
>
|
||||
<span className={cn('w-8 h-8 rounded-xl flex items-center justify-center text-white text-xs font-bold shadow-lg', avatarClassName)}>
|
||||
{initials}
|
||||
</span>
|
||||
<UserAvatar
|
||||
name={userName || initials}
|
||||
avatarUrl={avatar}
|
||||
className={cn('h-8 w-8 rounded-xl border-0 text-white shadow-lg', avatarClassName)}
|
||||
fallbackClassName="text-xs font-bold"
|
||||
/>
|
||||
<span className="hidden md:block text-left">
|
||||
<span className="text-sm font-medium text-slate-200 leading-tight block">{userName}</span>
|
||||
<span className="text-[10px] text-slate-500 leading-tight block">{campusLabel}</span>
|
||||
@ -63,9 +69,12 @@ export function TopBarProfileMenu({
|
||||
<div className="absolute right-0 top-full mt-2 w-64 bg-slate-800 rounded-xl shadow-2xl shadow-black/40 border border-slate-700/50 py-2 z-40">
|
||||
<div className="px-4 py-3 border-b border-slate-700/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn('w-10 h-10 rounded-xl flex items-center justify-center text-white text-sm font-bold shadow-lg', avatarClassName)}>
|
||||
{initials}
|
||||
</div>
|
||||
<UserAvatar
|
||||
name={userName || initials}
|
||||
avatarUrl={avatar}
|
||||
className={cn('h-10 w-10 rounded-xl border-0 text-white shadow-lg', avatarClassName)}
|
||||
fallbackClassName="text-sm font-bold"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{userName}</p>
|
||||
<p className="text-[10px] text-slate-400">{profileRoleLabel}</p>
|
||||
|
||||
@ -53,6 +53,7 @@ export function TopBarView({ page }: TopBarViewProps) {
|
||||
<TopBarProfileMenu
|
||||
userName={page.userName}
|
||||
initials={page.initials}
|
||||
avatar={page.avatar}
|
||||
campusInfo={page.campusInfo}
|
||||
campusLabel={page.campusLabel}
|
||||
profileRoleLabel={page.profileRoleLabel}
|
||||
|
||||
@ -13,6 +13,7 @@ import { NativeSelect } from '@/components/ui/native-select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { TenantParentPicker } from '@/components/tenant-create/TenantParentPicker';
|
||||
import { ImageUpload } from '@/components/common/ImageUpload';
|
||||
import { TenantLogo } from '@/components/common/TenantLogo';
|
||||
import { useScopeContext } from '@/contexts/scope-context';
|
||||
import { useTenantChildren } from '@/business/scope/queries';
|
||||
import { useIamCapabilities } from '@/business/iam-capabilities/hooks';
|
||||
@ -88,15 +89,6 @@ function displayName(row: TenantChild): string {
|
||||
return row.name?.trim() || row.id;
|
||||
}
|
||||
|
||||
function initials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join('') || 'L';
|
||||
}
|
||||
|
||||
function uniqueRows(rows: readonly TenantChild[]): TenantChild[] {
|
||||
const seen = new Set<string>();
|
||||
const result: TenantChild[] = [];
|
||||
@ -265,6 +257,7 @@ export default function CreateTenantPage() {
|
||||
const [editContact, setEditContact] = useState<ContactFields>(() => emptyContactFields());
|
||||
const [deleteCandidate, setDeleteCandidate] = useState<TenantChild | null>(null);
|
||||
const [expandedLocations, setExpandedLocations] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const [isLocationFormOpen, setIsLocationFormOpen] = useState(false);
|
||||
const [locationsPage, setLocationsPage] = useState(0);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
@ -429,9 +422,11 @@ export default function CreateTenantPage() {
|
||||
setPickedParentId(null);
|
||||
setOrgId('');
|
||||
setLogo(null);
|
||||
setIsLocationFormOpen(false);
|
||||
}
|
||||
|
||||
function startEdit(row: TenantChild) {
|
||||
setIsLocationFormOpen(true);
|
||||
setEditingLocation(row);
|
||||
setEditName(displayName(row));
|
||||
setEditLogo(row.logo ?? null);
|
||||
@ -446,6 +441,7 @@ export default function CreateTenantPage() {
|
||||
setEditLogo(null);
|
||||
setEditDescription('');
|
||||
setEditContact(emptyContactFields());
|
||||
setIsLocationFormOpen(false);
|
||||
}
|
||||
|
||||
function locationKey(row: TenantChild): string {
|
||||
@ -613,13 +609,11 @@ export default function CreateTenantPage() {
|
||||
>
|
||||
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</Button>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-slate-800 text-sm font-semibold text-slate-200">
|
||||
{row.logo ? (
|
||||
<img src={row.logo} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
initials(rowName)
|
||||
)}
|
||||
</div>
|
||||
<TenantLogo
|
||||
name={rowName}
|
||||
logoUrl={row.logo}
|
||||
className="h-10 w-10 bg-slate-800 text-sm text-slate-200"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-slate-200">{rowName}</p>
|
||||
<p className="text-xs text-slate-500">{TENANT_TYPE_LABELS[row.level]}</p>
|
||||
@ -692,10 +686,26 @@ export default function CreateTenantPage() {
|
||||
<Card className="border-slate-600/70 bg-slate-900/80 shadow-lg shadow-black/20">
|
||||
<CardHeader className="border-b border-slate-700/70">
|
||||
<CardTitle className="flex items-center gap-2 text-base text-slate-50">
|
||||
{editingLocation ? <Pencil size={16} /> : null}
|
||||
{editingLocation
|
||||
? `Edit ${TENANT_TYPE_LABELS[editingLocation.level]}`
|
||||
: 'New organization or location'}
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
aria-expanded={isLocationFormOpen}
|
||||
aria-controls="tenant-location-form"
|
||||
onClick={() => setIsLocationFormOpen((open) => !open)}
|
||||
>
|
||||
{editingLocation ? <Pencil size={16} /> : null}
|
||||
<span>
|
||||
{editingLocation
|
||||
? `Edit ${TENANT_TYPE_LABELS[editingLocation.level]}`
|
||||
: 'Add new organization'}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`ml-auto shrink-0 text-slate-400 transition-transform ${
|
||||
isLocationFormOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{editingLocation && (
|
||||
<Button
|
||||
type="button"
|
||||
@ -708,7 +718,8 @@ export default function CreateTenantPage() {
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLocationFormOpen && (
|
||||
<CardContent id="tenant-location-form">
|
||||
<form className="space-y-5 pt-6" onSubmit={editingLocation ? handleEditSubmit : handleSubmit}>
|
||||
{editingLocation ? (
|
||||
<>
|
||||
@ -1000,6 +1011,7 @@ export default function CreateTenantPage() {
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { FormEvent } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, Loader2, Pencil, Search, Trash2, UserPlus, Users, X } from 'lucide-react';
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronDown, Loader2, Pencil, Search, Trash2, UserPlus, Users, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@ -12,6 +12,7 @@ import { NativeSelect } from '@/components/ui/native-select';
|
||||
import { PageSkeleton } from '@/components/ui/page-skeleton';
|
||||
import { TenantParentPicker } from '@/components/tenant-create/TenantParentPicker';
|
||||
import { ImageUpload } from '@/components/common/ImageUpload';
|
||||
import { TenantLogo } from '@/components/common/TenantLogo';
|
||||
import { useScopeContext } from '@/contexts/scope-context';
|
||||
import { useAuth } from '@/contexts/useAuth';
|
||||
import { useTenantChildren } from '@/business/scope/queries';
|
||||
@ -76,6 +77,23 @@ function locationName(value?: { name?: string | null } | null): string {
|
||||
return value?.name?.trim() || '—';
|
||||
}
|
||||
|
||||
function locationCell(value?: { name?: string | null; logo?: string | null } | null) {
|
||||
const name = value?.name?.trim();
|
||||
if (!name) {
|
||||
return '—';
|
||||
}
|
||||
return (
|
||||
<span className="flex min-w-[140px] items-center gap-2">
|
||||
<TenantLogo
|
||||
name={name}
|
||||
logoUrl={value?.logo ?? null}
|
||||
className="h-7 w-7 rounded-md text-[9px]"
|
||||
/>
|
||||
<span className="truncate">{name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function sortText(value: string | null | undefined): string {
|
||||
return value?.trim().toLocaleLowerCase() || '';
|
||||
}
|
||||
@ -97,6 +115,7 @@ export default function UserAdminPage() {
|
||||
const [usersSearch, setUsersSearch] = useState('');
|
||||
const [usersSortField, setUsersSortField] = useState<UserListSortField>('name');
|
||||
const [usersSortDirection, setUsersSortDirection] = useState<UserListSortDirection>('asc');
|
||||
const [isUserFormOpen, setIsUserFormOpen] = useState(false);
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ['admin-users', usersSearch],
|
||||
queryFn: () =>
|
||||
@ -322,9 +341,11 @@ export default function UserAdminPage() {
|
||||
setStudentIds([]);
|
||||
setGrantPerms([]);
|
||||
setExcludePerms([]);
|
||||
setIsUserFormOpen(false);
|
||||
}
|
||||
|
||||
function startEdit(row: AdminUserRow) {
|
||||
setIsUserFormOpen(true);
|
||||
setEditingId(row.id);
|
||||
setNamePrefix(row.name_prefix ?? '');
|
||||
setFirstName(row.firstName ?? '');
|
||||
@ -456,8 +477,23 @@ export default function UserAdminPage() {
|
||||
<Card className="border-slate-600/70 bg-slate-900/80 shadow-lg shadow-black/20">
|
||||
<CardHeader className="border-b border-slate-700/70 px-4 py-4 md:px-5">
|
||||
<CardTitle className="flex items-center gap-2 text-base text-slate-50">
|
||||
{editingId ? <Pencil size={16} /> : <UserPlus size={16} />}
|
||||
{editingId ? 'Edit user' : 'New user'}
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
aria-expanded={isUserFormOpen}
|
||||
aria-controls="user-admin-form"
|
||||
onClick={() => setIsUserFormOpen((open) => !open)}
|
||||
>
|
||||
{editingId ? <Pencil size={16} /> : <UserPlus size={16} />}
|
||||
<span>{editingId ? 'Edit user' : 'Add new user'}</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={cn(
|
||||
'ml-auto shrink-0 text-slate-400 transition-transform',
|
||||
isUserFormOpen && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{editingId && (
|
||||
<Button
|
||||
type="button"
|
||||
@ -470,7 +506,8 @@ export default function UserAdminPage() {
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 md:px-5">
|
||||
{isUserFormOpen && (
|
||||
<CardContent id="user-admin-form" className="px-4 pb-4 md:px-5">
|
||||
<form className="space-y-5 pt-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-lg border border-slate-600/80 bg-slate-950/45 p-4 space-y-4">
|
||||
<div>
|
||||
@ -699,18 +736,6 @@ export default function UserAdminPage() {
|
||||
</details>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div
|
||||
className={`rounded-lg border px-3 py-2 text-sm ${
|
||||
status.type === 'success'
|
||||
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200'
|
||||
: 'border-red-500/30 bg-red-500/10 text-red-200'
|
||||
}`}
|
||||
>
|
||||
{status.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
@ -722,9 +747,22 @@ export default function UserAdminPage() {
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div
|
||||
className={`rounded-lg border px-3 py-2 text-sm ${
|
||||
status.type === 'success'
|
||||
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200'
|
||||
: 'border-red-500/30 bg-red-500/10 text-red-200'
|
||||
}`}
|
||||
>
|
||||
{status.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="px-4 py-4 md:px-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
@ -830,9 +868,9 @@ export default function UserAdminPage() {
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-slate-300 break-all">{row.email || '—'}</td>
|
||||
<td className="px-3 py-2.5 text-slate-300">{row.phoneNumber || '—'}</td>
|
||||
<td className="px-3 py-2.5 text-slate-300">{locationName(row.organizations)}</td>
|
||||
<td className="px-3 py-2.5 text-slate-300">{locationName(row.school)}</td>
|
||||
<td className="px-3 py-2.5 text-slate-300">{locationName(row.campus)}</td>
|
||||
<td className="px-3 py-2.5 text-slate-300">{locationCell(row.organizations)}</td>
|
||||
<td className="px-3 py-2.5 text-slate-300">{locationCell(row.school)}</td>
|
||||
<td className="px-3 py-2.5 text-slate-300">{locationCell(row.campus)}</td>
|
||||
<td className="px-3 py-2.5 text-slate-300">
|
||||
{roleName ? getAuthRoleLabel(roleName as UserRole) : '—'}
|
||||
</td>
|
||||
|
||||
@ -72,6 +72,20 @@ describe('http client', () => {
|
||||
expect(requests[0].input).toBe(`${API_BASE_URL}/auth/me`);
|
||||
expect(requests[0].init?.credentials).toBe('include');
|
||||
expect(requests[0].init?.method).toBe('GET');
|
||||
expect(requests[0].init?.cache).toBe('no-store');
|
||||
});
|
||||
|
||||
it('does not force no-store cache mode for write requests', async () => {
|
||||
const requests = stubFetch(createJsonResponse({ id: 'created' }));
|
||||
|
||||
await expect(
|
||||
apiRequest('/frame_entries', {
|
||||
method: 'POST',
|
||||
body: { formal: 'Plan' },
|
||||
}),
|
||||
).resolves.toEqual({ id: 'created' });
|
||||
|
||||
expect(requests[0].init?.cache).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for successful empty responses', async () => {
|
||||
|
||||
@ -145,6 +145,7 @@ export function setActiveTenant(
|
||||
|
||||
async function sendRequest(path: string, options: RequestOptions): Promise<Response> {
|
||||
const headers = new Headers(options.headers);
|
||||
const method = options.method || 'GET';
|
||||
|
||||
headers.set(API_HEADERS.contentType, API_CONTENT_TYPES.json);
|
||||
if (activeTenant) {
|
||||
@ -154,9 +155,10 @@ async function sendRequest(path: string, options: RequestOptions): Promise<Respo
|
||||
|
||||
return fetch(createApiUrl(path), {
|
||||
credentials: 'include',
|
||||
method: options.method || 'GET',
|
||||
method,
|
||||
headers,
|
||||
signal: options.signal,
|
||||
cache: method === 'GET' ? 'no-store' : undefined,
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@ -15,10 +15,10 @@ export interface AdminUserRow {
|
||||
readonly email: string;
|
||||
readonly avatar?: readonly { readonly privateUrl?: string | null }[];
|
||||
readonly app_role?: { id: string; name: string | null } | null;
|
||||
readonly organizations?: { id: string; name?: string | null } | null;
|
||||
readonly school?: { id: string; name?: string | null } | null;
|
||||
readonly campus?: { id: string; name?: string | null } | null;
|
||||
readonly class?: { id: string; name?: string | null } | null;
|
||||
readonly organizations?: { id: string; name?: string | null; logo?: string | null } | null;
|
||||
readonly school?: { id: string; name?: string | null; logo?: string | null } | null;
|
||||
readonly campus?: { id: string; name?: string | null; logo?: string | null } | null;
|
||||
readonly class?: { id: string; name?: string | null; logo?: string | null } | null;
|
||||
readonly campusId?: string | null;
|
||||
readonly schoolId?: string | null;
|
||||
readonly classId?: string | null;
|
||||
|
||||
@ -86,7 +86,7 @@ export const DASHBOARD_QUICK_ACTIONS: readonly DashboardQuickAction[] = [
|
||||
shadow: 'shadow-cyan-500/30',
|
||||
},
|
||||
{
|
||||
label: 'De-escalation',
|
||||
label: 'Behavior management',
|
||||
iconId: 'shield',
|
||||
module: 'qbs',
|
||||
color: 'from-blue-500 to-blue-600',
|
||||
|
||||
@ -9,7 +9,6 @@ export const DIRECTOR_DASHBOARD_FRAME_PREVIEW_LIMIT = 3;
|
||||
export const DIRECTOR_DASHBOARD_TEXT_PREVIEW_LENGTH = 60;
|
||||
export const DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD = 50;
|
||||
export const DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD = 3;
|
||||
export const DIRECTOR_DASHBOARD_HIGH_ABSENCE_THRESHOLD = 3;
|
||||
|
||||
export type DirectorQuickActionTone =
|
||||
| 'indigo'
|
||||
|
||||
@ -22,8 +22,9 @@ export interface BackendTenant {
|
||||
|
||||
export interface BackendCampus {
|
||||
readonly id: string;
|
||||
readonly name?: string;
|
||||
readonly code?: string;
|
||||
readonly name?: string | null;
|
||||
readonly code?: string | null;
|
||||
readonly logo?: string | null;
|
||||
}
|
||||
|
||||
export interface CurrentUser {
|
||||
|
||||
@ -61,6 +61,9 @@ export interface PersonalityCompletionRowDto {
|
||||
readonly userId: string;
|
||||
readonly name: string;
|
||||
readonly email: string | null;
|
||||
readonly avatar: string | null;
|
||||
readonly tenantName: string | null;
|
||||
readonly tenantLogo: string | null;
|
||||
readonly role: string | null;
|
||||
readonly status: 'complete' | 'pending';
|
||||
readonly completedKinds: readonly PersonalityQuizKind[];
|
||||
|
||||
@ -86,6 +86,9 @@ export interface PolicyAcknowledgmentReportStaffDto {
|
||||
readonly userId: string;
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
readonly avatar: string | null;
|
||||
readonly tenantName: string | null;
|
||||
readonly tenantLogo: string | null;
|
||||
readonly role: string | null;
|
||||
readonly campusId: string | null;
|
||||
readonly schoolId: string | null;
|
||||
|
||||
@ -36,6 +36,9 @@ export interface SafetyQuizCompletionRowDto {
|
||||
readonly userId: string;
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
readonly avatar: string | null;
|
||||
readonly tenantName: string | null;
|
||||
readonly tenantLogo: string | null;
|
||||
readonly role: UserRole | null;
|
||||
readonly status: 'complete' | 'pending';
|
||||
readonly result: SafetyQuizResultDto | null;
|
||||
|
||||
@ -29,6 +29,9 @@ export interface ZoneCheckinCompletionRowDto {
|
||||
readonly userId: string;
|
||||
readonly name: string;
|
||||
readonly email: string | null;
|
||||
readonly avatar: string | null;
|
||||
readonly tenantName: string | null;
|
||||
readonly tenantLogo: string | null;
|
||||
readonly role: string | null;
|
||||
readonly date: string;
|
||||
readonly status: 'complete' | 'pending';
|
||||
|
||||
@ -2,7 +2,9 @@ import type {
|
||||
CampusAttendancePrintInput,
|
||||
CampusAttendanceStats,
|
||||
CampusAttendanceSummaryViewModel,
|
||||
StaffAttendanceDailySummaryViewModel,
|
||||
} from '@/business/campus-attendance/types';
|
||||
import type { StaffAttendanceSummaryViewModel } from '@/business/staff-attendance/types';
|
||||
|
||||
export const CAMPUS_ATTENDANCE_TEST_SEED = {
|
||||
campusId: 'tigers',
|
||||
@ -42,6 +44,38 @@ export const campusAttendanceWeekRecord: CampusAttendanceSummaryViewModel = {
|
||||
notes: null,
|
||||
};
|
||||
|
||||
export const campusStaffAttendanceTodayRecord: StaffAttendanceDailySummaryViewModel = {
|
||||
id: 'staff:tigers:2026-06-08',
|
||||
campus_id: CAMPUS_ATTENDANCE_TEST_SEED.campusId,
|
||||
date: '2026-06-08',
|
||||
total_staff: 10,
|
||||
total_present: 9,
|
||||
total_absent: 1,
|
||||
total_late: 1,
|
||||
attendance_percentage: 90,
|
||||
notes: 'One staff late',
|
||||
};
|
||||
|
||||
export const campusStaffAttendanceWeekRecord: StaffAttendanceDailySummaryViewModel = {
|
||||
id: 'staff:tigers:2026-06-09',
|
||||
campus_id: CAMPUS_ATTENDANCE_TEST_SEED.campusId,
|
||||
date: '2026-06-09',
|
||||
total_staff: 10,
|
||||
total_present: 8,
|
||||
total_absent: 2,
|
||||
total_late: 0,
|
||||
attendance_percentage: 80,
|
||||
notes: null,
|
||||
};
|
||||
|
||||
export const campusStaffAttendanceSummary: StaffAttendanceSummaryViewModel = {
|
||||
staffCount: 10,
|
||||
recordsCount: 10,
|
||||
present: 8,
|
||||
late: 1,
|
||||
absent: 1,
|
||||
};
|
||||
|
||||
export const campusAttendanceStatsSeed: CampusAttendanceStats = {
|
||||
id: CAMPUS_ATTENDANCE_TEST_SEED.campusId,
|
||||
mascot: CAMPUS_ATTENDANCE_TEST_SEED.campusMascot,
|
||||
@ -57,6 +91,8 @@ export const campusAttendanceStatsSeed: CampusAttendanceStats = {
|
||||
config: null,
|
||||
recentData: [campusAttendanceTodayRecord, campusAttendanceWeekRecord],
|
||||
todayRecord: campusAttendanceTodayRecord,
|
||||
recentStaffData: [campusStaffAttendanceTodayRecord, campusStaffAttendanceWeekRecord],
|
||||
todayStaffRecord: campusStaffAttendanceTodayRecord,
|
||||
};
|
||||
|
||||
export const campusAttendancePrintInputSeed: CampusAttendancePrintInput = {
|
||||
@ -68,4 +104,5 @@ export const campusAttendancePrintInputSeed: CampusAttendancePrintInput = {
|
||||
campusesToPrint: [campusAttendanceStatsSeed],
|
||||
printTodayRecords: [campusAttendanceTodayRecord],
|
||||
printWeekRecords: [campusAttendanceTodayRecord, campusAttendanceWeekRecord],
|
||||
staffSummary: campusStaffAttendanceSummary,
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user