fixed attendance issues, removed students attendance, kept staff attendance. Added ability for teachers create and edit their calss students profiles and appropriate guardiance

This commit is contained in:
Dmitri 2026-06-23 11:51:46 +02:00
parent b1b08fea70
commit 7331acc913
61 changed files with 2391 additions and 1751 deletions

View File

@ -1,7 +1,7 @@
# Campus Attendance Backend
## Purpose
The campus attendance slice owns campus attendance system links (`campus_attendance_config`) and manually entered daily campus attendance summaries (`campus_attendance_summaries`), both scoped per organization. The backend is the source of truth for these records. The UI works with daily aggregate totals, not student-level attendance sessions; student-level data remains in the separate generated `attendance_sessions` / `attendance_records` models and is not handled here.
The campus attendance slice owns campus attendance system links (`campus_attendance_config`) and legacy manually entered daily campus attendance summaries (`campus_attendance_summaries`), both scoped per organization. The active frontend attendance workflow tracks staff attendance through `staff_attendance_records`; student/classroom aggregate entry has been removed from the UI. Student-level data remains in the separate generated `attendance_sessions` / `attendance_records` models and is not handled here.
## Slice Files (by layer)
- Route: `src/routes/campus_attendance.ts` (thin wiring; `GET /configs`, `PUT /configs/:campusKey`, `GET /summaries`, `PUT /summaries/:campusKey/:date`). Mounted at `/api/campus_attendance` behind the `authenticated` middleware in `src/index.ts`.
@ -28,7 +28,7 @@ Summary DTO fields: `id`, `campus_key`, `date` (from `attendance_date`), `total_
- Mutations (`PUT` config / summary) additionally require `FILL_ATTENDANCE` (`assertCanManageCampusAttendance`). Global-access users still pass through the shared global-access permission bypass.
- Campus-key access (`assertCanAccessCampusKey`): organization/global active scope may access any campus key in the active organization; school scope may access campus keys under the active school; campus/class scope may access only the current campus key. A mismatch or missing campus key throws `ForbiddenError`.
- The frontend does not send organization, campus UUID, creator, updater, or label fields. The backend derives them from the authenticated user (`requireOrganizationId`, `getCampusId`, `getDisplayName`, `currentUser.id`).
- Organization and school attendance entry still writes a campus-level summary. The aggregate screens choose a scoped campus and call the same `PUT /summaries/:campusKey/:date` endpoint; organization/school totals are read-time aggregates, not separate rows.
- The legacy summary mutation still writes a campus-level summary. Current frontend attendance entry no longer calls this endpoint.
## Tenant Scope
- Every read and write filters by `organizationId: requireOrganizationId(currentUser)`.
@ -50,9 +50,9 @@ Summary DTO fields: `id`, `campus_key`, `date` (from `attendance_date`), `total_
## Source-of-truth contract (Workstream 12)
Per the customer decision (2026-06-11), the **source of truth for campus attendance is manual entry by the `office_manager`** (and the higher campus/tenant roles), via the `PUT` config/summary endpoints guarded by the `FILL_ATTENDANCE` permission. There is no automatic derivation from student-level records.
Per the later staff-only attendance decision, the active attendance source of truth is `staff_attendance_records`. The campus summary endpoints remain for legacy data/API compatibility and are not used by the current frontend attendance entry form.
**Import from external office applications is deferred:** the customer expects to import these aggregates from other office apps in future, but the specific applications are not yet known, so no import endpoint is built now. When an application is chosen, add a dedicated import endpoint (server-side validated, same tenant/campus scoping) alongside the manual path; until then manual entry is the only writer. No frontend-only attendance source exists — every UI value traces to a `campus_attendance_summaries` row.
**Import from external office applications is deferred:** the customer expects to import these aggregates from other office apps in future, but the specific applications are not yet known, so no import endpoint is built now. When an application is chosen, add a dedicated import endpoint (server-side validated, same tenant/campus scoping) alongside the manual path.
## Tests
- `src/api/controllers/campus_attendance.controller.test.ts` covers controller delegation.

View File

@ -23,8 +23,8 @@ Also: every tenant-owned table carries `organizationId` (+ optional `campusId`)
|---|---|---|
| Staff acknowledge a policy/safety doc | **`policy_documents` + `policy_acknowledgments`** | per `userId × documentId × version`, idempotent. **Do not** repurpose `assessments` for acknowledgment. |
| Classroom-timer sounds (file/url/recipe) | **`audio_files`** | `kind` discriminator; recipe = synth params. |
| Campus daily attendance (working `/attendance`) | **`campus_attendance_config` + `campus_attendance_summaries`** | manual **aggregate** entry by `office_manager` (`FILL_ATTENDANCE`). NOT per-student rows. |
| Staff attendance | **`staff_attendance_records`** | the staff-attendance slice. |
| Campus attendance config / legacy aggregates | **`campus_attendance_config` + `campus_attendance_summaries`** | config plus legacy aggregate rows. Current `/attendance` entry is staff-only and does not write student/classroom aggregate rows. |
| Staff attendance (working `/attendance`) | **`staff_attendance_records`** | active staff-attendance slice for campus, school, and organization attendance rollups. |
| Weekly F.R.A.M.E. entry | **`frame_entries`** | `week_of` is the canonical Sunday-start ISO (`shared/constants/week.ts`). |
| Per-user progress / daily self-state | **`user_progress`** | `progress_type` + `item_id` + `value`. Backs sign-learned, Classroom Support favorites, and the daily zone check-in (`item_id` = campus-local date). |
| Backend-owned editable content | **`content_catalog`** | scoped/editable JSONB payloads by `content_type`; truly global static catalogs stay in frontend constants. |
@ -56,7 +56,7 @@ a coherent academic/SIS graph:
- **`attendance_sessions`** — one roll-call **event** for a class (`session_date`, `session_type`, `taken_by``users`, `class`/`class_subject`).
- **`attendance_records`** — a **student's status** in a session (`status` present/absent/late, `minutes_late`) per `studentId`.
- Two tables = one session → many student rows. **This is the per-student model and is currently unwired.** The working `/attendance` page uses the **aggregate** `campus_attendance_*` instead, and staff use `staff_attendance_records`. If per-student attendance is needed, wire these; do not fold student + staff + aggregate into one model without a deliberate decision.
- Two tables = one session → many student rows. **This is the per-student model and is currently unwired.** The working `/attendance` page uses `staff_attendance_records`; legacy `campus_attendance_*` aggregate rows remain separate. If per-student attendance is needed, wire these; do not fold student + staff + aggregate into one model without a deliberate decision.
### Scheduling (header/detail pair)

View File

@ -47,14 +47,19 @@ Invalid `startDate`/`endDate` (non-ISO) or a non-positive / non-integer `limit`
## Access Rules
Enforced by `visibilityScope` in the service:
Enforced by the service:
- A user who does NOT have `READ_STAFF_ATTENDANCE_REPORTS` sees only their own
records, scoped by `userId` (`requireUserId`).
records from `GET /records`, scoped by `userId` (`requireUserId`).
- A user with `READ_STAFF_ATTENDANCE_REPORTS` sees scope-filtered records:
organization-wide for owner/superintendent, school campuses plus users directly
assigned to that school for principal/registrar, and a single campus for
director/campus scope.
- `GET /summary` uses scope-filtered record counts for users with either
`READ_STAFF_ATTENDANCE_REPORTS` or `FILL_ATTENDANCE`; users without either
permission receive only their own summary counts. This lets attendance fillers
and report readers share the same completion signal without exposing the
report row list.
- A user with `FILL_ATTENDANCE` can upsert staff attendance only for staff users inside their
effective scope: organization office users at organization scope, school office users at school
scope, or campus users at campus/class scope.

View File

@ -62,6 +62,15 @@ record when `req.params.id`/`req.body.id` equals their own id.
otherwise `ValidationError('errors.forbidden.message')`.
- `create` rejects a duplicate email (`iam.errors.userAlreadyExists`) and a missing email
(`iam.errors.emailRequired`).
- Teacher users can be seeded with `CREATE_USERS` / `UPDATE_USERS` so they can manage student and
guardian accounts from `/my-class`. Service-level role policy limits that access to the `student`
and `guardian` target roles. Class-scope guards require student targets and requested `classId`
values to match the teacher's own `classId`; guardian updates require an existing
`guardian_students` link to a student in the teacher's class. Class-scoped user management cannot
add custom permissions or permission exclusions.
- Guardian-student links are handled by `guardian_students`. Teachers also receive
`CREATE_GUARDIAN_STUDENTS`; the link service verifies class-scoped actors only link `guardian`
users to `student` users in their own class.
## Tenant Scope
@ -70,6 +79,8 @@ record when `req.params.id`/`req.body.id` equals their own id.
(`currentUser.app_role.globalAccess`) have the org constraint removed and read across
organizations.
- On `create`, organization membership is set from `data.organizations` via `setOrganizations`.
- On `create` with `classId`, the service resolves the class and stamps the user's `campusId` and
organization from the class's campus.
- On `update`, role/org/custom-permission associations are only changed when their respective
fields are present in the input.

View File

@ -78,9 +78,9 @@ type UserListSortField =
| 'name'
| 'email'
| 'phoneNumber'
| 'organization'
| 'school'
| 'campus'
| 'class'
| 'role';
const NO_USER: CurrentUser = { id: null };
@ -253,9 +253,9 @@ function parseUserSortField(value: unknown): UserListSortField | null {
case 'name':
case 'email':
case 'phoneNumber':
case 'organization':
case 'school':
case 'campus':
case 'class':
case 'role':
return value;
default:
@ -282,18 +282,18 @@ function userListOrder(field: unknown, sort: unknown): OrderItem[] {
[col('users.lastName'), 'ASC'],
[col('users.firstName'), 'ASC'],
];
case 'organization':
return [
[col('organizations.name'), direction],
[col('users.lastName'), 'ASC'],
[col('users.firstName'), 'ASC'],
];
case 'school':
return [
[col('school.name'), direction],
[col('users.lastName'), 'ASC'],
[col('users.firstName'), 'ASC'],
];
case 'class':
return [
[col('class.name'), direction],
[col('users.lastName'), 'ASC'],
[col('users.firstName'), 'ASC'],
];
case 'campus':
return [
[col('campus.name'), direction],
@ -738,6 +738,20 @@ class UsersDBApi {
{ model: db.schools, as: 'school', required: false },
{ model: db.campuses, as: 'campus', required: false },
{ model: db.classes, as: 'class', required: false },
{
model: db.class_enrollments,
as: 'class_enrollments_student',
required: false,
attributes: ['id', 'classId', 'studentId'],
include: [
{
model: db.classes,
as: 'class',
attributes: ['id', 'name', 'logo'],
required: false,
},
],
},
{ model: db.permissions, as: 'custom_permissions', required: false },
{ model: db.permissions, as: 'custom_permissions_filter', required: false },
{ model: db.file, as: 'avatar' },

View File

@ -0,0 +1,117 @@
import { v4 as uuid } from 'uuid';
import type { QueryInterface } from 'sequelize';
import { ROLE_NAMES } from '@/shared/constants/roles';
const TEACHER_STUDENT_MANAGEMENT_PERMISSIONS = [
'CREATE_USERS',
'UPDATE_USERS',
'CREATE_GUARDIAN_STUDENTS',
] as const;
function isIdRow(value: unknown): value is { id: string } {
return (
value !== null
&& typeof value === 'object'
&& 'id' in value
&& typeof value.id === 'string'
);
}
function isNamedIdRow(value: unknown): value is { id: string; name: string } {
return (
isIdRow(value)
&& 'name' in value
&& typeof value.name === 'string'
);
}
function rows(value: unknown): readonly unknown[] {
return Array.isArray(value) ? value : [];
}
export default {
up: async (queryInterface: QueryInterface) => {
const now = new Date();
const [teacherRows] = await queryInterface.sequelize.query(
'SELECT "id" FROM "roles" WHERE "name" = :name',
{ replacements: { name: ROLE_NAMES.TEACHER } },
);
const teacherIds = rows(teacherRows).filter(isIdRow).map((row) => row.id);
if (teacherIds.length === 0) {
return;
}
const [permissionRows] = await queryInterface.sequelize.query(
'SELECT "id", "name" FROM "permissions" WHERE "name" IN (:names)',
{ replacements: { names: TEACHER_STUDENT_MANAGEMENT_PERMISSIONS } },
);
const permissionIds = new Map(
rows(permissionRows)
.filter(isNamedIdRow)
.map((permission) => [permission.name, permission.id]),
);
const missingPermissions = TEACHER_STUDENT_MANAGEMENT_PERMISSIONS
.filter((permissionName) => !permissionIds.has(permissionName));
if (missingPermissions.length > 0) {
await queryInterface.bulkInsert('permissions', missingPermissions.map((permissionName) => {
const id = uuid();
permissionIds.set(permissionName, id);
return {
id,
name: permissionName,
createdAt: now,
updatedAt: now,
};
}));
}
const permissionIdValues = TEACHER_STUDENT_MANAGEMENT_PERMISSIONS
.map((permissionName) => permissionIds.get(permissionName))
.filter((permissionId): permissionId is string => Boolean(permissionId));
if (permissionIdValues.length === 0) {
return;
}
const [existingRows] = await queryInterface.sequelize.query(
`SELECT "roles_permissionsId", "permissionId"
FROM "rolesPermissionsPermissions"
WHERE "roles_permissionsId" IN (:teacherIds)
AND "permissionId" IN (:permissionIds)`,
{ replacements: { teacherIds, permissionIds: permissionIdValues } },
);
const existingLinks = new Set(
rows(existingRows)
.filter((row): row is { roles_permissionsId: string; permissionId: string } => (
row !== null
&& typeof row === 'object'
&& 'roles_permissionsId' in row
&& typeof row.roles_permissionsId === 'string'
&& 'permissionId' in row
&& typeof row.permissionId === 'string'
))
.map((row) => `${row.roles_permissionsId}:${row.permissionId}`),
);
const missingLinks = teacherIds.flatMap((teacherId) => (
permissionIdValues
.filter((permissionId) => !existingLinks.has(`${teacherId}:${permissionId}`))
.map((permissionId) => ({
createdAt: now,
updatedAt: now,
roles_permissionsId: teacherId,
permissionId,
}))
));
if (missingLinks.length > 0) {
await queryInterface.bulkInsert('rolesPermissionsPermissions', missingLinks);
}
},
down: async () => {
// Keep permission grants on rollback; the user service enforces class/student scope.
},
};

View File

@ -0,0 +1 @@
export { default } from './20260623090000-grant-teacher-student-user-management';

View File

@ -0,0 +1 @@
export { default } from './20260623090000-grant-teacher-student-user-management';

View File

@ -24,6 +24,7 @@ import type {
HasManySetAssociationsMixin,
} from 'sequelize';
import type { Campuses } from './campuses';
import type { ClassEnrollments } from './class_enrollments';
import type { File } from './file';
import type { Messages } from './messages';
import type { Organizations } from './organizations';
@ -71,6 +72,7 @@ export class Users extends Model<
declare campus?: NonAttribute<Campuses>;
declare school?: NonAttribute<Schools>;
declare class?: NonAttribute<Classes>;
declare class_enrollments_student?: NonAttribute<ClassEnrollments[]>;
declare custom_permissions?: NonAttribute<Permissions[]>;
declare custom_permissions_filter?: NonAttribute<Permissions[]>;
declare avatar?: NonAttribute<File[]>;
@ -165,6 +167,14 @@ export class Users extends Model<
constraints: false,
});
db.users.hasMany(db.class_enrollments, {
as: 'class_enrollments_student',
foreignKey: {
name: 'studentId',
},
constraints: false,
});
db.users.hasMany(db.file, {
as: 'avatar',
foreignKey: 'belongsToId',

View File

@ -169,8 +169,12 @@ export function buildSeededPermissionNamesForRole(role: RoleName): readonly stri
}
if (READ_ONLY_ROLES.includes(role)) {
const extraEntityPermissions = role === ROLE_NAMES.TEACHER
? ['CREATE_USERS', 'UPDATE_USERS', 'CREATE_GUARDIAN_STUDENTS']
: [];
return uniquePermissionNames([
...entityPermissionNames.filter((name) => name.startsWith('READ_')),
...extraEntityPermissions,
...(MODULE_PERMISSIONS_BY_ROLE[role] ?? []),
]);
}

View File

@ -129,6 +129,38 @@ describe('user-role seed permission contract', () => {
]);
});
test('teacher user-management grants are limited to student roster writes', () => {
const teacherPermissions = granted(ROLE_NAMES.TEACHER);
const supportStaffPermissions = granted(ROLE_NAMES.SUPPORT_STAFF);
assert.equal(teacherPermissions.includes('READ_USERS'), true);
assert.equal(teacherPermissions.includes('CREATE_USERS'), true);
assert.equal(teacherPermissions.includes('UPDATE_USERS'), true);
assert.equal(teacherPermissions.includes('CREATE_GUARDIAN_STUDENTS'), true);
assert.equal(teacherPermissions.includes('DELETE_USERS'), false);
assert.equal(supportStaffPermissions.includes('CREATE_USERS'), false);
assert.equal(supportStaffPermissions.includes('UPDATE_USERS'), false);
});
test('attendance notification eligibility is seeded through fill or report permissions', () => {
const attendanceNotificationRoles = Object.values(ROLE_NAMES).filter((role) => {
const permissions = granted(role);
return permissions.includes(FEATURE_PERMISSIONS.FILL_ATTENDANCE)
|| permissions.includes(FEATURE_PERMISSIONS.READ_STAFF_ATTENDANCE_REPORTS);
});
assert.deepEqual(attendanceNotificationRoles, [
ROLE_NAMES.SYSTEM_ADMIN,
ROLE_NAMES.OWNER,
ROLE_NAMES.SUPERINTENDENT,
ROLE_NAMES.PRINCIPAL,
ROLE_NAMES.REGISTRAR,
ROLE_NAMES.DIRECTOR,
ROLE_NAMES.OFFICE_MANAGER,
ROLE_NAMES.TEACHER,
]);
});
test('ESA funding management grants match campus and parent-scope managers', () => {
const esaManagers = Object.values(ROLE_NAMES).filter((role) =>
granted(role).includes(FEATURE_PERMISSIONS.MANAGE_ESA_FUNDING_CONTENT),

View File

@ -101,6 +101,10 @@ function downloadLocal(req: Request, res: Response): void {
res.sendStatus(403);
return;
}
if (!fs.existsSync(resolved)) {
res.sendStatus(404);
return;
}
res.download(resolved);
}

View File

@ -0,0 +1,118 @@
import { afterEach, describe, mock, test } from 'node:test';
import assert from 'node:assert/strict';
import db from '@/db/models';
import GuardianStudentsService from '@/services/guardian_students';
import ValidationError from '@/shared/errors/validation';
import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles';
import { createTestUser } from '@/test-utils';
function permission(name: string) {
return { name };
}
afterEach(() => {
mock.restoreAll();
});
describe('GuardianStudentsService teacher class scope', () => {
test('links a guardian to a student enrolled in the teacher class', async () => {
const organizationId = '22222222-2222-4222-8222-222222222222';
const classId = '33333333-3333-4333-8333-333333333333';
const teacher = createTestUser({
id: '11111111-1111-4111-8111-111111111111',
organizationId,
organizations: { id: organizationId },
classId,
app_role: {
name: ROLE_NAMES.TEACHER,
scope: ROLE_SCOPES.CLASS,
globalAccess: false,
permissions: [permission('CREATE_GUARDIAN_STUDENTS')],
},
});
let createCalled = false;
mock.method(db.sequelize, 'transaction', async () => ({
commit: async () => undefined,
rollback: async () => undefined,
}));
mock.method(db.users, 'findOne', async (options: { where?: { id?: string } }) => {
if (options.where?.id === 'guardian-id') {
return { id: 'guardian-id' };
}
if (options.where?.id === 'student-id') {
return { id: 'student-id', classId: null };
}
return null;
});
mock.method(db.users, 'findByPk', async () => ({ id: 'student-id', classId: null }));
mock.method(db.class_enrollments, 'findOne', async () => ({ id: 'enrollment-id' }));
mock.method(db.guardian_students, 'findOne', async () => null);
mock.method(db.guardian_students, 'create', async () => {
createCalled = true;
return {
get: () => ({
id: 'link-id',
guardianId: 'guardian-id',
studentId: 'student-id',
relationship: null,
}),
};
});
const result = await GuardianStudentsService.link(
{ guardianId: 'guardian-id', studentId: 'student-id' },
teacher,
);
assert.equal(createCalled, true);
assert.equal(result.id, 'link-id');
});
test('rejects linking a guardian to a student outside the teacher class', async () => {
const organizationId = '22222222-2222-4222-8222-222222222222';
const teacher = createTestUser({
id: '11111111-1111-4111-8111-111111111111',
organizationId,
organizations: { id: organizationId },
classId: '33333333-3333-4333-8333-333333333333',
app_role: {
name: ROLE_NAMES.TEACHER,
scope: ROLE_SCOPES.CLASS,
globalAccess: false,
permissions: [permission('CREATE_GUARDIAN_STUDENTS')],
},
});
let createCalled = false;
mock.method(db.sequelize, 'transaction', async () => ({
commit: async () => undefined,
rollback: async () => undefined,
}));
mock.method(db.users, 'findOne', async (options: { where?: { id?: string } }) => {
if (options.where?.id === 'guardian-id') {
return { id: 'guardian-id' };
}
if (options.where?.id === 'student-id') {
return { id: 'student-id', classId: null };
}
return null;
});
mock.method(db.users, 'findByPk', async () => ({ id: 'student-id', classId: null }));
mock.method(db.class_enrollments, 'findOne', async () => null);
mock.method(db.guardian_students, 'create', async () => {
createCalled = true;
return null;
});
await assert.rejects(
() => GuardianStudentsService.link(
{ guardianId: 'guardian-id', studentId: 'student-id' },
teacher,
),
ValidationError,
);
assert.equal(createCalled, false);
});
});

View File

@ -2,6 +2,8 @@ import db from '@/db/models';
import { withTransaction } from '@/db/with-transaction';
import ValidationError from '@/shared/errors/validation';
import { getOrganizationIdOrGlobal } from '@/services/shared/access';
import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles';
import type { Transaction } from 'sequelize';
import type { GuardianStudents } from '@/db/models/guardian_students';
import type { CurrentUser } from '@/db/api/types';
@ -21,10 +23,44 @@ function orgFilter(currentUser?: CurrentUser): { organizationId?: string } {
return organizationId ? { organizationId } : {};
}
async function studentBelongsToClass(
studentId: string,
classId: string,
organizationId: string | undefined,
transaction: Transaction,
): Promise<boolean> {
const student = await db.users.findByPk(studentId, {
attributes: ['id', 'classId'],
transaction,
});
if (student?.classId === classId) {
return true;
}
const enrollment = await db.class_enrollments.findOne({
where: {
studentId,
classId,
...(organizationId ? { organizationId } : {}),
},
attributes: ['id'],
transaction,
});
return Boolean(enrollment);
}
function toDto(row: GuardianStudents) {
const plain = row.get({ plain: true }) as Record<string, unknown> & {
guardian?: { id?: string; firstName?: string; lastName?: string } | null;
student?: { id?: string; firstName?: string; lastName?: string } | null;
guardian?: {
id?: string;
name_prefix?: string | null;
firstName?: string | null;
lastName?: string | null;
email?: string;
phoneNumber?: string | null;
avatar?: readonly { privateUrl?: string | null }[];
} | null;
student?: { id?: string; firstName?: string | null; lastName?: string | null } | null;
};
return {
id: plain.id,
@ -34,8 +70,12 @@ function toDto(row: GuardianStudents) {
guardian: plain.guardian
? {
id: plain.guardian.id,
name_prefix: plain.guardian.name_prefix,
firstName: plain.guardian.firstName,
lastName: plain.guardian.lastName,
email: plain.guardian.email,
phoneNumber: plain.guardian.phoneNumber,
avatar: plain.guardian.avatar,
}
: null,
student: plain.student
@ -59,6 +99,30 @@ class GuardianStudentsService {
const where = orgFilter(currentUser);
return withTransaction(async (transaction) => {
if (currentUser?.app_role?.scope === ROLE_SCOPES.CLASS) {
if (!currentUser.classId) {
throw new ValidationError('auth.forbidden');
}
const [guardian, student] = await Promise.all([
db.users.findOne({
where: { id: guardianId, ...where },
include: [{ model: db.roles, as: 'app_role', where: { name: ROLE_NAMES.GUARDIAN }, required: true }],
transaction,
}),
db.users.findOne({
where: { id: studentId, ...where },
include: [{ model: db.roles, as: 'app_role', where: { name: ROLE_NAMES.STUDENT }, required: true }],
transaction,
}),
]);
const studentInClass = student
? await studentBelongsToClass(student.id, currentUser.classId, where.organizationId, transaction)
: false;
if (!guardian || !student || !studentInClass) {
throw new ValidationError('auth.forbidden');
}
}
const existing = await db.guardian_students.findOne({
where: { guardianId, studentId, ...where },
transaction,

View File

@ -77,7 +77,6 @@ test('read-only and external roles cannot create tenants or manage users', () =>
for (const role of [
ROLE_NAMES.REGISTRAR,
ROLE_NAMES.OFFICE_MANAGER,
ROLE_NAMES.TEACHER,
ROLE_NAMES.SUPPORT_STAFF,
ROLE_NAMES.STUDENT,
ROLE_NAMES.GUARDIAN,
@ -90,3 +89,12 @@ test('read-only and external roles cannot create tenants or manage users', () =>
assert.deepEqual(caps.manageableRoleNames, []);
}
});
test('teacher capabilities expose student and guardian management without tenant creation', () => {
const caps = IamCapabilitiesService.current(
user(ROLE_NAMES.TEACHER, ROLE_SCOPES.CLASS, ['READ_USERS', 'CREATE_USERS', 'UPDATE_USERS']),
);
assert.deepEqual(caps.creatableTenantTypes, []);
assert.deepEqual(caps.manageableRoleNames, [ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN]);
});

View File

@ -89,13 +89,20 @@ test('registrar (read-only) manages nobody', () => {
}
});
test('campus staff (e.g. teacher) cannot manage any user', () => {
test('teacher manages student and guardian users only', () => {
const teacher = actor(ROLE_NAMES.TEACHER);
for (const role of Object.values(ROLE_NAMES)) {
for (const role of Object.values(ROLE_NAMES).filter((item) => (
item !== ROLE_NAMES.STUDENT && item !== ROLE_NAMES.GUARDIAN
))) {
assert.equal(canManageUserWithRole(teacher, role), false);
}
// ...including a roleless target.
assert.equal(canManageUserWithRole(teacher, null), false);
assert.equal(canManageUserWithRole(teacher, ROLE_NAMES.STUDENT), true);
assert.equal(canManageUserWithRole(teacher, ROLE_NAMES.GUARDIAN), true);
assert.equal(canCreateUserWithRole(teacher, ROLE_NAMES.STUDENT), true);
assert.equal(canCreateUserWithRole(teacher, ROLE_NAMES.GUARDIAN), true);
assert.equal(canUpdateUserWithRole(teacher, ROLE_NAMES.STUDENT), true);
assert.equal(canUpdateUserWithRole(teacher, ROLE_NAMES.GUARDIAN), true);
});
test('a manager may act on a roleless (null) target', () => {

View File

@ -24,6 +24,7 @@ const ASSIGNABLE_ROLE_NAMES: readonly RoleName[] = Object.values(ROLE_NAMES).fil
* external), not org/system roles nor another principal.
* - `registrar`: nobody (read-only/audit assistant).
* - `director`: campus + external roles only (not director/principal/superintendent/owner/admins).
* - `teacher`: student and guardian accounts only; services also enforce own-class scope.
* - everyone else: nobody.
*/
const MANAGEABLE_ROLES_BY_ACTOR: Record<RoleName, readonly RoleName[]> = {
@ -55,7 +56,7 @@ const MANAGEABLE_ROLES_BY_ACTOR: Record<RoleName, readonly RoleName[]> = {
ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN,
],
[ROLE_NAMES.OFFICE_MANAGER]: [],
[ROLE_NAMES.TEACHER]: [],
[ROLE_NAMES.TEACHER]: [ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN],
[ROLE_NAMES.SUPPORT_STAFF]: [],
[ROLE_NAMES.STUDENT]: [],
[ROLE_NAMES.GUARDIAN]: [],
@ -92,6 +93,7 @@ export function canManageUserWithRole(
if (!actor) return false;
const manageable = MANAGEABLE_ROLES_BY_ACTOR[actor];
if (manageable.length === 0) return false;
if (actor === ROLE_NAMES.TEACHER && !isRoleName(targetRole)) return false;
if (!isRoleName(targetRole)) return true;
return manageable.includes(targetRole);
}

View File

@ -52,6 +52,47 @@ describe('StaffAttendanceService', () => {
assert.equal(Object.getOwnPropertySymbols(capturedWhere).includes(Op.or), true);
});
test('uses scope records for fill-only staff attendance summaries', async () => {
const organizationId = '11111111-1111-4111-8111-111111111111';
const campusId = '33333333-3333-4333-8333-333333333333';
const actor = createTestUser({
organizationId,
organizations: { id: organizationId },
campusId,
app_role: {
name: ROLE_NAMES.OFFICE_MANAGER,
scope: ROLE_SCOPES.CAMPUS,
globalAccess: false,
permissions: [permission(FEATURE_PERMISSIONS.FILL_ATTENDANCE)],
},
});
const recordScopes: unknown[] = [];
mock.method(db.staff_attendance_records, 'count', async (options: unknown) => {
if (isRecord(options)) {
recordScopes.push(options.where);
}
return 0;
});
mock.method(db.users, 'count', async () => 3);
await StaffAttendanceService.summary(
{ startDate: '2026-06-23', endDate: '2026-06-23' },
actor,
);
assert.equal(recordScopes.length, 3);
for (const where of recordScopes) {
assert.equal(isRecord(where), true);
if (!isRecord(where)) {
continue;
}
assert.equal(where.organizationId, organizationId);
assert.equal(where.campusId, campusId);
assert.equal('userId' in where, false);
}
});
test('upserts a school office staff attendance record inside school scope', async () => {
const organizationId = '11111111-1111-4111-8111-111111111111';
const schoolId = '22222222-2222-4222-8222-222222222222';

View File

@ -115,6 +115,35 @@ function visibilityScope(currentUser?: CurrentUser) {
return campusDimensionScope(currentUser);
}
function summaryRecordScope(currentUser?: CurrentUser) {
if (
!hasFeaturePermission(
currentUser,
FEATURE_PERMISSIONS.READ_STAFF_ATTENDANCE_REPORTS,
)
&& !hasFeaturePermission(currentUser, FEATURE_PERMISSIONS.FILL_ATTENDANCE)
) {
return { userId: requireUserId(currentUser) };
}
if (getRoleScope(currentUser) === ROLE_SCOPES.SCHOOL) {
const schoolId = getSchoolId(currentUser);
const campusSubquery = schoolId ? schoolCampusIdSubquery(schoolId) : null;
const userSubquery = schoolId ? schoolUserIdSubquery(schoolId) : null;
if (campusSubquery && userSubquery) {
return {
[Op.or]: [
{ campusId: { [Op.in]: campusSubquery } },
{ userId: { [Op.in]: userSubquery } },
],
};
}
}
return campusDimensionScope(currentUser);
}
function staffCountScope(currentUser?: CurrentUser): WhereOptions {
if (getRoleScope(currentUser) === ROLE_SCOPES.SCHOOL) {
const schoolId = getSchoolId(currentUser);
@ -282,7 +311,7 @@ class StaffAttendanceService {
// large row transfer.
const recordsWhere = {
organizationId: requireOrganizationId(currentUser),
...visibilityScope(currentUser),
...summaryRecordScope(currentUser),
...dateFilter(filter),
};

View File

@ -0,0 +1,402 @@
import { afterEach, describe, mock, test } from 'node:test';
import assert from 'node:assert/strict';
import db from '@/db/models';
import UsersDBApi from '@/db/api/users';
import UsersService from '@/services/users';
import ValidationError from '@/shared/errors/validation';
import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles';
import { createTestUser } from '@/test-utils';
function permission(name: string) {
return { name };
}
afterEach(() => {
mock.restoreAll();
});
describe('UsersService teacher class roster management', () => {
test('creates a student inside the teacher class scope', async () => {
const organizationId = '22222222-2222-4222-8222-222222222222';
const campusId = '55555555-5555-4555-8555-555555555555';
const classId = '33333333-3333-4333-8333-333333333333';
const teacher = createTestUser({
id: '11111111-1111-4111-8111-111111111111',
organizationId,
organizations: { id: organizationId },
classId,
app_role: {
name: ROLE_NAMES.TEACHER,
scope: ROLE_SCOPES.CLASS,
globalAccess: false,
permissions: [permission('CREATE_USERS')],
},
});
let createPayload: unknown = null;
mock.method(db.sequelize, 'transaction', async () => ({
commit: async () => undefined,
rollback: async () => undefined,
}));
mock.method(UsersDBApi, 'findBy', async () => null);
mock.method(db.roles, 'findByPk', async () => ({ name: ROLE_NAMES.STUDENT }));
mock.method(db.classes, 'findByPk', async () => ({
id: classId,
campusId,
organizationId,
}));
mock.method(db.campuses, 'findByPk', async () => ({
id: campusId,
schoolId: null,
organizationId,
}));
mock.method(UsersDBApi, 'create', async (payload: unknown) => {
createPayload = payload;
return { id: 'created-student-id' };
});
const result = await UsersService.create(
{
email: 'new-student@example.com',
firstName: 'New',
lastName: 'Student',
app_role: 'student-role-id',
classId,
},
teacher,
false,
);
assert.deepEqual(result, { id: 'created-student-id', organizationId: null });
assert.equal(typeof createPayload, 'object');
assert.notEqual(createPayload, null);
});
test('rejects creating a student outside the teacher class scope', async () => {
const teacher = createTestUser({
id: '11111111-1111-4111-8111-111111111111',
organizationId: '22222222-2222-4222-8222-222222222222',
organizations: { id: '22222222-2222-4222-8222-222222222222' },
classId: '33333333-3333-4333-8333-333333333333',
app_role: {
name: ROLE_NAMES.TEACHER,
scope: ROLE_SCOPES.CLASS,
globalAccess: false,
permissions: [permission('CREATE_USERS')],
},
});
let createCalled = false;
mock.method(db.sequelize, 'transaction', async () => ({
commit: async () => undefined,
rollback: async () => undefined,
}));
mock.method(UsersDBApi, 'findBy', async () => null);
mock.method(db.roles, 'findByPk', async () => ({ name: ROLE_NAMES.STUDENT }));
mock.method(UsersDBApi, 'create', async () => {
createCalled = true;
return { id: 'created-student-id' };
});
await assert.rejects(
() => UsersService.create(
{
email: 'new-student@example.com',
firstName: 'New',
lastName: 'Student',
app_role: 'student-role-id',
classId: '44444444-4444-4444-8444-444444444444',
},
teacher,
false,
),
ValidationError,
);
assert.equal(createCalled, false);
});
test('updates a student inside the teacher class scope', async () => {
const organizationId = '22222222-2222-4222-8222-222222222222';
const campusId = '55555555-5555-4555-8555-555555555555';
const classId = '33333333-3333-4333-8333-333333333333';
const teacher = createTestUser({
id: '11111111-1111-4111-8111-111111111111',
organizationId,
organizations: { id: organizationId },
classId,
app_role: {
name: ROLE_NAMES.TEACHER,
scope: ROLE_SCOPES.CLASS,
globalAccess: false,
permissions: [permission('UPDATE_USERS')],
},
});
let updateCalled = false;
mock.method(db.sequelize, 'transaction', async () => ({
commit: async () => undefined,
rollback: async () => undefined,
}));
mock.method(UsersDBApi, 'findBy', async () => ({
id: 'student-id',
organizationId,
classId,
app_role: { name: ROLE_NAMES.STUDENT },
}));
mock.method(db.roles, 'findByPk', async () => ({ name: ROLE_NAMES.STUDENT }));
mock.method(db.classes, 'findByPk', async () => ({
id: classId,
campusId,
organizationId,
}));
mock.method(db.campuses, 'findByPk', async () => ({
id: campusId,
schoolId: null,
organizationId,
}));
mock.method(UsersDBApi, 'update', async () => {
updateCalled = true;
return null;
});
await UsersService.update(
{
firstName: 'Updated',
app_role: 'student-role-id',
classId,
},
'student-id',
teacher,
);
assert.equal(updateCalled, true);
});
test('rejects updating a student outside the teacher class scope', async () => {
const teacher = createTestUser({
id: '11111111-1111-4111-8111-111111111111',
organizationId: '22222222-2222-4222-8222-222222222222',
organizations: { id: '22222222-2222-4222-8222-222222222222' },
classId: '33333333-3333-4333-8333-333333333333',
app_role: {
name: ROLE_NAMES.TEACHER,
scope: ROLE_SCOPES.CLASS,
globalAccess: false,
permissions: [permission('UPDATE_USERS')],
},
});
let updateCalled = false;
mock.method(db.sequelize, 'transaction', async () => ({
commit: async () => undefined,
rollback: async () => undefined,
}));
mock.method(UsersDBApi, 'findBy', async () => ({
id: 'student-id',
organizationId: '22222222-2222-4222-8222-222222222222',
classId: '44444444-4444-4444-8444-444444444444',
app_role: { name: ROLE_NAMES.STUDENT },
}));
mock.method(db.class_enrollments, 'findOne', async () => null);
mock.method(UsersDBApi, 'update', async () => {
updateCalled = true;
return null;
});
await assert.rejects(
() => UsersService.update(
{
firstName: 'Updated',
},
'student-id',
teacher,
),
ValidationError,
);
assert.equal(updateCalled, false);
});
test('updates an enrolled student when the user row has no direct class scope', async () => {
const organizationId = '22222222-2222-4222-8222-222222222222';
const classId = '33333333-3333-4333-8333-333333333333';
const teacher = createTestUser({
id: '11111111-1111-4111-8111-111111111111',
organizationId,
organizations: { id: organizationId },
classId,
app_role: {
name: ROLE_NAMES.TEACHER,
scope: ROLE_SCOPES.CLASS,
globalAccess: false,
permissions: [permission('UPDATE_USERS')],
},
});
let updateCalled = false;
mock.method(db.sequelize, 'transaction', async () => ({
commit: async () => undefined,
rollback: async () => undefined,
}));
mock.method(UsersDBApi, 'findBy', async () => ({
id: 'student-id',
organizationId,
classId: null,
app_role: { name: ROLE_NAMES.STUDENT },
}));
mock.method(db.class_enrollments, 'findOne', async () => ({ id: 'enrollment-id' }));
mock.method(UsersDBApi, 'update', async () => {
updateCalled = true;
return null;
});
await UsersService.update(
{
firstName: 'Updated',
},
'student-id',
teacher,
);
assert.equal(updateCalled, true);
});
test('creates a guardian from the teacher class scope', async () => {
const organizationId = '22222222-2222-4222-8222-222222222222';
const teacher = createTestUser({
id: '11111111-1111-4111-8111-111111111111',
organizationId,
organizations: { id: organizationId },
classId: '33333333-3333-4333-8333-333333333333',
app_role: {
name: ROLE_NAMES.TEACHER,
scope: ROLE_SCOPES.CLASS,
globalAccess: false,
permissions: [permission('CREATE_USERS')],
},
});
let createCalled = false;
mock.method(db.sequelize, 'transaction', async () => ({
commit: async () => undefined,
rollback: async () => undefined,
}));
mock.method(UsersDBApi, 'findBy', async () => null);
mock.method(db.roles, 'findByPk', async () => ({ name: ROLE_NAMES.GUARDIAN }));
mock.method(UsersDBApi, 'create', async () => {
createCalled = true;
return { id: 'created-guardian-id' };
});
const result = await UsersService.create(
{
email: 'guardian@example.com',
firstName: 'Pat',
lastName: 'Guardian',
app_role: 'guardian-role-id',
},
teacher,
false,
);
assert.deepEqual(result, { id: 'created-guardian-id', organizationId: null });
assert.equal(createCalled, true);
});
test('updates a linked guardian from the teacher class scope', async () => {
const organizationId = '22222222-2222-4222-8222-222222222222';
const classId = '33333333-3333-4333-8333-333333333333';
const teacher = createTestUser({
id: '11111111-1111-4111-8111-111111111111',
organizationId,
organizations: { id: organizationId },
classId,
app_role: {
name: ROLE_NAMES.TEACHER,
scope: ROLE_SCOPES.CLASS,
globalAccess: false,
permissions: [permission('UPDATE_USERS')],
},
});
let updateCalled = false;
mock.method(db.sequelize, 'transaction', async () => ({
commit: async () => undefined,
rollback: async () => undefined,
}));
mock.method(UsersDBApi, 'findBy', async () => ({
id: 'guardian-id',
organizationId,
classId: null,
app_role: { name: ROLE_NAMES.GUARDIAN },
}));
mock.method(db.roles, 'findByPk', async () => ({ name: ROLE_NAMES.GUARDIAN }));
mock.method(db.guardian_students, 'findOne', async () => ({ id: 'guardian-link-id' }));
mock.method(UsersDBApi, 'update', async () => {
updateCalled = true;
return null;
});
await UsersService.update(
{
firstName: 'Updated',
app_role: 'guardian-role-id',
},
'guardian-id',
teacher,
);
assert.equal(updateCalled, true);
});
test('updates a guardian linked to an enrolled student in the teacher class scope', async () => {
const organizationId = '22222222-2222-4222-8222-222222222222';
const classId = '33333333-3333-4333-8333-333333333333';
const teacher = createTestUser({
id: '11111111-1111-4111-8111-111111111111',
organizationId,
organizations: { id: organizationId },
classId,
app_role: {
name: ROLE_NAMES.TEACHER,
scope: ROLE_SCOPES.CLASS,
globalAccess: false,
permissions: [permission('UPDATE_USERS')],
},
});
let updateCalled = false;
let guardianStudentLookup = 0;
mock.method(db.sequelize, 'transaction', async () => ({
commit: async () => undefined,
rollback: async () => undefined,
}));
mock.method(UsersDBApi, 'findBy', async () => ({
id: 'guardian-id',
organizationId,
classId: null,
app_role: { name: ROLE_NAMES.GUARDIAN },
}));
mock.method(db.roles, 'findByPk', async () => ({ name: ROLE_NAMES.GUARDIAN }));
mock.method(db.guardian_students, 'findOne', async () => {
guardianStudentLookup += 1;
return guardianStudentLookup === 1 ? null : { id: 'guardian-link-id' };
});
mock.method(UsersDBApi, 'update', async () => {
updateCalled = true;
return null;
});
await UsersService.update(
{
firstName: 'Updated',
app_role: 'guardian-role-id',
},
'guardian-id',
teacher,
);
assert.equal(updateCalled, true);
assert.equal(guardianStudentLookup, 2);
});
});

View File

@ -13,7 +13,7 @@ import {
assertCanUpdateUserWithRole,
} from '@/services/shared/role-policy';
import { getOrganizationId, hasGlobalAccess } from '@/services/shared/access';
import { ROLE_NAMES } from '@/shared/constants/roles';
import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles';
import type { AuthenticatedUser, CurrentUser, FileInput } from '@/db/api/types';
/**
@ -103,6 +103,150 @@ function normalizeAvatarInput(data: CreateData | UpdateData): void {
data.avatar = [{ new: true, name, privateUrl, publicUrl: privateUrl }] satisfies FileInput[];
}
function assertNoCustomPermissionChanges(data: CreateData | UpdateData): void {
if (
data.custom_permissions !== undefined
|| data.custom_permissions_filter !== undefined
) {
throw new ValidationError('auth.forbidden');
}
}
function assertClassScopedUserCreate(
currentUser: CurrentUser | undefined,
targetRole: string | null | undefined,
data: CreateData,
): void {
if (currentUser?.app_role?.scope !== ROLE_SCOPES.CLASS) {
return;
}
if (!currentUser.classId) {
throw new ValidationError('auth.forbidden');
}
if (targetRole === ROLE_NAMES.STUDENT && data.classId === currentUser.classId) {
assertNoCustomPermissionChanges(data);
return;
}
if (
targetRole === ROLE_NAMES.GUARDIAN
&& data.classId === undefined
&& data.campusId === undefined
&& data.schoolId === undefined
&& (data.organizations === undefined || data.organizations === null)
) {
assertNoCustomPermissionChanges(data);
return;
}
throw new ValidationError('auth.forbidden');
}
async function isGuardianLinkedToCurrentClass(
guardianId: string,
classId: string,
transaction: Transaction,
): Promise<boolean> {
const directClassLink = await db.guardian_students.findOne({
where: { guardianId },
include: [
{
model: db.users,
as: 'student',
attributes: ['id', 'classId'],
where: { classId },
required: true,
},
],
transaction,
});
if (directClassLink) {
return true;
}
const link = await db.guardian_students.findOne({
where: { guardianId },
include: [
{
model: db.users,
as: 'student',
attributes: ['id'],
required: true,
include: [
{
model: db.class_enrollments,
as: 'class_enrollments_student',
attributes: ['id'],
where: { classId },
required: true,
},
],
},
],
transaction,
});
return Boolean(link);
}
async function isStudentInClass(
studentId: string,
classId: string,
transaction: Transaction,
): Promise<boolean> {
const enrollment = await db.class_enrollments.findOne({
where: { studentId, classId },
attributes: ['id'],
transaction,
});
return Boolean(enrollment);
}
async function assertClassScopedUserUpdate(
currentUser: CurrentUser | undefined,
target: AuthenticatedUser,
nextRole: string | null | undefined,
data: UpdateData,
transaction: Transaction,
): Promise<void> {
if (currentUser?.app_role?.scope !== ROLE_SCOPES.CLASS) {
return;
}
if (!currentUser.classId) {
throw new ValidationError('auth.forbidden');
}
const targetRole = target.app_role?.name ?? null;
const targetClassId = target.classId ?? null;
const requestedClassId = data.classId !== undefined ? data.classId : targetClassId;
const targetIsInCurrentClass = targetRole === ROLE_NAMES.STUDENT && (
targetClassId === currentUser.classId
|| await isStudentInClass(target.id, currentUser.classId, transaction)
);
const requestedClassIsAllowed =
requestedClassId === null || requestedClassId === currentUser.classId;
if (
targetRole === ROLE_NAMES.STUDENT
&& nextRole === ROLE_NAMES.STUDENT
&& targetIsInCurrentClass
&& requestedClassIsAllowed
) {
assertNoCustomPermissionChanges(data);
return;
}
if (
targetRole === ROLE_NAMES.GUARDIAN
&& nextRole === ROLE_NAMES.GUARDIAN
&& data.classId === undefined
&& data.campusId === undefined
&& data.schoolId === undefined
&& data.organizations === undefined
&& await isGuardianLinkedToCurrentClass(target.id, currentUser.classId, transaction)
) {
assertNoCustomPermissionChanges(data);
return;
}
assertNoCustomPermissionChanges(data);
throw new ValidationError('auth.forbidden');
}
class UsersService {
static async create(
data: CreateData,
@ -131,6 +275,7 @@ class UsersService {
transaction,
});
assertCanCreateUserWithRole(currentUser, newRole?.name ?? null);
assertClassScopedUserCreate(currentUser, newRole?.name ?? null, data);
// §3.4 provisioning: creating an `owner` auto-creates the company and
// links the owner to it. The org starts minimal; the owner fills it in.
@ -142,6 +287,8 @@ class UsersService {
data.organizations = organization.id;
createdOrganizationId = organization.id;
}
} else {
assertClassScopedUserCreate(currentUser, null, data);
}
// Non-global actors create users only within their own organization.
@ -263,8 +410,10 @@ class UsersService {
// new role if it is being reassigned).
assertSameTenant(currentUser, users);
assertCanUpdateUserWithRole(currentUser, users.app_role?.name ?? null);
if (data.app_role) {
const newRole = await db.roles.findByPk(data.app_role, { transaction });
const newRole = data.app_role
? await db.roles.findByPk(data.app_role, { transaction })
: null;
if (data.app_role !== undefined) {
assertCanAssignUserRole(
currentUser,
users.app_role?.name ?? null,
@ -272,6 +421,13 @@ class UsersService {
);
}
await normalizeTenantAssignment(data, transaction);
await assertClassScopedUserUpdate(
currentUser,
users,
data.app_role !== undefined ? newRole?.name ?? null : users.app_role?.name ?? null,
data,
transaction,
);
normalizeAvatarInput(data);
const updatedUser = await UsersDBApi.update(id, data, globalAccess, {

View File

@ -2,7 +2,10 @@
## Purpose
Campus attendance config and daily aggregate summaries follow the frontend three-layer architecture.
Attendance now tracks staff attendance only. The page still reads legacy campus
attendance config/summary endpoints where existing report plumbing requires
them, but visible entry, rollups, details, and dashboard-facing metrics use
`staff_attendance_records`.
```text
View -> Business Logic -> API/Data Access -> Backend
@ -42,57 +45,49 @@ API/data access layer:
- Attendance links load from `GET /api/campus_attendance/configs`.
- 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`.
- The active UI no longer reads or writes `GET /api/campus_attendance/summaries`; those backend
endpoints remain legacy plumbing outside the staff-only attendance workflow.
- 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.
`READ_STAFF_ATTENDANCE_REPORTS`. Campus views group these records by campus and date.
- 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.
- organization effective scope aggregates all scoped school/campus summaries plus organization staff attendance.
- Users with `FILL_ATTENDANCE` can enter daily student attendance from organization, school,
campus, or class effective scope. Campus/class scope can render Present/Late/Absent controls
per student when a roster is available and derives the aggregate campus summary from those rows.
Late students count as present and increment the tardy count. When no roster is available, the
form falls back to manual aggregate totals.
- Class effective scope resolves the class's parent campus for the campus attendance summary, but
loads the student roster with `users?classId=...` so the classroom form shows only students in
that classroom.
- Organization/school screens render a student attendance rollup table for every scoped child
campus. Each row is prefilled from the campus summary for the selected date; campuses without
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.
- campus effective scope shows campus staff attendance.
- class effective scope is read-only for attendance entry.
- school effective scope aggregates scoped campus staff attendance plus school staff attendance.
- organization effective scope aggregates scoped school/campus staff attendance plus organization staff attendance.
- Users with `FILL_ATTENDANCE` can enter daily staff attendance from organization, school, or
campus effective scope. Class effective scope no longer renders attendance entry controls.
- Organization office attendance targets organization-owned users without school/campus assignment.
School office attendance targets school-owned users without campus assignment. Campus attendance
targets campus-bound and class-scoped staff inside the campus.
- School and organization percentage cards use staff attendance only. Campus attendance entered by
office managers appears in school-level rollups; school-level staff attendance appears in
organization-level rollups with staff records from other schools and campuses.
- The top-bar notification uses the current staff summary as a completion signal. Users with
`FILL_ATTENDANCE` or `READ_STAFF_ATTENDANCE_REPORTS` are nudged at organization, school, or
campus effective scope until today's staff attendance record count reaches the current scope's
staff count. Class effective scope does not receive this attendance reminder.
- 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
scope's student attendance summaries and staff attendance records.
- Organization and school screens also expose office staff attendance entry as a batch table.
Organization office attendance targets organization-owned users without school/campus
assignment. School office attendance targets school-owned users without campus assignment.
Each staff row has Present/Late/Absent controls, and saving writes one record per user through
`PUT /api/staff_attendance/records/:userId/:date`. These records feed the staff attendance
summary.
- 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.
`/attendance/details/:level/:tenantId`. The details page shows staff attendance records for that
child scope.
- Each staff row has Present/Late/Absent controls, and saving writes one record per user through
`PUT /api/staff_attendance/records/:userId/:date`. Late staff count as present for attendance
percentage and remain visible as late exceptions.
- Attendance page staff summary cards load from
`GET /api/staff_attendance/summary?startDate=today&endDate=today` when the user has
`READ_STAFF_ATTENDANCE_REPORTS`; the top-bar completion reminder uses the same summary endpoint
for users with `FILL_ATTENDANCE` or `READ_STAFF_ATTENDANCE_REPORTS`.
- Campus screens show staff-only summary cards and recent staff attendance history.
- Aggregate views render only campus cards represented by scoped campus tenants or 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.
- CampusAttendance uses typed business hooks/selectors for access, form state, today, weekly, campus, overall summary calculations, and print report generation.
- CampusAttendance uses typed business hooks/selectors for access, form state, today, weekly staff rollups, campus summary calculations, and print report generation.
- Print report generation escapes dynamic strings before writing report HTML.
- Blocked print popups return an explicit print result and show a visible attendance status error.
## Verification
- `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/selectors.test.ts` covers attendance calculations, scope titles, staff daily summaries, and staff-only summary selectors.
- `frontend/src/business/campus-attendance/printReport.test.ts` covers printable report generation with separate student, staff, and combined attendance totals.
- `frontend/src/business/campus-attendance/printReport.test.ts` covers blocked-popup handling for attendance report printing.
- `frontend/src/business/campus-attendance/mappers.test.ts` covers API DTO mapping.

View File

@ -46,8 +46,10 @@ Constants:
- QBS safety quiz completion loads through `useSafetyQuizResults`.
- Emotional Intelligence and Personality Type completion loads through `usePersonalityCompletion`.
- Daily Zone Check-In completion loads through `useZoneCheckInCompletion`.
- Staff attendance records and summary load through staff attendance business
hooks with the selected period range.
- Staff attendance records load through staff attendance business hooks with
today's date as both `startDate` and `endDate`. Attendance risk areas and the
Staff Attendance overview card always reflect today's staff attendance, not
the selected Week / Month / Quarter period.
- Policy acknowledgment report loads through `usePolicyAcknowledgmentReport`.
- Overview metrics, risk areas, unified quiz result rows, and FRAME previews are derived in business selectors.
- Document acknowledgment tracking renders as a collapsible section in the main

View File

@ -207,8 +207,9 @@ The active frontend already has:
- Safety quiz results under `frontend/src/business/safety-quiz/`, with typed API calls in `frontend/src/shared/api/safetyQuizResults.ts`.
- Walk-through check-ins under `frontend/src/business/walkthrough/`, with typed API calls in `frontend/src/shared/api/walkthrough.ts`, shared constants in `frontend/src/shared/constants/walkthrough.ts`, and summary calculations in typed selectors.
- Communications under `frontend/src/business/communications/`, with typed API calls in `frontend/src/shared/api/communications.ts` for parent messages, internal alerts, and dashboard upcoming events.
- My Class under `frontend/src/business/my-class/` and `frontend/src/pages/modules/MyClassPage.tsx`, with class roster queries plus student-only create/edit payload selectors that reuse the shared Users API while backend service guards enforce own-class scope.
- EI/personality results under `frontend/src/business/personality/`, with typed API calls in `frontend/src/shared/api/personality.ts`, DTO mappers, distribution selectors, workflow-specific hook files, and explicit loading/error states in the view.
- Campus attendance config and daily summaries under `frontend/src/business/campus-attendance/`, with typed API calls in `frontend/src/shared/api/campusAttendance.ts`, DTO mappers, summary selectors, and explicit loading/error states in the view.
- Campus attendance config and staff-only attendance rollups under `frontend/src/business/campus-attendance/`, with typed API calls through `frontend/src/shared/api/campusAttendance.ts` for config and `frontend/src/shared/api/staffAttendance.ts` for daily staff records, DTO mappers, staff rollup selectors, and explicit loading/error states in the view.
- Staff attendance snapshot and director staff counts under `frontend/src/business/staff-attendance/`, with typed API calls in `frontend/src/shared/api/staffAttendance.ts`, DTO mappers, rollup selectors, and explicit loading/error states in the view.
- Handbook policies and safety protocols under `frontend/src/business/policies/` and `frontend/src/business/safety-protocols/`, backed by the unified `policy_documents` store (`frontend/src/shared/api/policyDocuments.ts` + `policyAcknowledgments.ts`), with DTO mappers, selectors, persistent acknowledgment, and explicit loading/error states in the view.
- Classroom timer sounds are a unified library: hardcoded built-ins (local Web Audio), generated `recipe` rows, and uploaded `file`/`url` rows from `frontend/src/business/audio-files/`. Generation uses a local stub (`business/audio-files/generate.ts`) pending an AI key; playback branches by kind.

View File

@ -0,0 +1,49 @@
# My Class Integration
## Purpose
`/my-class` gives class-scoped staff a roster view for their assigned class. The page lists students,
linked guardians, and assigned class staff. Teacher users with the required user-management
permissions can add and edit student user accounts directly from the Students section, including
linked guardian accounts for each student.
## Slice Files
- Page: `frontend/src/pages/modules/MyClassPage.tsx`
- Business API re-exports: `frontend/src/business/my-class/api.ts`
- Business selectors: `frontend/src/business/my-class/selectors.ts`
- Shared APIs reused from User Admin: `frontend/src/shared/api/users.ts`, `frontend/src/shared/api/roles.ts`
- Shared UI reused from User Admin: `ImageUpload`, `UserAvatar`, common inputs/selects/buttons
## Behavior
- The page requires the signed-in user to have a `classId`; otherwise it shows the existing
unassigned-class message.
- The Students section lists class-scoped student users from `GET /api/users?classId=<classId>`.
- The add/edit form is collapsible. It is fixed to the current class, the seeded `student` role id,
and the seeded `guardian` role id. It does not show arbitrary role, tenant, or custom-permission
controls.
- Form fields mirror the reusable User Admin identity fields: avatar, title, first name, last name,
email, and phone number.
- Creating a student calls `POST /api/users` with `app_role=<student role id>` and the current
`classId`; editing calls `PUT /api/users/:id` with the same fixed role and class scope.
- The same form includes a repeatable Guardians section with photo upload. When guardian fields are
entered, the UI creates or updates each `guardian` user through the shared user API, then calls
`POST /api/guardian_students` to link that guardian to the student. The link endpoint is
idempotent.
- On edit, all existing guardian links for the student are loaded into the Guardians section. The
teacher can add additional unsaved guardian rows from the same form.
- The UI enables create/edit controls only when the current user has `CREATE_USERS` or
`UPDATE_USERS`, a current `classId`, and the `student` / `guardian` role ids have loaded.
- Backend user service guards remain authoritative: class-scoped users can only manage student
accounts in their own class and guardian accounts linked to students in their own class. The
guardian-student link service also verifies that class-scoped links connect a `guardian` user to
a `student` user in the teacher's own class. Class-scoped user management cannot add custom
permissions or permission exclusions.
## Tests
- `frontend/src/business/my-class/selectors.test.ts` covers payload normalization and permission /
class / role gating.
- Backend coverage lives in `backend/src/services/users.test.ts`,
`backend/src/services/shared/role-policy.test.ts`, and `backend/src/db/seeders/user-roles.test.ts`.

View File

@ -47,8 +47,9 @@ Shared config:
- Manager reminders are permission-gated and derived from current status queries:
`MANAGE_CONTENT_CATALOG` organization users are nudged to select the current
week's Sign of the Week, `MANAGE_FRAME` users are nudged when the current
week has no F.R.A.M.E. entry, and `FILL_ATTENDANCE` users are nudged when no
attendance row exists for today in their current attendance scope.
week has no F.R.A.M.E. entry, and users with `FILL_ATTENDANCE` or
`READ_STAFF_ATTENDANCE_REPORTS` are nudged until today's staff attendance
records cover the current scope's staff count.
- Selectors handle initials, campus label fallback, shared role labels, and unread notification count.
- **Header search** (`TopBarSearch`) is a combobox over the user's **accessible modules** (permission- and scope-filtered via `getScopedModules`) **plus their product content** from the content catalog (classroom strategies, sign-language signs, regulation zones). Content is fetched **lazily** (only once the user types, and only for modules available in the current effective scope) via `useContentCatalogPayload({ enabled })`; results are combined by `buildTopBarSearchResults` (modules first, then content, capped). Selecting a result navigates to its module (`setCurrentModule`) and clears the query. Keyboard: ↑/↓ to move, Enter to open, Esc to close; the dropdown closes on outside click (`useOnClickOutside`). The backend `/api/search` is a separate admin SIS-record search and is intentionally **not** used here.
- View components receive a prepared page model and do not call API/data access modules.

View File

@ -54,6 +54,7 @@ const scopedModules: readonly Module[] = [
{ id: 'platform-dashboard', name: 'Platform', icon: 'chart', permissions: ['READ_PLATFORM_DASHBOARD'], color: '', routePath: '/platform-dashboard' },
{ id: 'dashboard', name: 'Dashboard', icon: 'home', permissions: ['READ_DASHBOARD'], color: '', routePath: '/dashboard' },
{ id: 'classroom', name: 'Classroom Support', icon: 'book', permissions: ['READ_CLASSROOM'], color: '', routePath: '/classroom-support' },
{ id: 'attendance', name: 'Attendance', icon: 'clock', permissions: ['READ_ATTENDANCE'], color: '', routePath: '/attendance' },
{ id: 'timer', name: 'Timer', icon: 'timer', permissions: ['READ_TIMER'], color: '', routePath: '/timer' },
{ id: 'qbs', name: 'Behavior Management', icon: 'shield', permissions: ['READ_QBS'], color: '', routePath: '/qbs-safety' },
{ id: 'zones', name: 'Zones', icon: 'layers', permissions: ['READ_ZONES'], color: '', routePath: '/zones-of-regulation' },
@ -105,6 +106,15 @@ describe('app-shell selectors', () => {
expect(getScopedModules(scopedModules, behaviorUser, 'class', false).map((m) => m.id)).toEqual(['qbs']);
});
it('hides Attendance from class scope', () => {
const attendanceUser = user(['READ_ATTENDANCE']);
expect(getScopedModules(scopedModules, attendanceUser, 'organization', false).map((m) => m.id)).toEqual(['attendance']);
expect(getScopedModules(scopedModules, attendanceUser, 'school', false).map((m) => m.id)).toEqual(['attendance']);
expect(getScopedModules(scopedModules, attendanceUser, 'campus', false).map((m) => m.id)).toEqual(['attendance']);
expect(getScopedModules(scopedModules, attendanceUser, 'class', false).map((m) => m.id)).toEqual([]);
});
it('never shows the Director Dashboard via drill-down', () => {
expect(
getScopedModules(scopedModules, user(['READ_DASHBOARD', 'READ_DIRECTOR_DASHBOARD']), 'campus', true).map((m) => m.id),

View File

@ -32,6 +32,7 @@ const MODULE_SCOPE_TIERS: Partial<Record<ModuleId, readonly ScopeTier[]>> = {
'organization-management': ['global', 'organization', 'school', 'campus'],
'user-admin': ['global', 'organization', 'school', 'campus'],
class: ['class'],
attendance: ['organization', 'school', 'campus'],
classroom: ['organization', 'school', 'campus', 'class'],
timer: ['class'],
qbs: ['organization', 'school', 'campus', 'class'],

View File

@ -2,32 +2,19 @@ import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import {
listCampusAttendanceConfigs,
listCampusAttendanceSummaries,
saveCampusAttendanceConfig,
saveCampusAttendanceSummary,
} from '@/shared/api/campusAttendance';
import { useCampusCatalog } from '@/business/campuses/hooks';
import { CAMPUS_ATTENDANCE_QUERY_KEYS } from '@/shared/constants/campusAttendance';
import { findCampusByNameOrCode } from '@/shared/constants/campusDisplay';
import { UI_FEEDBACK_CLEAR_DELAY_MS } from '@/shared/constants/ui';
import type {
CampusAttendanceCampusKey,
CampusAttendanceListFilter,
} from '@/shared/types/campusAttendance';
import type { CampusAttendanceCampusKey } from '@/shared/types/campusAttendance';
import { toCampusAttendanceConfigViewModel } from '@/business/campus-attendance/mappers';
import {
toCampusAttendanceConfigViewModel,
toCampusAttendanceSummaryMutationDto,
toCampusAttendanceSummaryViewModel,
} from '@/business/campus-attendance/mappers';
import {
buildAttendanceEntryInput,
buildCampusAttendanceScopeModel,
buildCampusAttendanceStats,
buildCombinedAttendanceStats,
buildOverallAttendanceStats,
getToday,
getTodayPercentage,
getWeeklyAverage,
getWeekEnd,
getWeekStart,
} from '@/business/campus-attendance/selectors';
@ -42,9 +29,6 @@ import {
} from '@/business/campus-attendance/printReport';
import type {
CampusAttendanceChildStats,
CampusAttendanceEntryDraft,
CampusAttendanceEntryInput,
CampusAttendanceRollupDraft,
AttendanceRosterStatus,
StaffAttendanceEntryDraft,
} from '@/business/campus-attendance/types';
@ -63,7 +47,6 @@ 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[] = [];
@ -96,28 +79,6 @@ export function useSaveCampusAttendanceConfig() {
});
}
export function useCampusAttendanceSummaries(filter?: CampusAttendanceListFilter, enabled = true) {
return useQuery({
queryKey: [CAMPUS_ATTENDANCE_QUERY_KEYS.summaries, filter],
enabled,
queryFn: () => mapApiListRows(
listCampusAttendanceSummaries(filter),
toCampusAttendanceSummaryViewModel,
),
});
}
export function useSaveCampusAttendanceSummary() {
return useInvalidatingMutation({
mutationFn: (input: CampusAttendanceEntryInput) => saveCampusAttendanceSummary(
input.campusId,
input.date,
toCampusAttendanceSummaryMutationDto(input),
),
invalidateQueryKey: [CAMPUS_ATTENDANCE_QUERY_KEYS.summaries],
});
}
export function useAttendanceDetailsChildCampuses(
level: TenantLevel | undefined,
tenantId: string | undefined,
@ -149,16 +110,6 @@ type UseCampusAttendancePageInput = {
readonly userName: string;
};
const emptyEntryDraft = (date: string, campusId: CampusId = ''): CampusAttendanceEntryDraft => ({
date,
campusId,
enrolled: '',
present: '',
absent: '',
tardy: '',
notes: '',
});
const emptyStaffEntryDraft = (date: string): StaffAttendanceEntryDraft => ({
date,
userId: '',
@ -167,10 +118,6 @@ const emptyStaffEntryDraft = (date: string): StaffAttendanceEntryDraft => ({
});
type AttendanceStatusMap = Record<string, AttendanceRosterStatus>;
type StudentRollupOverrideMap = Record<CampusId, Partial<Pick<
CampusAttendanceRollupDraft,
'enrolled' | 'present' | 'absent' | 'tardy' | 'notes'
>>>;
function userDisplayName(user: AdminUserRow): string {
const fullName = `${user.firstName ?? ''} ${user.lastName ?? ''}`.trim();
@ -188,28 +135,6 @@ function reconcileAttendanceStatuses(
return next;
}
function isBlankRollupRow(row: CampusAttendanceRollupDraft): boolean {
return !row.enrolled && !row.present && !row.absent && !row.tardy && !row.notes.trim();
}
function toRollupEntryInput(
row: CampusAttendanceRollupDraft,
date: string,
): CampusAttendanceEntryInput | null {
return buildAttendanceEntryInput(
{
date,
campusId: row.campusId,
enrolled: row.enrolled,
present: row.present,
absent: row.absent,
tardy: row.tardy,
notes: row.notes,
},
row.campusId,
);
}
function isOfficeStaffUser(user: AdminUserRow, mode: 'organization' | 'school' | 'campus'): boolean {
const role = user.app_role?.name;
if (role === 'student' || role === 'guardian') {
@ -294,14 +219,6 @@ async function listScopedAttendanceChildren(
};
}
function percentageFromRecords(
records: readonly ReturnType<typeof toCampusAttendanceSummaryViewModel>[],
): number | null {
const enrolled = records.reduce((sum, record) => sum + record.total_enrolled, 0);
const present = records.reduce((sum, record) => sum + record.total_present, 0);
return enrolled > 0 ? Number(((present / enrolled) * 100).toFixed(2)) : null;
}
function percentageFromStaffDailySummaries(
records: readonly { readonly total_staff: number; readonly total_present: number }[],
): number | null {
@ -310,6 +227,17 @@ function percentageFromStaffDailySummaries(
return staff > 0 ? Number(((present / staff) * 100).toFixed(2)) : null;
}
function averageStaffAttendancePercentage(
records: readonly { readonly date: string; readonly attendance_percentage: number }[],
weekStart: string,
weekEnd: string,
): number | null {
const weekRecords = records.filter((record) => record.date >= weekStart && record.date <= weekEnd);
return weekRecords.length > 0
? Number((weekRecords.reduce((sum, record) => sum + record.attendance_percentage, 0) / weekRecords.length).toFixed(2))
: null;
}
export function useCampusAttendancePage({
userRole,
userCampus,
@ -329,7 +257,6 @@ export function useCampusAttendancePage({
effectiveTier === 'organization'
|| effectiveTier === 'school'
|| effectiveTier === 'campus'
|| effectiveTier === 'class'
),
canPrint: userRole === 'superintendent' || userRole === 'director' || userRole === 'office_manager' || permissions.has('READ_STAFF_ATTENDANCE_REPORTS'),
canReadStaffReports: permissions.has('READ_STAFF_ATTENDANCE_REPORTS'),
@ -356,7 +283,6 @@ export function useCampusAttendancePage({
campusInfo?.fullName || userCampus,
);
const attendanceCampusId = scopeModel.campusId;
const attendanceSummaryFilter = attendanceCampusId ? { campusKey: attendanceCampusId } : undefined;
const scopedAttendanceChildrenQuery = useQuery({
queryKey: ['attendance-scoped-children', effectiveTier, effectiveTenant?.id ?? null],
enabled: Boolean(
@ -371,10 +297,6 @@ export function useCampusAttendancePage({
attendanceCampusId ?? undefined,
hasAttendanceScope,
);
const summariesQuery = useCampusAttendanceSummaries(
attendanceSummaryFilter,
hasAttendanceScope,
);
const staffSummaryQuery = useStaffAttendanceSummary(
{ startDate: today, endDate: today },
roleAccess.canReadStaffReports && hasAttendanceScope,
@ -389,11 +311,9 @@ export function useCampusAttendancePage({
queryFn: ({ signal }) => listUsers({ limit: 500, field: 'name', sort: 'asc' }, { signal }),
});
const saveConfigMutation = useSaveCampusAttendanceConfig();
const saveSummaryMutation = useSaveCampusAttendanceSummary();
const saveStaffAttendanceMutation = useSaveStaffAttendanceRecord();
const configs = configsQuery.data ?? EMPTY_CONFIGS;
const attendanceData = summariesQuery.data ?? EMPTY_SUMMARIES;
const staffSummary = staffSummaryQuery.data ?? null;
const staffRecords = staffRecordsQuery.data ?? EMPTY_STAFF_RECORDS;
const officeStaffUsers = useMemo(() => (
@ -418,21 +338,19 @@ export function useCampusAttendancePage({
const loading = campusCatalog.isLoading
|| (effectiveTier === 'class' && classQuery.isLoading)
|| configsQuery.isLoading
|| summariesQuery.isLoading
|| (roleAccess.canReadStaffReports && staffSummaryQuery.isLoading)
|| (roleAccess.canReadStaffReports && staffRecordsQuery.isLoading)
|| (roleAccess.canEnterData && officeStaffQuery.isLoading)
|| (roleAccess.canSeeAllCampuses && scopedAttendanceChildrenQuery.isLoading);
const saving = saveConfigMutation.isPending || saveSummaryMutation.isPending || saveStaffAttendanceMutation.isPending;
const saving = saveConfigMutation.isPending || saveStaffAttendanceMutation.isPending;
const loadError = campusCatalog.error
?? (effectiveTier === 'class' ? classQuery.error : null)
?? configsQuery.error
?? summariesQuery.error
?? (roleAccess.canReadStaffReports ? staffSummaryQuery.error : null)
?? (roleAccess.canReadStaffReports ? staffRecordsQuery.error : null)
?? (roleAccess.canEnterData ? officeStaffQuery.error : null)
?? (roleAccess.canSeeAllCampuses ? scopedAttendanceChildrenQuery.error : null);
const saveError = saveConfigMutation.error ?? saveSummaryMutation.error ?? saveStaffAttendanceMutation.error;
const saveError = saveConfigMutation.error ?? saveStaffAttendanceMutation.error;
const [successMessage, setSuccessMessage] = useState('');
const [printError, setPrintError] = useState<string | null>(null);
const errorMessage = printError ?? getOptionalErrorMessage(loadError ?? saveError);
@ -440,17 +358,13 @@ export function useCampusAttendancePage({
const [linkValue, setLinkValue] = useState('');
const [showEntryForm, setShowEntryForm] = useState(false);
const [expandedCampus, setExpandedCampus] = useState<CampusId | null>(null);
const [entryDraft, setEntryDraft] = useState<CampusAttendanceEntryDraft>(() => emptyEntryDraft(today, attendanceCampusId ?? ''));
const [staffEntryDraft, setStaffEntryDraft] = useState<StaffAttendanceEntryDraft>(() => emptyStaffEntryDraft(today));
const [entryError, setEntryError] = useState<string | null>(null);
const [staffEntryError, setStaffEntryError] = useState<string | null>(null);
const [studentAttendanceStatusOverrides, setStudentAttendanceStatusOverrides] = useState<AttendanceStatusMap>({});
const [staffAttendanceStatusOverrides, setStaffAttendanceStatusOverrides] = useState<AttendanceStatusMap>({});
const [studentRollupOverrides, setStudentRollupOverrides] = useState<StudentRollupOverrideMap>({});
const campusStats = useMemo(
() => buildCampusAttendanceStats(campusCatalog.campuses, configs, attendanceData, today, weekStart, weekEnd, staffRecords),
[attendanceData, campusCatalog.campuses, configs, staffRecords, today, weekEnd, weekStart],
() => buildCampusAttendanceStats(campusCatalog.campuses, configs, today, weekStart, weekEnd, staffRecords),
[campusCatalog.campuses, configs, staffRecords, today, weekEnd, weekStart],
);
const visibleCampusStats = useMemo(() => {
if (scopeModel.mode === 'campus') {
@ -462,11 +376,10 @@ export function useCampusAttendancePage({
const scopedCampusIds = new Set([
...scopedCampusOptions.map((campus) => campus.id),
...configs.map((config) => config.campus_id),
...attendanceData.map((record) => record.campus_id),
]);
return campusStats.filter((campus) => scopedCampusIds.has(campus.id));
}, [attendanceCampusId, attendanceData, campusStats, configs, scopeModel.mode, scopedCampusOptions]);
}, [attendanceCampusId, campusStats, configs, scopeModel.mode, scopedCampusOptions]);
const attendanceChildStats = useMemo((): readonly CampusAttendanceChildStats[] => {
if (scopeModel.mode === 'campus') {
return visibleCampusStats.map((campus) => ({
@ -476,10 +389,8 @@ export function useCampusAttendancePage({
fullName: campus.fullName,
bgGradient: campus.bgGradient,
isOnline: campus.isOnline,
todayPct: campus.todayPct,
weekAvg: campus.weekAvg,
recentData: campus.recentData,
todayRecord: campus.todayRecord,
todayPct: campus.todayStaffRecord?.attendance_percentage ?? null,
weekAvg: averageStaffAttendancePercentage(campus.recentStaffData, weekStart, weekEnd),
childCampusIds: [campus.id],
recentStaffData: campus.recentStaffData,
todayStaffRecord: campus.todayStaffRecord,
@ -499,10 +410,8 @@ export function useCampusAttendancePage({
fullName: campus?.fullName ?? child.name ?? 'Campus',
bgGradient: campus?.bgGradient ?? SCHOOL_TILE_GRADIENTS[index % SCHOOL_TILE_GRADIENTS.length],
isOnline: campus?.isOnline,
todayPct: campus?.todayPct ?? null,
weekAvg: campus?.weekAvg ?? null,
recentData: campus?.recentData ?? [],
todayRecord: campus?.todayRecord ?? null,
todayPct: campus?.todayStaffRecord?.attendance_percentage ?? null,
weekAvg: averageStaffAttendancePercentage(campus?.recentStaffData ?? [], weekStart, weekEnd),
childCampusIds: campus ? [campus.id] : [],
recentStaffData: campus?.recentStaffData ?? [],
todayStaffRecord: campus?.todayStaffRecord ?? null,
@ -517,14 +426,6 @@ export function useCampusAttendancePage({
.map((campusChild) => campusCatalog.campuses.find((campus) => campus.tenantId === campusChild.id))
.filter((campus): campus is CampusInfo => Boolean(campus));
const childCampusIds = childCampuses.map((campus) => campus.id);
const childTodayRecords = attendanceData.filter((record) => (
childCampusIds.includes(record.campus_id) && record.date === today
));
const childWeekRecords = attendanceData.filter((record) => (
childCampusIds.includes(record.campus_id)
&& record.date >= weekStart
&& record.date <= weekEnd
));
const childStaffRecords = campusStats
.filter((campus) => childCampusIds.includes(campus.id))
.flatMap((campus) => campus.recentStaffData);
@ -542,12 +443,7 @@ export function useCampusAttendancePage({
notes: null,
}
: null;
const weekDays = Array.from(new Set(childWeekRecords.map((record) => record.date)));
const weekAvg = weekDays.length > 0
? Number((weekDays.reduce((sum, day) => (
sum + (percentageFromRecords(childWeekRecords.filter((record) => record.date === day)) ?? 0)
), 0) / weekDays.length).toFixed(2))
: null;
const weekAvg = averageStaffAttendancePercentage(childStaffRecords, weekStart, weekEnd);
return {
id: child.id,
@ -555,17 +451,14 @@ export function useCampusAttendancePage({
mascot: child.name ?? 'School',
fullName: child.name ?? 'School',
bgGradient: SCHOOL_TILE_GRADIENTS[index % SCHOOL_TILE_GRADIENTS.length],
todayPct: percentageFromRecords(childTodayRecords),
todayPct: todayStaffRecord?.attendance_percentage ?? null,
weekAvg,
recentData: childWeekRecords.slice(0, 10),
todayRecord: null,
childCampusIds,
recentStaffData: childStaffRecords.slice(0, 10),
todayStaffRecord,
};
});
}, [
attendanceData,
campusCatalog.campuses,
campusChildrenByParentId,
campusStats,
@ -576,58 +469,16 @@ export function useCampusAttendancePage({
weekEnd,
weekStart,
]);
const overallStats = useMemo(
() => buildOverallAttendanceStats(attendanceData, today, weekStart, weekEnd),
[attendanceData, today, weekEnd, weekStart],
);
const combinedStats = useMemo(
() => buildCombinedAttendanceStats(
overallStats,
staffSummary,
scopeModel.mode === 'campus' ? undefined : attendanceChildStats,
),
[attendanceChildStats, overallStats, scopeModel.mode, staffSummary],
[attendanceChildStats, scopeModel.mode, staffSummary],
);
const myCampusConfig = attendanceCampusId ? configs.find((config) => config.campus_id === attendanceCampusId) : undefined;
const myCampusData = attendanceCampusId ? attendanceData.filter((record) => record.campus_id === attendanceCampusId) : [];
const myCampusStats = attendanceCampusId ? visibleCampusStats.find((campus) => campus.id === attendanceCampusId) : undefined;
const myStaffData = myCampusStats?.recentStaffData ?? [];
const myTodayPct = attendanceCampusId ? getTodayPercentage(attendanceData, attendanceCampusId, today) : null;
const myWeekAvg = attendanceCampusId ? getWeeklyAverage(attendanceData, attendanceCampusId, weekStart, weekEnd) : null;
const selectedEntryCampus = scopedCampusOptions.find((campus) => campus.id === entryDraft.campusId);
const selectedEntryCampusTenantId = scopeModel.mode === 'campus'
? scopedCampusTenantId
: selectedEntryCampus?.tenantId ?? null;
const selectedEntryClassId = scopeModel.mode === 'campus' ? selectedClassId : null;
const attendanceStudentsQuery = useQuery({
queryKey: ['attendance-student-users', selectedEntryCampusTenantId, selectedEntryClassId],
enabled: roleAccess.canEnterData
&& showEntryForm
&& Boolean(selectedEntryClassId || selectedEntryCampusTenantId),
queryFn: ({ signal }) => listUsers({
app_role: 'student',
classId: selectedEntryClassId ?? undefined,
campusId: selectedEntryClassId ? undefined : selectedEntryCampusTenantId ?? undefined,
limit: 1000,
field: 'name',
sort: 'asc',
}, { signal }),
});
const attendanceStudents = useMemo(() => (
(attendanceStudentsQuery.data?.rows ?? []).map((user) => ({
id: user.id,
name: userDisplayName(user),
role: user.app_role?.name ?? null,
}))
), [attendanceStudentsQuery.data?.rows]);
const studentAttendanceStatuses = useMemo(
() => reconcileAttendanceStatuses(
studentAttendanceStatusOverrides,
attendanceStudents.map((student) => student.id),
),
[attendanceStudents, studentAttendanceStatusOverrides],
);
const staffAttendanceStatuses = useMemo(
() => reconcileAttendanceStatuses(
staffAttendanceStatusOverrides,
@ -635,77 +486,23 @@ export function useCampusAttendancePage({
),
[officeStaffUsers, staffAttendanceStatusOverrides],
);
const studentRollupRows = useMemo((): readonly CampusAttendanceRollupDraft[] => {
if (scopeModel.mode === 'campus') {
return [];
}
return scopedCampusOptions.map((campus) => {
const todayRecord = attendanceData.find((record) => (
record.campus_id === campus.id && record.date === entryDraft.date
));
const override = studentRollupOverrides[campus.id] ?? {};
return {
campusId: campus.id,
campusName: campus.fullName,
enrolled: override.enrolled ?? (todayRecord ? String(todayRecord.total_enrolled) : ''),
present: override.present ?? (todayRecord ? String(todayRecord.total_present) : ''),
absent: override.absent ?? (todayRecord ? String(todayRecord.total_absent) : ''),
tardy: override.tardy ?? (todayRecord ? String(todayRecord.total_tardy) : ''),
notes: override.notes ?? todayRecord?.notes ?? '',
hasRecordedData: Boolean(todayRecord),
};
});
}, [attendanceData, entryDraft.date, scopeModel.mode, scopedCampusOptions, studentRollupOverrides]);
const showSuccess = (message: string) => {
setPrintError(null);
setSuccessMessage(message);
window.setTimeout(() => setSuccessMessage(''), UI_FEEDBACK_CLEAR_DELAY_MS);
};
const updateEntryDraft = (patch: Partial<CampusAttendanceEntryDraft>) => {
setEntryDraft((currentDraft) => ({ ...currentDraft, ...patch }));
setEntryError(null);
};
const updateStaffEntryDraft = (patch: Partial<StaffAttendanceEntryDraft>) => {
setStaffEntryDraft((currentDraft) => ({ ...currentDraft, ...patch }));
setStaffEntryError(null);
};
const updateStudentAttendanceStatus = (userId: string, status: AttendanceRosterStatus) => {
setStudentAttendanceStatusOverrides((currentStatuses) => ({ ...currentStatuses, [userId]: status }));
setEntryError(null);
};
const updateStudentRollupDraft = (
campusIdToUpdate: CampusId,
patch: Partial<Pick<CampusAttendanceRollupDraft, 'enrolled' | 'present' | 'absent' | 'tardy' | 'notes'>>,
) => {
setStudentRollupOverrides((currentOverrides) => ({
...currentOverrides,
[campusIdToUpdate]: {
...currentOverrides[campusIdToUpdate],
...patch,
},
}));
setEntryError(null);
};
const updateStaffAttendanceStatus = (userId: string, status: AttendanceRosterStatus) => {
setStaffAttendanceStatusOverrides((currentStatuses) => ({ ...currentStatuses, [userId]: status }));
setStaffEntryError(null);
};
const setEntryFormVisibility = (nextShowEntryForm: boolean) => {
if (nextShowEntryForm) {
setEntryDraft((currentDraft) => ({
...currentDraft,
campusId: attendanceCampusId ?? currentDraft.campusId,
}));
}
setEntryError(null);
setStaffEntryError(null);
setShowEntryForm(nextShowEntryForm);
};
@ -720,77 +517,6 @@ export function useCampusAttendancePage({
setEditingLink(null);
};
const saveStudentAttendance = async (): Promise<boolean> => {
if (scopeModel.mode !== 'campus') {
const rowsToSave: CampusAttendanceEntryInput[] = [];
let hasInvalidRow = false;
for (const row of studentRollupRows) {
if (isBlankRollupRow(row)) {
continue;
}
const input = toRollupEntryInput(row, entryDraft.date);
if (!input) {
hasInvalidRow = true;
break;
}
rowsToSave.push(input);
}
if (hasInvalidRow) {
setEntryError('Each campus row needs enrolled, present, and absent counts before saving.');
return false;
}
if (rowsToSave.length === 0) {
setEntryError('Enter at least one campus attendance row before saving.');
return false;
}
for (const input of rowsToSave) {
await saveSummaryMutation.mutateAsync(input);
}
setStudentRollupOverrides({});
return true;
}
const targetCampusId = attendanceCampusId ?? entryDraft.campusId;
if (!targetCampusId) {
setEntryError('Select a campus before saving attendance.');
return false;
}
const input = attendanceStudents.length > 0
? {
campusId: targetCampusId,
date: entryDraft.date,
totalEnrolled: attendanceStudents.length,
totalPresent: attendanceStudents.filter((student) => (
(studentAttendanceStatuses[student.id] ?? 'present') !== 'absent'
)).length,
totalAbsent: attendanceStudents.filter((student) => (
(studentAttendanceStatuses[student.id] ?? 'present') === 'absent'
)).length,
totalTardy: attendanceStudents.filter((student) => (
(studentAttendanceStatuses[student.id] ?? 'present') === 'late'
)).length,
notes: entryDraft.notes.trim() || null,
}
: buildAttendanceEntryInput(entryDraft, targetCampusId);
if (!input) {
setEntryError('Enter enrolled, present, and absent counts before saving.');
return false;
}
await saveSummaryMutation.mutateAsync(input);
setEntryDraft(emptyEntryDraft(today, attendanceCampusId ?? targetCampusId));
return true;
};
const saveStaffBatchAttendance = async (
requireStaff: boolean,
input: Pick<StaffAttendanceEntryDraft, 'date' | 'note'> = staffEntryDraft,
@ -815,31 +541,6 @@ export function useCampusAttendancePage({
return true;
};
const handleSubmitEntry = async () => {
setPrintError(null);
const saved = await saveStudentAttendance();
if (!saved) {
return;
}
showSuccess('Student attendance saved!');
setShowEntryForm(false);
};
const handleSubmitStaffEntry = async () => {
setPrintError(null);
if (!staffEntryDraft.userId) {
setStaffEntryError('Select an office staff member before saving attendance.');
return;
}
await saveStaffAttendanceMutation.mutateAsync(staffEntryDraft);
showSuccess('Office staff attendance saved!');
setStaffEntryDraft(emptyStaffEntryDraft(today));
};
const handleSubmitStaffBatch = async () => {
setPrintError(null);
@ -849,25 +550,6 @@ export function useCampusAttendancePage({
}
showSuccess('Staff attendance saved!');
};
const handleSubmitAttendanceForm = async () => {
setPrintError(null);
const studentSaved = await saveStudentAttendance();
if (!studentSaved) {
return;
}
const staffSaved = await saveStaffBatchAttendance(false, {
date: entryDraft.date,
note: staffEntryDraft.note,
});
if (!staffSaved) {
return;
}
showSuccess('Attendance saved!');
setShowEntryForm(false);
};
@ -878,23 +560,13 @@ export function useCampusAttendancePage({
const reportTitle = roleAccess.canSeeAllCampuses
? `${scopeModel.reportLabel} Attendance Report`
: `${campusInfo?.fullName || userCampus} Attendance Report`;
const printTodayRecords = roleAccess.canSeeAllCampuses
? overallStats.todayRecords
: attendanceData.filter((record) => record.campus_id === attendanceCampusId && record.date === today);
const printWeekRecords = roleAccess.canSeeAllCampuses
? overallStats.weekRecords
: attendanceData.filter((record) => record.campus_id === attendanceCampusId && record.date >= weekStart && record.date <= weekEnd);
const printResult = openCampusAttendancePrintReport({
input: {
reportTitle,
generatedByName: userName,
generatedByRole: `${userRole.charAt(0).toUpperCase()}${userRole.slice(1)}`,
today,
weekStart,
campusesToPrint,
printTodayRecords,
printWeekRecords,
staffSummary,
},
});
@ -917,7 +589,6 @@ export function useCampusAttendancePage({
weekStart,
weekEnd,
configs,
attendanceData,
staffRecords,
loading,
saving,
@ -927,20 +598,13 @@ export function useCampusAttendancePage({
linkValue,
showEntryForm,
expandedCampus,
entryDraft,
entryError,
staffEntryDraft,
staffEntryError,
officeStaffUsers,
staffAttendanceStatuses,
studentRollupRows,
attendanceStudents,
studentAttendanceStatuses,
attendanceStudentsLoading: attendanceStudentsQuery.isLoading,
scopedCampusOptions,
campusStats: visibleCampusStats,
attendanceChildStats,
overallStats,
combinedStats,
staffSummary: {
summary: staffSummary,
@ -948,10 +612,7 @@ export function useCampusAttendancePage({
error: staffSummaryQuery.error,
},
myCampusConfig,
myCampusData,
myStaffData,
myTodayPct,
myWeekAvg,
userCampus,
},
actions: {
@ -959,16 +620,10 @@ export function useCampusAttendancePage({
setLinkValue,
setShowEntryForm: setEntryFormVisibility,
setExpandedCampus,
updateEntryDraft,
updateStaffEntryDraft,
updateStudentAttendanceStatus,
updateStudentRollupDraft,
updateStaffAttendanceStatus,
handleSaveLink,
handleSubmitEntry,
handleSubmitStaffEntry,
handleSubmitStaffBatch,
handleSubmitAttendanceForm,
handlePrint,
},
};

View File

@ -1,14 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
toCampusAttendanceConfigViewModel,
toCampusAttendanceSummaryMutationDto,
toCampusAttendanceSummaryViewModel,
} from '@/business/campus-attendance/mappers';
import type { CampusAttendanceEntryInput } from '@/business/campus-attendance/types';
import type {
CampusAttendanceConfigDto,
CampusAttendanceSummaryDto,
} from '@/shared/types/campusAttendance';
import { toCampusAttendanceConfigViewModel } from '@/business/campus-attendance/mappers';
import type { CampusAttendanceConfigDto } from '@/shared/types/campusAttendance';
describe('campus attendance mappers', () => {
it('maps backend config DTO fields into the frontend view model shape', () => {
@ -33,58 +25,4 @@ describe('campus attendance mappers', () => {
updated_at: '2026-06-08T09:00:00.000Z',
});
});
it('maps backend summary DTO fields into the frontend view model shape', () => {
const dto: CampusAttendanceSummaryDto = {
id: 'summary-1',
campus_key: 'gators',
date: '2026-06-08',
total_enrolled: 80,
total_present: 72,
total_absent: 6,
total_tardy: 2,
attendance_percentage: 90,
recorded_by_label: 'Director',
notes: 'Two late bus arrivals',
organizationId: 'org-1',
campusId: 'campus-2',
createdById: 'user-3',
updatedById: 'user-3',
createdAt: '2026-06-08T10:00:00.000Z',
updatedAt: '2026-06-08T10:15:00.000Z',
};
expect(toCampusAttendanceSummaryViewModel(dto)).toEqual({
id: 'summary-1',
campus_id: 'gators',
date: '2026-06-08',
total_enrolled: 80,
total_present: 72,
total_absent: 6,
total_tardy: 2,
attendance_percentage: 90,
recorded_by: 'Director',
notes: 'Two late bus arrivals',
});
});
it('maps entry input back into the backend mutation DTO shape', () => {
const input: CampusAttendanceEntryInput = {
campusId: 'hawks',
date: '2026-06-08',
totalEnrolled: 42,
totalPresent: 39,
totalAbsent: 2,
totalTardy: 1,
notes: null,
};
expect(toCampusAttendanceSummaryMutationDto(input)).toEqual({
total_enrolled: 42,
total_present: 39,
total_absent: 2,
total_tardy: 1,
notes: null,
});
});
});

View File

@ -1,12 +1,8 @@
import type {
CampusAttendanceConfigDto,
CampusAttendanceSummaryDto,
CampusAttendanceSummaryMutationDto,
} from '@/shared/types/campusAttendance';
import type {
CampusAttendanceConfigViewModel,
CampusAttendanceEntryInput,
CampusAttendanceSummaryViewModel,
} from '@/business/campus-attendance/types';
export function toCampusAttendanceConfigViewModel(
@ -20,32 +16,3 @@ export function toCampusAttendanceConfigViewModel(
updated_at: dto.updatedAt,
};
}
export function toCampusAttendanceSummaryViewModel(
dto: CampusAttendanceSummaryDto,
): CampusAttendanceSummaryViewModel {
return {
id: dto.id,
campus_id: dto.campus_key,
date: dto.date,
total_enrolled: dto.total_enrolled,
total_present: dto.total_present,
total_absent: dto.total_absent,
total_tardy: dto.total_tardy,
attendance_percentage: dto.attendance_percentage,
recorded_by: dto.recorded_by_label,
notes: dto.notes,
};
}
export function toCampusAttendanceSummaryMutationDto(
input: CampusAttendanceEntryInput,
): CampusAttendanceSummaryMutationDto {
return {
total_enrolled: input.totalEnrolled,
total_present: input.totalPresent,
total_absent: input.totalAbsent,
total_tardy: input.totalTardy,
notes: input.notes,
};
}

View File

@ -28,15 +28,13 @@ describe('campus attendance print report', () => {
`Printed by: ${CAMPUS_ATTENDANCE_TEST_SEED.generatedByName} (${CAMPUS_ATTENDANCE_TEST_SEED.generatedByRoleEscaped})`,
);
expect(html).toContain(CAMPUS_ATTENDANCE_TEST_SEED.campusFullNameEscaped);
expect(html).toContain(CAMPUS_ATTENDANCE_TEST_SEED.todayNotesEscaped);
expect(html).toContain('One staff late');
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('Present or late');
expect(html).toContain('<div class="label">Staff Records</div>');
expect(html).toContain('<div class="value">10</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>');
@ -51,14 +49,10 @@ describe('campus attendance print report', () => {
...campusAttendanceStatsSeed,
todayPct: null,
weekAvg: null,
recentData: [],
todayRecord: null,
recentStaffData: [],
todayStaffRecord: null,
},
],
printTodayRecords: [],
printWeekRecords: [],
staffSummary: null,
});

View File

@ -52,37 +52,14 @@ const inlinePercentageClass = (percentage: number | null): string => {
};
function buildTodayReportRows(campus: CampusAttendanceStats): readonly CombinedAttendanceHistoryRow[] {
const date = campus.todayRecord?.date ?? campus.todayStaffRecord?.date ?? '';
const studentRecord = campus.todayRecord;
const date = campus.todayStaffRecord?.date ?? '';
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,
@ -100,12 +77,12 @@ function buildTodayReportRows(campus: CampusAttendanceStats): readonly CombinedA
date,
group: 'total',
label: 'Total',
total: combinedTotal,
present: combinedPresent,
absent: studentAbsent + staffAbsent,
late: studentLate + staffLate,
attendancePercentage: combinedPercentage,
notes: notes.length > 0 ? notes.join('; ') : null,
total: staffTotal,
present: staffPresent,
absent: staffAbsent,
late: staffLate,
attendancePercentage: staffRecord?.attendance_percentage ?? null,
notes: staffRecord?.notes ?? null,
},
];
}
@ -113,7 +90,7 @@ function buildTodayReportRows(campus: CampusAttendanceStats): readonly CombinedA
function renderAttendanceRows(rows: readonly CombinedAttendanceHistoryRow[]): string {
return rows.map((record) => `
<tr>
<td>${record.group === 'students' ? formatAttendanceDate(record.date) : ''}</td>
<td>${record.group === 'staff' ? formatAttendanceDate(record.date) : ''}</td>
<td>${record.label}</td>
<td>${record.total}</td>
<td>${record.present}</td>
@ -126,7 +103,7 @@ function renderAttendanceRows(rows: readonly CombinedAttendanceHistoryRow[]): st
}
function renderAttendanceHistoryTable(campus: CampusAttendanceStats): string {
const historyRows = buildCombinedAttendanceHistoryRows(campus.recentData, campus.recentStaffData);
const historyRows = buildCombinedAttendanceHistoryRows(campus.recentStaffData);
if (historyRows.length === 0) {
return '';
@ -191,9 +168,6 @@ 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',
@ -251,19 +225,19 @@ export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput
</div>
<div class="summary-grid">
<div class="summary-box">
<div class="label">Today's Attendance</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 class="label">Today's Staff Attendance</div>
<div class="value ${percentageClass(printStats.staffPct)}">${printStats.staffPct !== null ? `${printStats.staffPct}%` : 'No data'}</div>
<div class="label" style="margin-top:4px">Present or late</div>
</div>
<div class="summary-box">
<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 class="label">Staff Records</div>
<div class="value">${printStats.staffTotal}</div>
<div class="label" style="margin-top:4px">Recorded today</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 class="label">Report Date</div>
<div class="value">${formatAttendanceDate(input.today)}</div>
<div class="label" style="margin-top:4px">Staff attendance</div>
</div>
</div>
${input.campusesToPrint.map((campus) => `
@ -275,7 +249,7 @@ 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 || campus.todayStaffRecord ? `
${campus.todayStaffRecord ? `
<div class="section-label">Today's report</div>
<table class="history-table">
<thead>

View File

@ -1,62 +1,17 @@
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 { CampusAttendanceChildStats } from '@/business/campus-attendance/types';
import type { CampusInfo } from '@/shared/types/app';
import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types';
const summaries: readonly CampusAttendanceSummaryViewModel[] = [
{
id: 'summary-1',
campus_id: 'tigers',
date: '2026-06-08',
total_enrolled: 100,
total_present: 90,
total_absent: 10,
total_tardy: 3,
attendance_percentage: 90,
recorded_by: 'director-1',
notes: null,
},
{
id: 'summary-2',
campus_id: 'gators',
date: '2026-06-08',
total_enrolled: 50,
total_present: 40,
total_absent: 10,
total_tardy: 1,
attendance_percentage: 80,
recorded_by: 'director-1',
notes: null,
},
{
id: 'summary-3',
campus_id: 'tigers',
date: '2026-06-09',
total_enrolled: 100,
total_present: 95,
total_absent: 5,
total_tardy: 2,
attendance_percentage: 95,
recorded_by: 'director-1',
notes: null,
},
];
const staffRecords: readonly StaffAttendanceRecordViewModel[] = [
{
id: 'staff-1',
@ -114,53 +69,6 @@ describe('campus attendance selectors', () => {
expect(getWeekEnd(date)).toBe('2026-06-12');
});
it('builds validated attendance input and normalizes optional notes', () => {
const draft: CampusAttendanceEntryDraft = {
date: '2026-06-08',
campusId: 'tigers',
enrolled: '25',
present: '21',
absent: '4',
tardy: '',
notes: ' ',
};
expect(buildAttendanceEntryInput(draft, 'tigers')).toEqual({
campusId: 'tigers',
date: '2026-06-08',
totalEnrolled: 25,
totalPresent: 21,
totalAbsent: 4,
totalTardy: 0,
notes: null,
});
});
it('rejects attendance input without a positive enrollment count', () => {
const draft: CampusAttendanceEntryDraft = {
date: '2026-06-08',
campusId: 'tigers',
enrolled: '0',
present: '0',
absent: '0',
tardy: '0',
notes: 'Closed',
};
expect(buildAttendanceEntryInput(draft, 'tigers')).toBeNull();
});
it('aggregates daily and weekly attendance percentages across campuses', () => {
expect(
buildOverallAttendanceStats(summaries, '2026-06-08', '2026-06-08', '2026-06-12'),
).toMatchObject({
todayEnrolled: 150,
todayPresent: 130,
todayPct: 86.67,
weekPct: 90.83,
});
});
it('builds scope-aware attendance titles and descriptions', () => {
expect(
buildCampusAttendanceScopeModel(
@ -212,11 +120,9 @@ describe('campus attendance selectors', () => {
});
});
it('combines student attendance aggregates with staff attendance summary', () => {
const overallStats = buildOverallAttendanceStats(summaries, '2026-06-08', '2026-06-08', '2026-06-12');
it('builds combined attendance from staff attendance summary only', () => {
expect(
buildCombinedAttendanceStats(overallStats, {
buildCombinedAttendanceStats({
staffCount: 12,
recordsCount: 10,
present: 8,
@ -224,15 +130,12 @@ describe('campus attendance selectors', () => {
absent: 1,
}),
).toEqual({
studentTodayPct: 86.67,
studentWeekPct: 90.83,
staffTodayPct: 75,
combinedTodayPct: 85.8,
combinedTodayPct: 75,
});
});
it('counts missing child attendance reports as incomplete in aggregate percentages', () => {
const overallStats = buildOverallAttendanceStats(summaries, '2026-06-08', '2026-06-08', '2026-06-12');
it('uses scoped child staff records for aggregate percentages', () => {
const childStats: readonly CampusAttendanceChildStats[] = [
{
id: 'school-1',
@ -242,11 +145,19 @@ describe('campus attendance selectors', () => {
bgGradient: 'from-emerald-500 to-green-500',
todayPct: 100,
weekAvg: 100,
recentData: [],
todayRecord: null,
childCampusIds: ['tigers'],
recentStaffData: [],
todayStaffRecord: null,
todayStaffRecord: {
id: 'staff:school-1:2026-06-08',
campus_id: null,
date: '2026-06-08',
total_staff: 10,
total_present: 9,
total_absent: 1,
total_late: 1,
attendance_percentage: 90,
notes: null,
},
},
{
id: 'school-2',
@ -256,19 +167,15 @@ describe('campus attendance selectors', () => {
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,
expect(buildCombinedAttendanceStats(null, childStats)).toEqual({
staffTodayPct: 45,
combinedTodayPct: 45,
});
});
@ -292,14 +199,14 @@ describe('campus attendance selectors', () => {
const [campus] = buildCampusAttendanceStats(
campusInfo,
[],
summaries,
'2026-06-08',
'2026-06-08',
'2026-06-12',
staffRecords,
);
expect(campus?.todayRecord?.total_enrolled).toBe(100);
expect(campus?.todayPct).toBe(66.67);
expect(campus?.weekAvg).toBe(66.67);
expect(campus?.todayStaffRecord).toMatchObject({
total_staff: 3,
total_present: 2,
@ -309,20 +216,8 @@ describe('campus attendance selectors', () => {
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,
},
it('builds staff-only attendance history with staff and total rows per date', () => {
expect(buildCombinedAttendanceHistoryRows(buildStaffAttendanceDailySummaries(staffRecords))).toEqual([
{
id: '2026-06-08:staff',
date: '2026-06-08',
@ -340,11 +235,11 @@ describe('campus attendance selectors', () => {
date: '2026-06-08',
group: 'total',
label: 'Total',
total: 103,
present: 92,
absent: 11,
late: 4,
attendancePercentage: 89.32,
total: 3,
present: 2,
absent: 1,
late: 1,
attendancePercentage: 66.67,
notes: 'Traffic',
},
]);

View File

@ -5,14 +5,11 @@ import type {
AttendanceScopeMode,
CampusAttendanceCombinedStats,
CampusAttendanceChildStats,
CampusAttendanceEntryDraft,
CampusAttendanceConfigViewModel,
CombinedAttendanceHistoryRow,
CampusAttendanceOverallStats,
CampusAttendancePrintInput,
CampusAttendanceScopeModel,
CampusAttendanceStats,
CampusAttendanceSummaryViewModel,
StaffAttendanceDailySummaryViewModel,
} from '@/business/campus-attendance/types';
import type {
@ -50,91 +47,9 @@ export function formatAttendanceDate(date: string): string {
});
}
export function parseAttendanceCount(value: string): number | null {
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? null : parsed;
}
export function buildAttendanceEntryInput(draft: CampusAttendanceEntryDraft, campusId: CampusId) {
const totalEnrolled = parseAttendanceCount(draft.enrolled);
const totalPresent = parseAttendanceCount(draft.present);
const totalAbsent = parseAttendanceCount(draft.absent);
const totalTardy = parseAttendanceCount(draft.tardy) ?? 0;
if (totalEnrolled === null || totalPresent === null || totalAbsent === null || totalEnrolled <= 0) {
return null;
}
return {
campusId,
date: draft.date,
totalEnrolled,
totalPresent,
totalAbsent,
totalTardy,
notes: draft.notes.trim() || null,
};
}
export function getDraftAttendancePercentage(draft: CampusAttendanceEntryDraft): number | null {
const totalEnrolled = parseAttendanceCount(draft.enrolled);
const totalPresent = parseAttendanceCount(draft.present);
if (totalEnrolled === null || totalPresent === null || totalEnrolled <= 0) {
return null;
}
return (totalPresent / totalEnrolled) * 100;
}
export function getTodayData(
summaries: readonly CampusAttendanceSummaryViewModel[],
campusId: CampusId,
today: string,
): readonly CampusAttendanceSummaryViewModel[] {
return summaries.filter((record) => record.campus_id === campusId && record.date === today);
}
export function getWeekData(
summaries: readonly CampusAttendanceSummaryViewModel[],
campusId: CampusId,
weekStart: string,
weekEnd: string,
): readonly CampusAttendanceSummaryViewModel[] {
return summaries.filter(
(record) => record.campus_id === campusId && record.date >= weekStart && record.date <= weekEnd,
);
}
export function getWeeklyAverage(
summaries: readonly CampusAttendanceSummaryViewModel[],
campusId: CampusId,
weekStart: string,
weekEnd: string,
): number | null {
const weekData = getWeekData(summaries, campusId, weekStart, weekEnd);
if (weekData.length === 0) {
return null;
}
const avg = weekData.reduce((sum, record) => sum + record.attendance_percentage, 0) / weekData.length;
return Number(avg.toFixed(2));
}
export function getTodayPercentage(
summaries: readonly CampusAttendanceSummaryViewModel[],
campusId: CampusId,
today: string,
): number | null {
const todayData = getTodayData(summaries, campusId, today);
return todayData[0]?.attendance_percentage ?? null;
}
export function buildCampusAttendanceStats(
campuses: readonly CampusInfo[],
configs: readonly CampusAttendanceConfigViewModel[],
summaries: readonly CampusAttendanceSummaryViewModel[],
today: string,
weekStart: string,
weekEnd: string,
@ -143,60 +58,24 @@ export function buildCampusAttendanceStats(
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;
const weekAvg = averageStaffAttendancePercentage(recentStaffData, weekStart, weekEnd);
return {
...campus,
todayPct,
todayPct: todayStaffRecord?.attendance_percentage ?? null,
weekAvg,
config,
recentData,
todayRecord,
recentStaffData,
todayStaffRecord,
};
});
}
export function buildOverallAttendanceStats(
summaries: readonly CampusAttendanceSummaryViewModel[],
today: string,
weekStart: string,
weekEnd: string,
): CampusAttendanceOverallStats {
const todayRecords = summaries.filter((record) => record.date === today);
const todayEnrolled = todayRecords.reduce((sum, record) => sum + record.total_enrolled, 0);
const todayPresent = todayRecords.reduce((sum, record) => sum + record.total_present, 0);
const todayPct = todayEnrolled > 0 ? Number(((todayPresent / todayEnrolled) * 100).toFixed(2)) : null;
const weekRecords = summaries.filter((record) => record.date >= weekStart && record.date <= weekEnd);
const weekDays = Array.from(new Set(weekRecords.map((record) => record.date)));
const weekPct = weekDays.length > 0
? Number((weekDays.reduce((sum, day) => {
const dayRecords = weekRecords.filter((record) => record.date === day);
const dayEnrolled = dayRecords.reduce((daySum, record) => daySum + record.total_enrolled, 0);
const dayPresent = dayRecords.reduce((daySum, record) => daySum + record.total_present, 0);
return sum + (dayEnrolled > 0 ? (dayPresent / dayEnrolled) * 100 : 0);
}, 0) / weekDays.length).toFixed(2))
: null;
return {
todayRecords,
todayEnrolled,
todayPresent,
todayPct,
weekRecords,
weekPct,
};
}
function getAttendanceScopeMode(tier: ScopeTier): AttendanceScopeMode {
if (tier === 'organization') {
return 'organization';
@ -289,6 +168,17 @@ function percentageFromScopedChildren(
return percentageFromCounts(presentEquivalent, total);
}
function averageStaffAttendancePercentage(
records: readonly StaffAttendanceDailySummaryViewModel[],
weekStart: string,
weekEnd: string,
): number | null {
const weekRecords = records.filter((record) => record.date >= weekStart && record.date <= weekEnd);
return weekRecords.length > 0
? Number((weekRecords.reduce((sum, record) => sum + record.attendance_percentage, 0) / weekRecords.length).toFixed(2))
: null;
}
function staffAttendancePercentage(present: number, late: number, total: number): number {
return percentageFromCounts(present + late, total) ?? 0;
}
@ -349,7 +239,6 @@ export function buildStaffAttendanceDailySummaries(
}
export function buildCombinedAttendanceStats(
overallStats: CampusAttendanceOverallStats,
staffSummary: StaffAttendanceSummaryViewModel | null,
childStats?: readonly CampusAttendanceChildStats[],
): CampusAttendanceCombinedStats {
@ -359,21 +248,15 @@ export function buildCombinedAttendanceStats(
: 0;
const staffTotal = staffSummary ? Math.max(staffSummary.staffCount, recordedStaffTotal) : 0;
const staffPresent = staffSummary ? staffSummary.present + staffSummary.late : 0;
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;
const scopedStaffTodayPct = percentageFromScopedChildren(
scopedChildren,
(child) => child.todayStaffRecord?.attendance_percentage ?? null,
);
const staffTodayPct = scopedStaffTodayPct ?? percentageFromCounts(staffPresent, staffTotal);
return {
studentTodayPct,
studentWeekPct,
staffTodayPct: percentageFromCounts(staffPresent, staffTotal),
combinedTodayPct: percentageFromCounts(studentPresent + staffPresent, studentTotal + staffTotal),
staffTodayPct,
combinedTodayPct: staffTodayPct,
};
}
@ -382,52 +265,21 @@ function notesFromParts(...parts: readonly (string | null | undefined)[]): strin
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,
@ -445,21 +297,18 @@ export function buildCombinedAttendanceHistoryRows(
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),
total: staffTotal,
present: staffPresent,
absent: staffAbsent,
late: staffLate,
attendancePercentage: staffRecord?.attendance_percentage ?? null,
notes: notesFromParts(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;
@ -467,21 +316,9 @@ export function buildPrintAttendanceStats(input: CampusAttendancePrintInput) {
? 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) => {
const dayRecords = input.printWeekRecords.filter((record) => record.date === day);
const dayEnrolled = dayRecords.reduce((daySum, record) => daySum + record.total_enrolled, 0);
const dayPresent = dayRecords.reduce((daySum, record) => daySum + record.total_present, 0);
return sum + (dayEnrolled > 0 ? (dayPresent / dayEnrolled) * 100 : 0);
}, 0) / weekDays.length).toFixed(2))
: null;
return {
todayPct,
staffPct,
combinedPct,
weekPct,
staffTotal,
};
}

View File

@ -14,35 +14,10 @@ export interface CampusAttendanceConfigViewModel {
readonly updated_at: string;
}
export interface CampusAttendanceSummaryViewModel {
readonly id: string;
readonly campus_id: CampusId;
readonly date: string;
readonly total_enrolled: number;
readonly total_present: number;
readonly total_absent: number;
readonly total_tardy: number;
readonly attendance_percentage: number;
readonly recorded_by: string | null;
readonly notes: string | null;
}
export interface CampusAttendanceEntryInput {
readonly campusId: CampusId;
readonly date: string;
readonly totalEnrolled: number;
readonly totalPresent: number;
readonly totalAbsent: number;
readonly totalTardy: number;
readonly notes: string | null;
}
export interface CampusAttendanceStats extends CampusInfo {
readonly todayPct: number | null;
readonly weekAvg: number | null;
readonly config: CampusAttendanceConfigViewModel | null;
readonly recentData: readonly CampusAttendanceSummaryViewModel[];
readonly todayRecord: CampusAttendanceSummaryViewModel | null;
readonly recentStaffData: readonly StaffAttendanceDailySummaryViewModel[];
readonly todayStaffRecord: StaffAttendanceDailySummaryViewModel | null;
}
@ -56,32 +31,11 @@ export interface CampusAttendanceChildStats {
readonly isOnline?: boolean;
readonly todayPct: number | null;
readonly weekAvg: number | null;
readonly recentData: readonly CampusAttendanceSummaryViewModel[];
readonly todayRecord: CampusAttendanceSummaryViewModel | null;
readonly childCampusIds: readonly CampusId[];
readonly recentStaffData: readonly StaffAttendanceDailySummaryViewModel[];
readonly todayStaffRecord: StaffAttendanceDailySummaryViewModel | null;
}
export interface CampusAttendanceOverallStats {
readonly todayRecords: readonly CampusAttendanceSummaryViewModel[];
readonly todayEnrolled: number;
readonly todayPresent: number;
readonly todayPct: number | null;
readonly weekRecords: readonly CampusAttendanceSummaryViewModel[];
readonly weekPct: number | null;
}
export interface CampusAttendanceEntryDraft {
readonly date: string;
readonly campusId: CampusId;
readonly enrolled: string;
readonly present: string;
readonly absent: string;
readonly tardy: string;
readonly notes: string;
}
export interface StaffAttendanceEntryDraft {
readonly date: string;
readonly userId: string;
@ -131,8 +85,6 @@ export interface CampusAttendanceScopeModel {
}
export interface CampusAttendanceCombinedStats {
readonly studentTodayPct: number | null;
readonly studentWeekPct: number | null;
readonly staffTodayPct: number | null;
readonly combinedTodayPct: number | null;
}
@ -149,7 +101,7 @@ export interface StaffAttendanceDailySummaryViewModel {
readonly notes: string | null;
}
export type CombinedAttendanceGroup = 'students' | 'staff' | 'total';
export type CombinedAttendanceGroup = 'staff' | 'total';
export interface CombinedAttendanceHistoryRow {
readonly id: string;
@ -175,9 +127,6 @@ export interface CampusAttendancePrintInput {
readonly generatedByName: string;
readonly generatedByRole: string;
readonly today: string;
readonly weekStart: string;
readonly campusesToPrint: readonly CampusAttendanceStats[];
readonly printTodayRecords: readonly CampusAttendanceSummaryViewModel[];
readonly printWeekRecords: readonly CampusAttendanceSummaryViewModel[];
readonly staffSummary: StaffAttendanceSummaryViewModel | null;
}

View File

@ -7,7 +7,6 @@ import { usePersonalityCompletion } from '@/business/personality/queryHooks';
import { useZoneCheckInCompletion } from '@/business/zone-checkin/hooks';
import {
useStaffAttendanceRecords,
useStaffAttendanceSummary,
} from '@/business/staff-attendance/hooks';
import { usePolicyAcknowledgmentReport } from '@/business/policies/hooks';
import {
@ -80,13 +79,14 @@ export function useDirectorDashboardPage(): DirectorDashboardPage {
const scopeKey = activeTenant ? `${activeTenant.level}:${activeTenant.id}` : null;
const [timeRange, setTimeRangeState] = useState<DirectorDashboardTimeRange>(readStoredTimeRange);
const periodFilter = getDirectorDashboardDateRange(timeRange);
const today = format(new Date(), 'yyyy-MM-dd');
const todayFilter = { startDate: today, endDate: today };
const safetyQuizWeek = getCurrentSafetyQuizWeek(new Date());
const frameEntriesQuery = useFrameEntries(periodFilter);
const quizCompletionQuery = useSafetyQuizCompliance(safetyQuizWeek, true);
const emotionalIntelligenceCompletionQuery = usePersonalityCompletion(true);
const zoneCheckinCompletionQuery = useZoneCheckInCompletion(true, scopeKey);
const staffAttendanceRecordsQuery = useStaffAttendanceRecords(periodFilter);
const staffAttendanceSummaryQuery = useStaffAttendanceSummary(periodFilter);
const staffAttendanceRecordsQuery = useStaffAttendanceRecords(todayFilter);
const acknowledgmentReportQuery = usePolicyAcknowledgmentReport();
const frameEntries = frameEntriesQuery.data ?? [];
const quizRows = quizCompletionQuery.data?.rows ?? [];
@ -105,14 +105,12 @@ export function useDirectorDashboardPage(): DirectorDashboardPage {
|| emotionalIntelligenceCompletionQuery.isLoading
|| zoneCheckinCompletionQuery.isLoading
|| staffAttendanceRecordsQuery.isLoading
|| staffAttendanceSummaryQuery.isLoading
|| acknowledgmentReportQuery.isLoading;
const error = frameEntriesQuery.error
?? quizCompletionQuery.error
?? emotionalIntelligenceCompletionQuery.error
?? zoneCheckinCompletionQuery.error
?? staffAttendanceRecordsQuery.error
?? staffAttendanceSummaryQuery.error
?? acknowledgmentReportQuery.error;
return {

View File

@ -323,7 +323,7 @@ describe('director dashboard selectors', () => {
action: 'openAcknowledgments',
},
{
issue: '4 staff attendance exceptions this period (1 late, 3 absent)',
issue: '4 staff attendance exceptions today (1 late, 3 absent)',
severity: 'high',
module: 'attendance',
},

View File

@ -52,7 +52,7 @@ export function buildDirectorOverviewCards(
{
label: 'Staff Attendance',
value: `${attendanceRate}%`,
change: `${attendanceRecords.length} records`,
change: `${attendanceRecords.length} today`,
trend: 'up',
iconId: 'clock',
tone: 'orange',
@ -179,7 +179,7 @@ export function buildDirectorRiskAreas(
if (staffAttendanceExceptionCount > 0) {
risks.push({
issue: `${staffAttendanceExceptionCount} ${pluralize('staff attendance exception', staffAttendanceExceptionCount)} this period (${lateCount} late, ${absenceCount} absent)`,
issue: `${staffAttendanceExceptionCount} ${pluralize('staff attendance exception', staffAttendanceExceptionCount)} today (${lateCount} late, ${absenceCount} absent)`,
severity: 'high',
module: 'attendance',
});

View File

@ -1,7 +1,11 @@
export { getClass } from '@/shared/api/classes';
export {
getClassAttendanceSummary,
upsertClassAttendance,
} from '@/shared/api/classAttendance';
export { listGuardianStudents } from '@/shared/api/guardianStudents';
export { listUsers, type AdminUserRow } from '@/shared/api/users';
export { listRoles, type RoleRow } from '@/shared/api/roles';
export {
createUser,
linkGuardianStudent,
listUsers,
updateUser,
type AdminUserRow,
type SaveUserData,
} from '@/shared/api/users';

View File

@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import {
buildMyClassGuardianSaveData,
buildMyClassStudentSaveData,
canManageMyClassStudents,
hasMyClassGuardianFormValues,
} from '@/business/my-class/selectors';
describe('my-class selectors', () => {
it('builds a scoped student user payload from form values', () => {
expect(buildMyClassStudentSaveData(
{
namePrefix: '',
firstName: ' Emma ',
lastName: ' Clark ',
email: ' student@example.com ',
phoneNumber: ' ',
avatar: 'users/avatar/student.png',
guardians: [],
},
'class-1',
'role-student',
)).toEqual({
name_prefix: null,
firstName: 'Emma',
lastName: 'Clark',
email: 'student@example.com',
phoneNumber: null,
avatar: 'users/avatar/student.png',
app_role: 'role-student',
classId: 'class-1',
});
});
it('builds a guardian payload and detects whether guardian fields were entered', () => {
const values = {
key: 'guardian-1',
id: null,
namePrefix: ' Mr ',
firstName: ' Pat ',
lastName: ' Adams ',
email: ' guardian@example.com ',
phoneNumber: ' 555-1000 ',
avatar: 'users/avatar/guardian.png',
};
expect(hasMyClassGuardianFormValues(values)).toBe(true);
expect(buildMyClassGuardianSaveData(values, 'guardian-role')).toEqual({
name_prefix: 'Mr',
firstName: 'Pat',
lastName: 'Adams',
email: 'guardian@example.com',
phoneNumber: '555-1000',
avatar: 'users/avatar/guardian.png',
app_role: 'guardian-role',
});
expect(hasMyClassGuardianFormValues({
...values,
namePrefix: '',
firstName: '',
lastName: '',
email: '',
phoneNumber: '',
avatar: null,
})).toBe(false);
});
it('requires permission, class scope, and the student role id before enabling roster management', () => {
expect(canManageMyClassStudents({
hasUserPermission: true,
classId: 'class-1',
studentRoleId: 'role-student',
})).toBe(true);
expect(canManageMyClassStudents({
hasUserPermission: false,
classId: 'class-1',
studentRoleId: 'role-student',
})).toBe(false);
expect(canManageMyClassStudents({
hasUserPermission: true,
classId: null,
studentRoleId: 'role-student',
})).toBe(false);
expect(canManageMyClassStudents({
hasUserPermission: true,
classId: 'class-1',
studentRoleId: null,
})).toBe(false);
});
});

View File

@ -0,0 +1,75 @@
import type { SaveUserData } from '@/business/my-class/api';
export interface MyClassStudentFormValues {
readonly namePrefix: string;
readonly firstName: string;
readonly lastName: string;
readonly email: string;
readonly phoneNumber: string;
readonly avatar: string | null;
readonly guardians: readonly MyClassGuardianFormValues[];
}
export interface MyClassGuardianFormValues {
readonly key: string;
readonly id: string | null;
readonly namePrefix: string;
readonly firstName: string;
readonly lastName: string;
readonly email: string;
readonly phoneNumber: string;
readonly avatar: string | null;
}
export function buildMyClassStudentSaveData(
values: MyClassStudentFormValues,
classId: string,
studentRoleId: string,
): SaveUserData {
return {
name_prefix: values.namePrefix === '' ? null : values.namePrefix,
firstName: values.firstName.trim(),
lastName: values.lastName.trim(),
email: values.email.trim(),
phoneNumber: values.phoneNumber.trim() || null,
avatar: values.avatar,
app_role: studentRoleId,
classId,
};
}
export function hasMyClassGuardianFormValues(values: MyClassGuardianFormValues): boolean {
return Boolean(
values.namePrefix.trim()
|| values.firstName.trim()
|| values.lastName.trim()
|| values.email.trim()
|| values.phoneNumber.trim()
|| values.avatar,
);
}
export function buildMyClassGuardianSaveData(
values: MyClassGuardianFormValues,
guardianRoleId: string,
): SaveUserData {
return {
name_prefix: values.namePrefix.trim() === '' ? null : values.namePrefix.trim(),
firstName: values.firstName.trim(),
lastName: values.lastName.trim(),
email: values.email.trim(),
phoneNumber: values.phoneNumber.trim() || null,
avatar: values.avatar,
app_role: guardianRoleId,
};
}
export function canManageMyClassStudents(input: {
readonly hasUserPermission: boolean;
readonly classId: string | null;
readonly studentRoleId: string | null;
}): boolean {
return input.hasUserPermission
&& Boolean(input.classId)
&& Boolean(input.studentRoleId);
}

View File

@ -22,8 +22,8 @@ describe('staff attendance selectors', () => {
expect(countStaffAttendanceStatus(records, 'absent')).toBe(3);
});
it('calculates present attendance rate from the whole record set', () => {
expect(staffAttendanceRate(records)).toBe(20);
it('calculates present-or-late attendance rate from the whole record set', () => {
expect(staffAttendanceRate(records)).toBe(40);
expect(staffAttendanceRate([])).toBe(0);
});

View File

@ -15,7 +15,8 @@ export function staffAttendanceRate(records: readonly StaffAttendanceRecordViewM
return 0;
}
return Math.round((countStaffAttendanceStatus(records, 'present') / records.length) * 100);
const presentOrLate = countStaffAttendanceStatus(records, 'present') + countStaffAttendanceStatus(records, 'late');
return Math.round((presentOrLate / records.length) * 100);
}
export function recentStaffAttendanceRecords(

View File

@ -1,14 +1,16 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { listCampusAttendanceSummaries } from '@/shared/api/campusAttendance';
import { listFrameEntries } from '@/shared/api/frame';
import { getStaffAttendanceSummary } from '@/shared/api/staffAttendance';
import {
buildTopBarNotifications,
canQueryDailyStaffAttendanceForScope,
countUnreadTopBarNotifications,
getTopBarCampusLabel,
getTopBarInitials,
getTopBarRoleLabel,
shouldShowDailyStaffAttendanceNotification,
} from '@/business/top-bar/selectors';
import {
buildTopBarSearchResults,
@ -33,7 +35,7 @@ import { useCurrentPersonalityResult } from '@/business/personality/queryHooks';
import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks';
import { canPersistPersonalScopeResults } from '@/business/scope/selectors';
import { useSafetyProtocols } from '@/business/safety-protocols/hooks';
import { hasPermission } from '@/business/auth/permissions';
import { hasAnyPermission, hasPermission } from '@/business/auth/permissions';
import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog';
import { MODULES } from '@/shared/constants/appData';
import { PERSONALITY_QUIZ_KINDS } from '@/shared/constants/personality';
@ -129,27 +131,15 @@ export function useTopBarPage({
return response.rows;
},
});
const canManageDailyAttendance = hasPermission(user, 'FILL_ATTENDANCE')
const canMonitorDailyAttendance = hasAnyPermission(user, ['FILL_ATTENDANCE', 'READ_STAFF_ATTENDANCE_REPORTS'])
&& accessibleModuleIds.has('attendance');
const attendanceCampusKey =
effectiveTier === 'campus' || effectiveTier === 'class'
? campusInfo?.id
: undefined;
const attendanceContentQuery = useQuery({
queryKey: ['top-bar-daily-attendance-content', today, attendanceCampusKey ?? null, effectiveTier],
enabled: canManageDailyAttendance && Boolean(today) && (
effectiveTier === 'organization'
|| effectiveTier === 'school'
|| Boolean(attendanceCampusKey)
),
queryFn: async () => {
const response = await listCampusAttendanceSummaries({
...(attendanceCampusKey ? { campusKey: attendanceCampusKey } : {}),
startDate: today,
endDate: today,
});
return response.rows;
},
queryKey: ['top-bar-daily-staff-attendance-content', today, effectiveTier, selectedTenant?.id ?? null, campusInfo?.id ?? null],
enabled: canMonitorDailyAttendance && Boolean(today) && canQueryDailyStaffAttendanceForScope(effectiveTier),
queryFn: () => getStaffAttendanceSummary({
startDate: today,
endDate: today,
}),
});
const canReceiveEmotionalIntelligenceNotifications = canPersistPersonalResults
&& hasPermission(user, 'TAKE_QUIZ')
@ -242,10 +232,14 @@ export function useTopBarPage({
&& !frameContentQuery.isLoading
&& !frameContentQuery.error
&& (frameContentQuery.data?.length ?? 0) === 0;
const needsDailyAttendanceContent = canManageDailyAttendance
&& !attendanceContentQuery.isLoading
&& !attendanceContentQuery.error
&& (attendanceContentQuery.data?.length ?? 0) === 0;
const needsDailyAttendanceContent = shouldShowDailyStaffAttendanceNotification({
canMonitorDailyAttendance,
tier: effectiveTier,
loading: attendanceContentQuery.isLoading,
hasError: Boolean(attendanceContentQuery.error),
staffCount: attendanceContentQuery.data?.staffCount ?? 0,
recordsCount: attendanceContentQuery.data?.recordsCount ?? 0,
});
const notifications = buildTopBarNotifications({
needsZoneCheckIn,
needsSafetyQuiz,

View File

@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest';
import {
buildTopBarNotifications,
canQueryDailyStaffAttendanceForScope,
countUnreadTopBarNotifications,
getTopBarCampusLabel,
getTopBarInitials,
getTopBarRoleLabel,
shouldShowDailyStaffAttendanceNotification,
} from '@/business/top-bar/selectors';
import { APP_ROUTE_PATHS } from '@/shared/constants/routes';
import type { CommunicationEventDto } from '@/shared/types/communications';
@ -119,7 +121,7 @@ describe('top bar selectors', () => {
},
{
id: 'daily-attendance-content',
text: "Submit today's attendance",
text: "Today's staff attendance is incomplete",
time: 'Today',
unread: true,
href: APP_ROUTE_PATHS.attendance,
@ -127,6 +129,71 @@ describe('top bar selectors', () => {
]);
});
it('derives the daily staff attendance reminder from permission, scope, and completion state', () => {
expect(canQueryDailyStaffAttendanceForScope('organization')).toBe(true);
expect(canQueryDailyStaffAttendanceForScope('school')).toBe(true);
expect(canQueryDailyStaffAttendanceForScope('campus')).toBe(true);
expect(canQueryDailyStaffAttendanceForScope('class')).toBe(false);
expect(canQueryDailyStaffAttendanceForScope('global')).toBe(false);
expect(shouldShowDailyStaffAttendanceNotification({
canMonitorDailyAttendance: true,
tier: 'school',
loading: false,
hasError: false,
staffCount: 10,
recordsCount: 9,
})).toBe(true);
expect(shouldShowDailyStaffAttendanceNotification({
canMonitorDailyAttendance: true,
tier: 'school',
loading: false,
hasError: false,
staffCount: 10,
recordsCount: 10,
})).toBe(false);
expect(shouldShowDailyStaffAttendanceNotification({
canMonitorDailyAttendance: true,
tier: 'school',
loading: false,
hasError: false,
staffCount: 0,
recordsCount: 0,
})).toBe(false);
expect(shouldShowDailyStaffAttendanceNotification({
canMonitorDailyAttendance: false,
tier: 'school',
loading: false,
hasError: false,
staffCount: 10,
recordsCount: 0,
})).toBe(false);
expect(shouldShowDailyStaffAttendanceNotification({
canMonitorDailyAttendance: true,
tier: 'class',
loading: false,
hasError: false,
staffCount: 10,
recordsCount: 0,
})).toBe(false);
expect(shouldShowDailyStaffAttendanceNotification({
canMonitorDailyAttendance: true,
tier: 'school',
loading: true,
hasError: false,
staffCount: 10,
recordsCount: 0,
})).toBe(false);
expect(shouldShowDailyStaffAttendanceNotification({
canMonitorDailyAttendance: true,
tier: 'school',
loading: false,
hasError: true,
staffCount: 10,
recordsCount: 0,
})).toBe(false);
});
it('surfaces EI self-assessment and personality quiz completion reminders', () => {
const reminders = buildTopBarNotifications({
needsZoneCheckIn: false,

View File

@ -5,6 +5,7 @@ import type {
CampusInfo,
UserRole,
} from '@/shared/types/app';
import type { ScopeTier } from '@/business/scope/selectors';
import type { TopBarNotification } from '@/business/top-bar/types';
import type { CommunicationEventDto } from '@/shared/types/communications';
import type { PolicyViewModel } from '@/business/policies/types';
@ -38,6 +39,26 @@ export function countUnreadTopBarNotifications(
return notifications.filter((notification) => notification.unread).length;
}
export function canQueryDailyStaffAttendanceForScope(tier: ScopeTier): boolean {
return tier === 'organization' || tier === 'school' || tier === 'campus';
}
export function shouldShowDailyStaffAttendanceNotification(input: {
readonly canMonitorDailyAttendance: boolean;
readonly tier: ScopeTier;
readonly loading: boolean;
readonly hasError: boolean;
readonly staffCount: number;
readonly recordsCount: number;
}): boolean {
return input.canMonitorDailyAttendance
&& canQueryDailyStaffAttendanceForScope(input.tier)
&& !input.loading
&& !input.hasError
&& input.staffCount > 0
&& input.recordsCount < input.staffCount;
}
const ZONE_CHECKIN_NOTIFICATION_ID = 'zone-checkin-today';
const SAFETY_QUIZ_NOTIFICATION_ID = 'safety-quiz-weekly';
const SIGN_OF_WEEK_NOTIFICATION_ID = 'sign-of-week';
@ -122,7 +143,7 @@ export function buildTopBarNotifications(input: {
if (input.needsDailyAttendanceContent) {
notifications.push({
id: DAILY_ATTENDANCE_NOTIFICATION_ID,
text: "Submit today's attendance",
text: "Today's staff attendance is incomplete",
time: 'Today',
unread: true,
href: APP_ROUTE_PATHS.attendance,

View File

@ -1,4 +1,5 @@
export { fileDownloadUrl } from '@/shared/api/files';
export { listGuardianStudents } from '@/shared/api/guardianStudents';
export { listPermissions } from '@/shared/api/permissions';
export { listRoles, type RoleRow } from '@/shared/api/roles';
export {

View File

@ -1,28 +1,18 @@
import { Calendar, Loader2, Save, X } from 'lucide-react';
import { generatePath, Link } from 'react-router-dom';
import { Calendar, Save, X } from 'lucide-react';
import { getDraftAttendancePercentage } from '@/business/campus-attendance/selectors';
import type {
CampusAttendancePageActions,
CampusAttendancePageState,
} from '@/components/campus-attendance/types';
import type { StaffAttendanceStatus } from '@/shared/types/staffAttendance';
import { percentageTextClass } from '@/components/campus-attendance/styles';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useScopeContext } from '@/contexts/scope-context';
import { APP_ROUTE_PATHS } from '@/shared/constants/routes';
import type { ActiveTenant } from '@/shared/types/scope';
type CampusAttendanceEntryFormProps = {
state: CampusAttendancePageState;
actions: CampusAttendancePageActions;
};
type AttendanceRouteState = {
readonly __scope: ActiveTenant | null;
};
const STAFF_ATTENDANCE_STATUS_OPTIONS: readonly {
value: StaffAttendanceStatus;
label: string;
@ -44,28 +34,14 @@ function formatRoleName(role: string | null): string {
}
export function CampusAttendanceEntryForm({ state, actions }: CampusAttendanceEntryFormProps) {
const { selectedTenant } = useScopeContext();
const {
campusInfo,
entryDraft,
entryError,
saving,
scopeModel,
staffEntryDraft,
staffEntryError,
officeStaffUsers,
staffAttendanceStatuses,
studentRollupRows,
attendanceStudents,
studentAttendanceStatuses,
attendanceStudentsLoading,
} = state;
const draftPercentage = getDraftAttendancePercentage(entryDraft);
const showStudentRollup = scopeModel.mode !== 'campus';
const entryTargetLabel = showStudentRollup
? scopeModel.reportLabel
: scopeModel.tenantName || campusInfo?.fullName || 'Current Scope';
const hasStudentRoster = attendanceStudents.length > 0;
const staffAttendanceLabel = scopeModel.tier === 'class'
? 'Classroom Staff Attendance'
: scopeModel.mode === 'campus'
@ -76,21 +52,13 @@ export function CampusAttendanceEntryForm({ state, actions }: CampusAttendanceEn
: scopeModel.mode === 'campus'
? 'Record attendance for campus staff.'
: `Record attendance for ${scopeModel.reportLabel.toLowerCase()} office staff.`;
const noStudentRosterText = scopeModel.tier === 'class'
? 'No students are available for this classroom.'
: 'No students are available for this campus.';
const scopeRouteState: AttendanceRouteState = { __scope: selectedTenant };
const studentAttendanceSaveDisabled = attendanceStudentsLoading
|| (showStudentRollup
? studentRollupRows.length === 0
: !hasStudentRoster && (!entryDraft.enrolled || !entryDraft.present || !entryDraft.absent));
return (
<div className="bg-slate-800/60 backdrop-blur-sm rounded-2xl border border-orange-500/20 p-6">
<div className="flex items-center justify-between mb-5">
<h3 className="font-semibold text-white flex items-center gap-2">
<Calendar size={18} className="text-orange-400" />
Enter Attendance for {entryTargetLabel}
Enter {staffAttendanceLabel}
</h3>
<button
type="button"
@ -101,62 +69,7 @@ export function CampusAttendanceEntryForm({ state, actions }: CampusAttendanceEn
<X size={18} />
</button>
</div>
{showStudentRollup && (
<div className="mb-5">
<h4 className="text-sm font-semibold text-slate-200">Student Attendance Rollup</h4>
<p className="mt-1 text-xs text-slate-400">
Review and edit totals collected from campuses inside this {scopeModel.mode}.
</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<AttendanceEntryInput label="Date" type="date" value={entryDraft.date} onChange={(value) => actions.updateEntryDraft({ date: value })} />
{!showStudentRollup && (
<AttendanceEntryInput label="Notes (optional)" type="text" value={entryDraft.notes} placeholder="Any notes..." onChange={(value) => actions.updateEntryDraft({ notes: value })} />
)}
</div>
{showStudentRollup ? (
<>
<StudentRollupTable
className="mt-4"
rows={studentRollupRows}
routeState={scopeRouteState}
onChange={actions.updateStudentRollupDraft}
/>
</>
) : attendanceStudentsLoading ? (
<div className="mt-4 flex items-center gap-2 rounded-xl border border-slate-700/70 bg-slate-900/40 px-4 py-3 text-sm text-slate-400">
<Loader2 size={16} className="animate-spin" />
Loading student roster...
</div>
) : hasStudentRoster ? (
<AttendanceRosterTable
className="mt-4"
users={attendanceStudents}
statuses={studentAttendanceStatuses}
emptyText={noStudentRosterText}
onStatusChange={actions.updateStudentAttendanceStatus}
/>
) : (
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<AttendanceEntryInput label="Total Enrolled" value={entryDraft.enrolled} placeholder="e.g. 45" onChange={(value) => actions.updateEntryDraft({ enrolled: value })} />
<AttendanceEntryInput label="Total Present" value={entryDraft.present} placeholder="e.g. 42" onChange={(value) => actions.updateEntryDraft({ present: value })} />
<AttendanceEntryInput label="Total Absent" value={entryDraft.absent} placeholder="e.g. 2" onChange={(value) => actions.updateEntryDraft({ absent: value })} />
<AttendanceEntryInput label="Total Tardy" value={entryDraft.tardy} placeholder="e.g. 1" onChange={(value) => actions.updateEntryDraft({ tardy: value })} />
</div>
)}
{!hasStudentRoster && draftPercentage !== null && (
<div className="mt-4 p-3 rounded-xl bg-slate-700/30 border border-slate-600/30">
<p className="text-sm text-slate-300">
Calculated Attendance:{' '}
<span className={`font-bold text-lg ${percentageTextClass(draftPercentage)}`}>
{draftPercentage.toFixed(1)}%
</span>
</p>
</div>
)}
{entryError && <p className="mt-3 text-sm text-red-300">{entryError}</p>}
<div className="mt-6 border-t border-slate-700/70 pt-6">
<div className="mt-2">
<div className="mb-4">
<h4 className="text-sm font-semibold text-slate-200">{staffAttendanceLabel}</h4>
<p className="mt-1 text-xs text-slate-400">
@ -200,8 +113,8 @@ export function CampusAttendanceEntryForm({ state, actions }: CampusAttendanceEn
<div className="mt-6 flex justify-end border-t border-slate-700/70 pt-6">
<Button
type="button"
onClick={actions.handleSubmitAttendanceForm}
disabled={saving || studentAttendanceSaveDisabled}
onClick={actions.handleSubmitStaffBatch}
disabled={saving || officeStaffUsers.length === 0}
loading={saving}
loadingLabel="Saving..."
leadingIcon={<Save size={14} />}
@ -214,139 +127,6 @@ export function CampusAttendanceEntryForm({ state, actions }: CampusAttendanceEn
);
}
type StudentRollupTableProps = {
rows: CampusAttendancePageState['studentRollupRows'];
routeState: AttendanceRouteState;
className?: string;
onChange: CampusAttendancePageActions['updateStudentRollupDraft'];
};
function StudentRollupTable({
rows,
routeState,
className,
onChange,
}: StudentRollupTableProps) {
if (rows.length === 0) {
return (
<div className={className}>
<div className="rounded-xl border border-slate-700/70 bg-slate-900/40 px-4 py-3 text-sm text-slate-400">
No campuses are available in this scope.
</div>
</div>
);
}
const totals = rows.reduce(
(currentTotals, row) => ({
enrolled: currentTotals.enrolled + (Number.parseInt(row.enrolled, 10) || 0),
present: currentTotals.present + (Number.parseInt(row.present, 10) || 0),
absent: currentTotals.absent + (Number.parseInt(row.absent, 10) || 0),
tardy: currentTotals.tardy + (Number.parseInt(row.tardy, 10) || 0),
}),
{ enrolled: 0, present: 0, absent: 0, tardy: 0 },
);
const hasAnyValue = rows.some((row) => row.enrolled || row.present || row.absent || row.tardy);
return (
<div className={className}>
<div className="overflow-x-auto rounded-xl border border-slate-700/70 bg-slate-900/30">
<table className="w-full min-w-[900px] text-left text-sm">
<thead className="bg-slate-900/70 text-xs uppercase text-slate-400">
<tr>
<th className="px-4 py-3 font-semibold">Campus</th>
<th className="w-32 px-3 py-3 font-semibold">Enrolled</th>
<th className="w-32 px-3 py-3 font-semibold">Present</th>
<th className="w-32 px-3 py-3 font-semibold">Absent</th>
<th className="w-32 px-3 py-3 font-semibold">Tardy</th>
<th className="px-3 py-3 font-semibold">Notes</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/80">
{rows.map((row) => (
<tr key={row.campusId} className="text-slate-200">
<td className="px-4 py-3">
<Link
to={generatePath(APP_ROUTE_PATHS.attendanceDetails, { level: 'campus', tenantId: row.campusId })}
state={routeState}
className="text-left font-medium text-white transition-colors hover:text-orange-300 focus:outline-none focus:ring-2 focus:ring-orange-500/50 rounded"
>
{row.campusName}
</Link>
<div className="mt-1 text-xs text-slate-500">
{row.hasRecordedData ? 'Recorded for selected date' : 'No child data yet'}
</div>
</td>
<StudentRollupInput
label={`Enrolled for ${row.campusName}`}
value={row.enrolled}
onChange={(value) => onChange(row.campusId, { enrolled: value })}
/>
<StudentRollupInput
label={`Present for ${row.campusName}`}
value={row.present}
onChange={(value) => onChange(row.campusId, { present: value })}
/>
<StudentRollupInput
label={`Absent for ${row.campusName}`}
value={row.absent}
onChange={(value) => onChange(row.campusId, { absent: value })}
/>
<StudentRollupInput
label={`Tardy for ${row.campusName}`}
value={row.tardy}
onChange={(value) => onChange(row.campusId, { tardy: value })}
/>
<td className="px-3 py-3">
<Input
type="text"
value={row.notes}
onChange={(event) => onChange(row.campusId, { notes: event.target.value })}
aria-label={`Notes for ${row.campusName}`}
placeholder="Any notes..."
className="w-full px-3 py-2 bg-slate-800/80 border border-slate-600/50 rounded-lg text-sm text-white focus:ring-2 focus:ring-orange-500/50 outline-none placeholder:text-slate-500"
/>
</td>
</tr>
))}
</tbody>
<tfoot className="border-t border-slate-700/80 bg-slate-900/50 text-slate-200">
<tr>
<td className="px-4 py-3 font-semibold">Current totals</td>
<td className="px-3 py-3 font-semibold">{hasAnyValue ? totals.enrolled : ''}</td>
<td className="px-3 py-3 font-semibold">{hasAnyValue ? totals.present : ''}</td>
<td className="px-3 py-3 font-semibold">{hasAnyValue ? totals.absent : ''}</td>
<td className="px-3 py-3 font-semibold">{hasAnyValue ? totals.tardy : ''}</td>
<td className="px-3 py-3 text-xs text-slate-500">Saved per campus</td>
</tr>
</tfoot>
</table>
</div>
</div>
);
}
type StudentRollupInputProps = {
label: string;
value: string;
onChange: (value: string) => void;
};
function StudentRollupInput({ label, value, onChange }: StudentRollupInputProps) {
return (
<td className="px-3 py-3">
<Input
type="number"
min="0"
value={value}
onChange={(event) => onChange(event.target.value)}
aria-label={label}
className="w-full px-3 py-2 bg-slate-800/80 border border-slate-600/50 rounded-lg text-sm text-white focus:ring-2 focus:ring-orange-500/50 outline-none placeholder:text-slate-500"
/>
</td>
);
}
type AttendanceRosterTableProps = {
users: readonly {
id: string;

View File

@ -29,43 +29,29 @@ export function IndividualCampusAttendanceView({ state, actions }: IndividualCam
today,
weekStart,
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 combinedHistoryRows = buildCombinedAttendanceHistoryRows(myStaffData);
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]))
const weekDates = Array.from(weekStaffDates)
.filter((date) => date >= weekStart && date <= weekEnd);
const combinedWeekPct = weekDates.length > 0
const staffWeekPct = 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);
const total = staffRecord?.total_staff ?? 0;
const present = 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))
@ -76,23 +62,23 @@ export function IndividualCampusAttendanceView({ state, actions }: IndividualCam
<>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<AttendanceSummaryCard
label="Today's Attendance"
value={combinedTodayPct !== null ? `${combinedTodayPct}%` : 'N/A'}
helper={`${studentTodayLabel} · ${staffTodayLabel}`}
label="Today's Staff Attendance"
value={todayStaffRecord ? `${todayStaffRecord.attendance_percentage}%` : 'N/A'}
helper={staffTodayLabel}
icon={Calendar}
percentage={combinedTodayPct}
percentage={todayStaffRecord?.attendance_percentage ?? null}
/>
<AttendanceSummaryCard
label="Weekly Average"
value={combinedWeekPct !== null ? `${combinedWeekPct}%` : 'N/A'}
helper={`${weekStudentLabel} · ${weekStaffLabel}`}
value={staffWeekPct !== null ? `${staffWeekPct}%` : 'N/A'}
helper={weekStaffLabel}
icon={BarChart3}
percentage={combinedWeekPct}
percentage={staffWeekPct}
/>
<AttendanceSummaryCard
label="People"
value={combinedTodayTotal || 'N/A'}
helper={`${todayStudentTotal} students · ${todayStaffTotal} staff`}
label="Staff"
value={todayStaffTotal || 'N/A'}
helper={`${todayStaffPresent} present today`}
icon={Users}
color="blue"
/>
@ -143,7 +129,7 @@ export function IndividualCampusAttendanceView({ state, actions }: IndividualCam
.filter((record) => record.date === today || record.group === 'total')
.slice(0, 45)
.map((record) => {
const shouldShowDate = record.date !== today || record.group === 'students';
const shouldShowDate = record.date !== today || record.group === 'staff';
return (
<TableRow

View File

@ -34,8 +34,6 @@ export function SuperintendentAttendanceView({ state, actions }: SuperintendentA
const { selectedTenant } = useScopeContext();
const {
today,
weekStart,
overallStats,
combinedStats,
staffSummary,
campusStats,
@ -61,55 +59,24 @@ export function SuperintendentAttendanceView({ state, actions }: SuperintendentA
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<AttendanceSummaryCard
label="Combined Attendance"
label="Today's Staff Attendance"
value={combinedStats.combinedTodayPct !== null ? `${combinedStats.combinedTodayPct}%` : 'N/A'}
helper={formatAttendanceDate(today)}
icon={Calendar}
percentage={combinedStats.combinedTodayPct}
/>
<AttendanceSummaryCard
label="Student Attendance"
value={combinedStats.studentTodayPct !== null ? `${combinedStats.studentTodayPct}%` : 'N/A'}
helper={scopeModel.aggregateHelper}
icon={Users}
percentage={combinedStats.studentTodayPct}
/>
<AttendanceSummaryCard
label="Staff Attendance"
label="Present or Late"
value={combinedStats.staffTodayPct !== null ? `${combinedStats.staffTodayPct}%` : 'N/A'}
helper={staffAttendanceHelper}
icon={UserCheck}
percentage={combinedStats.staffTodayPct}
/>
<AttendanceSummaryCard
label="Weekly Student Average"
value={combinedStats.studentWeekPct !== null ? `${combinedStats.studentWeekPct}%` : 'N/A'}
helper={`Week of ${formatAttendanceDate(weekStart)}`}
icon={BarChart3}
percentage={combinedStats.studentWeekPct}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<AttendanceSummaryCard
label="Students Enrolled Today"
value={overallStats.todayEnrolled || 'N/A'}
helper={scopeModel.aggregateHelper}
icon={Users}
color="blue"
/>
<AttendanceSummaryCard
label="Students Present Today"
value={overallStats.todayPresent || 'N/A'}
helper={scopeModel.aggregateHelper}
icon={UserCheck}
color="violet"
/>
<AttendanceSummaryCard
label="Staff Records Today"
value={staffSummary.summary?.recordsCount ?? 'N/A'}
helper={scopeModel.aggregateHelper}
icon={UserCheck}
helper={formatAttendanceDate(today)}
icon={BarChart3}
color="blue"
/>
<AttendanceSummaryCard
@ -155,19 +122,19 @@ export function SuperintendentAttendanceView({ state, actions }: SuperintendentA
</p>
</div>
</div>
{child.todayRecord && (
{child.todayStaffRecord && (
<div className="grid grid-cols-3 gap-2 text-center">
<div className="bg-slate-700/30 rounded-lg p-2">
<p className="text-xs text-slate-400">Enrolled</p>
<p className="text-sm font-bold text-white">{child.todayRecord.total_enrolled}</p>
<p className="text-xs text-slate-400">Staff</p>
<p className="text-sm font-bold text-white">{child.todayStaffRecord.total_staff}</p>
</div>
<div className="bg-slate-700/30 rounded-lg p-2">
<p className="text-xs text-slate-400">Present</p>
<p className="text-sm font-bold text-emerald-400">{child.todayRecord.total_present}</p>
<p className="text-sm font-bold text-emerald-400">{child.todayStaffRecord.total_present}</p>
</div>
<div className="bg-slate-700/30 rounded-lg p-2">
<p className="text-xs text-slate-400">Absent</p>
<p className="text-sm font-bold text-red-400">{child.todayRecord.total_absent}</p>
<p className="text-sm font-bold text-red-400">{child.todayStaffRecord.total_absent}</p>
</div>
</div>
)}
@ -179,13 +146,13 @@ export function SuperintendentAttendanceView({ state, actions }: SuperintendentA
{expandedCampus === child.id ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
{expandedCampus === child.id ? 'Hide History' : 'View History'}
</button>
{expandedCampus === child.id && child.recentData.length > 0 && (
{expandedCampus === child.id && child.recentStaffData.length > 0 && (
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{child.recentData.map((record) => (
{child.recentStaffData.map((record) => (
<div key={record.id} className="flex items-center justify-between px-3 py-2 bg-slate-700/20 rounded-lg text-xs">
<span className="text-slate-400">{formatAttendanceDate(record.date)}</span>
<div className="flex items-center gap-3">
<span className="text-slate-500">{record.total_present}/{record.total_enrolled}</span>
<span className="text-slate-500">{record.total_present}/{record.total_staff}</span>
<span className={`font-bold ${percentageTextClass(record.attendance_percentage)}`}>
{record.attendance_percentage.toFixed(1)}%
</span>

View File

@ -16,6 +16,7 @@ interface ImageUploadProps {
readonly field: string;
readonly label?: string;
readonly shape?: 'square' | 'circle';
readonly previewSize?: 'sm' | 'lg';
}
/** Reusable logo/avatar uploader: pick → upload → report the stored URL. */
@ -26,6 +27,7 @@ export function ImageUpload({
field,
label = 'Image',
shape = 'square',
previewSize = 'sm',
}: ImageUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
@ -48,11 +50,12 @@ export function ImageUpload({
return (
<div className="space-y-1.5">
<Label>{label}</Label>
<div className="flex items-center gap-3">
<div className="flex flex-col items-start gap-2">
<div
className={cn(
'flex h-16 w-16 items-center justify-center overflow-hidden border border-slate-700/50 bg-slate-800/40 text-slate-500',
shape === 'circle' ? 'rounded-full' : 'rounded-xl',
'flex shrink-0 items-center justify-center overflow-hidden border border-slate-700/50 bg-slate-800/40 text-slate-500',
previewSize === 'lg' ? 'h-28 w-28' : 'h-16 w-16',
shape === 'circle' ? 'rounded-full' : 'rounded-lg',
)}
>
{uploading ? (
@ -63,10 +66,10 @@ export function ImageUpload({
) : value ? (
<img src={fileAssetUrl(value)} alt={label} className="h-full w-full object-cover" />
) : (
<ImagePlus size={20} />
<ImagePlus size={previewSize === 'lg' ? 28 : 20} />
)}
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<input
ref={inputRef}
type="file"

View File

@ -1,3 +1,4 @@
import { useState } from 'react';
import { fileDownloadUrl } from '@/business/files/api';
import { cn } from '@/lib/utils';
@ -24,6 +25,9 @@ export function UserAvatar({
fallbackClassName,
imageClassName,
}: UserAvatarProps) {
const [failedAvatarUrl, setFailedAvatarUrl] = useState<string | null>(null);
const showImage = Boolean(avatarUrl && failedAvatarUrl !== avatarUrl);
return (
<span
className={cn(
@ -31,10 +35,11 @@ export function UserAvatar({
className,
)}
>
{avatarUrl ? (
{showImage && avatarUrl ? (
<img
src={fileDownloadUrl(avatarUrl)}
alt=""
onError={() => setFailedAvatarUrl(avatarUrl)}
className={cn('h-full w-full object-cover', imageClassName)}
/>
) : (

View File

@ -16,48 +16,55 @@ export function EsaFundingImpactRoles({
schoolImpactEditor,
staffRoleEditor,
}: EsaFundingImpactRolesProps) {
if (schoolImpactItems.length === 0 && staffRoleItems.length === 0 && !schoolImpactEditor && !staffRoleEditor) {
const showSchoolImpact = schoolImpactItems.length > 0 || Boolean(schoolImpactEditor);
const showStaffRole = staffRoleItems.length > 0 || Boolean(staffRoleEditor);
if (!showSchoolImpact && !showStaffRole) {
return null;
}
return (
<section className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<div className="bg-gradient-to-br from-violet-500/10 to-violet-600/5 rounded-2xl p-6 border border-violet-500/20">
<div className="flex items-center gap-2 mb-4">
<Shield size={20} className="text-violet-400" />
<h3 className="font-bold text-white">Why This Matters for Our School</h3>
<section className={`grid grid-cols-1 gap-5 ${showSchoolImpact && showStaffRole ? 'lg:grid-cols-2' : ''}`}>
{showSchoolImpact && (
<div className="bg-gradient-to-br from-violet-500/10 to-violet-600/5 rounded-2xl p-6 border border-violet-500/20">
<div className="flex items-center gap-2 mb-4">
<Shield size={20} className="text-violet-400" />
<h3 className="font-bold text-white">Why This Matters for Our School</h3>
</div>
<div className="space-y-3">
{schoolImpactItems.map((item) => (
<div key={item.id} className="flex items-start gap-2.5">
<CheckCircle2 size={16} className="text-violet-400 flex-shrink-0 mt-0.5" />
<span className="text-sm text-slate-300 leading-relaxed">{item.text}</span>
</div>
))}
</div>
{schoolImpactEditor}
</div>
<div className="space-y-3">
{schoolImpactItems.map((item) => (
<div key={item.id} className="flex items-start gap-2.5">
<CheckCircle2 size={16} className="text-violet-400 flex-shrink-0 mt-0.5" />
<span className="text-sm text-slate-300 leading-relaxed">{item.text}</span>
</div>
))}
</div>
{schoolImpactEditor}
</div>
)}
<div className="bg-gradient-to-br from-amber-500/10 to-amber-600/5 rounded-2xl p-6 border border-amber-500/20">
<div className="flex items-center gap-2 mb-4">
<Info size={20} className="text-amber-400" />
<h3 className="font-bold text-white">Your Role as Staff</h3>
</div>
<div className="space-y-3">
{staffRoleItems.map((item, index) => (
<div key={item.title} className="flex items-start gap-2.5">
<div className="w-6 h-6 rounded-full bg-amber-500/20 flex items-center justify-center flex-shrink-0 mt-0.5">
<span className="text-xs font-bold text-amber-400">{index + 1}</span>
{showStaffRole && (
<div className="bg-gradient-to-br from-amber-500/10 to-amber-600/5 rounded-2xl p-6 border border-amber-500/20">
<div className="flex items-center gap-2 mb-4">
<Info size={20} className="text-amber-400" />
<h3 className="font-bold text-white">Your Role as Staff</h3>
</div>
<div className="space-y-3">
{staffRoleItems.map((item, index) => (
<div key={item.title} className="flex items-start gap-2.5">
<div className="w-6 h-6 rounded-full bg-amber-500/20 flex items-center justify-center flex-shrink-0 mt-0.5">
<span className="text-xs font-bold text-amber-400">{index + 1}</span>
</div>
<div>
<span className="text-sm font-semibold text-white">{item.title}</span>
<p className="text-xs text-slate-400 mt-0.5">{item.description}</p>
</div>
</div>
<div>
<span className="text-sm font-semibold text-white">{item.title}</span>
<p className="text-xs text-slate-400 mt-0.5">{item.description}</p>
</div>
</div>
))}
))}
</div>
{staffRoleEditor}
</div>
{staffRoleEditor}
</div>
)}
</section>
);
}

View File

@ -76,10 +76,13 @@ function ReadOnlyField({ label, value }: { label: string; value: string }) {
export default function ProfilePage() {
const { user, profile, refreshUser } = useAuth();
const capabilitiesQuery = useIamCapabilities();
const safetyQuizStatus = useMySafetyQuizStatus(undefined, Boolean(user));
const personalityHistoryStatus = useCurrentPersonalityResultHistory(Boolean(user));
const isExternalProfileUser =
user?.app_role?.name === 'student' || user?.app_role?.name === 'guardian';
const canShowQuizResults = Boolean(user) && !isExternalProfileUser;
const safetyQuizStatus = useMySafetyQuizStatus(undefined, canShowQuizResults);
const personalityHistoryStatus = useCurrentPersonalityResultHistory(canShowQuizResults);
const canUseZoneCheckin = canZoneCheckIn(user);
const zoneCheckinStatus = useTodayZoneCheckIn({ enabled: canUseZoneCheckin });
const zoneCheckinStatus = useTodayZoneCheckIn({ enabled: canShowQuizResults && canUseZoneCheckin });
const canReadAcknowledgedDocuments = hasPermission(user, 'ACK_POLICY');
const policyAcknowledgmentsStatus = usePolicyAcknowledgments(canReadAcknowledgedDocuments);
const handbookPoliciesStatus = usePolicies(canReadAcknowledgedDocuments);
@ -273,7 +276,8 @@ export default function ProfilePage() {
table="users"
field="avatar"
label="Avatar"
shape="circle"
shape="square"
previewSize="lg"
/>
<div className="grid content-start gap-3 sm:grid-cols-2">
<ReadOnlyField label="Role" value={roleLabel} />
@ -404,53 +408,55 @@ export default function ProfilePage() {
)}
</div>
<Card className={profileCardClassName}>
<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">
<ClipboardList size={16} />
Quiz results
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 md:px-5">
<div className={`${formPanelClassName} mt-6`}>
{safetyQuizStatus.isLoading || personalityHistoryStatus.isLoading || zoneCheckinStatus.isLoading ? (
<p className="text-sm text-slate-300">Loading quiz results...</p>
) : (
<div className="overflow-hidden rounded-lg border border-slate-700/70">
<Table>
<TableHeader>
<TableRow className="border-slate-700/70 bg-slate-950/60 hover:bg-slate-950/60">
<TableHead className="h-auto p-3 text-slate-400">Quiz</TableHead>
<TableHead className="h-auto p-3 text-slate-400">Category</TableHead>
<TableHead className="h-auto p-3 text-slate-400">Result</TableHead>
<TableHead className="h-auto p-3 text-slate-400">Completed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{quizResultRows.map((result) => (
<TableRow
key={result.id}
className="border-slate-800/80 hover:bg-slate-800/20"
>
<TableCell className="p-3">
<p className="font-semibold text-slate-100">{result.quiz}</p>
</TableCell>
<TableCell className="p-3 text-slate-300">{result.category}</TableCell>
<TableCell className={`p-3 font-semibold ${
result.status === 'complete' ? 'text-slate-100' : 'text-amber-300'
}`}>
{result.result}
</TableCell>
<TableCell className="p-3 text-slate-300">{result.completed}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</CardContent>
</Card>
{canShowQuizResults && (
<Card className={profileCardClassName}>
<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">
<ClipboardList size={16} />
Quiz results
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 md:px-5">
<div className={`${formPanelClassName} mt-6`}>
{safetyQuizStatus.isLoading || personalityHistoryStatus.isLoading || zoneCheckinStatus.isLoading ? (
<p className="text-sm text-slate-300">Loading quiz results...</p>
) : (
<div className="overflow-hidden rounded-lg border border-slate-700/70">
<Table>
<TableHeader>
<TableRow className="border-slate-700/70 bg-slate-950/60 hover:bg-slate-950/60">
<TableHead className="h-auto p-3 text-slate-400">Quiz</TableHead>
<TableHead className="h-auto p-3 text-slate-400">Category</TableHead>
<TableHead className="h-auto p-3 text-slate-400">Result</TableHead>
<TableHead className="h-auto p-3 text-slate-400">Completed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{quizResultRows.map((result) => (
<TableRow
key={result.id}
className="border-slate-800/80 hover:bg-slate-800/20"
>
<TableCell className="p-3">
<p className="font-semibold text-slate-100">{result.quiz}</p>
</TableCell>
<TableCell className="p-3 text-slate-300">{result.category}</TableCell>
<TableCell className={`p-3 font-semibold ${
result.status === 'complete' ? 'text-slate-100' : 'text-amber-300'
}`}>
{result.result}
</TableCell>
<TableCell className="p-3 text-slate-300">{result.completed}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</CardContent>
</Card>
)}
{canReadAcknowledgedDocuments && (
<Card className={profileCardClassName}>

View File

@ -1,4 +1,4 @@
import { ArrowLeft, Calendar, UserCheck, Users } from 'lucide-react';
import { ArrowLeft, UserCheck, Users } from 'lucide-react';
import { Link, useParams } from 'react-router-dom';
import { useShellOutletContext } from '@/app/shellOutletContext';
import {
@ -8,7 +8,6 @@ import {
import { formatAttendanceDate } from '@/business/campus-attendance/selectors';
import { useStaffAttendanceRecords } from '@/business/staff-attendance/hooks';
import { CampusAttendanceLoadingState } from '@/components/campus-attendance/CampusAttendanceStatus';
import { percentageTextClass } from '@/components/campus-attendance/styles';
import {
Table,
TableBody,
@ -89,7 +88,6 @@ export default function CampusAttendanceDetailsPage() {
selectedCampus?.id,
selectedCampus?.tenantId,
].filter((id): id is string => Boolean(id)));
const studentRecords = state.attendanceData.filter((record) => selectedCampusIds.has(record.campus_id));
const staffRecords = (staffRecordsQuery.data ?? []).filter((record) => (
record.campusId ? selectedStaffCampusIds.has(record.campusId) : false
));
@ -136,59 +134,13 @@ export default function CampusAttendanceDetailsPage() {
<div>
<h2 className="text-2xl font-bold text-white">{displayName} Attendance Details</h2>
<p className="mt-1 text-sm text-slate-400">
Separate student and staff attendance records for this child scope.
Staff attendance records for this child scope.
</p>
</div>
</div>
</div>
</div>
<section className="rounded-2xl border border-slate-700/40 bg-slate-800/60">
<div className="border-b border-slate-700/40 p-5">
<h3 className="flex items-center gap-2 font-semibold text-white">
<Users size={18} className="text-orange-400" />
Student Attendance
</h3>
</div>
{studentRecords.length > 0 ? (
<Table>
<TableHeader>
<TableRow className="bg-slate-700/30 hover:bg-slate-700/30">
<TableHead className="h-auto p-3 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-xs text-slate-400">Notes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{studentRecords.map((record) => (
<TableRow key={record.id} className="border-t border-slate-700/20 hover:bg-slate-700/20">
<TableCell className="p-3 font-medium text-slate-300">{formatAttendanceDate(record.date)}</TableCell>
<TableCell className="p-3 text-center text-slate-300">{record.total_enrolled}</TableCell>
<TableCell className="p-3 text-center text-emerald-300">{record.total_present}</TableCell>
<TableCell className="p-3 text-center text-red-300">{record.total_absent}</TableCell>
<TableCell className="p-3 text-center text-amber-300">{record.total_tardy}</TableCell>
<TableCell className={`p-3 text-center font-bold ${percentageTextClass(record.attendance_percentage)}`}>
{record.attendance_percentage.toFixed(1)}%
</TableCell>
<TableCell className="max-w-[260px] truncate p-3 text-xs text-slate-500">
{record.notes || '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="p-10 text-center">
<Calendar size={40} className="mx-auto mb-3 text-slate-600" />
<p className="text-sm text-slate-400">No student attendance records are saved for this child scope.</p>
</div>
)}
</section>
<section className="rounded-2xl border border-slate-700/40 bg-slate-800/60">
<div className="border-b border-slate-700/40 p-5">
<h3 className="flex items-center gap-2 font-semibold text-white">

View File

@ -1,56 +1,117 @@
import type { FormEvent } from 'react';
import { useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ClipboardCheck, GraduationCap, Loader2, Users } from 'lucide-react';
import { ChevronDown, GraduationCap, Loader2, Pencil, Plus, UserPlus, Users, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ImageUpload } from '@/components/common/ImageUpload';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ModuleHeader } from '@/components/ui/module-header';
import { NativeSelect } from '@/components/ui/native-select';
import { PageSkeleton } from '@/components/ui/page-skeleton';
import { UserAvatar } from '@/components/common/UserAvatar';
import { useAuth } from '@/contexts/useAuth';
import { usePermissions } from '@/hooks/usePermissions';
import {
createUser,
getClass,
getClassAttendanceSummary,
linkGuardianStudent,
listGuardianStudents,
listRoles,
listUsers,
updateUser,
type AdminUserRow,
upsertClassAttendance,
} from '@/business/my-class/api';
import {
buildMyClassStudentSaveData,
buildMyClassGuardianSaveData,
canManageMyClassStudents,
hasMyClassGuardianFormValues,
type MyClassGuardianFormValues,
type MyClassStudentFormValues,
} from '@/business/my-class/selectors';
import { USER_NAME_PREFIX_OPTIONS } from '@/shared/constants/users';
import { getErrorMessage } from '@/shared/errors/errorMessages';
import { cn } from '@/lib/utils';
function personName(row: { firstName?: string | null; lastName?: string | null; email?: string }): string {
return [row.firstName, row.lastName].filter(Boolean).join(' ').trim() || row.email || '—';
}
const today = () => new Date().toISOString().slice(0, 10);
type StudentAttendanceStatus = 'present' | 'late' | 'absent';
const STUDENT_ATTENDANCE_STATUS_OPTIONS: readonly {
value: StudentAttendanceStatus;
label: string;
}[] = [
{ value: 'present', label: 'Present' },
{ value: 'late', label: 'Late' },
{ value: 'absent', label: 'Absent' },
];
function reconcileStudentStatuses(
current: Record<string, StudentAttendanceStatus>,
students: readonly AdminUserRow[],
): Record<string, StudentAttendanceStatus> {
const next: Record<string, StudentAttendanceStatus> = {};
for (const student of students) {
next[student.id] = current[student.id] ?? 'present';
}
return next;
interface StatusMessage {
readonly type: 'success' | 'error';
readonly text: string;
}
const emptyStudentForm = (): MyClassStudentFormValues => ({
namePrefix: '',
firstName: '',
lastName: '',
email: '',
phoneNumber: '',
avatar: null,
guardians: [emptyGuardianForm()],
});
function emptyGuardianForm(): MyClassGuardianFormValues {
return {
key: `guardian-${Date.now()}-${Math.random().toString(36).slice(2)}`,
id: null,
namePrefix: '',
firstName: '',
lastName: '',
email: '',
phoneNumber: '',
avatar: null,
};
}
function studentFormFromRow(
row: AdminUserRow,
guardians: readonly GuardianStudentLink[] = [],
): MyClassStudentFormValues {
const guardianForms = guardians
.map((link): MyClassGuardianFormValues | null => {
const guardian = link.guardian;
if (!guardian?.id) return null;
return {
key: link.id,
id: guardian.id,
namePrefix: guardian.name_prefix ?? '',
firstName: guardian.firstName ?? '',
lastName: guardian.lastName ?? '',
email: guardian.email ?? '',
phoneNumber: guardian.phoneNumber ?? '',
avatar: guardian.avatar?.[0]?.privateUrl ?? null,
};
})
.filter((guardian): guardian is MyClassGuardianFormValues => Boolean(guardian));
return {
namePrefix: row.name_prefix ?? '',
firstName: row.firstName ?? '',
lastName: row.lastName ?? '',
email: row.email,
phoneNumber: row.phoneNumber ?? '',
avatar: row.avatar?.[0]?.privateUrl ?? null,
guardians: guardianForms.length > 0 ? guardianForms : [emptyGuardianForm()],
};
}
type GuardianStudentLink = NonNullable<Awaited<ReturnType<typeof listGuardianStudents>>['rows'][number]>;
export default function MyClassPage() {
const { user } = useAuth();
const permissions = usePermissions();
const queryClient = useQueryClient();
const classId = user?.classId ?? null;
const canFill = (user?.permissions ?? []).includes('FILL_ATTENDANCE');
const [studentForm, setStudentForm] = useState<MyClassStudentFormValues>(() => emptyStudentForm());
const [editingStudentId, setEditingStudentId] = useState<string | null>(null);
const [isStudentFormOpen, setIsStudentFormOpen] = useState(false);
const [studentFormSaving, setStudentFormSaving] = useState(false);
const [studentFormStatus, setStudentFormStatus] = useState<StatusMessage | null>(null);
const classQuery = useQuery({
queryKey: ['my-class', classId],
@ -67,10 +128,10 @@ export default function MyClassPage() {
queryFn: () => listGuardianStudents(),
enabled: Boolean(classId),
});
const summaryQuery = useQuery({
queryKey: ['my-class-attendance', classId],
queryFn: () => getClassAttendanceSummary(),
enabled: Boolean(classId),
const rolesQuery = useQuery({
queryKey: ['roles'],
queryFn: listRoles,
enabled: Boolean(classId) && (permissions.has('CREATE_USERS') || permissions.has('UPDATE_USERS')),
});
const members = useMemo(
@ -85,6 +146,24 @@ export default function MyClassPage() {
() => members.filter((m) => m.app_role?.name === 'teacher' || m.app_role?.name === 'support_staff'),
[members],
);
const studentRoleId = useMemo(
() => rolesQuery.data?.rows.find((role) => role.name === 'student')?.id ?? null,
[rolesQuery.data],
);
const guardianRoleId = useMemo(
() => rolesQuery.data?.rows.find((role) => role.name === 'guardian')?.id ?? null,
[rolesQuery.data],
);
const canCreateStudents = canManageMyClassStudents({
hasUserPermission: permissions.has('CREATE_USERS'),
classId,
studentRoleId,
});
const canUpdateStudents = canManageMyClassStudents({
hasUserPermission: permissions.has('UPDATE_USERS'),
classId,
studentRoleId,
});
// studentId → guardian display names.
const guardiansByStudent = useMemo(() => {
@ -98,50 +177,126 @@ export default function MyClassPage() {
}
return map;
}, [guardiansQuery.data]);
const guardianLinksByStudent = useMemo(() => {
const map = new Map<string, GuardianStudentLink[]>();
for (const row of guardiansQuery.data?.rows ?? []) {
const current = map.get(row.studentId) ?? [];
map.set(row.studentId, [...current, row]);
}
return map;
}, [guardiansQuery.data]);
const [date, setDate] = useState(today());
const [studentStatusOverrides, setStudentStatusOverrides] = useState<Record<string, StudentAttendanceStatus>>({});
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const studentStatuses = useMemo(
() => reconcileStudentStatuses(studentStatusOverrides, students),
[studentStatusOverrides, students],
);
const updateStudentForm = (patch: Partial<MyClassStudentFormValues>) => {
setStudentForm((current) => ({ ...current, ...patch }));
setStudentFormStatus(null);
};
const updateGuardianForm = (guardianKey: string, patch: Partial<MyClassGuardianFormValues>) => {
setStudentForm((current) => ({
...current,
guardians: current.guardians.map((guardian) => (
guardian.key === guardianKey ? { ...guardian, ...patch } : guardian
)),
}));
setStudentFormStatus(null);
};
const addGuardianForm = () => {
setStudentForm((current) => ({
...current,
guardians: [...current.guardians, emptyGuardianForm()],
}));
setStudentFormStatus(null);
};
const removeGuardianForm = (guardianKey: string) => {
setStudentForm((current) => ({
...current,
guardians: current.guardians.length === 1
? [emptyGuardianForm()]
: current.guardians.filter((guardian) => guardian.key !== guardianKey),
}));
setStudentFormStatus(null);
};
async function handleAttendance() {
setStatus(null);
if (!classId) return;
if (students.length === 0) {
setStatus({ type: 'error', text: 'No students are available for attendance.' });
const resetStudentForm = () => {
setStudentForm(emptyStudentForm());
setEditingStudentId(null);
setIsStudentFormOpen(false);
};
const startCreateStudent = () => {
setStudentFormStatus(null);
if (isStudentFormOpen && !editingStudentId) {
resetStudentForm();
return;
}
setStudentForm(emptyStudentForm());
setEditingStudentId(null);
setIsStudentFormOpen(true);
};
const startEditStudent = (student: AdminUserRow) => {
const guardians = guardianLinksByStudent.get(student.id) ?? [];
setStudentForm(studentFormFromRow(student, guardians));
setEditingStudentId(student.id);
setStudentFormStatus(null);
setIsStudentFormOpen(true);
};
const handleStudentSubmit = async (event: FormEvent) => {
event.preventDefault();
setStudentFormStatus(null);
if (!classId || !studentRoleId || !guardianRoleId) {
setStudentFormStatus({ type: 'error', text: 'Student access is not available for this class.' });
return;
}
const guardiansToSave = studentForm.guardians.filter(hasMyClassGuardianFormValues);
if (guardiansToSave.some((guardian) => guardian.email.trim() === '')) {
setStudentFormStatus({ type: 'error', text: 'Guardian email is required for each entered guardian.' });
return;
}
const totalPresent = students.filter((student) => (
(studentStatuses[student.id] ?? 'present') !== 'absent'
)).length;
const totalAbsent = students.filter((student) => (
(studentStatuses[student.id] ?? 'present') === 'absent'
)).length;
const totalTardy = students.filter((student) => (
(studentStatuses[student.id] ?? 'present') === 'late'
)).length;
const payload = buildMyClassStudentSaveData(studentForm, classId, studentRoleId);
setSaving(true);
setStudentFormSaving(true);
try {
await upsertClassAttendance(classId, date, {
total_enrolled: students.length,
total_present: totalPresent,
total_absent: totalAbsent,
total_tardy: totalTardy,
let studentId = editingStudentId;
if (editingStudentId) {
await updateUser(editingStudentId, payload);
} else {
const created = await createUser(payload);
studentId = created.id;
}
if (studentId) {
for (const guardian of guardiansToSave) {
const guardianPayload = buildMyClassGuardianSaveData(guardian, guardianRoleId);
const guardianId = guardian.id
? guardian.id
: (await createUser(guardianPayload)).id;
if (guardian.id) {
await updateUser(guardian.id, guardianPayload);
}
if (guardianId) {
await linkGuardianStudent(guardianId, studentId);
}
}
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['my-class-members', classId] }),
queryClient.invalidateQueries({ queryKey: ['my-class-guardians'] }),
]);
setStudentFormStatus({
type: 'success',
text: editingStudentId ? 'Student updated.' : 'Student created.',
});
await queryClient.invalidateQueries({ queryKey: ['my-class-attendance', classId] });
setStatus({ type: 'success', text: 'Attendance saved.' });
resetStudentForm();
} catch (error) {
setStatus({ type: 'error', text: getErrorMessage(error, 'Could not save attendance') });
setStudentFormStatus({ type: 'error', text: getErrorMessage(error, 'Could not save student') });
} finally {
setSaving(false);
setStudentFormSaving(false);
}
}
};
if (!classId) {
return (
@ -153,15 +308,18 @@ export default function MyClassPage() {
);
}
if (classQuery.isLoading || membersQuery.isLoading) {
if (classQuery.isLoading || membersQuery.isLoading || rolesQuery.isLoading) {
return <PageSkeleton />;
}
const formControlClassName =
'border-slate-600 bg-slate-950/80 text-slate-100 placeholder:text-slate-500 focus-visible:ring-lime-400 focus-visible:ring-offset-slate-950';
return (
<div className="space-y-6 p-4 md:p-6">
<ModuleHeader
title={classQuery.data?.name ?? 'My Class'}
description="Your class roster and daily attendance."
description="Your class roster and linked guardians."
icon={GraduationCap}
iconClassName="bg-gradient-to-br from-lime-500 to-lime-700"
/>
@ -169,12 +327,249 @@ export default function MyClassPage() {
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Users size={16} />
Students ({students.length})
</CardTitle>
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle className="flex items-center gap-2 text-base">
<Users size={16} />
Students ({students.length})
</CardTitle>
{canCreateStudents && (
<Button type="button" size="sm" onClick={startCreateStudent}>
<UserPlus className="mr-2 h-4 w-4" />
{isStudentFormOpen && !editingStudentId ? 'Hide form' : 'Add student'}
<ChevronDown
className={cn(
'ml-2 h-4 w-4 transition-transform',
isStudentFormOpen && !editingStudentId && 'rotate-180',
)}
/>
</Button>
)}
</div>
</CardHeader>
<CardContent>
<CardContent className="space-y-4">
{studentFormStatus && (
<div
className={`rounded-lg border px-3 py-2 text-sm ${
studentFormStatus.type === 'success'
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200'
: 'border-red-500/30 bg-red-500/10 text-red-200'
}`}
>
{studentFormStatus.text}
</div>
)}
{isStudentFormOpen && (
<form
className="space-y-4 rounded-lg border border-slate-600/80 bg-slate-950/45 p-4"
onSubmit={handleStudentSubmit}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-100">
{editingStudentId ? 'Edit student' : 'Add student'}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={resetStudentForm}
className="text-slate-300"
>
<X className="mr-2 h-4 w-4" />
Cancel
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-[auto_1fr]">
<ImageUpload
value={studentForm.avatar}
onChange={(avatar) => updateStudentForm({ avatar })}
table="users"
field="avatar"
label="Avatar"
shape="square"
previewSize="lg"
/>
<div className="grid gap-4 sm:grid-cols-4">
<div className="space-y-1.5">
<Label htmlFor="student-prefix" className="text-slate-100">Title</Label>
<NativeSelect
id="student-prefix"
value={studentForm.namePrefix}
className={formControlClassName}
onChange={(event) => updateStudentForm({ namePrefix: event.target.value })}
>
<option value="">None</option>
{USER_NAME_PREFIX_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</NativeSelect>
</div>
<div className="space-y-1.5">
<Label htmlFor="student-first-name" className="text-slate-100">First name</Label>
<Input
id="student-first-name"
value={studentForm.firstName}
className={formControlClassName}
onChange={(event) => updateStudentForm({ firstName: event.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="student-last-name" className="text-slate-100">Last name</Label>
<Input
id="student-last-name"
value={studentForm.lastName}
className={formControlClassName}
onChange={(event) => updateStudentForm({ lastName: event.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="student-email" className="text-slate-100">Email</Label>
<Input
id="student-email"
type="email"
value={studentForm.email}
className={formControlClassName}
onChange={(event) => updateStudentForm({ email: event.target.value })}
required
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="student-phone" className="text-slate-100">Phone number</Label>
<Input
id="student-phone"
type="tel"
value={studentForm.phoneNumber}
className={formControlClassName}
onChange={(event) => updateStudentForm({ phoneNumber: event.target.value })}
/>
</div>
</div>
</div>
<div className="space-y-4 rounded-lg border border-slate-700/70 bg-slate-950/35 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold text-slate-100">Guardians</p>
<Button type="button" variant="outline" size="sm" onClick={addGuardianForm}>
<Plus className="mr-2 h-4 w-4" />
Add guardian
</Button>
</div>
{studentForm.guardians.map((guardian, index) => (
<div
key={guardian.key}
className="space-y-4 rounded-lg border border-slate-700/70 bg-slate-950/45 p-4"
>
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-slate-200">Guardian {index + 1}</p>
{guardian.id ? (
<span className="text-xs text-slate-400">Linked guardian</span>
) : (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeGuardianForm(guardian.key)}
className="text-slate-300"
>
<X className="mr-2 h-4 w-4" />
Remove
</Button>
)}
</div>
<div className="grid gap-4 sm:grid-cols-[auto_1fr]">
<ImageUpload
value={guardian.avatar}
onChange={(avatar) => updateGuardianForm(guardian.key, { avatar })}
table="users"
field="avatar"
label="Photo"
shape="square"
previewSize="lg"
/>
<div className="grid gap-4 sm:grid-cols-4">
<div className="space-y-1.5">
<Label htmlFor={`guardian-prefix-${guardian.key}`} className="text-slate-100">
Title
</Label>
<NativeSelect
id={`guardian-prefix-${guardian.key}`}
value={guardian.namePrefix}
className={formControlClassName}
onChange={(event) => updateGuardianForm(guardian.key, { namePrefix: event.target.value })}
>
<option value="">None</option>
{USER_NAME_PREFIX_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</NativeSelect>
</div>
<div className="space-y-1.5">
<Label htmlFor={`guardian-first-name-${guardian.key}`} className="text-slate-100">
First name
</Label>
<Input
id={`guardian-first-name-${guardian.key}`}
value={guardian.firstName}
className={formControlClassName}
onChange={(event) => updateGuardianForm(guardian.key, { firstName: event.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`guardian-last-name-${guardian.key}`} className="text-slate-100">
Last name
</Label>
<Input
id={`guardian-last-name-${guardian.key}`}
value={guardian.lastName}
className={formControlClassName}
onChange={(event) => updateGuardianForm(guardian.key, { lastName: event.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`guardian-email-${guardian.key}`} className="text-slate-100">
Email
</Label>
<Input
id={`guardian-email-${guardian.key}`}
type="email"
value={guardian.email}
className={formControlClassName}
onChange={(event) => updateGuardianForm(guardian.key, { email: event.target.value })}
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor={`guardian-phone-${guardian.key}`} className="text-slate-100">
Phone number
</Label>
<Input
id={`guardian-phone-${guardian.key}`}
type="tel"
value={guardian.phoneNumber}
className={formControlClassName}
onChange={(event) => updateGuardianForm(guardian.key, { phoneNumber: event.target.value })}
/>
</div>
</div>
</div>
</div>
))}
</div>
<div className="flex justify-end">
<Button
type="submit"
disabled={studentFormSaving || (editingStudentId ? !canUpdateStudents : !canCreateStudents)}
>
{studentFormSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{editingStudentId ? 'Save changes' : 'Create student'}
</Button>
</div>
</form>
)}
{students.length === 0 ? (
<p className="text-sm text-slate-400">No students in this class yet.</p>
) : (
@ -182,11 +577,32 @@ export default function MyClassPage() {
{students.map((s) => {
const guardians = guardiansByStudent.get(s.id) ?? [];
return (
<li key={s.id} className="py-2">
<p className="text-sm font-medium text-slate-200">{personName(s)}</p>
<p className="text-xs text-slate-500">
{guardians.length > 0 ? `Guardians: ${guardians.join(', ')}` : 'No guardian linked'}
</p>
<li key={s.id} className="flex items-center justify-between gap-3 py-3">
<div className="flex min-w-0 items-center gap-3">
<UserAvatar
name={personName(s)}
avatarUrl={s.avatar?.[0]?.privateUrl ?? null}
className="h-9 w-9 text-[11px]"
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-slate-200">{personName(s)}</p>
<p className="truncate text-xs text-slate-500">
{guardians.length > 0 ? `Guardians: ${guardians.join(', ')}` : 'No guardian linked'}
</p>
</div>
</div>
{canUpdateStudents && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => startEditStudent(s)}
className="shrink-0"
aria-label={`Edit ${personName(s)}`}
>
<Pencil className="h-4 w-4" />
</Button>
)}
</li>
);
})}
@ -218,144 +634,6 @@ export default function MyClassPage() {
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ClipboardCheck size={16} />
Daily attendance
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{canFill ? (
<div className="space-y-4">
<div className="max-w-xs space-y-1.5">
<Label htmlFor="att-date">Date</Label>
<input
id="att-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
className="flex h-10 w-full rounded-md border border-border/60 bg-background px-3 py-2 text-sm text-slate-100 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
</div>
<StudentAttendanceTable
students={students}
guardiansByStudent={guardiansByStudent}
statuses={studentStatuses}
onStatusChange={(studentId, nextStatus) => {
setStudentStatusOverrides((current) => ({ ...current, [studentId]: nextStatus }));
setStatus(null);
}}
/>
{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="button" onClick={handleAttendance} disabled={saving || students.length === 0}>
{saving && <Loader2 size={16} className="mr-2 animate-spin" />}
Save attendance
</Button>
</div>
</div>
) : (
<p className="text-sm text-slate-400">You have read-only access to attendance.</p>
)}
<div>
<p className="mb-2 text-xs font-semibold text-slate-300">Recent</p>
{(summaryQuery.data?.rows ?? []).length === 0 ? (
<p className="text-sm text-slate-400">No attendance recorded yet.</p>
) : (
<ul className="divide-y divide-slate-700/50">
{(summaryQuery.data?.rows ?? []).slice(0, 7).map((row) => (
<li key={row.date} className="flex items-center justify-between py-1.5 text-sm">
<span className="text-slate-300">{row.date}</span>
<span className="text-slate-400">
{row.total_present}/{row.total_enrolled} present ({row.attendance_percentage}%)
</span>
</li>
))}
</ul>
)}
</div>
</CardContent>
</Card>
</div>
);
}
function StudentAttendanceTable({
students,
guardiansByStudent,
statuses,
onStatusChange,
}: {
students: readonly AdminUserRow[];
guardiansByStudent: ReadonlyMap<string, readonly string[]>;
statuses: Readonly<Record<string, StudentAttendanceStatus>>;
onStatusChange: (studentId: string, status: StudentAttendanceStatus) => void;
}) {
if (students.length === 0) {
return (
<div className="rounded-lg border border-slate-700/50 bg-slate-900/30 px-4 py-3 text-sm text-slate-400">
No students in this class yet.
</div>
);
}
return (
<div className="overflow-hidden rounded-lg border border-slate-700/60">
<table className="w-full min-w-[640px] text-left text-sm">
<thead className="bg-slate-900/70 text-xs uppercase text-slate-400">
<tr>
<th className="px-4 py-3 font-semibold">Student</th>
{STUDENT_ATTENDANCE_STATUS_OPTIONS.map((option) => (
<th key={option.value} className="w-28 px-4 py-3 text-center font-semibold">
{option.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-800/80 bg-slate-950/20">
{students.map((student) => {
const currentStatus = statuses[student.id] ?? 'present';
const guardians = guardiansByStudent.get(student.id) ?? [];
return (
<tr key={student.id}>
<td className="px-4 py-3">
<p className="font-medium text-slate-200">{personName(student)}</p>
<p className="text-xs text-slate-500">
{guardians.length > 0 ? `Guardians: ${guardians.join(', ')}` : 'No guardian linked'}
</p>
</td>
{STUDENT_ATTENDANCE_STATUS_OPTIONS.map((option) => (
<td key={option.value} className="px-4 py-3 text-center">
<input
type="checkbox"
checked={currentStatus === option.value}
onChange={() => onStatusChange(student.id, option.value)}
aria-label={`${option.label} for ${personName(student)}`}
className="h-4 w-4 rounded border-slate-500 bg-slate-800 text-lime-500 focus:ring-lime-500"
/>
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@ -1,7 +1,7 @@
import type { FormEvent } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronDown, Loader2, Pencil, Search, Trash2, UserPlus, Users, X } from 'lucide-react';
import { ArrowDown, ArrowUp, ArrowUpDown, Check, 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';
@ -10,9 +10,19 @@ import { Label } from '@/components/ui/label';
import { ModuleHeader } from '@/components/ui/module-header';
import { NativeSelect } from '@/components/ui/native-select';
import { PageSkeleton } from '@/components/ui/page-skeleton';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { TenantParentPicker } from '@/components/tenant-create/TenantParentPicker';
import { ImageUpload } from '@/components/common/ImageUpload';
import { TenantLogo } from '@/components/common/TenantLogo';
import { UserAvatar } from '@/components/common/UserAvatar';
import { useScopeContext } from '@/contexts/scope-context';
import { useAuth } from '@/contexts/useAuth';
import { useTenantChildren } from '@/business/scope/queries';
@ -27,8 +37,8 @@ import {
createOwnerWithOrganization,
createUser,
deleteUser,
fileDownloadUrl,
linkGuardianStudent,
listGuardianStudents,
listPermissions,
listRoles,
listUsers,
@ -55,9 +65,9 @@ type UserListSortField =
| 'name'
| 'email'
| 'phoneNumber'
| 'organization'
| 'school'
| 'campus'
| 'class'
| 'role';
type UserListSortDirection = 'asc' | 'desc';
@ -94,6 +104,43 @@ function locationCell(value?: { name?: string | null; logo?: string | null } | n
);
}
function classNamesForUser(
row: AdminUserRow,
guardianClassNamesById: ReadonlyMap<string, readonly string[]>,
): readonly string[] {
const names: string[] = [];
if (row.class?.name) {
names.push(row.class.name);
}
for (const enrollment of row.class_enrollments_student ?? []) {
const className = enrollment.class?.name;
if (className && !names.includes(className)) {
names.push(className);
}
}
for (const className of guardianClassNamesById.get(row.id) ?? []) {
if (!names.includes(className)) {
names.push(className);
}
}
return names;
}
function classCell(
row: AdminUserRow,
guardianClassNamesById: ReadonlyMap<string, readonly string[]>,
) {
const classNames = classNamesForUser(row, guardianClassNamesById);
if (classNames.length === 0) {
return '—';
}
return (
<span className="block min-w-[140px] truncate">
{classNames.join(', ')}
</span>
);
}
function sortText(value: string | null | undefined): string {
return value?.trim().toLocaleLowerCase() || '';
}
@ -102,6 +149,16 @@ function permissionLabel(name: string | null | undefined, id: string): string {
return (name?.trim() || id).replace(/_/g, ' ');
}
function studentPickerLabel(students: readonly AdminUserRow[], selectedIds: readonly string[]): string {
if (selectedIds.length === 0) return 'Select students...';
const names = students
.filter((student) => selectedIds.includes(student.id))
.map(userName);
if (names.length === 0) return `${selectedIds.length} selected`;
if (names.length <= 2) return names.join(', ');
return `${names.slice(0, 2).join(', ')} +${names.length - 2}`;
}
export default function UserAdminPage() {
const { tier, ownTenant } = useScopeContext();
const { user } = useAuth();
@ -110,12 +167,17 @@ export default function UserAdminPage() {
const capabilitiesQuery = useIamCapabilities();
const rolesQuery = useQuery({ queryKey: ['roles'], queryFn: listRoles });
const permissionsQuery = useQuery({ queryKey: ['permissions'], queryFn: listPermissions });
const guardianStudentsQuery = useQuery({
queryKey: ['guardian-students'],
queryFn: () => listGuardianStudents(),
});
const [usersPage, setUsersPage] = useState(0);
const [usersSearchDraft, setUsersSearchDraft] = useState('');
const [usersSearch, setUsersSearch] = useState('');
const [usersSortField, setUsersSortField] = useState<UserListSortField>('name');
const [usersSortDirection, setUsersSortDirection] = useState<UserListSortDirection>('asc');
const [isUserFormOpen, setIsUserFormOpen] = useState(false);
const [isStudentPickerOpen, setIsStudentPickerOpen] = useState(false);
const usersQuery = useQuery({
queryKey: ['admin-users', usersSearch],
queryFn: () =>
@ -204,6 +266,11 @@ export default function UserAdminPage() {
[allPermissions, effectivePermissionIds],
);
const tenantInput = selectedRole ? getRoleTenantInput(selectedRole) : 'none';
const canManageAdvancedPermissions =
canEditPermissions
&& selectedRole !== 'super_admin'
&& selectedRole !== 'student'
&& selectedRole !== 'guardian';
const canAutoCreateOwnerOrganization =
selectedRole === 'owner' && capabilitiesQuery.data?.canCreateOwnerWithOrganization === true;
const targetLevel =
@ -224,12 +291,12 @@ export default function UserAdminPage() {
);
// Students for guardian linking (role 'student' in the actor's scope).
const studentRoleId = roleIdByName.get('student');
const studentsQuery = useQuery({
queryKey: ['admin-users', 'students', studentRoleId],
queryFn: () => listUsers({ app_role: studentRoleId }),
enabled: tenantInput === 'guardian' && Boolean(studentRoleId),
queryKey: ['admin-users', 'students', 'student'],
queryFn: () => listUsers({ app_role: 'student' }),
enabled: tenantInput === 'guardian',
});
const guardianStudentOptions = studentsQuery.data?.rows ?? [];
const handlePickerChange = useCallback((id: string | null) => {
setPickedTenantId(id);
@ -281,6 +348,29 @@ export default function UserAdminPage() {
}, [rolePermissionIds]);
const fetchedRows = usersQuery.data?.rows;
const classNameByStudentId = useMemo(() => {
const map = new Map<string, string>();
for (const row of fetchedRows ?? []) {
const className = classNamesForUser(row, new Map()).join(', ');
if (row.app_role?.name === 'student' && className) {
map.set(row.id, className);
}
}
return map;
}, [fetchedRows]);
const guardianClassNamesById = useMemo(() => {
const map = new Map<string, string[]>();
for (const link of guardianStudentsQuery.data?.rows ?? []) {
const className = classNameByStudentId.get(link.studentId);
if (!className) continue;
const current = map.get(link.guardianId) ?? [];
if (!current.includes(className)) {
current.push(className);
map.set(link.guardianId, current);
}
}
return map;
}, [classNameByStudentId, guardianStudentsQuery.data]);
const sortedRows = useMemo(() => {
const collator = new Intl.Collator(undefined, {
numeric: true,
@ -295,12 +385,12 @@ export default function UserAdminPage() {
return sortText(row.email);
case 'phoneNumber':
return sortText(row.phoneNumber);
case 'organization':
return sortText(locationName(row.organizations));
case 'school':
return sortText(locationName(row.school));
case 'campus':
return sortText(locationName(row.campus));
case 'class':
return sortText(classNamesForUser(row, guardianClassNamesById).join(' '));
case 'role':
return sortText(
row.app_role?.name ? getAuthRoleLabel(row.app_role.name as UserRole) : '—',
@ -320,7 +410,7 @@ export default function UserAdminPage() {
return collator.compare(sortText(userName(left)), sortText(userName(right)));
});
}, [fetchedRows, usersSortDirection, usersSortField]);
}, [fetchedRows, guardianClassNamesById, usersSortDirection, usersSortField]);
const usersTotal = usersQuery.data?.count ?? sortedRows.length;
const rows = useMemo(() => {
const start = usersPage * USER_LIST_PAGE_SIZE;
@ -404,8 +494,8 @@ export default function UserAdminPage() {
email: email.trim(),
avatar,
app_role: roleId ?? null,
custom_permissions: grantPerms,
custom_permissions_filter: excludePerms,
custom_permissions: canManageAdvancedPermissions ? grantPerms : [],
custom_permissions_filter: canManageAdvancedPermissions ? excludePerms : [],
...buildTenantPayload(),
};
@ -422,6 +512,7 @@ export default function UserAdminPage() {
}
}
await queryClient.invalidateQueries({ queryKey: ['admin-users'] });
await queryClient.invalidateQueries({ queryKey: ['guardian-students'] });
setUsersPage(0);
setStatus({ type: 'success', text: editingId ? 'User updated.' : 'User created (invite sent).' });
resetForm();
@ -523,7 +614,8 @@ export default function UserAdminPage() {
table="users"
field="avatar"
label="Avatar"
shape="circle"
shape="square"
previewSize="lg"
/>
<div className="grid gap-4 sm:grid-cols-4">
<div className="space-y-1.5">
@ -603,6 +695,10 @@ export default function UserAdminPage() {
setPickedTenantId(null);
setClassId('');
setStudentIds([]);
if (e.target.value === 'student' || e.target.value === 'guardian') {
setGrantPerms([]);
setExcludePerms([]);
}
setStatus(null);
}}
>
@ -646,31 +742,69 @@ export default function UserAdminPage() {
{tenantInput === 'guardian' && !editingId && (
<div className="space-y-1.5">
<Label className="text-slate-100">Students</Label>
<div className="max-h-40 space-y-1 overflow-y-auto rounded-lg border border-slate-600 bg-slate-950/60 p-2">
{(studentsQuery.data?.rows ?? []).length === 0 ? (
<p className="text-xs text-slate-300">No students found in your scope.</p>
) : (
(studentsQuery.data?.rows ?? []).map((s) => (
<label key={s.id} className="flex items-center gap-2 text-sm text-slate-100">
<input
type="checkbox"
checked={studentIds.includes(s.id)}
onChange={(e) =>
setStudentIds((prev) =>
e.target.checked ? [...prev, s.id] : prev.filter((x) => x !== s.id),
)
}
/>
{userName(s)}
</label>
))
)}
</div>
<Popover open={isStudentPickerOpen} onOpenChange={setIsStudentPickerOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="h-12 w-full justify-between border-slate-600 bg-slate-950/80 px-3 text-left text-slate-100 hover:bg-slate-900"
>
<span className={cn('truncate', studentIds.length === 0 && 'text-slate-400')}>
{studentPickerLabel(guardianStudentOptions, studentIds)}
</span>
<ChevronDown className="ml-3 h-4 w-4 shrink-0 text-slate-400" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] border-slate-700 bg-slate-950 p-0 text-slate-100"
>
<Command className="bg-slate-950 text-slate-100">
<CommandInput placeholder="Search students..." />
<CommandList className="max-h-64">
<CommandEmpty>No students found in your scope.</CommandEmpty>
<CommandGroup>
{guardianStudentOptions.map((student) => {
const selected = studentIds.includes(student.id);
return (
<CommandItem
key={student.id}
value={`${userName(student)} ${student.email}`}
onSelect={() => {
setStudentIds((prev) =>
selected
? prev.filter((id) => id !== student.id)
: [...prev, student.id],
);
}}
className="flex cursor-pointer items-center gap-2 text-slate-100"
>
<span
className={cn(
'flex h-4 w-4 items-center justify-center rounded border border-slate-500',
selected && 'border-sky-400 bg-sky-500 text-white',
)}
aria-hidden="true"
>
{selected && <Check className="h-3 w-3" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">{userName(student)}</span>
<span className="block truncate text-xs text-slate-400">{student.email}</span>
</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
</div>
{canEditPermissions && selectedRole !== 'super_admin' && (
{canManageAdvancedPermissions && (
<details className="rounded-lg border border-slate-600/80 bg-slate-950/45 p-4">
<summary className="cursor-pointer text-sm font-semibold text-slate-100">
Advanced permissions (optional)
@ -784,7 +918,7 @@ export default function UserAdminPage() {
<Input
value={usersSearchDraft}
onChange={(event) => setUsersSearchDraft(event.target.value)}
placeholder="Search by name, email, phone, organization, school, campus, or role"
placeholder="Search by name, email, phone, school, campus, class, or role"
className="pl-9"
/>
</div>
@ -817,9 +951,9 @@ export default function UserAdminPage() {
['name', 'User'],
['email', 'Email'],
['phoneNumber', 'Phone'],
['organization', 'Organization'],
['school', 'School'],
['campus', 'Campus'],
['class', 'Class'],
['role', 'Role'],
] as const).map(([field, label]) => (
<th key={field} className="px-3 py-3 font-medium">
@ -850,17 +984,11 @@ export default function UserAdminPage() {
<tr key={row.id} className="align-middle">
<td className="px-3 py-2.5">
<div className="flex min-w-[180px] items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-slate-700 bg-slate-800 text-[11px] font-semibold text-slate-300">
{row.avatar?.[0]?.privateUrl ? (
<img
src={fileDownloadUrl(row.avatar[0].privateUrl)}
alt=""
className="h-full w-full object-cover"
/>
) : (
userName(row).slice(0, 2).toUpperCase()
)}
</div>
<UserAvatar
name={userName(row)}
avatarUrl={row.avatar?.[0]?.privateUrl ?? null}
className="h-9 w-9 text-[11px]"
/>
<div className="min-w-0">
<p className="truncate font-medium text-slate-100">{userName(row)}</p>
</div>
@ -868,9 +996,11 @@ 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">{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">
{classCell(row, guardianClassNamesById)}
</td>
<td className="px-3 py-2.5 text-slate-300">
{roleName ? getAuthRoleLabel(roleName as UserRole) : '—'}
</td>

View File

@ -5,7 +5,15 @@ export interface GuardianStudentRow {
readonly guardianId: string;
readonly studentId: string;
readonly relationship?: string | null;
readonly guardian?: { id: string; firstName?: string | null; lastName?: string | null } | null;
readonly guardian?: {
readonly id: string;
readonly name_prefix?: string | null;
readonly firstName?: string | null;
readonly lastName?: string | null;
readonly email?: string;
readonly phoneNumber?: string | null;
readonly avatar?: readonly { readonly privateUrl?: string | null }[];
} | null;
readonly student?: { id: string; firstName?: string | null; lastName?: string | null } | null;
}

View File

@ -19,6 +19,12 @@ export interface AdminUserRow {
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 class_enrollments_student?: readonly {
readonly id: string;
readonly classId?: string | null;
readonly studentId?: string | null;
readonly class?: { id: string; name?: string | null; logo?: string | null } | null;
}[];
readonly campusId?: string | null;
readonly schoolId?: string | null;
readonly classId?: string | null;
@ -54,7 +60,7 @@ export interface ListUsersParams {
readonly campusId?: string;
readonly limit?: number;
readonly page?: number;
readonly field?: 'name' | 'email' | 'phoneNumber' | 'organization' | 'school' | 'campus' | 'role';
readonly field?: 'name' | 'email' | 'phoneNumber' | 'school' | 'campus' | 'class' | 'role';
readonly sort?: 'asc' | 'desc';
}

View File

@ -1,7 +1,6 @@
import type {
CampusAttendancePrintInput,
CampusAttendanceStats,
CampusAttendanceSummaryViewModel,
StaffAttendanceDailySummaryViewModel,
} from '@/business/campus-attendance/types';
import type { StaffAttendanceSummaryViewModel } from '@/business/staff-attendance/types';
@ -15,35 +14,8 @@ export const CAMPUS_ATTENDANCE_TEST_SEED = {
generatedByRole: 'Director & Campus Lead',
generatedByRoleEscaped: 'Director &amp; Campus Lead',
reportTitleEscaped: 'Attendance &amp; Daily &lt;Report&gt;',
todayNotesEscaped: 'Needs &lt;support&gt; &amp; follow-up',
} as const;
export const campusAttendanceTodayRecord: CampusAttendanceSummaryViewModel = {
id: 'summary-1',
campus_id: CAMPUS_ATTENDANCE_TEST_SEED.campusId,
date: '2026-06-08',
total_enrolled: 50,
total_present: 45,
total_absent: 5,
total_tardy: 1,
attendance_percentage: 90,
recorded_by: 'director-1',
notes: 'Needs <support> & follow-up',
};
export const campusAttendanceWeekRecord: CampusAttendanceSummaryViewModel = {
id: 'summary-2',
campus_id: CAMPUS_ATTENDANCE_TEST_SEED.campusId,
date: '2026-06-09',
total_enrolled: 50,
total_present: 40,
total_absent: 10,
total_tardy: 2,
attendance_percentage: 80,
recorded_by: 'director-1',
notes: null,
};
export const campusStaffAttendanceTodayRecord: StaffAttendanceDailySummaryViewModel = {
id: 'staff:tigers:2026-06-08',
campus_id: CAMPUS_ATTENDANCE_TEST_SEED.campusId,
@ -89,8 +61,6 @@ export const campusAttendanceStatsSeed: CampusAttendanceStats = {
todayPct: 90,
weekAvg: 85,
config: null,
recentData: [campusAttendanceTodayRecord, campusAttendanceWeekRecord],
todayRecord: campusAttendanceTodayRecord,
recentStaffData: [campusStaffAttendanceTodayRecord, campusStaffAttendanceWeekRecord],
todayStaffRecord: campusStaffAttendanceTodayRecord,
};
@ -100,9 +70,6 @@ export const campusAttendancePrintInputSeed: CampusAttendancePrintInput = {
generatedByName: CAMPUS_ATTENDANCE_TEST_SEED.generatedByName,
generatedByRole: CAMPUS_ATTENDANCE_TEST_SEED.generatedByRole,
today: '2026-06-08',
weekStart: '2026-06-08',
campusesToPrint: [campusAttendanceStatsSeed],
printTodayRecords: [campusAttendanceTodayRecord],
printWeekRecords: [campusAttendanceTodayRecord, campusAttendanceWeekRecord],
staffSummary: campusStaffAttendanceSummary,
};