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:
parent
b1b08fea70
commit
7331acc913
@ -1,7 +1,7 @@
|
|||||||
# Campus Attendance Backend
|
# Campus Attendance Backend
|
||||||
|
|
||||||
## Purpose
|
## 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)
|
## 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`.
|
- 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.
|
- 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`.
|
- 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`).
|
- 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
|
## Tenant Scope
|
||||||
- Every read and write filters by `organizationId: requireOrganizationId(currentUser)`.
|
- 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)
|
## 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
|
## Tests
|
||||||
- `src/api/controllers/campus_attendance.controller.test.ts` covers controller delegation.
|
- `src/api/controllers/campus_attendance.controller.test.ts` covers controller delegation.
|
||||||
|
|||||||
@ -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. |
|
| 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. |
|
| 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. |
|
| 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 | **`staff_attendance_records`** | the staff-attendance slice. |
|
| 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`). |
|
| 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). |
|
| 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. |
|
| 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_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`.
|
- **`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)
|
### Scheduling (header/detail pair)
|
||||||
|
|
||||||
|
|||||||
@ -47,14 +47,19 @@ Invalid `startDate`/`endDate` (non-ISO) or a non-positive / non-integer `limit`
|
|||||||
|
|
||||||
## Access Rules
|
## 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
|
- 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:
|
- A user with `READ_STAFF_ATTENDANCE_REPORTS` sees scope-filtered records:
|
||||||
organization-wide for owner/superintendent, school campuses plus users directly
|
organization-wide for owner/superintendent, school campuses plus users directly
|
||||||
assigned to that school for principal/registrar, and a single campus for
|
assigned to that school for principal/registrar, and a single campus for
|
||||||
director/campus scope.
|
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
|
- 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
|
effective scope: organization office users at organization scope, school office users at school
|
||||||
scope, or campus users at campus/class scope.
|
scope, or campus users at campus/class scope.
|
||||||
|
|||||||
@ -62,6 +62,15 @@ record when `req.params.id`/`req.body.id` equals their own id.
|
|||||||
otherwise `ValidationError('errors.forbidden.message')`.
|
otherwise `ValidationError('errors.forbidden.message')`.
|
||||||
- `create` rejects a duplicate email (`iam.errors.userAlreadyExists`) and a missing email
|
- `create` rejects a duplicate email (`iam.errors.userAlreadyExists`) and a missing email
|
||||||
(`iam.errors.emailRequired`).
|
(`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
|
## 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
|
(`currentUser.app_role.globalAccess`) have the org constraint removed and read across
|
||||||
organizations.
|
organizations.
|
||||||
- On `create`, organization membership is set from `data.organizations` via `setOrganizations`.
|
- 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
|
- On `update`, role/org/custom-permission associations are only changed when their respective
|
||||||
fields are present in the input.
|
fields are present in the input.
|
||||||
|
|
||||||
|
|||||||
@ -78,9 +78,9 @@ type UserListSortField =
|
|||||||
| 'name'
|
| 'name'
|
||||||
| 'email'
|
| 'email'
|
||||||
| 'phoneNumber'
|
| 'phoneNumber'
|
||||||
| 'organization'
|
|
||||||
| 'school'
|
| 'school'
|
||||||
| 'campus'
|
| 'campus'
|
||||||
|
| 'class'
|
||||||
| 'role';
|
| 'role';
|
||||||
|
|
||||||
const NO_USER: CurrentUser = { id: null };
|
const NO_USER: CurrentUser = { id: null };
|
||||||
@ -253,9 +253,9 @@ function parseUserSortField(value: unknown): UserListSortField | null {
|
|||||||
case 'name':
|
case 'name':
|
||||||
case 'email':
|
case 'email':
|
||||||
case 'phoneNumber':
|
case 'phoneNumber':
|
||||||
case 'organization':
|
|
||||||
case 'school':
|
case 'school':
|
||||||
case 'campus':
|
case 'campus':
|
||||||
|
case 'class':
|
||||||
case 'role':
|
case 'role':
|
||||||
return value;
|
return value;
|
||||||
default:
|
default:
|
||||||
@ -282,18 +282,18 @@ function userListOrder(field: unknown, sort: unknown): OrderItem[] {
|
|||||||
[col('users.lastName'), 'ASC'],
|
[col('users.lastName'), 'ASC'],
|
||||||
[col('users.firstName'), 'ASC'],
|
[col('users.firstName'), 'ASC'],
|
||||||
];
|
];
|
||||||
case 'organization':
|
|
||||||
return [
|
|
||||||
[col('organizations.name'), direction],
|
|
||||||
[col('users.lastName'), 'ASC'],
|
|
||||||
[col('users.firstName'), 'ASC'],
|
|
||||||
];
|
|
||||||
case 'school':
|
case 'school':
|
||||||
return [
|
return [
|
||||||
[col('school.name'), direction],
|
[col('school.name'), direction],
|
||||||
[col('users.lastName'), 'ASC'],
|
[col('users.lastName'), 'ASC'],
|
||||||
[col('users.firstName'), 'ASC'],
|
[col('users.firstName'), 'ASC'],
|
||||||
];
|
];
|
||||||
|
case 'class':
|
||||||
|
return [
|
||||||
|
[col('class.name'), direction],
|
||||||
|
[col('users.lastName'), 'ASC'],
|
||||||
|
[col('users.firstName'), 'ASC'],
|
||||||
|
];
|
||||||
case 'campus':
|
case 'campus':
|
||||||
return [
|
return [
|
||||||
[col('campus.name'), direction],
|
[col('campus.name'), direction],
|
||||||
@ -738,6 +738,20 @@ class UsersDBApi {
|
|||||||
{ model: db.schools, as: 'school', required: false },
|
{ model: db.schools, as: 'school', required: false },
|
||||||
{ model: db.campuses, as: 'campus', required: false },
|
{ model: db.campuses, as: 'campus', required: false },
|
||||||
{ model: db.classes, as: 'class', 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', required: false },
|
||||||
{ model: db.permissions, as: 'custom_permissions_filter', required: false },
|
{ model: db.permissions, as: 'custom_permissions_filter', required: false },
|
||||||
{ model: db.file, as: 'avatar' },
|
{ model: db.file, as: 'avatar' },
|
||||||
|
|||||||
@ -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.
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { default } from './20260623090000-grant-teacher-student-user-management';
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { default } from './20260623090000-grant-teacher-student-user-management';
|
||||||
@ -24,6 +24,7 @@ import type {
|
|||||||
HasManySetAssociationsMixin,
|
HasManySetAssociationsMixin,
|
||||||
} from 'sequelize';
|
} from 'sequelize';
|
||||||
import type { Campuses } from './campuses';
|
import type { Campuses } from './campuses';
|
||||||
|
import type { ClassEnrollments } from './class_enrollments';
|
||||||
import type { File } from './file';
|
import type { File } from './file';
|
||||||
import type { Messages } from './messages';
|
import type { Messages } from './messages';
|
||||||
import type { Organizations } from './organizations';
|
import type { Organizations } from './organizations';
|
||||||
@ -71,6 +72,7 @@ export class Users extends Model<
|
|||||||
declare campus?: NonAttribute<Campuses>;
|
declare campus?: NonAttribute<Campuses>;
|
||||||
declare school?: NonAttribute<Schools>;
|
declare school?: NonAttribute<Schools>;
|
||||||
declare class?: NonAttribute<Classes>;
|
declare class?: NonAttribute<Classes>;
|
||||||
|
declare class_enrollments_student?: NonAttribute<ClassEnrollments[]>;
|
||||||
declare custom_permissions?: NonAttribute<Permissions[]>;
|
declare custom_permissions?: NonAttribute<Permissions[]>;
|
||||||
declare custom_permissions_filter?: NonAttribute<Permissions[]>;
|
declare custom_permissions_filter?: NonAttribute<Permissions[]>;
|
||||||
declare avatar?: NonAttribute<File[]>;
|
declare avatar?: NonAttribute<File[]>;
|
||||||
@ -165,6 +167,14 @@ export class Users extends Model<
|
|||||||
constraints: false,
|
constraints: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
db.users.hasMany(db.class_enrollments, {
|
||||||
|
as: 'class_enrollments_student',
|
||||||
|
foreignKey: {
|
||||||
|
name: 'studentId',
|
||||||
|
},
|
||||||
|
constraints: false,
|
||||||
|
});
|
||||||
|
|
||||||
db.users.hasMany(db.file, {
|
db.users.hasMany(db.file, {
|
||||||
as: 'avatar',
|
as: 'avatar',
|
||||||
foreignKey: 'belongsToId',
|
foreignKey: 'belongsToId',
|
||||||
|
|||||||
@ -169,8 +169,12 @@ export function buildSeededPermissionNamesForRole(role: RoleName): readonly stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (READ_ONLY_ROLES.includes(role)) {
|
if (READ_ONLY_ROLES.includes(role)) {
|
||||||
|
const extraEntityPermissions = role === ROLE_NAMES.TEACHER
|
||||||
|
? ['CREATE_USERS', 'UPDATE_USERS', 'CREATE_GUARDIAN_STUDENTS']
|
||||||
|
: [];
|
||||||
return uniquePermissionNames([
|
return uniquePermissionNames([
|
||||||
...entityPermissionNames.filter((name) => name.startsWith('READ_')),
|
...entityPermissionNames.filter((name) => name.startsWith('READ_')),
|
||||||
|
...extraEntityPermissions,
|
||||||
...(MODULE_PERMISSIONS_BY_ROLE[role] ?? []),
|
...(MODULE_PERMISSIONS_BY_ROLE[role] ?? []),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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', () => {
|
test('ESA funding management grants match campus and parent-scope managers', () => {
|
||||||
const esaManagers = Object.values(ROLE_NAMES).filter((role) =>
|
const esaManagers = Object.values(ROLE_NAMES).filter((role) =>
|
||||||
granted(role).includes(FEATURE_PERMISSIONS.MANAGE_ESA_FUNDING_CONTENT),
|
granted(role).includes(FEATURE_PERMISSIONS.MANAGE_ESA_FUNDING_CONTENT),
|
||||||
|
|||||||
@ -101,6 +101,10 @@ function downloadLocal(req: Request, res: Response): void {
|
|||||||
res.sendStatus(403);
|
res.sendStatus(403);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!fs.existsSync(resolved)) {
|
||||||
|
res.sendStatus(404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
res.download(resolved);
|
res.download(resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
118
backend/src/services/guardian_students.test.ts
Normal file
118
backend/src/services/guardian_students.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -2,6 +2,8 @@ import db from '@/db/models';
|
|||||||
import { withTransaction } from '@/db/with-transaction';
|
import { withTransaction } from '@/db/with-transaction';
|
||||||
import ValidationError from '@/shared/errors/validation';
|
import ValidationError from '@/shared/errors/validation';
|
||||||
import { getOrganizationIdOrGlobal } from '@/services/shared/access';
|
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 { GuardianStudents } from '@/db/models/guardian_students';
|
||||||
import type { CurrentUser } from '@/db/api/types';
|
import type { CurrentUser } from '@/db/api/types';
|
||||||
|
|
||||||
@ -21,10 +23,44 @@ function orgFilter(currentUser?: CurrentUser): { organizationId?: string } {
|
|||||||
return organizationId ? { organizationId } : {};
|
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) {
|
function toDto(row: GuardianStudents) {
|
||||||
const plain = row.get({ plain: true }) as Record<string, unknown> & {
|
const plain = row.get({ plain: true }) as Record<string, unknown> & {
|
||||||
guardian?: { id?: string; firstName?: string; lastName?: string } | null;
|
guardian?: {
|
||||||
student?: { id?: string; firstName?: string; lastName?: string } | null;
|
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 {
|
return {
|
||||||
id: plain.id,
|
id: plain.id,
|
||||||
@ -34,8 +70,12 @@ function toDto(row: GuardianStudents) {
|
|||||||
guardian: plain.guardian
|
guardian: plain.guardian
|
||||||
? {
|
? {
|
||||||
id: plain.guardian.id,
|
id: plain.guardian.id,
|
||||||
|
name_prefix: plain.guardian.name_prefix,
|
||||||
firstName: plain.guardian.firstName,
|
firstName: plain.guardian.firstName,
|
||||||
lastName: plain.guardian.lastName,
|
lastName: plain.guardian.lastName,
|
||||||
|
email: plain.guardian.email,
|
||||||
|
phoneNumber: plain.guardian.phoneNumber,
|
||||||
|
avatar: plain.guardian.avatar,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
student: plain.student
|
student: plain.student
|
||||||
@ -59,6 +99,30 @@ class GuardianStudentsService {
|
|||||||
const where = orgFilter(currentUser);
|
const where = orgFilter(currentUser);
|
||||||
|
|
||||||
return withTransaction(async (transaction) => {
|
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({
|
const existing = await db.guardian_students.findOne({
|
||||||
where: { guardianId, studentId, ...where },
|
where: { guardianId, studentId, ...where },
|
||||||
transaction,
|
transaction,
|
||||||
|
|||||||
@ -77,7 +77,6 @@ test('read-only and external roles cannot create tenants or manage users', () =>
|
|||||||
for (const role of [
|
for (const role of [
|
||||||
ROLE_NAMES.REGISTRAR,
|
ROLE_NAMES.REGISTRAR,
|
||||||
ROLE_NAMES.OFFICE_MANAGER,
|
ROLE_NAMES.OFFICE_MANAGER,
|
||||||
ROLE_NAMES.TEACHER,
|
|
||||||
ROLE_NAMES.SUPPORT_STAFF,
|
ROLE_NAMES.SUPPORT_STAFF,
|
||||||
ROLE_NAMES.STUDENT,
|
ROLE_NAMES.STUDENT,
|
||||||
ROLE_NAMES.GUARDIAN,
|
ROLE_NAMES.GUARDIAN,
|
||||||
@ -90,3 +89,12 @@ test('read-only and external roles cannot create tenants or manage users', () =>
|
|||||||
assert.deepEqual(caps.manageableRoleNames, []);
|
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]);
|
||||||
|
});
|
||||||
|
|||||||
@ -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);
|
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);
|
assert.equal(canManageUserWithRole(teacher, role), false);
|
||||||
}
|
}
|
||||||
// ...including a roleless target.
|
|
||||||
assert.equal(canManageUserWithRole(teacher, null), false);
|
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', () => {
|
test('a manager may act on a roleless (null) target', () => {
|
||||||
|
|||||||
@ -24,6 +24,7 @@ const ASSIGNABLE_ROLE_NAMES: readonly RoleName[] = Object.values(ROLE_NAMES).fil
|
|||||||
* external), not org/system roles nor another principal.
|
* external), not org/system roles nor another principal.
|
||||||
* - `registrar`: nobody (read-only/audit assistant).
|
* - `registrar`: nobody (read-only/audit assistant).
|
||||||
* - `director`: campus + external roles only (not director/principal/superintendent/owner/admins).
|
* - `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.
|
* - everyone else: nobody.
|
||||||
*/
|
*/
|
||||||
const MANAGEABLE_ROLES_BY_ACTOR: Record<RoleName, readonly RoleName[]> = {
|
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.STUDENT, ROLE_NAMES.GUARDIAN,
|
||||||
],
|
],
|
||||||
[ROLE_NAMES.OFFICE_MANAGER]: [],
|
[ROLE_NAMES.OFFICE_MANAGER]: [],
|
||||||
[ROLE_NAMES.TEACHER]: [],
|
[ROLE_NAMES.TEACHER]: [ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN],
|
||||||
[ROLE_NAMES.SUPPORT_STAFF]: [],
|
[ROLE_NAMES.SUPPORT_STAFF]: [],
|
||||||
[ROLE_NAMES.STUDENT]: [],
|
[ROLE_NAMES.STUDENT]: [],
|
||||||
[ROLE_NAMES.GUARDIAN]: [],
|
[ROLE_NAMES.GUARDIAN]: [],
|
||||||
@ -92,6 +93,7 @@ export function canManageUserWithRole(
|
|||||||
if (!actor) return false;
|
if (!actor) return false;
|
||||||
const manageable = MANAGEABLE_ROLES_BY_ACTOR[actor];
|
const manageable = MANAGEABLE_ROLES_BY_ACTOR[actor];
|
||||||
if (manageable.length === 0) return false;
|
if (manageable.length === 0) return false;
|
||||||
|
if (actor === ROLE_NAMES.TEACHER && !isRoleName(targetRole)) return false;
|
||||||
if (!isRoleName(targetRole)) return true;
|
if (!isRoleName(targetRole)) return true;
|
||||||
return manageable.includes(targetRole);
|
return manageable.includes(targetRole);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,6 +52,47 @@ describe('StaffAttendanceService', () => {
|
|||||||
assert.equal(Object.getOwnPropertySymbols(capturedWhere).includes(Op.or), true);
|
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 () => {
|
test('upserts a school office staff attendance record inside school scope', async () => {
|
||||||
const organizationId = '11111111-1111-4111-8111-111111111111';
|
const organizationId = '11111111-1111-4111-8111-111111111111';
|
||||||
const schoolId = '22222222-2222-4222-8222-222222222222';
|
const schoolId = '22222222-2222-4222-8222-222222222222';
|
||||||
|
|||||||
@ -115,6 +115,35 @@ function visibilityScope(currentUser?: CurrentUser) {
|
|||||||
return campusDimensionScope(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 {
|
function staffCountScope(currentUser?: CurrentUser): WhereOptions {
|
||||||
if (getRoleScope(currentUser) === ROLE_SCOPES.SCHOOL) {
|
if (getRoleScope(currentUser) === ROLE_SCOPES.SCHOOL) {
|
||||||
const schoolId = getSchoolId(currentUser);
|
const schoolId = getSchoolId(currentUser);
|
||||||
@ -282,7 +311,7 @@ class StaffAttendanceService {
|
|||||||
// large row transfer.
|
// large row transfer.
|
||||||
const recordsWhere = {
|
const recordsWhere = {
|
||||||
organizationId: requireOrganizationId(currentUser),
|
organizationId: requireOrganizationId(currentUser),
|
||||||
...visibilityScope(currentUser),
|
...summaryRecordScope(currentUser),
|
||||||
...dateFilter(filter),
|
...dateFilter(filter),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
402
backend/src/services/users.test.ts
Normal file
402
backend/src/services/users.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -13,7 +13,7 @@ import {
|
|||||||
assertCanUpdateUserWithRole,
|
assertCanUpdateUserWithRole,
|
||||||
} from '@/services/shared/role-policy';
|
} from '@/services/shared/role-policy';
|
||||||
import { getOrganizationId, hasGlobalAccess } from '@/services/shared/access';
|
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';
|
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[];
|
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 {
|
class UsersService {
|
||||||
static async create(
|
static async create(
|
||||||
data: CreateData,
|
data: CreateData,
|
||||||
@ -131,6 +275,7 @@ class UsersService {
|
|||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
assertCanCreateUserWithRole(currentUser, newRole?.name ?? null);
|
assertCanCreateUserWithRole(currentUser, newRole?.name ?? null);
|
||||||
|
assertClassScopedUserCreate(currentUser, newRole?.name ?? null, data);
|
||||||
|
|
||||||
// §3.4 provisioning: creating an `owner` auto-creates the company and
|
// §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.
|
// links the owner to it. The org starts minimal; the owner fills it in.
|
||||||
@ -142,6 +287,8 @@ class UsersService {
|
|||||||
data.organizations = organization.id;
|
data.organizations = organization.id;
|
||||||
createdOrganizationId = organization.id;
|
createdOrganizationId = organization.id;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
assertClassScopedUserCreate(currentUser, null, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-global actors create users only within their own organization.
|
// Non-global actors create users only within their own organization.
|
||||||
@ -263,8 +410,10 @@ class UsersService {
|
|||||||
// new role if it is being reassigned).
|
// new role if it is being reassigned).
|
||||||
assertSameTenant(currentUser, users);
|
assertSameTenant(currentUser, users);
|
||||||
assertCanUpdateUserWithRole(currentUser, users.app_role?.name ?? null);
|
assertCanUpdateUserWithRole(currentUser, users.app_role?.name ?? null);
|
||||||
if (data.app_role) {
|
const newRole = data.app_role
|
||||||
const newRole = await db.roles.findByPk(data.app_role, { transaction });
|
? await db.roles.findByPk(data.app_role, { transaction })
|
||||||
|
: null;
|
||||||
|
if (data.app_role !== undefined) {
|
||||||
assertCanAssignUserRole(
|
assertCanAssignUserRole(
|
||||||
currentUser,
|
currentUser,
|
||||||
users.app_role?.name ?? null,
|
users.app_role?.name ?? null,
|
||||||
@ -272,6 +421,13 @@ class UsersService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await normalizeTenantAssignment(data, transaction);
|
await normalizeTenantAssignment(data, transaction);
|
||||||
|
await assertClassScopedUserUpdate(
|
||||||
|
currentUser,
|
||||||
|
users,
|
||||||
|
data.app_role !== undefined ? newRole?.name ?? null : users.app_role?.name ?? null,
|
||||||
|
data,
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
normalizeAvatarInput(data);
|
normalizeAvatarInput(data);
|
||||||
|
|
||||||
const updatedUser = await UsersDBApi.update(id, data, globalAccess, {
|
const updatedUser = await UsersDBApi.update(id, data, globalAccess, {
|
||||||
|
|||||||
@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
## Purpose
|
## 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
|
```text
|
||||||
View -> Business Logic -> API/Data Access -> Backend
|
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 load from `GET /api/campus_attendance/configs`.
|
||||||
- Attendance links save through `PUT /api/campus_attendance/configs/:campusKey`.
|
- Attendance links save through `PUT /api/campus_attendance/configs/:campusKey`.
|
||||||
- Daily campus summaries load from `GET /api/campus_attendance/summaries`.
|
- The active UI no longer reads or writes `GET /api/campus_attendance/summaries`; those backend
|
||||||
- Daily campus summaries save through `PUT /api/campus_attendance/summaries/:campusKey/:date`.
|
endpoints remain legacy plumbing outside the staff-only attendance workflow.
|
||||||
- Staff attendance records load from `GET /api/staff_attendance/records` when the user has
|
- 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
|
`READ_STAFF_ATTENDANCE_REPORTS`. Campus views group these records by campus and date.
|
||||||
attendance and staff attendance remain separate in history tables and reporting totals.
|
|
||||||
- The page derives its mode from the effective scope, not from the signed-in role label:
|
- The page derives its mode from the effective scope, not from the signed-in role label:
|
||||||
- campus/class effective scope shows campus-only attendance.
|
- campus effective scope shows campus staff attendance.
|
||||||
- school effective scope aggregates all scoped campus summaries and school staff attendance.
|
- class effective scope is read-only for attendance entry.
|
||||||
- organization effective scope aggregates all scoped school/campus summaries plus organization staff attendance.
|
- school effective scope aggregates scoped campus staff attendance plus school staff attendance.
|
||||||
- Users with `FILL_ATTENDANCE` can enter daily student attendance from organization, school,
|
- organization effective scope aggregates scoped school/campus staff attendance plus organization staff attendance.
|
||||||
campus, or class effective scope. Campus/class scope can render Present/Late/Absent controls
|
- Users with `FILL_ATTENDANCE` can enter daily staff attendance from organization, school, or
|
||||||
per student when a roster is available and derives the aggregate campus summary from those rows.
|
campus effective scope. Class effective scope no longer renders attendance entry controls.
|
||||||
Late students count as present and increment the tardy count. When no roster is available, the
|
- Organization office attendance targets organization-owned users without school/campus assignment.
|
||||||
form falls back to manual aggregate totals.
|
School office attendance targets school-owned users without campus assignment. Campus attendance
|
||||||
- Class effective scope resolves the class's parent campus for the campus attendance summary, but
|
targets campus-bound and class-scoped staff inside the campus.
|
||||||
loads the student roster with `users?classId=...` so the classroom form shows only students in
|
- School and organization percentage cards use staff attendance only. Campus attendance entered by
|
||||||
that classroom.
|
office managers appears in school-level rollups; school-level staff attendance appears in
|
||||||
- Organization/school screens render a student attendance rollup table for every scoped child
|
organization-level rollups with staff records from other schools and campuses.
|
||||||
campus. Each row is prefilled from the campus summary for the selected date; campuses without
|
- The top-bar notification uses the current staff summary as a completion signal. Users with
|
||||||
child data show empty inputs. Saving writes valid edited rows back to
|
`FILL_ATTENDANCE` or `READ_STAFF_ATTENDANCE_REPORTS` are nudged at organization, school, or
|
||||||
`campus_attendance_summaries` per campus. Organization and school totals are computed from those
|
campus effective scope until today's staff attendance record count reaches the current scope's
|
||||||
campus rows plus staff attendance reports.
|
staff count. Class effective scope does not receive this attendance reminder.
|
||||||
- Organization and school percentage cards include every scoped child row in the denominator.
|
|
||||||
A child scope without a saved report counts as incomplete instead of being ignored, and staff
|
|
||||||
attendance percentages use the scoped staff count as the minimum denominator.
|
|
||||||
- Aggregate cards follow the tenant hierarchy: organization scope shows school cards, and school
|
- Aggregate cards follow the tenant hierarchy: organization scope shows school cards, and school
|
||||||
scope shows campus cards. Clicking a child card opens
|
scope shows campus cards. Clicking a child card opens
|
||||||
`/attendance/details/:level/:tenantId`. The details page shows separate tables for that child
|
`/attendance/details/:level/:tenantId`. The details page shows staff attendance records for that
|
||||||
scope's student attendance summaries and staff attendance records.
|
child scope.
|
||||||
- Organization and school screens also expose office staff attendance entry as a batch table.
|
- Each staff row has Present/Late/Absent controls, and saving writes one record per user through
|
||||||
Organization office attendance targets organization-owned users without school/campus
|
`PUT /api/staff_attendance/records/:userId/:date`. Late staff count as present for attendance
|
||||||
assignment. School office attendance targets school-owned users without campus assignment.
|
percentage and remain visible as late exceptions.
|
||||||
Each staff row has Present/Late/Absent controls, and saving writes one record per user through
|
- Attendance page staff summary cards load from
|
||||||
`PUT /api/staff_attendance/records/:userId/:date`. These records feed the staff attendance
|
`GET /api/staff_attendance/summary?startDate=today&endDate=today` when the user has
|
||||||
summary.
|
`READ_STAFF_ATTENDANCE_REPORTS`; the top-bar completion reminder uses the same summary endpoint
|
||||||
- Campus screens expose the same staff attendance table for campus-bound and class-scoped staff
|
for users with `FILL_ATTENDANCE` or `READ_STAFF_ATTENDANCE_REPORTS`.
|
||||||
inside the campus.
|
- Campus screens show staff-only summary cards and recent staff attendance history.
|
||||||
- Staff summary loads from `GET /api/staff_attendance/summary?startDate=today&endDate=today` only when the user has `READ_STAFF_ATTENDANCE_REPORTS`.
|
- 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.
|
||||||
- Campus screens show combined summary cards with student/staff breakdowns, a single recent
|
|
||||||
attendance history table with Students/Staff/Total rows per date, and print reports with the
|
|
||||||
same combined report structure.
|
|
||||||
- Aggregate views render only campus cards represented by scoped attendance/config rows, because the campus catalog endpoint is not the source of scoped reporting data.
|
|
||||||
- The backend calculates the attendance percentage.
|
- The backend calculates the attendance percentage.
|
||||||
- `CampusAttendance.tsx` is a thin composition wrapper.
|
- `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.
|
- 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.
|
- Blocked print popups return an explicit print result and show a visible attendance status error.
|
||||||
|
|
||||||
## Verification
|
## 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 printable report generation with separate student, staff, and combined attendance totals.
|
||||||
- `frontend/src/business/campus-attendance/printReport.test.ts` covers blocked-popup handling for attendance report printing.
|
- `frontend/src/business/campus-attendance/printReport.test.ts` covers blocked-popup handling for attendance report printing.
|
||||||
- `frontend/src/business/campus-attendance/mappers.test.ts` covers API DTO mapping.
|
- `frontend/src/business/campus-attendance/mappers.test.ts` covers API DTO mapping.
|
||||||
|
|||||||
@ -46,8 +46,10 @@ Constants:
|
|||||||
- QBS safety quiz completion loads through `useSafetyQuizResults`.
|
- QBS safety quiz completion loads through `useSafetyQuizResults`.
|
||||||
- Emotional Intelligence and Personality Type completion loads through `usePersonalityCompletion`.
|
- Emotional Intelligence and Personality Type completion loads through `usePersonalityCompletion`.
|
||||||
- Daily Zone Check-In completion loads through `useZoneCheckInCompletion`.
|
- Daily Zone Check-In completion loads through `useZoneCheckInCompletion`.
|
||||||
- Staff attendance records and summary load through staff attendance business
|
- Staff attendance records load through staff attendance business hooks with
|
||||||
hooks with the selected period range.
|
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`.
|
- Policy acknowledgment report loads through `usePolicyAcknowledgmentReport`.
|
||||||
- Overview metrics, risk areas, unified quiz result rows, and FRAME previews are derived in business selectors.
|
- 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
|
- Document acknowledgment tracking renders as a collapsible section in the main
|
||||||
|
|||||||
@ -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`.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
49
frontend/docs/my-class-integration.md
Normal file
49
frontend/docs/my-class-integration.md
Normal 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`.
|
||||||
@ -47,8 +47,9 @@ Shared config:
|
|||||||
- Manager reminders are permission-gated and derived from current status queries:
|
- Manager reminders are permission-gated and derived from current status queries:
|
||||||
`MANAGE_CONTENT_CATALOG` organization users are nudged to select the current
|
`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'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
|
week has no F.R.A.M.E. entry, and users with `FILL_ATTENDANCE` or
|
||||||
attendance row exists for today in their current attendance scope.
|
`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.
|
- 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.
|
- **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 components receive a prepared page model and do not call API/data access modules.
|
||||||
|
|||||||
@ -54,6 +54,7 @@ const scopedModules: readonly Module[] = [
|
|||||||
{ id: 'platform-dashboard', name: 'Platform', icon: 'chart', permissions: ['READ_PLATFORM_DASHBOARD'], color: '', routePath: '/platform-dashboard' },
|
{ 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: '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: '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: '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: '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' },
|
{ 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']);
|
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', () => {
|
it('never shows the Director Dashboard via drill-down', () => {
|
||||||
expect(
|
expect(
|
||||||
getScopedModules(scopedModules, user(['READ_DASHBOARD', 'READ_DIRECTOR_DASHBOARD']), 'campus', true).map((m) => m.id),
|
getScopedModules(scopedModules, user(['READ_DASHBOARD', 'READ_DIRECTOR_DASHBOARD']), 'campus', true).map((m) => m.id),
|
||||||
|
|||||||
@ -32,6 +32,7 @@ const MODULE_SCOPE_TIERS: Partial<Record<ModuleId, readonly ScopeTier[]>> = {
|
|||||||
'organization-management': ['global', 'organization', 'school', 'campus'],
|
'organization-management': ['global', 'organization', 'school', 'campus'],
|
||||||
'user-admin': ['global', 'organization', 'school', 'campus'],
|
'user-admin': ['global', 'organization', 'school', 'campus'],
|
||||||
class: ['class'],
|
class: ['class'],
|
||||||
|
attendance: ['organization', 'school', 'campus'],
|
||||||
classroom: ['organization', 'school', 'campus', 'class'],
|
classroom: ['organization', 'school', 'campus', 'class'],
|
||||||
timer: ['class'],
|
timer: ['class'],
|
||||||
qbs: ['organization', 'school', 'campus', 'class'],
|
qbs: ['organization', 'school', 'campus', 'class'],
|
||||||
|
|||||||
@ -2,32 +2,19 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
listCampusAttendanceConfigs,
|
listCampusAttendanceConfigs,
|
||||||
listCampusAttendanceSummaries,
|
|
||||||
saveCampusAttendanceConfig,
|
saveCampusAttendanceConfig,
|
||||||
saveCampusAttendanceSummary,
|
|
||||||
} from '@/shared/api/campusAttendance';
|
} from '@/shared/api/campusAttendance';
|
||||||
import { useCampusCatalog } from '@/business/campuses/hooks';
|
import { useCampusCatalog } from '@/business/campuses/hooks';
|
||||||
import { CAMPUS_ATTENDANCE_QUERY_KEYS } from '@/shared/constants/campusAttendance';
|
import { CAMPUS_ATTENDANCE_QUERY_KEYS } from '@/shared/constants/campusAttendance';
|
||||||
import { findCampusByNameOrCode } from '@/shared/constants/campusDisplay';
|
import { findCampusByNameOrCode } from '@/shared/constants/campusDisplay';
|
||||||
import { UI_FEEDBACK_CLEAR_DELAY_MS } from '@/shared/constants/ui';
|
import { UI_FEEDBACK_CLEAR_DELAY_MS } from '@/shared/constants/ui';
|
||||||
import type {
|
import type { CampusAttendanceCampusKey } from '@/shared/types/campusAttendance';
|
||||||
CampusAttendanceCampusKey,
|
import { toCampusAttendanceConfigViewModel } from '@/business/campus-attendance/mappers';
|
||||||
CampusAttendanceListFilter,
|
|
||||||
} from '@/shared/types/campusAttendance';
|
|
||||||
import {
|
import {
|
||||||
toCampusAttendanceConfigViewModel,
|
|
||||||
toCampusAttendanceSummaryMutationDto,
|
|
||||||
toCampusAttendanceSummaryViewModel,
|
|
||||||
} from '@/business/campus-attendance/mappers';
|
|
||||||
import {
|
|
||||||
buildAttendanceEntryInput,
|
|
||||||
buildCampusAttendanceScopeModel,
|
buildCampusAttendanceScopeModel,
|
||||||
buildCampusAttendanceStats,
|
buildCampusAttendanceStats,
|
||||||
buildCombinedAttendanceStats,
|
buildCombinedAttendanceStats,
|
||||||
buildOverallAttendanceStats,
|
|
||||||
getToday,
|
getToday,
|
||||||
getTodayPercentage,
|
|
||||||
getWeeklyAverage,
|
|
||||||
getWeekEnd,
|
getWeekEnd,
|
||||||
getWeekStart,
|
getWeekStart,
|
||||||
} from '@/business/campus-attendance/selectors';
|
} from '@/business/campus-attendance/selectors';
|
||||||
@ -42,9 +29,6 @@ import {
|
|||||||
} from '@/business/campus-attendance/printReport';
|
} from '@/business/campus-attendance/printReport';
|
||||||
import type {
|
import type {
|
||||||
CampusAttendanceChildStats,
|
CampusAttendanceChildStats,
|
||||||
CampusAttendanceEntryDraft,
|
|
||||||
CampusAttendanceEntryInput,
|
|
||||||
CampusAttendanceRollupDraft,
|
|
||||||
AttendanceRosterStatus,
|
AttendanceRosterStatus,
|
||||||
StaffAttendanceEntryDraft,
|
StaffAttendanceEntryDraft,
|
||||||
} from '@/business/campus-attendance/types';
|
} from '@/business/campus-attendance/types';
|
||||||
@ -63,7 +47,6 @@ import { getClass } from '@/shared/api/classes';
|
|||||||
import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types';
|
import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types';
|
||||||
|
|
||||||
const EMPTY_CONFIGS: ReturnType<typeof toCampusAttendanceConfigViewModel>[] = [];
|
const EMPTY_CONFIGS: ReturnType<typeof toCampusAttendanceConfigViewModel>[] = [];
|
||||||
const EMPTY_SUMMARIES: ReturnType<typeof toCampusAttendanceSummaryViewModel>[] = [];
|
|
||||||
const EMPTY_STAFF_RECORDS: readonly StaffAttendanceRecordViewModel[] = [];
|
const EMPTY_STAFF_RECORDS: readonly StaffAttendanceRecordViewModel[] = [];
|
||||||
const EMPTY_CAMPUSES: CampusInfo[] = [];
|
const EMPTY_CAMPUSES: CampusInfo[] = [];
|
||||||
const EMPTY_TENANT_CHILDREN: TenantChild[] = [];
|
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(
|
export function useAttendanceDetailsChildCampuses(
|
||||||
level: TenantLevel | undefined,
|
level: TenantLevel | undefined,
|
||||||
tenantId: string | undefined,
|
tenantId: string | undefined,
|
||||||
@ -149,16 +110,6 @@ type UseCampusAttendancePageInput = {
|
|||||||
readonly userName: string;
|
readonly userName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyEntryDraft = (date: string, campusId: CampusId = ''): CampusAttendanceEntryDraft => ({
|
|
||||||
date,
|
|
||||||
campusId,
|
|
||||||
enrolled: '',
|
|
||||||
present: '',
|
|
||||||
absent: '',
|
|
||||||
tardy: '',
|
|
||||||
notes: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const emptyStaffEntryDraft = (date: string): StaffAttendanceEntryDraft => ({
|
const emptyStaffEntryDraft = (date: string): StaffAttendanceEntryDraft => ({
|
||||||
date,
|
date,
|
||||||
userId: '',
|
userId: '',
|
||||||
@ -167,10 +118,6 @@ const emptyStaffEntryDraft = (date: string): StaffAttendanceEntryDraft => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
type AttendanceStatusMap = Record<string, AttendanceRosterStatus>;
|
type AttendanceStatusMap = Record<string, AttendanceRosterStatus>;
|
||||||
type StudentRollupOverrideMap = Record<CampusId, Partial<Pick<
|
|
||||||
CampusAttendanceRollupDraft,
|
|
||||||
'enrolled' | 'present' | 'absent' | 'tardy' | 'notes'
|
|
||||||
>>>;
|
|
||||||
|
|
||||||
function userDisplayName(user: AdminUserRow): string {
|
function userDisplayName(user: AdminUserRow): string {
|
||||||
const fullName = `${user.firstName ?? ''} ${user.lastName ?? ''}`.trim();
|
const fullName = `${user.firstName ?? ''} ${user.lastName ?? ''}`.trim();
|
||||||
@ -188,28 +135,6 @@ function reconcileAttendanceStatuses(
|
|||||||
return next;
|
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 {
|
function isOfficeStaffUser(user: AdminUserRow, mode: 'organization' | 'school' | 'campus'): boolean {
|
||||||
const role = user.app_role?.name;
|
const role = user.app_role?.name;
|
||||||
if (role === 'student' || role === 'guardian') {
|
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(
|
function percentageFromStaffDailySummaries(
|
||||||
records: readonly { readonly total_staff: number; readonly total_present: number }[],
|
records: readonly { readonly total_staff: number; readonly total_present: number }[],
|
||||||
): number | null {
|
): number | null {
|
||||||
@ -310,6 +227,17 @@ function percentageFromStaffDailySummaries(
|
|||||||
return staff > 0 ? Number(((present / staff) * 100).toFixed(2)) : null;
|
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({
|
export function useCampusAttendancePage({
|
||||||
userRole,
|
userRole,
|
||||||
userCampus,
|
userCampus,
|
||||||
@ -329,7 +257,6 @@ export function useCampusAttendancePage({
|
|||||||
effectiveTier === 'organization'
|
effectiveTier === 'organization'
|
||||||
|| effectiveTier === 'school'
|
|| effectiveTier === 'school'
|
||||||
|| effectiveTier === 'campus'
|
|| effectiveTier === 'campus'
|
||||||
|| effectiveTier === 'class'
|
|
||||||
),
|
),
|
||||||
canPrint: userRole === 'superintendent' || userRole === 'director' || userRole === 'office_manager' || permissions.has('READ_STAFF_ATTENDANCE_REPORTS'),
|
canPrint: userRole === 'superintendent' || userRole === 'director' || userRole === 'office_manager' || permissions.has('READ_STAFF_ATTENDANCE_REPORTS'),
|
||||||
canReadStaffReports: permissions.has('READ_STAFF_ATTENDANCE_REPORTS'),
|
canReadStaffReports: permissions.has('READ_STAFF_ATTENDANCE_REPORTS'),
|
||||||
@ -356,7 +283,6 @@ export function useCampusAttendancePage({
|
|||||||
campusInfo?.fullName || userCampus,
|
campusInfo?.fullName || userCampus,
|
||||||
);
|
);
|
||||||
const attendanceCampusId = scopeModel.campusId;
|
const attendanceCampusId = scopeModel.campusId;
|
||||||
const attendanceSummaryFilter = attendanceCampusId ? { campusKey: attendanceCampusId } : undefined;
|
|
||||||
const scopedAttendanceChildrenQuery = useQuery({
|
const scopedAttendanceChildrenQuery = useQuery({
|
||||||
queryKey: ['attendance-scoped-children', effectiveTier, effectiveTenant?.id ?? null],
|
queryKey: ['attendance-scoped-children', effectiveTier, effectiveTenant?.id ?? null],
|
||||||
enabled: Boolean(
|
enabled: Boolean(
|
||||||
@ -371,10 +297,6 @@ export function useCampusAttendancePage({
|
|||||||
attendanceCampusId ?? undefined,
|
attendanceCampusId ?? undefined,
|
||||||
hasAttendanceScope,
|
hasAttendanceScope,
|
||||||
);
|
);
|
||||||
const summariesQuery = useCampusAttendanceSummaries(
|
|
||||||
attendanceSummaryFilter,
|
|
||||||
hasAttendanceScope,
|
|
||||||
);
|
|
||||||
const staffSummaryQuery = useStaffAttendanceSummary(
|
const staffSummaryQuery = useStaffAttendanceSummary(
|
||||||
{ startDate: today, endDate: today },
|
{ startDate: today, endDate: today },
|
||||||
roleAccess.canReadStaffReports && hasAttendanceScope,
|
roleAccess.canReadStaffReports && hasAttendanceScope,
|
||||||
@ -389,11 +311,9 @@ export function useCampusAttendancePage({
|
|||||||
queryFn: ({ signal }) => listUsers({ limit: 500, field: 'name', sort: 'asc' }, { signal }),
|
queryFn: ({ signal }) => listUsers({ limit: 500, field: 'name', sort: 'asc' }, { signal }),
|
||||||
});
|
});
|
||||||
const saveConfigMutation = useSaveCampusAttendanceConfig();
|
const saveConfigMutation = useSaveCampusAttendanceConfig();
|
||||||
const saveSummaryMutation = useSaveCampusAttendanceSummary();
|
|
||||||
const saveStaffAttendanceMutation = useSaveStaffAttendanceRecord();
|
const saveStaffAttendanceMutation = useSaveStaffAttendanceRecord();
|
||||||
|
|
||||||
const configs = configsQuery.data ?? EMPTY_CONFIGS;
|
const configs = configsQuery.data ?? EMPTY_CONFIGS;
|
||||||
const attendanceData = summariesQuery.data ?? EMPTY_SUMMARIES;
|
|
||||||
const staffSummary = staffSummaryQuery.data ?? null;
|
const staffSummary = staffSummaryQuery.data ?? null;
|
||||||
const staffRecords = staffRecordsQuery.data ?? EMPTY_STAFF_RECORDS;
|
const staffRecords = staffRecordsQuery.data ?? EMPTY_STAFF_RECORDS;
|
||||||
const officeStaffUsers = useMemo(() => (
|
const officeStaffUsers = useMemo(() => (
|
||||||
@ -418,21 +338,19 @@ export function useCampusAttendancePage({
|
|||||||
const loading = campusCatalog.isLoading
|
const loading = campusCatalog.isLoading
|
||||||
|| (effectiveTier === 'class' && classQuery.isLoading)
|
|| (effectiveTier === 'class' && classQuery.isLoading)
|
||||||
|| configsQuery.isLoading
|
|| configsQuery.isLoading
|
||||||
|| summariesQuery.isLoading
|
|
||||||
|| (roleAccess.canReadStaffReports && staffSummaryQuery.isLoading)
|
|| (roleAccess.canReadStaffReports && staffSummaryQuery.isLoading)
|
||||||
|| (roleAccess.canReadStaffReports && staffRecordsQuery.isLoading)
|
|| (roleAccess.canReadStaffReports && staffRecordsQuery.isLoading)
|
||||||
|| (roleAccess.canEnterData && officeStaffQuery.isLoading)
|
|| (roleAccess.canEnterData && officeStaffQuery.isLoading)
|
||||||
|| (roleAccess.canSeeAllCampuses && scopedAttendanceChildrenQuery.isLoading);
|
|| (roleAccess.canSeeAllCampuses && scopedAttendanceChildrenQuery.isLoading);
|
||||||
const saving = saveConfigMutation.isPending || saveSummaryMutation.isPending || saveStaffAttendanceMutation.isPending;
|
const saving = saveConfigMutation.isPending || saveStaffAttendanceMutation.isPending;
|
||||||
const loadError = campusCatalog.error
|
const loadError = campusCatalog.error
|
||||||
?? (effectiveTier === 'class' ? classQuery.error : null)
|
?? (effectiveTier === 'class' ? classQuery.error : null)
|
||||||
?? configsQuery.error
|
?? configsQuery.error
|
||||||
?? summariesQuery.error
|
|
||||||
?? (roleAccess.canReadStaffReports ? staffSummaryQuery.error : null)
|
?? (roleAccess.canReadStaffReports ? staffSummaryQuery.error : null)
|
||||||
?? (roleAccess.canReadStaffReports ? staffRecordsQuery.error : null)
|
?? (roleAccess.canReadStaffReports ? staffRecordsQuery.error : null)
|
||||||
?? (roleAccess.canEnterData ? officeStaffQuery.error : null)
|
?? (roleAccess.canEnterData ? officeStaffQuery.error : null)
|
||||||
?? (roleAccess.canSeeAllCampuses ? scopedAttendanceChildrenQuery.error : null);
|
?? (roleAccess.canSeeAllCampuses ? scopedAttendanceChildrenQuery.error : null);
|
||||||
const saveError = saveConfigMutation.error ?? saveSummaryMutation.error ?? saveStaffAttendanceMutation.error;
|
const saveError = saveConfigMutation.error ?? saveStaffAttendanceMutation.error;
|
||||||
const [successMessage, setSuccessMessage] = useState('');
|
const [successMessage, setSuccessMessage] = useState('');
|
||||||
const [printError, setPrintError] = useState<string | null>(null);
|
const [printError, setPrintError] = useState<string | null>(null);
|
||||||
const errorMessage = printError ?? getOptionalErrorMessage(loadError ?? saveError);
|
const errorMessage = printError ?? getOptionalErrorMessage(loadError ?? saveError);
|
||||||
@ -440,17 +358,13 @@ export function useCampusAttendancePage({
|
|||||||
const [linkValue, setLinkValue] = useState('');
|
const [linkValue, setLinkValue] = useState('');
|
||||||
const [showEntryForm, setShowEntryForm] = useState(false);
|
const [showEntryForm, setShowEntryForm] = useState(false);
|
||||||
const [expandedCampus, setExpandedCampus] = useState<CampusId | null>(null);
|
const [expandedCampus, setExpandedCampus] = useState<CampusId | null>(null);
|
||||||
const [entryDraft, setEntryDraft] = useState<CampusAttendanceEntryDraft>(() => emptyEntryDraft(today, attendanceCampusId ?? ''));
|
|
||||||
const [staffEntryDraft, setStaffEntryDraft] = useState<StaffAttendanceEntryDraft>(() => emptyStaffEntryDraft(today));
|
const [staffEntryDraft, setStaffEntryDraft] = useState<StaffAttendanceEntryDraft>(() => emptyStaffEntryDraft(today));
|
||||||
const [entryError, setEntryError] = useState<string | null>(null);
|
|
||||||
const [staffEntryError, setStaffEntryError] = useState<string | null>(null);
|
const [staffEntryError, setStaffEntryError] = useState<string | null>(null);
|
||||||
const [studentAttendanceStatusOverrides, setStudentAttendanceStatusOverrides] = useState<AttendanceStatusMap>({});
|
|
||||||
const [staffAttendanceStatusOverrides, setStaffAttendanceStatusOverrides] = useState<AttendanceStatusMap>({});
|
const [staffAttendanceStatusOverrides, setStaffAttendanceStatusOverrides] = useState<AttendanceStatusMap>({});
|
||||||
const [studentRollupOverrides, setStudentRollupOverrides] = useState<StudentRollupOverrideMap>({});
|
|
||||||
|
|
||||||
const campusStats = useMemo(
|
const campusStats = useMemo(
|
||||||
() => buildCampusAttendanceStats(campusCatalog.campuses, configs, attendanceData, today, weekStart, weekEnd, staffRecords),
|
() => buildCampusAttendanceStats(campusCatalog.campuses, configs, today, weekStart, weekEnd, staffRecords),
|
||||||
[attendanceData, campusCatalog.campuses, configs, staffRecords, today, weekEnd, weekStart],
|
[campusCatalog.campuses, configs, staffRecords, today, weekEnd, weekStart],
|
||||||
);
|
);
|
||||||
const visibleCampusStats = useMemo(() => {
|
const visibleCampusStats = useMemo(() => {
|
||||||
if (scopeModel.mode === 'campus') {
|
if (scopeModel.mode === 'campus') {
|
||||||
@ -462,11 +376,10 @@ export function useCampusAttendancePage({
|
|||||||
const scopedCampusIds = new Set([
|
const scopedCampusIds = new Set([
|
||||||
...scopedCampusOptions.map((campus) => campus.id),
|
...scopedCampusOptions.map((campus) => campus.id),
|
||||||
...configs.map((config) => config.campus_id),
|
...configs.map((config) => config.campus_id),
|
||||||
...attendanceData.map((record) => record.campus_id),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return campusStats.filter((campus) => scopedCampusIds.has(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[] => {
|
const attendanceChildStats = useMemo((): readonly CampusAttendanceChildStats[] => {
|
||||||
if (scopeModel.mode === 'campus') {
|
if (scopeModel.mode === 'campus') {
|
||||||
return visibleCampusStats.map((campus) => ({
|
return visibleCampusStats.map((campus) => ({
|
||||||
@ -476,10 +389,8 @@ export function useCampusAttendancePage({
|
|||||||
fullName: campus.fullName,
|
fullName: campus.fullName,
|
||||||
bgGradient: campus.bgGradient,
|
bgGradient: campus.bgGradient,
|
||||||
isOnline: campus.isOnline,
|
isOnline: campus.isOnline,
|
||||||
todayPct: campus.todayPct,
|
todayPct: campus.todayStaffRecord?.attendance_percentage ?? null,
|
||||||
weekAvg: campus.weekAvg,
|
weekAvg: averageStaffAttendancePercentage(campus.recentStaffData, weekStart, weekEnd),
|
||||||
recentData: campus.recentData,
|
|
||||||
todayRecord: campus.todayRecord,
|
|
||||||
childCampusIds: [campus.id],
|
childCampusIds: [campus.id],
|
||||||
recentStaffData: campus.recentStaffData,
|
recentStaffData: campus.recentStaffData,
|
||||||
todayStaffRecord: campus.todayStaffRecord,
|
todayStaffRecord: campus.todayStaffRecord,
|
||||||
@ -499,10 +410,8 @@ export function useCampusAttendancePage({
|
|||||||
fullName: campus?.fullName ?? child.name ?? 'Campus',
|
fullName: campus?.fullName ?? child.name ?? 'Campus',
|
||||||
bgGradient: campus?.bgGradient ?? SCHOOL_TILE_GRADIENTS[index % SCHOOL_TILE_GRADIENTS.length],
|
bgGradient: campus?.bgGradient ?? SCHOOL_TILE_GRADIENTS[index % SCHOOL_TILE_GRADIENTS.length],
|
||||||
isOnline: campus?.isOnline,
|
isOnline: campus?.isOnline,
|
||||||
todayPct: campus?.todayPct ?? null,
|
todayPct: campus?.todayStaffRecord?.attendance_percentage ?? null,
|
||||||
weekAvg: campus?.weekAvg ?? null,
|
weekAvg: averageStaffAttendancePercentage(campus?.recentStaffData ?? [], weekStart, weekEnd),
|
||||||
recentData: campus?.recentData ?? [],
|
|
||||||
todayRecord: campus?.todayRecord ?? null,
|
|
||||||
childCampusIds: campus ? [campus.id] : [],
|
childCampusIds: campus ? [campus.id] : [],
|
||||||
recentStaffData: campus?.recentStaffData ?? [],
|
recentStaffData: campus?.recentStaffData ?? [],
|
||||||
todayStaffRecord: campus?.todayStaffRecord ?? null,
|
todayStaffRecord: campus?.todayStaffRecord ?? null,
|
||||||
@ -517,14 +426,6 @@ export function useCampusAttendancePage({
|
|||||||
.map((campusChild) => campusCatalog.campuses.find((campus) => campus.tenantId === campusChild.id))
|
.map((campusChild) => campusCatalog.campuses.find((campus) => campus.tenantId === campusChild.id))
|
||||||
.filter((campus): campus is CampusInfo => Boolean(campus));
|
.filter((campus): campus is CampusInfo => Boolean(campus));
|
||||||
const childCampusIds = childCampuses.map((campus) => campus.id);
|
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
|
const childStaffRecords = campusStats
|
||||||
.filter((campus) => childCampusIds.includes(campus.id))
|
.filter((campus) => childCampusIds.includes(campus.id))
|
||||||
.flatMap((campus) => campus.recentStaffData);
|
.flatMap((campus) => campus.recentStaffData);
|
||||||
@ -542,12 +443,7 @@ export function useCampusAttendancePage({
|
|||||||
notes: null,
|
notes: null,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
const weekDays = Array.from(new Set(childWeekRecords.map((record) => record.date)));
|
const weekAvg = averageStaffAttendancePercentage(childStaffRecords, weekStart, weekEnd);
|
||||||
const weekAvg = weekDays.length > 0
|
|
||||||
? Number((weekDays.reduce((sum, day) => (
|
|
||||||
sum + (percentageFromRecords(childWeekRecords.filter((record) => record.date === day)) ?? 0)
|
|
||||||
), 0) / weekDays.length).toFixed(2))
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: child.id,
|
id: child.id,
|
||||||
@ -555,17 +451,14 @@ export function useCampusAttendancePage({
|
|||||||
mascot: child.name ?? 'School',
|
mascot: child.name ?? 'School',
|
||||||
fullName: child.name ?? 'School',
|
fullName: child.name ?? 'School',
|
||||||
bgGradient: SCHOOL_TILE_GRADIENTS[index % SCHOOL_TILE_GRADIENTS.length],
|
bgGradient: SCHOOL_TILE_GRADIENTS[index % SCHOOL_TILE_GRADIENTS.length],
|
||||||
todayPct: percentageFromRecords(childTodayRecords),
|
todayPct: todayStaffRecord?.attendance_percentage ?? null,
|
||||||
weekAvg,
|
weekAvg,
|
||||||
recentData: childWeekRecords.slice(0, 10),
|
|
||||||
todayRecord: null,
|
|
||||||
childCampusIds,
|
childCampusIds,
|
||||||
recentStaffData: childStaffRecords.slice(0, 10),
|
recentStaffData: childStaffRecords.slice(0, 10),
|
||||||
todayStaffRecord,
|
todayStaffRecord,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
attendanceData,
|
|
||||||
campusCatalog.campuses,
|
campusCatalog.campuses,
|
||||||
campusChildrenByParentId,
|
campusChildrenByParentId,
|
||||||
campusStats,
|
campusStats,
|
||||||
@ -576,58 +469,16 @@ export function useCampusAttendancePage({
|
|||||||
weekEnd,
|
weekEnd,
|
||||||
weekStart,
|
weekStart,
|
||||||
]);
|
]);
|
||||||
const overallStats = useMemo(
|
|
||||||
() => buildOverallAttendanceStats(attendanceData, today, weekStart, weekEnd),
|
|
||||||
[attendanceData, today, weekEnd, weekStart],
|
|
||||||
);
|
|
||||||
const combinedStats = useMemo(
|
const combinedStats = useMemo(
|
||||||
() => buildCombinedAttendanceStats(
|
() => buildCombinedAttendanceStats(
|
||||||
overallStats,
|
|
||||||
staffSummary,
|
staffSummary,
|
||||||
scopeModel.mode === 'campus' ? undefined : attendanceChildStats,
|
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 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 myCampusStats = attendanceCampusId ? visibleCampusStats.find((campus) => campus.id === attendanceCampusId) : undefined;
|
||||||
const myStaffData = myCampusStats?.recentStaffData ?? [];
|
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(
|
const staffAttendanceStatuses = useMemo(
|
||||||
() => reconcileAttendanceStatuses(
|
() => reconcileAttendanceStatuses(
|
||||||
staffAttendanceStatusOverrides,
|
staffAttendanceStatusOverrides,
|
||||||
@ -635,77 +486,23 @@ export function useCampusAttendancePage({
|
|||||||
),
|
),
|
||||||
[officeStaffUsers, staffAttendanceStatusOverrides],
|
[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) => {
|
const showSuccess = (message: string) => {
|
||||||
setPrintError(null);
|
setPrintError(null);
|
||||||
setSuccessMessage(message);
|
setSuccessMessage(message);
|
||||||
window.setTimeout(() => setSuccessMessage(''), UI_FEEDBACK_CLEAR_DELAY_MS);
|
window.setTimeout(() => setSuccessMessage(''), UI_FEEDBACK_CLEAR_DELAY_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateEntryDraft = (patch: Partial<CampusAttendanceEntryDraft>) => {
|
|
||||||
setEntryDraft((currentDraft) => ({ ...currentDraft, ...patch }));
|
|
||||||
setEntryError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateStaffEntryDraft = (patch: Partial<StaffAttendanceEntryDraft>) => {
|
const updateStaffEntryDraft = (patch: Partial<StaffAttendanceEntryDraft>) => {
|
||||||
setStaffEntryDraft((currentDraft) => ({ ...currentDraft, ...patch }));
|
setStaffEntryDraft((currentDraft) => ({ ...currentDraft, ...patch }));
|
||||||
setStaffEntryError(null);
|
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) => {
|
const updateStaffAttendanceStatus = (userId: string, status: AttendanceRosterStatus) => {
|
||||||
setStaffAttendanceStatusOverrides((currentStatuses) => ({ ...currentStatuses, [userId]: status }));
|
setStaffAttendanceStatusOverrides((currentStatuses) => ({ ...currentStatuses, [userId]: status }));
|
||||||
setStaffEntryError(null);
|
setStaffEntryError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setEntryFormVisibility = (nextShowEntryForm: boolean) => {
|
const setEntryFormVisibility = (nextShowEntryForm: boolean) => {
|
||||||
if (nextShowEntryForm) {
|
|
||||||
setEntryDraft((currentDraft) => ({
|
|
||||||
...currentDraft,
|
|
||||||
campusId: attendanceCampusId ?? currentDraft.campusId,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
setEntryError(null);
|
|
||||||
setStaffEntryError(null);
|
setStaffEntryError(null);
|
||||||
setShowEntryForm(nextShowEntryForm);
|
setShowEntryForm(nextShowEntryForm);
|
||||||
};
|
};
|
||||||
@ -720,77 +517,6 @@ export function useCampusAttendancePage({
|
|||||||
setEditingLink(null);
|
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 (
|
const saveStaffBatchAttendance = async (
|
||||||
requireStaff: boolean,
|
requireStaff: boolean,
|
||||||
input: Pick<StaffAttendanceEntryDraft, 'date' | 'note'> = staffEntryDraft,
|
input: Pick<StaffAttendanceEntryDraft, 'date' | 'note'> = staffEntryDraft,
|
||||||
@ -815,31 +541,6 @@ export function useCampusAttendancePage({
|
|||||||
return true;
|
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 () => {
|
const handleSubmitStaffBatch = async () => {
|
||||||
setPrintError(null);
|
setPrintError(null);
|
||||||
|
|
||||||
@ -849,25 +550,6 @@ export function useCampusAttendancePage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
showSuccess('Staff attendance saved!');
|
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);
|
setShowEntryForm(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -878,23 +560,13 @@ export function useCampusAttendancePage({
|
|||||||
const reportTitle = roleAccess.canSeeAllCampuses
|
const reportTitle = roleAccess.canSeeAllCampuses
|
||||||
? `${scopeModel.reportLabel} Attendance Report`
|
? `${scopeModel.reportLabel} Attendance Report`
|
||||||
: `${campusInfo?.fullName || userCampus} 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({
|
const printResult = openCampusAttendancePrintReport({
|
||||||
input: {
|
input: {
|
||||||
reportTitle,
|
reportTitle,
|
||||||
generatedByName: userName,
|
generatedByName: userName,
|
||||||
generatedByRole: `${userRole.charAt(0).toUpperCase()}${userRole.slice(1)}`,
|
generatedByRole: `${userRole.charAt(0).toUpperCase()}${userRole.slice(1)}`,
|
||||||
today,
|
today,
|
||||||
weekStart,
|
|
||||||
campusesToPrint,
|
campusesToPrint,
|
||||||
printTodayRecords,
|
|
||||||
printWeekRecords,
|
|
||||||
staffSummary,
|
staffSummary,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -917,7 +589,6 @@ export function useCampusAttendancePage({
|
|||||||
weekStart,
|
weekStart,
|
||||||
weekEnd,
|
weekEnd,
|
||||||
configs,
|
configs,
|
||||||
attendanceData,
|
|
||||||
staffRecords,
|
staffRecords,
|
||||||
loading,
|
loading,
|
||||||
saving,
|
saving,
|
||||||
@ -927,20 +598,13 @@ export function useCampusAttendancePage({
|
|||||||
linkValue,
|
linkValue,
|
||||||
showEntryForm,
|
showEntryForm,
|
||||||
expandedCampus,
|
expandedCampus,
|
||||||
entryDraft,
|
|
||||||
entryError,
|
|
||||||
staffEntryDraft,
|
staffEntryDraft,
|
||||||
staffEntryError,
|
staffEntryError,
|
||||||
officeStaffUsers,
|
officeStaffUsers,
|
||||||
staffAttendanceStatuses,
|
staffAttendanceStatuses,
|
||||||
studentRollupRows,
|
|
||||||
attendanceStudents,
|
|
||||||
studentAttendanceStatuses,
|
|
||||||
attendanceStudentsLoading: attendanceStudentsQuery.isLoading,
|
|
||||||
scopedCampusOptions,
|
scopedCampusOptions,
|
||||||
campusStats: visibleCampusStats,
|
campusStats: visibleCampusStats,
|
||||||
attendanceChildStats,
|
attendanceChildStats,
|
||||||
overallStats,
|
|
||||||
combinedStats,
|
combinedStats,
|
||||||
staffSummary: {
|
staffSummary: {
|
||||||
summary: staffSummary,
|
summary: staffSummary,
|
||||||
@ -948,10 +612,7 @@ export function useCampusAttendancePage({
|
|||||||
error: staffSummaryQuery.error,
|
error: staffSummaryQuery.error,
|
||||||
},
|
},
|
||||||
myCampusConfig,
|
myCampusConfig,
|
||||||
myCampusData,
|
|
||||||
myStaffData,
|
myStaffData,
|
||||||
myTodayPct,
|
|
||||||
myWeekAvg,
|
|
||||||
userCampus,
|
userCampus,
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
@ -959,16 +620,10 @@ export function useCampusAttendancePage({
|
|||||||
setLinkValue,
|
setLinkValue,
|
||||||
setShowEntryForm: setEntryFormVisibility,
|
setShowEntryForm: setEntryFormVisibility,
|
||||||
setExpandedCampus,
|
setExpandedCampus,
|
||||||
updateEntryDraft,
|
|
||||||
updateStaffEntryDraft,
|
updateStaffEntryDraft,
|
||||||
updateStudentAttendanceStatus,
|
|
||||||
updateStudentRollupDraft,
|
|
||||||
updateStaffAttendanceStatus,
|
updateStaffAttendanceStatus,
|
||||||
handleSaveLink,
|
handleSaveLink,
|
||||||
handleSubmitEntry,
|
|
||||||
handleSubmitStaffEntry,
|
|
||||||
handleSubmitStaffBatch,
|
handleSubmitStaffBatch,
|
||||||
handleSubmitAttendanceForm,
|
|
||||||
handlePrint,
|
handlePrint,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,14 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import { toCampusAttendanceConfigViewModel } from '@/business/campus-attendance/mappers';
|
||||||
toCampusAttendanceConfigViewModel,
|
import type { CampusAttendanceConfigDto } from '@/shared/types/campusAttendance';
|
||||||
toCampusAttendanceSummaryMutationDto,
|
|
||||||
toCampusAttendanceSummaryViewModel,
|
|
||||||
} from '@/business/campus-attendance/mappers';
|
|
||||||
import type { CampusAttendanceEntryInput } from '@/business/campus-attendance/types';
|
|
||||||
import type {
|
|
||||||
CampusAttendanceConfigDto,
|
|
||||||
CampusAttendanceSummaryDto,
|
|
||||||
} from '@/shared/types/campusAttendance';
|
|
||||||
|
|
||||||
describe('campus attendance mappers', () => {
|
describe('campus attendance mappers', () => {
|
||||||
it('maps backend config DTO fields into the frontend view model shape', () => {
|
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',
|
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,12 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
CampusAttendanceConfigDto,
|
CampusAttendanceConfigDto,
|
||||||
CampusAttendanceSummaryDto,
|
|
||||||
CampusAttendanceSummaryMutationDto,
|
|
||||||
} from '@/shared/types/campusAttendance';
|
} from '@/shared/types/campusAttendance';
|
||||||
import type {
|
import type {
|
||||||
CampusAttendanceConfigViewModel,
|
CampusAttendanceConfigViewModel,
|
||||||
CampusAttendanceEntryInput,
|
|
||||||
CampusAttendanceSummaryViewModel,
|
|
||||||
} from '@/business/campus-attendance/types';
|
} from '@/business/campus-attendance/types';
|
||||||
|
|
||||||
export function toCampusAttendanceConfigViewModel(
|
export function toCampusAttendanceConfigViewModel(
|
||||||
@ -20,32 +16,3 @@ export function toCampusAttendanceConfigViewModel(
|
|||||||
updated_at: dto.updatedAt,
|
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@ -28,15 +28,13 @@ describe('campus attendance print report', () => {
|
|||||||
`Printed by: ${CAMPUS_ATTENDANCE_TEST_SEED.generatedByName} (${CAMPUS_ATTENDANCE_TEST_SEED.generatedByRoleEscaped})`,
|
`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.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 green">90%</div>');
|
||||||
expect(html).toContain('<div class="value amber">85%</div>');
|
expect(html).toContain('Present or late');
|
||||||
expect(html).toContain('Students 90% · Staff 90%');
|
expect(html).toContain('<div class="label">Staff Records</div>');
|
||||||
expect(html).toContain('<div class="label">People</div>');
|
expect(html).toContain('<div class="value">10</div>');
|
||||||
expect(html).toContain('<div class="value">60</div>');
|
|
||||||
expect(html).toContain("Today's report");
|
expect(html).toContain("Today's report");
|
||||||
expect(html).toContain('Attendance history');
|
expect(html).toContain('Attendance history');
|
||||||
expect(html).toContain('<td>Students</td>');
|
|
||||||
expect(html).toContain('<td>Staff</td>');
|
expect(html).toContain('<td>Staff</td>');
|
||||||
expect(html).toContain('<td>Total</td>');
|
expect(html).toContain('<td>Total</td>');
|
||||||
expect(html).toContain('<td class="pct-good">90.0%</td>');
|
expect(html).toContain('<td class="pct-good">90.0%</td>');
|
||||||
@ -51,14 +49,10 @@ describe('campus attendance print report', () => {
|
|||||||
...campusAttendanceStatsSeed,
|
...campusAttendanceStatsSeed,
|
||||||
todayPct: null,
|
todayPct: null,
|
||||||
weekAvg: null,
|
weekAvg: null,
|
||||||
recentData: [],
|
|
||||||
todayRecord: null,
|
|
||||||
recentStaffData: [],
|
recentStaffData: [],
|
||||||
todayStaffRecord: null,
|
todayStaffRecord: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
printTodayRecords: [],
|
|
||||||
printWeekRecords: [],
|
|
||||||
staffSummary: null,
|
staffSummary: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -52,37 +52,14 @@ const inlinePercentageClass = (percentage: number | null): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function buildTodayReportRows(campus: CampusAttendanceStats): readonly CombinedAttendanceHistoryRow[] {
|
function buildTodayReportRows(campus: CampusAttendanceStats): readonly CombinedAttendanceHistoryRow[] {
|
||||||
const date = campus.todayRecord?.date ?? campus.todayStaffRecord?.date ?? '';
|
const date = campus.todayStaffRecord?.date ?? '';
|
||||||
const studentRecord = campus.todayRecord;
|
|
||||||
const staffRecord = campus.todayStaffRecord;
|
const staffRecord = campus.todayStaffRecord;
|
||||||
const studentTotal = studentRecord?.total_enrolled ?? 0;
|
|
||||||
const staffTotal = staffRecord?.total_staff ?? 0;
|
const staffTotal = staffRecord?.total_staff ?? 0;
|
||||||
const studentPresent = studentRecord?.total_present ?? 0;
|
|
||||||
const staffPresent = staffRecord?.total_present ?? 0;
|
const staffPresent = staffRecord?.total_present ?? 0;
|
||||||
const studentAbsent = studentRecord?.total_absent ?? 0;
|
|
||||||
const staffAbsent = staffRecord?.total_absent ?? 0;
|
const staffAbsent = staffRecord?.total_absent ?? 0;
|
||||||
const studentLate = studentRecord?.total_tardy ?? 0;
|
|
||||||
const staffLate = staffRecord?.total_late ?? 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 [
|
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`,
|
id: `${campus.id}:today:staff`,
|
||||||
date,
|
date,
|
||||||
@ -100,12 +77,12 @@ function buildTodayReportRows(campus: CampusAttendanceStats): readonly CombinedA
|
|||||||
date,
|
date,
|
||||||
group: 'total',
|
group: 'total',
|
||||||
label: 'Total',
|
label: 'Total',
|
||||||
total: combinedTotal,
|
total: staffTotal,
|
||||||
present: combinedPresent,
|
present: staffPresent,
|
||||||
absent: studentAbsent + staffAbsent,
|
absent: staffAbsent,
|
||||||
late: studentLate + staffLate,
|
late: staffLate,
|
||||||
attendancePercentage: combinedPercentage,
|
attendancePercentage: staffRecord?.attendance_percentage ?? null,
|
||||||
notes: notes.length > 0 ? notes.join('; ') : null,
|
notes: staffRecord?.notes ?? null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -113,7 +90,7 @@ function buildTodayReportRows(campus: CampusAttendanceStats): readonly CombinedA
|
|||||||
function renderAttendanceRows(rows: readonly CombinedAttendanceHistoryRow[]): string {
|
function renderAttendanceRows(rows: readonly CombinedAttendanceHistoryRow[]): string {
|
||||||
return rows.map((record) => `
|
return rows.map((record) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${record.group === 'students' ? formatAttendanceDate(record.date) : ''}</td>
|
<td>${record.group === 'staff' ? formatAttendanceDate(record.date) : ''}</td>
|
||||||
<td>${record.label}</td>
|
<td>${record.label}</td>
|
||||||
<td>${record.total}</td>
|
<td>${record.total}</td>
|
||||||
<td>${record.present}</td>
|
<td>${record.present}</td>
|
||||||
@ -126,7 +103,7 @@ function renderAttendanceRows(rows: readonly CombinedAttendanceHistoryRow[]): st
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderAttendanceHistoryTable(campus: CampusAttendanceStats): string {
|
function renderAttendanceHistoryTable(campus: CampusAttendanceStats): string {
|
||||||
const historyRows = buildCombinedAttendanceHistoryRows(campus.recentData, campus.recentStaffData);
|
const historyRows = buildCombinedAttendanceHistoryRows(campus.recentStaffData);
|
||||||
|
|
||||||
if (historyRows.length === 0) {
|
if (historyRows.length === 0) {
|
||||||
return '';
|
return '';
|
||||||
@ -191,9 +168,6 @@ export function openCampusAttendancePrintReport({
|
|||||||
|
|
||||||
export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput): string {
|
export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput): string {
|
||||||
const printStats = buildPrintAttendanceStats(input);
|
const printStats = buildPrintAttendanceStats(input);
|
||||||
const staffTotal = input.staffSummary
|
|
||||||
? input.staffSummary.present + input.staffSummary.late + input.staffSummary.absent
|
|
||||||
: 0;
|
|
||||||
const generatedAtDate = new Date().toLocaleDateString('en-US', {
|
const generatedAtDate = new Date().toLocaleDateString('en-US', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@ -251,19 +225,19 @@ export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput
|
|||||||
</div>
|
</div>
|
||||||
<div class="summary-grid">
|
<div class="summary-grid">
|
||||||
<div class="summary-box">
|
<div class="summary-box">
|
||||||
<div class="label">Today's Attendance</div>
|
<div class="label">Today's Staff Attendance</div>
|
||||||
<div class="value ${percentageClass(printStats.combinedPct)}">${printStats.combinedPct !== null ? `${printStats.combinedPct}%` : 'No data'}</div>
|
<div class="value ${percentageClass(printStats.staffPct)}">${printStats.staffPct !== null ? `${printStats.staffPct}%` : '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" style="margin-top:4px">Present or late</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-box">
|
<div class="summary-box">
|
||||||
<div class="label">People</div>
|
<div class="label">Staff Records</div>
|
||||||
<div class="value">${input.printTodayRecords.reduce((sum, record) => sum + record.total_enrolled, 0) + staffTotal}</div>
|
<div class="value">${printStats.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" style="margin-top:4px">Recorded today</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-box">
|
<div class="summary-box">
|
||||||
<div class="label">This Week's Student Average</div>
|
<div class="label">Report Date</div>
|
||||||
<div class="value ${percentageClass(printStats.weekPct)}">${printStats.weekPct !== null ? `${printStats.weekPct}%` : 'No data'}</div>
|
<div class="value">${formatAttendanceDate(input.today)}</div>
|
||||||
<div class="label" style="margin-top:4px">Week of ${formatAttendanceDate(input.weekStart)}</div>
|
<div class="label" style="margin-top:4px">Staff attendance</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${input.campusesToPrint.map((campus) => `
|
${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>
|
<span>Week Avg: <span class="${inlinePercentageClass(campus.weekAvg)}">${campus.weekAvg !== null ? `${campus.weekAvg}%` : 'N/A'}</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${campus.todayRecord || campus.todayStaffRecord ? `
|
${campus.todayStaffRecord ? `
|
||||||
<div class="section-label">Today's report</div>
|
<div class="section-label">Today's report</div>
|
||||||
<table class="history-table">
|
<table class="history-table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@ -1,62 +1,17 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
buildAttendanceEntryInput,
|
|
||||||
buildCampusAttendanceScopeModel,
|
buildCampusAttendanceScopeModel,
|
||||||
buildCampusAttendanceStats,
|
buildCampusAttendanceStats,
|
||||||
buildCombinedAttendanceHistoryRows,
|
buildCombinedAttendanceHistoryRows,
|
||||||
buildCombinedAttendanceStats,
|
buildCombinedAttendanceStats,
|
||||||
buildOverallAttendanceStats,
|
|
||||||
buildStaffAttendanceDailySummaries,
|
buildStaffAttendanceDailySummaries,
|
||||||
getWeekEnd,
|
getWeekEnd,
|
||||||
getWeekStart,
|
getWeekStart,
|
||||||
} from '@/business/campus-attendance/selectors';
|
} from '@/business/campus-attendance/selectors';
|
||||||
import type {
|
import type { CampusAttendanceChildStats } from '@/business/campus-attendance/types';
|
||||||
CampusAttendanceChildStats,
|
|
||||||
CampusAttendanceEntryDraft,
|
|
||||||
CampusAttendanceSummaryViewModel,
|
|
||||||
} from '@/business/campus-attendance/types';
|
|
||||||
import type { CampusInfo } from '@/shared/types/app';
|
import type { CampusInfo } from '@/shared/types/app';
|
||||||
import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types';
|
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[] = [
|
const staffRecords: readonly StaffAttendanceRecordViewModel[] = [
|
||||||
{
|
{
|
||||||
id: 'staff-1',
|
id: 'staff-1',
|
||||||
@ -114,53 +69,6 @@ describe('campus attendance selectors', () => {
|
|||||||
expect(getWeekEnd(date)).toBe('2026-06-12');
|
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', () => {
|
it('builds scope-aware attendance titles and descriptions', () => {
|
||||||
expect(
|
expect(
|
||||||
buildCampusAttendanceScopeModel(
|
buildCampusAttendanceScopeModel(
|
||||||
@ -212,11 +120,9 @@ describe('campus attendance selectors', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('combines student attendance aggregates with staff attendance summary', () => {
|
it('builds combined attendance from staff attendance summary only', () => {
|
||||||
const overallStats = buildOverallAttendanceStats(summaries, '2026-06-08', '2026-06-08', '2026-06-12');
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
buildCombinedAttendanceStats(overallStats, {
|
buildCombinedAttendanceStats({
|
||||||
staffCount: 12,
|
staffCount: 12,
|
||||||
recordsCount: 10,
|
recordsCount: 10,
|
||||||
present: 8,
|
present: 8,
|
||||||
@ -224,15 +130,12 @@ describe('campus attendance selectors', () => {
|
|||||||
absent: 1,
|
absent: 1,
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
studentTodayPct: 86.67,
|
|
||||||
studentWeekPct: 90.83,
|
|
||||||
staffTodayPct: 75,
|
staffTodayPct: 75,
|
||||||
combinedTodayPct: 85.8,
|
combinedTodayPct: 75,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('counts missing child attendance reports as incomplete in aggregate percentages', () => {
|
it('uses scoped child staff records for aggregate percentages', () => {
|
||||||
const overallStats = buildOverallAttendanceStats(summaries, '2026-06-08', '2026-06-08', '2026-06-12');
|
|
||||||
const childStats: readonly CampusAttendanceChildStats[] = [
|
const childStats: readonly CampusAttendanceChildStats[] = [
|
||||||
{
|
{
|
||||||
id: 'school-1',
|
id: 'school-1',
|
||||||
@ -242,11 +145,19 @@ describe('campus attendance selectors', () => {
|
|||||||
bgGradient: 'from-emerald-500 to-green-500',
|
bgGradient: 'from-emerald-500 to-green-500',
|
||||||
todayPct: 100,
|
todayPct: 100,
|
||||||
weekAvg: 100,
|
weekAvg: 100,
|
||||||
recentData: [],
|
|
||||||
todayRecord: null,
|
|
||||||
childCampusIds: ['tigers'],
|
childCampusIds: ['tigers'],
|
||||||
recentStaffData: [],
|
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',
|
id: 'school-2',
|
||||||
@ -256,19 +167,15 @@ describe('campus attendance selectors', () => {
|
|||||||
bgGradient: 'from-orange-600 to-amber-500',
|
bgGradient: 'from-orange-600 to-amber-500',
|
||||||
todayPct: null,
|
todayPct: null,
|
||||||
weekAvg: null,
|
weekAvg: null,
|
||||||
recentData: [],
|
|
||||||
todayRecord: null,
|
|
||||||
childCampusIds: ['gators'],
|
childCampusIds: ['gators'],
|
||||||
recentStaffData: [],
|
recentStaffData: [],
|
||||||
todayStaffRecord: null,
|
todayStaffRecord: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(buildCombinedAttendanceStats(overallStats, null, childStats)).toEqual({
|
expect(buildCombinedAttendanceStats(null, childStats)).toEqual({
|
||||||
studentTodayPct: 50,
|
staffTodayPct: 45,
|
||||||
studentWeekPct: 50,
|
combinedTodayPct: 45,
|
||||||
staffTodayPct: null,
|
|
||||||
combinedTodayPct: 50,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -292,14 +199,14 @@ describe('campus attendance selectors', () => {
|
|||||||
const [campus] = buildCampusAttendanceStats(
|
const [campus] = buildCampusAttendanceStats(
|
||||||
campusInfo,
|
campusInfo,
|
||||||
[],
|
[],
|
||||||
summaries,
|
|
||||||
'2026-06-08',
|
'2026-06-08',
|
||||||
'2026-06-08',
|
'2026-06-08',
|
||||||
'2026-06-12',
|
'2026-06-12',
|
||||||
staffRecords,
|
staffRecords,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(campus?.todayRecord?.total_enrolled).toBe(100);
|
expect(campus?.todayPct).toBe(66.67);
|
||||||
|
expect(campus?.weekAvg).toBe(66.67);
|
||||||
expect(campus?.todayStaffRecord).toMatchObject({
|
expect(campus?.todayStaffRecord).toMatchObject({
|
||||||
total_staff: 3,
|
total_staff: 3,
|
||||||
total_present: 2,
|
total_present: 2,
|
||||||
@ -309,20 +216,8 @@ describe('campus attendance selectors', () => {
|
|||||||
expect(campus?.recentStaffData).toHaveLength(1);
|
expect(campus?.recentStaffData).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('builds combined attendance history with students, staff, and total rows per date', () => {
|
it('builds staff-only attendance history with staff and total rows per date', () => {
|
||||||
expect(buildCombinedAttendanceHistoryRows([summaries[0]], buildStaffAttendanceDailySummaries(staffRecords))).toEqual([
|
expect(buildCombinedAttendanceHistoryRows(buildStaffAttendanceDailySummaries(staffRecords))).toEqual([
|
||||||
{
|
|
||||||
id: '2026-06-08:students',
|
|
||||||
date: '2026-06-08',
|
|
||||||
group: 'students',
|
|
||||||
label: 'Students',
|
|
||||||
total: 100,
|
|
||||||
present: 90,
|
|
||||||
absent: 10,
|
|
||||||
late: 3,
|
|
||||||
attendancePercentage: 90,
|
|
||||||
notes: null,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: '2026-06-08:staff',
|
id: '2026-06-08:staff',
|
||||||
date: '2026-06-08',
|
date: '2026-06-08',
|
||||||
@ -340,11 +235,11 @@ describe('campus attendance selectors', () => {
|
|||||||
date: '2026-06-08',
|
date: '2026-06-08',
|
||||||
group: 'total',
|
group: 'total',
|
||||||
label: 'Total',
|
label: 'Total',
|
||||||
total: 103,
|
total: 3,
|
||||||
present: 92,
|
present: 2,
|
||||||
absent: 11,
|
absent: 1,
|
||||||
late: 4,
|
late: 1,
|
||||||
attendancePercentage: 89.32,
|
attendancePercentage: 66.67,
|
||||||
notes: 'Traffic',
|
notes: 'Traffic',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -5,14 +5,11 @@ import type {
|
|||||||
AttendanceScopeMode,
|
AttendanceScopeMode,
|
||||||
CampusAttendanceCombinedStats,
|
CampusAttendanceCombinedStats,
|
||||||
CampusAttendanceChildStats,
|
CampusAttendanceChildStats,
|
||||||
CampusAttendanceEntryDraft,
|
|
||||||
CampusAttendanceConfigViewModel,
|
CampusAttendanceConfigViewModel,
|
||||||
CombinedAttendanceHistoryRow,
|
CombinedAttendanceHistoryRow,
|
||||||
CampusAttendanceOverallStats,
|
|
||||||
CampusAttendancePrintInput,
|
CampusAttendancePrintInput,
|
||||||
CampusAttendanceScopeModel,
|
CampusAttendanceScopeModel,
|
||||||
CampusAttendanceStats,
|
CampusAttendanceStats,
|
||||||
CampusAttendanceSummaryViewModel,
|
|
||||||
StaffAttendanceDailySummaryViewModel,
|
StaffAttendanceDailySummaryViewModel,
|
||||||
} from '@/business/campus-attendance/types';
|
} from '@/business/campus-attendance/types';
|
||||||
import type {
|
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(
|
export function buildCampusAttendanceStats(
|
||||||
campuses: readonly CampusInfo[],
|
campuses: readonly CampusInfo[],
|
||||||
configs: readonly CampusAttendanceConfigViewModel[],
|
configs: readonly CampusAttendanceConfigViewModel[],
|
||||||
summaries: readonly CampusAttendanceSummaryViewModel[],
|
|
||||||
today: string,
|
today: string,
|
||||||
weekStart: string,
|
weekStart: string,
|
||||||
weekEnd: string,
|
weekEnd: string,
|
||||||
@ -143,60 +58,24 @@ export function buildCampusAttendanceStats(
|
|||||||
const staffDailySummaries = buildStaffAttendanceDailySummaries(staffRecords);
|
const staffDailySummaries = buildStaffAttendanceDailySummaries(staffRecords);
|
||||||
|
|
||||||
return campuses.map((campus) => {
|
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 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
|
const recentStaffData = staffDailySummaries
|
||||||
.filter((record) => isStaffSummaryForCampus(record, campus))
|
.filter((record) => isStaffSummaryForCampus(record, campus))
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
const todayStaffRecord = recentStaffData.find((record) => record.date === today) ?? null;
|
const todayStaffRecord = recentStaffData.find((record) => record.date === today) ?? null;
|
||||||
|
const weekAvg = averageStaffAttendancePercentage(recentStaffData, weekStart, weekEnd);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...campus,
|
...campus,
|
||||||
todayPct,
|
todayPct: todayStaffRecord?.attendance_percentage ?? null,
|
||||||
weekAvg,
|
weekAvg,
|
||||||
config,
|
config,
|
||||||
recentData,
|
|
||||||
todayRecord,
|
|
||||||
recentStaffData,
|
recentStaffData,
|
||||||
todayStaffRecord,
|
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 {
|
function getAttendanceScopeMode(tier: ScopeTier): AttendanceScopeMode {
|
||||||
if (tier === 'organization') {
|
if (tier === 'organization') {
|
||||||
return 'organization';
|
return 'organization';
|
||||||
@ -289,6 +168,17 @@ function percentageFromScopedChildren(
|
|||||||
return percentageFromCounts(presentEquivalent, total);
|
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 {
|
function staffAttendancePercentage(present: number, late: number, total: number): number {
|
||||||
return percentageFromCounts(present + late, total) ?? 0;
|
return percentageFromCounts(present + late, total) ?? 0;
|
||||||
}
|
}
|
||||||
@ -349,7 +239,6 @@ export function buildStaffAttendanceDailySummaries(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildCombinedAttendanceStats(
|
export function buildCombinedAttendanceStats(
|
||||||
overallStats: CampusAttendanceOverallStats,
|
|
||||||
staffSummary: StaffAttendanceSummaryViewModel | null,
|
staffSummary: StaffAttendanceSummaryViewModel | null,
|
||||||
childStats?: readonly CampusAttendanceChildStats[],
|
childStats?: readonly CampusAttendanceChildStats[],
|
||||||
): CampusAttendanceCombinedStats {
|
): CampusAttendanceCombinedStats {
|
||||||
@ -359,21 +248,15 @@ export function buildCombinedAttendanceStats(
|
|||||||
: 0;
|
: 0;
|
||||||
const staffTotal = staffSummary ? Math.max(staffSummary.staffCount, recordedStaffTotal) : 0;
|
const staffTotal = staffSummary ? Math.max(staffSummary.staffCount, recordedStaffTotal) : 0;
|
||||||
const staffPresent = staffSummary ? staffSummary.present + staffSummary.late : 0;
|
const staffPresent = staffSummary ? staffSummary.present + staffSummary.late : 0;
|
||||||
const scopedStudentTodayPct = percentageFromScopedChildren(scopedChildren, (child) => child.todayPct);
|
const scopedStaffTodayPct = percentageFromScopedChildren(
|
||||||
const scopedStudentWeekPct = percentageFromScopedChildren(scopedChildren, (child) => child.weekAvg);
|
scopedChildren,
|
||||||
const hasScopedChildren = scopedChildren.length > 0;
|
(child) => child.todayStaffRecord?.attendance_percentage ?? null,
|
||||||
const studentTodayPct = scopedStudentTodayPct ?? overallStats.todayPct;
|
);
|
||||||
const studentWeekPct = scopedStudentWeekPct ?? overallStats.weekPct;
|
const staffTodayPct = scopedStaffTodayPct ?? percentageFromCounts(staffPresent, staffTotal);
|
||||||
const studentTotal = hasScopedChildren ? scopedChildren.length : overallStats.todayEnrolled;
|
|
||||||
const studentPresent = hasScopedChildren
|
|
||||||
? scopedChildren.reduce((sum, child) => sum + ((child.todayPct ?? 0) / 100), 0)
|
|
||||||
: overallStats.todayPresent;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
studentTodayPct,
|
staffTodayPct,
|
||||||
studentWeekPct,
|
combinedTodayPct: staffTodayPct,
|
||||||
staffTodayPct: percentageFromCounts(staffPresent, staffTotal),
|
|
||||||
combinedTodayPct: percentageFromCounts(studentPresent + staffPresent, studentTotal + staffTotal),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -382,52 +265,21 @@ function notesFromParts(...parts: readonly (string | null | undefined)[]): strin
|
|||||||
return notes.length > 0 ? notes.join('; ') : null;
|
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(
|
export function buildCombinedAttendanceHistoryRows(
|
||||||
studentRecords: readonly CampusAttendanceSummaryViewModel[],
|
|
||||||
staffRecords: readonly StaffAttendanceDailySummaryViewModel[],
|
staffRecords: readonly StaffAttendanceDailySummaryViewModel[],
|
||||||
): readonly CombinedAttendanceHistoryRow[] {
|
): readonly CombinedAttendanceHistoryRow[] {
|
||||||
const dates = Array.from(new Set([
|
const dates = Array.from(new Set([
|
||||||
...studentRecords.map((record) => record.date),
|
|
||||||
...staffRecords.map((record) => record.date),
|
...staffRecords.map((record) => record.date),
|
||||||
])).sort((left, right) => right.localeCompare(left));
|
])).sort((left, right) => right.localeCompare(left));
|
||||||
|
|
||||||
return dates.flatMap((date) => {
|
return dates.flatMap((date) => {
|
||||||
const studentRecord = studentRecords.find((record) => record.date === date) ?? null;
|
|
||||||
const staffRecord = staffRecords.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 staffTotal = staffRecord?.total_staff ?? 0;
|
||||||
const studentPresent = studentRecord?.total_present ?? 0;
|
|
||||||
const staffPresent = staffRecord?.total_present ?? 0;
|
const staffPresent = staffRecord?.total_present ?? 0;
|
||||||
const studentAbsent = studentRecord?.total_absent ?? 0;
|
|
||||||
const staffAbsent = staffRecord?.total_absent ?? 0;
|
const staffAbsent = staffRecord?.total_absent ?? 0;
|
||||||
const studentLate = studentRecord?.total_tardy ?? 0;
|
|
||||||
const staffLate = staffRecord?.total_late ?? 0;
|
const staffLate = staffRecord?.total_late ?? 0;
|
||||||
|
|
||||||
return [
|
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`,
|
id: `${date}:staff`,
|
||||||
date,
|
date,
|
||||||
@ -445,21 +297,18 @@ export function buildCombinedAttendanceHistoryRows(
|
|||||||
date,
|
date,
|
||||||
group: 'total',
|
group: 'total',
|
||||||
label: 'Total',
|
label: 'Total',
|
||||||
total: studentTotal + staffTotal,
|
total: staffTotal,
|
||||||
present: studentPresent + staffPresent,
|
present: staffPresent,
|
||||||
absent: studentAbsent + staffAbsent,
|
absent: staffAbsent,
|
||||||
late: studentLate + staffLate,
|
late: staffLate,
|
||||||
attendancePercentage: combinedAttendancePercentage(studentRecord, staffRecord),
|
attendancePercentage: staffRecord?.attendance_percentage ?? null,
|
||||||
notes: notesFromParts(studentRecord?.notes, staffRecord?.notes),
|
notes: notesFromParts(staffRecord?.notes),
|
||||||
},
|
},
|
||||||
] satisfies readonly CombinedAttendanceHistoryRow[];
|
] satisfies readonly CombinedAttendanceHistoryRow[];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildPrintAttendanceStats(input: CampusAttendancePrintInput) {
|
export function buildPrintAttendanceStats(input: CampusAttendancePrintInput) {
|
||||||
const printEnrolled = input.printTodayRecords.reduce((sum, record) => sum + record.total_enrolled, 0);
|
|
||||||
const 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
|
const staffTotal = input.staffSummary
|
||||||
? Math.max(input.staffSummary.staffCount, input.staffSummary.present + input.staffSummary.late + input.staffSummary.absent)
|
? Math.max(input.staffSummary.staffCount, input.staffSummary.present + input.staffSummary.late + input.staffSummary.absent)
|
||||||
: 0;
|
: 0;
|
||||||
@ -467,21 +316,9 @@ export function buildPrintAttendanceStats(input: CampusAttendancePrintInput) {
|
|||||||
? input.staffSummary.present + input.staffSummary.late
|
? input.staffSummary.present + input.staffSummary.late
|
||||||
: 0;
|
: 0;
|
||||||
const staffPct = percentageFromCounts(staffPresent, staffTotal);
|
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 {
|
return {
|
||||||
todayPct,
|
|
||||||
staffPct,
|
staffPct,
|
||||||
combinedPct,
|
staffTotal,
|
||||||
weekPct,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,35 +14,10 @@ export interface CampusAttendanceConfigViewModel {
|
|||||||
readonly updated_at: string;
|
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 {
|
export interface CampusAttendanceStats extends CampusInfo {
|
||||||
readonly todayPct: number | null;
|
readonly todayPct: number | null;
|
||||||
readonly weekAvg: number | null;
|
readonly weekAvg: number | null;
|
||||||
readonly config: CampusAttendanceConfigViewModel | null;
|
readonly config: CampusAttendanceConfigViewModel | null;
|
||||||
readonly recentData: readonly CampusAttendanceSummaryViewModel[];
|
|
||||||
readonly todayRecord: CampusAttendanceSummaryViewModel | null;
|
|
||||||
readonly recentStaffData: readonly StaffAttendanceDailySummaryViewModel[];
|
readonly recentStaffData: readonly StaffAttendanceDailySummaryViewModel[];
|
||||||
readonly todayStaffRecord: StaffAttendanceDailySummaryViewModel | null;
|
readonly todayStaffRecord: StaffAttendanceDailySummaryViewModel | null;
|
||||||
}
|
}
|
||||||
@ -56,32 +31,11 @@ export interface CampusAttendanceChildStats {
|
|||||||
readonly isOnline?: boolean;
|
readonly isOnline?: boolean;
|
||||||
readonly todayPct: number | null;
|
readonly todayPct: number | null;
|
||||||
readonly weekAvg: number | null;
|
readonly weekAvg: number | null;
|
||||||
readonly recentData: readonly CampusAttendanceSummaryViewModel[];
|
|
||||||
readonly todayRecord: CampusAttendanceSummaryViewModel | null;
|
|
||||||
readonly childCampusIds: readonly CampusId[];
|
readonly childCampusIds: readonly CampusId[];
|
||||||
readonly recentStaffData: readonly StaffAttendanceDailySummaryViewModel[];
|
readonly recentStaffData: readonly StaffAttendanceDailySummaryViewModel[];
|
||||||
readonly todayStaffRecord: StaffAttendanceDailySummaryViewModel | null;
|
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 {
|
export interface StaffAttendanceEntryDraft {
|
||||||
readonly date: string;
|
readonly date: string;
|
||||||
readonly userId: string;
|
readonly userId: string;
|
||||||
@ -131,8 +85,6 @@ export interface CampusAttendanceScopeModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CampusAttendanceCombinedStats {
|
export interface CampusAttendanceCombinedStats {
|
||||||
readonly studentTodayPct: number | null;
|
|
||||||
readonly studentWeekPct: number | null;
|
|
||||||
readonly staffTodayPct: number | null;
|
readonly staffTodayPct: number | null;
|
||||||
readonly combinedTodayPct: number | null;
|
readonly combinedTodayPct: number | null;
|
||||||
}
|
}
|
||||||
@ -149,7 +101,7 @@ export interface StaffAttendanceDailySummaryViewModel {
|
|||||||
readonly notes: string | null;
|
readonly notes: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CombinedAttendanceGroup = 'students' | 'staff' | 'total';
|
export type CombinedAttendanceGroup = 'staff' | 'total';
|
||||||
|
|
||||||
export interface CombinedAttendanceHistoryRow {
|
export interface CombinedAttendanceHistoryRow {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
@ -175,9 +127,6 @@ export interface CampusAttendancePrintInput {
|
|||||||
readonly generatedByName: string;
|
readonly generatedByName: string;
|
||||||
readonly generatedByRole: string;
|
readonly generatedByRole: string;
|
||||||
readonly today: string;
|
readonly today: string;
|
||||||
readonly weekStart: string;
|
|
||||||
readonly campusesToPrint: readonly CampusAttendanceStats[];
|
readonly campusesToPrint: readonly CampusAttendanceStats[];
|
||||||
readonly printTodayRecords: readonly CampusAttendanceSummaryViewModel[];
|
|
||||||
readonly printWeekRecords: readonly CampusAttendanceSummaryViewModel[];
|
|
||||||
readonly staffSummary: StaffAttendanceSummaryViewModel | null;
|
readonly staffSummary: StaffAttendanceSummaryViewModel | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import { usePersonalityCompletion } from '@/business/personality/queryHooks';
|
|||||||
import { useZoneCheckInCompletion } from '@/business/zone-checkin/hooks';
|
import { useZoneCheckInCompletion } from '@/business/zone-checkin/hooks';
|
||||||
import {
|
import {
|
||||||
useStaffAttendanceRecords,
|
useStaffAttendanceRecords,
|
||||||
useStaffAttendanceSummary,
|
|
||||||
} from '@/business/staff-attendance/hooks';
|
} from '@/business/staff-attendance/hooks';
|
||||||
import { usePolicyAcknowledgmentReport } from '@/business/policies/hooks';
|
import { usePolicyAcknowledgmentReport } from '@/business/policies/hooks';
|
||||||
import {
|
import {
|
||||||
@ -80,13 +79,14 @@ export function useDirectorDashboardPage(): DirectorDashboardPage {
|
|||||||
const scopeKey = activeTenant ? `${activeTenant.level}:${activeTenant.id}` : null;
|
const scopeKey = activeTenant ? `${activeTenant.level}:${activeTenant.id}` : null;
|
||||||
const [timeRange, setTimeRangeState] = useState<DirectorDashboardTimeRange>(readStoredTimeRange);
|
const [timeRange, setTimeRangeState] = useState<DirectorDashboardTimeRange>(readStoredTimeRange);
|
||||||
const periodFilter = getDirectorDashboardDateRange(timeRange);
|
const periodFilter = getDirectorDashboardDateRange(timeRange);
|
||||||
|
const today = format(new Date(), 'yyyy-MM-dd');
|
||||||
|
const todayFilter = { startDate: today, endDate: today };
|
||||||
const safetyQuizWeek = getCurrentSafetyQuizWeek(new Date());
|
const safetyQuizWeek = getCurrentSafetyQuizWeek(new Date());
|
||||||
const frameEntriesQuery = useFrameEntries(periodFilter);
|
const frameEntriesQuery = useFrameEntries(periodFilter);
|
||||||
const quizCompletionQuery = useSafetyQuizCompliance(safetyQuizWeek, true);
|
const quizCompletionQuery = useSafetyQuizCompliance(safetyQuizWeek, true);
|
||||||
const emotionalIntelligenceCompletionQuery = usePersonalityCompletion(true);
|
const emotionalIntelligenceCompletionQuery = usePersonalityCompletion(true);
|
||||||
const zoneCheckinCompletionQuery = useZoneCheckInCompletion(true, scopeKey);
|
const zoneCheckinCompletionQuery = useZoneCheckInCompletion(true, scopeKey);
|
||||||
const staffAttendanceRecordsQuery = useStaffAttendanceRecords(periodFilter);
|
const staffAttendanceRecordsQuery = useStaffAttendanceRecords(todayFilter);
|
||||||
const staffAttendanceSummaryQuery = useStaffAttendanceSummary(periodFilter);
|
|
||||||
const acknowledgmentReportQuery = usePolicyAcknowledgmentReport();
|
const acknowledgmentReportQuery = usePolicyAcknowledgmentReport();
|
||||||
const frameEntries = frameEntriesQuery.data ?? [];
|
const frameEntries = frameEntriesQuery.data ?? [];
|
||||||
const quizRows = quizCompletionQuery.data?.rows ?? [];
|
const quizRows = quizCompletionQuery.data?.rows ?? [];
|
||||||
@ -105,14 +105,12 @@ export function useDirectorDashboardPage(): DirectorDashboardPage {
|
|||||||
|| emotionalIntelligenceCompletionQuery.isLoading
|
|| emotionalIntelligenceCompletionQuery.isLoading
|
||||||
|| zoneCheckinCompletionQuery.isLoading
|
|| zoneCheckinCompletionQuery.isLoading
|
||||||
|| staffAttendanceRecordsQuery.isLoading
|
|| staffAttendanceRecordsQuery.isLoading
|
||||||
|| staffAttendanceSummaryQuery.isLoading
|
|
||||||
|| acknowledgmentReportQuery.isLoading;
|
|| acknowledgmentReportQuery.isLoading;
|
||||||
const error = frameEntriesQuery.error
|
const error = frameEntriesQuery.error
|
||||||
?? quizCompletionQuery.error
|
?? quizCompletionQuery.error
|
||||||
?? emotionalIntelligenceCompletionQuery.error
|
?? emotionalIntelligenceCompletionQuery.error
|
||||||
?? zoneCheckinCompletionQuery.error
|
?? zoneCheckinCompletionQuery.error
|
||||||
?? staffAttendanceRecordsQuery.error
|
?? staffAttendanceRecordsQuery.error
|
||||||
?? staffAttendanceSummaryQuery.error
|
|
||||||
?? acknowledgmentReportQuery.error;
|
?? acknowledgmentReportQuery.error;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -323,7 +323,7 @@ describe('director dashboard selectors', () => {
|
|||||||
action: 'openAcknowledgments',
|
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',
|
severity: 'high',
|
||||||
module: 'attendance',
|
module: 'attendance',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -52,7 +52,7 @@ export function buildDirectorOverviewCards(
|
|||||||
{
|
{
|
||||||
label: 'Staff Attendance',
|
label: 'Staff Attendance',
|
||||||
value: `${attendanceRate}%`,
|
value: `${attendanceRate}%`,
|
||||||
change: `${attendanceRecords.length} records`,
|
change: `${attendanceRecords.length} today`,
|
||||||
trend: 'up',
|
trend: 'up',
|
||||||
iconId: 'clock',
|
iconId: 'clock',
|
||||||
tone: 'orange',
|
tone: 'orange',
|
||||||
@ -179,7 +179,7 @@ export function buildDirectorRiskAreas(
|
|||||||
|
|
||||||
if (staffAttendanceExceptionCount > 0) {
|
if (staffAttendanceExceptionCount > 0) {
|
||||||
risks.push({
|
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',
|
severity: 'high',
|
||||||
module: 'attendance',
|
module: 'attendance',
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
export { getClass } from '@/shared/api/classes';
|
export { getClass } from '@/shared/api/classes';
|
||||||
export {
|
|
||||||
getClassAttendanceSummary,
|
|
||||||
upsertClassAttendance,
|
|
||||||
} from '@/shared/api/classAttendance';
|
|
||||||
export { listGuardianStudents } from '@/shared/api/guardianStudents';
|
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';
|
||||||
|
|||||||
90
frontend/src/business/my-class/selectors.test.ts
Normal file
90
frontend/src/business/my-class/selectors.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
75
frontend/src/business/my-class/selectors.ts
Normal file
75
frontend/src/business/my-class/selectors.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -22,8 +22,8 @@ describe('staff attendance selectors', () => {
|
|||||||
expect(countStaffAttendanceStatus(records, 'absent')).toBe(3);
|
expect(countStaffAttendanceStatus(records, 'absent')).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calculates present attendance rate from the whole record set', () => {
|
it('calculates present-or-late attendance rate from the whole record set', () => {
|
||||||
expect(staffAttendanceRate(records)).toBe(20);
|
expect(staffAttendanceRate(records)).toBe(40);
|
||||||
expect(staffAttendanceRate([])).toBe(0);
|
expect(staffAttendanceRate([])).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,8 @@ export function staffAttendanceRate(records: readonly StaffAttendanceRecordViewM
|
|||||||
return 0;
|
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(
|
export function recentStaffAttendanceRecords(
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { listCampusAttendanceSummaries } from '@/shared/api/campusAttendance';
|
|
||||||
import { listFrameEntries } from '@/shared/api/frame';
|
import { listFrameEntries } from '@/shared/api/frame';
|
||||||
|
import { getStaffAttendanceSummary } from '@/shared/api/staffAttendance';
|
||||||
import {
|
import {
|
||||||
buildTopBarNotifications,
|
buildTopBarNotifications,
|
||||||
|
canQueryDailyStaffAttendanceForScope,
|
||||||
countUnreadTopBarNotifications,
|
countUnreadTopBarNotifications,
|
||||||
getTopBarCampusLabel,
|
getTopBarCampusLabel,
|
||||||
getTopBarInitials,
|
getTopBarInitials,
|
||||||
getTopBarRoleLabel,
|
getTopBarRoleLabel,
|
||||||
|
shouldShowDailyStaffAttendanceNotification,
|
||||||
} from '@/business/top-bar/selectors';
|
} from '@/business/top-bar/selectors';
|
||||||
import {
|
import {
|
||||||
buildTopBarSearchResults,
|
buildTopBarSearchResults,
|
||||||
@ -33,7 +35,7 @@ import { useCurrentPersonalityResult } from '@/business/personality/queryHooks';
|
|||||||
import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks';
|
import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks';
|
||||||
import { canPersistPersonalScopeResults } from '@/business/scope/selectors';
|
import { canPersistPersonalScopeResults } from '@/business/scope/selectors';
|
||||||
import { useSafetyProtocols } from '@/business/safety-protocols/hooks';
|
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 { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog';
|
||||||
import { MODULES } from '@/shared/constants/appData';
|
import { MODULES } from '@/shared/constants/appData';
|
||||||
import { PERSONALITY_QUIZ_KINDS } from '@/shared/constants/personality';
|
import { PERSONALITY_QUIZ_KINDS } from '@/shared/constants/personality';
|
||||||
@ -129,27 +131,15 @@ export function useTopBarPage({
|
|||||||
return response.rows;
|
return response.rows;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const canManageDailyAttendance = hasPermission(user, 'FILL_ATTENDANCE')
|
const canMonitorDailyAttendance = hasAnyPermission(user, ['FILL_ATTENDANCE', 'READ_STAFF_ATTENDANCE_REPORTS'])
|
||||||
&& accessibleModuleIds.has('attendance');
|
&& accessibleModuleIds.has('attendance');
|
||||||
const attendanceCampusKey =
|
|
||||||
effectiveTier === 'campus' || effectiveTier === 'class'
|
|
||||||
? campusInfo?.id
|
|
||||||
: undefined;
|
|
||||||
const attendanceContentQuery = useQuery({
|
const attendanceContentQuery = useQuery({
|
||||||
queryKey: ['top-bar-daily-attendance-content', today, attendanceCampusKey ?? null, effectiveTier],
|
queryKey: ['top-bar-daily-staff-attendance-content', today, effectiveTier, selectedTenant?.id ?? null, campusInfo?.id ?? null],
|
||||||
enabled: canManageDailyAttendance && Boolean(today) && (
|
enabled: canMonitorDailyAttendance && Boolean(today) && canQueryDailyStaffAttendanceForScope(effectiveTier),
|
||||||
effectiveTier === 'organization'
|
queryFn: () => getStaffAttendanceSummary({
|
||||||
|| effectiveTier === 'school'
|
startDate: today,
|
||||||
|| Boolean(attendanceCampusKey)
|
endDate: today,
|
||||||
),
|
}),
|
||||||
queryFn: async () => {
|
|
||||||
const response = await listCampusAttendanceSummaries({
|
|
||||||
...(attendanceCampusKey ? { campusKey: attendanceCampusKey } : {}),
|
|
||||||
startDate: today,
|
|
||||||
endDate: today,
|
|
||||||
});
|
|
||||||
return response.rows;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const canReceiveEmotionalIntelligenceNotifications = canPersistPersonalResults
|
const canReceiveEmotionalIntelligenceNotifications = canPersistPersonalResults
|
||||||
&& hasPermission(user, 'TAKE_QUIZ')
|
&& hasPermission(user, 'TAKE_QUIZ')
|
||||||
@ -242,10 +232,14 @@ export function useTopBarPage({
|
|||||||
&& !frameContentQuery.isLoading
|
&& !frameContentQuery.isLoading
|
||||||
&& !frameContentQuery.error
|
&& !frameContentQuery.error
|
||||||
&& (frameContentQuery.data?.length ?? 0) === 0;
|
&& (frameContentQuery.data?.length ?? 0) === 0;
|
||||||
const needsDailyAttendanceContent = canManageDailyAttendance
|
const needsDailyAttendanceContent = shouldShowDailyStaffAttendanceNotification({
|
||||||
&& !attendanceContentQuery.isLoading
|
canMonitorDailyAttendance,
|
||||||
&& !attendanceContentQuery.error
|
tier: effectiveTier,
|
||||||
&& (attendanceContentQuery.data?.length ?? 0) === 0;
|
loading: attendanceContentQuery.isLoading,
|
||||||
|
hasError: Boolean(attendanceContentQuery.error),
|
||||||
|
staffCount: attendanceContentQuery.data?.staffCount ?? 0,
|
||||||
|
recordsCount: attendanceContentQuery.data?.recordsCount ?? 0,
|
||||||
|
});
|
||||||
const notifications = buildTopBarNotifications({
|
const notifications = buildTopBarNotifications({
|
||||||
needsZoneCheckIn,
|
needsZoneCheckIn,
|
||||||
needsSafetyQuiz,
|
needsSafetyQuiz,
|
||||||
|
|||||||
@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildTopBarNotifications,
|
buildTopBarNotifications,
|
||||||
|
canQueryDailyStaffAttendanceForScope,
|
||||||
countUnreadTopBarNotifications,
|
countUnreadTopBarNotifications,
|
||||||
getTopBarCampusLabel,
|
getTopBarCampusLabel,
|
||||||
getTopBarInitials,
|
getTopBarInitials,
|
||||||
getTopBarRoleLabel,
|
getTopBarRoleLabel,
|
||||||
|
shouldShowDailyStaffAttendanceNotification,
|
||||||
} from '@/business/top-bar/selectors';
|
} from '@/business/top-bar/selectors';
|
||||||
import { APP_ROUTE_PATHS } from '@/shared/constants/routes';
|
import { APP_ROUTE_PATHS } from '@/shared/constants/routes';
|
||||||
import type { CommunicationEventDto } from '@/shared/types/communications';
|
import type { CommunicationEventDto } from '@/shared/types/communications';
|
||||||
@ -119,7 +121,7 @@ describe('top bar selectors', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'daily-attendance-content',
|
id: 'daily-attendance-content',
|
||||||
text: "Submit today's attendance",
|
text: "Today's staff attendance is incomplete",
|
||||||
time: 'Today',
|
time: 'Today',
|
||||||
unread: true,
|
unread: true,
|
||||||
href: APP_ROUTE_PATHS.attendance,
|
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', () => {
|
it('surfaces EI self-assessment and personality quiz completion reminders', () => {
|
||||||
const reminders = buildTopBarNotifications({
|
const reminders = buildTopBarNotifications({
|
||||||
needsZoneCheckIn: false,
|
needsZoneCheckIn: false,
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type {
|
|||||||
CampusInfo,
|
CampusInfo,
|
||||||
UserRole,
|
UserRole,
|
||||||
} from '@/shared/types/app';
|
} from '@/shared/types/app';
|
||||||
|
import type { ScopeTier } from '@/business/scope/selectors';
|
||||||
import type { TopBarNotification } from '@/business/top-bar/types';
|
import type { TopBarNotification } from '@/business/top-bar/types';
|
||||||
import type { CommunicationEventDto } from '@/shared/types/communications';
|
import type { CommunicationEventDto } from '@/shared/types/communications';
|
||||||
import type { PolicyViewModel } from '@/business/policies/types';
|
import type { PolicyViewModel } from '@/business/policies/types';
|
||||||
@ -38,6 +39,26 @@ export function countUnreadTopBarNotifications(
|
|||||||
return notifications.filter((notification) => notification.unread).length;
|
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 ZONE_CHECKIN_NOTIFICATION_ID = 'zone-checkin-today';
|
||||||
const SAFETY_QUIZ_NOTIFICATION_ID = 'safety-quiz-weekly';
|
const SAFETY_QUIZ_NOTIFICATION_ID = 'safety-quiz-weekly';
|
||||||
const SIGN_OF_WEEK_NOTIFICATION_ID = 'sign-of-week';
|
const SIGN_OF_WEEK_NOTIFICATION_ID = 'sign-of-week';
|
||||||
@ -122,7 +143,7 @@ export function buildTopBarNotifications(input: {
|
|||||||
if (input.needsDailyAttendanceContent) {
|
if (input.needsDailyAttendanceContent) {
|
||||||
notifications.push({
|
notifications.push({
|
||||||
id: DAILY_ATTENDANCE_NOTIFICATION_ID,
|
id: DAILY_ATTENDANCE_NOTIFICATION_ID,
|
||||||
text: "Submit today's attendance",
|
text: "Today's staff attendance is incomplete",
|
||||||
time: 'Today',
|
time: 'Today',
|
||||||
unread: true,
|
unread: true,
|
||||||
href: APP_ROUTE_PATHS.attendance,
|
href: APP_ROUTE_PATHS.attendance,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
export { fileDownloadUrl } from '@/shared/api/files';
|
export { fileDownloadUrl } from '@/shared/api/files';
|
||||||
|
export { listGuardianStudents } from '@/shared/api/guardianStudents';
|
||||||
export { listPermissions } from '@/shared/api/permissions';
|
export { listPermissions } from '@/shared/api/permissions';
|
||||||
export { listRoles, type RoleRow } from '@/shared/api/roles';
|
export { listRoles, type RoleRow } from '@/shared/api/roles';
|
||||||
export {
|
export {
|
||||||
|
|||||||
@ -1,28 +1,18 @@
|
|||||||
import { Calendar, Loader2, Save, X } from 'lucide-react';
|
import { Calendar, Save, X } from 'lucide-react';
|
||||||
import { generatePath, Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { getDraftAttendancePercentage } from '@/business/campus-attendance/selectors';
|
|
||||||
import type {
|
import type {
|
||||||
CampusAttendancePageActions,
|
CampusAttendancePageActions,
|
||||||
CampusAttendancePageState,
|
CampusAttendancePageState,
|
||||||
} from '@/components/campus-attendance/types';
|
} from '@/components/campus-attendance/types';
|
||||||
import type { StaffAttendanceStatus } from '@/shared/types/staffAttendance';
|
import type { StaffAttendanceStatus } from '@/shared/types/staffAttendance';
|
||||||
import { percentageTextClass } from '@/components/campus-attendance/styles';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
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 = {
|
type CampusAttendanceEntryFormProps = {
|
||||||
state: CampusAttendancePageState;
|
state: CampusAttendancePageState;
|
||||||
actions: CampusAttendancePageActions;
|
actions: CampusAttendancePageActions;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AttendanceRouteState = {
|
|
||||||
readonly __scope: ActiveTenant | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const STAFF_ATTENDANCE_STATUS_OPTIONS: readonly {
|
const STAFF_ATTENDANCE_STATUS_OPTIONS: readonly {
|
||||||
value: StaffAttendanceStatus;
|
value: StaffAttendanceStatus;
|
||||||
label: string;
|
label: string;
|
||||||
@ -44,28 +34,14 @@ function formatRoleName(role: string | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CampusAttendanceEntryForm({ state, actions }: CampusAttendanceEntryFormProps) {
|
export function CampusAttendanceEntryForm({ state, actions }: CampusAttendanceEntryFormProps) {
|
||||||
const { selectedTenant } = useScopeContext();
|
|
||||||
const {
|
const {
|
||||||
campusInfo,
|
|
||||||
entryDraft,
|
|
||||||
entryError,
|
|
||||||
saving,
|
saving,
|
||||||
scopeModel,
|
scopeModel,
|
||||||
staffEntryDraft,
|
staffEntryDraft,
|
||||||
staffEntryError,
|
staffEntryError,
|
||||||
officeStaffUsers,
|
officeStaffUsers,
|
||||||
staffAttendanceStatuses,
|
staffAttendanceStatuses,
|
||||||
studentRollupRows,
|
|
||||||
attendanceStudents,
|
|
||||||
studentAttendanceStatuses,
|
|
||||||
attendanceStudentsLoading,
|
|
||||||
} = state;
|
} = 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'
|
const staffAttendanceLabel = scopeModel.tier === 'class'
|
||||||
? 'Classroom Staff Attendance'
|
? 'Classroom Staff Attendance'
|
||||||
: scopeModel.mode === 'campus'
|
: scopeModel.mode === 'campus'
|
||||||
@ -76,21 +52,13 @@ export function CampusAttendanceEntryForm({ state, actions }: CampusAttendanceEn
|
|||||||
: scopeModel.mode === 'campus'
|
: scopeModel.mode === 'campus'
|
||||||
? 'Record attendance for campus staff.'
|
? 'Record attendance for campus staff.'
|
||||||
: `Record attendance for ${scopeModel.reportLabel.toLowerCase()} office 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 (
|
return (
|
||||||
<div className="bg-slate-800/60 backdrop-blur-sm rounded-2xl border border-orange-500/20 p-6">
|
<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">
|
<div className="flex items-center justify-between mb-5">
|
||||||
<h3 className="font-semibold text-white flex items-center gap-2">
|
<h3 className="font-semibold text-white flex items-center gap-2">
|
||||||
<Calendar size={18} className="text-orange-400" />
|
<Calendar size={18} className="text-orange-400" />
|
||||||
Enter Attendance for {entryTargetLabel}
|
Enter {staffAttendanceLabel}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -101,62 +69,7 @@ export function CampusAttendanceEntryForm({ state, actions }: CampusAttendanceEn
|
|||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{showStudentRollup && (
|
<div className="mt-2">
|
||||||
<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="mb-4">
|
<div className="mb-4">
|
||||||
<h4 className="text-sm font-semibold text-slate-200">{staffAttendanceLabel}</h4>
|
<h4 className="text-sm font-semibold text-slate-200">{staffAttendanceLabel}</h4>
|
||||||
<p className="mt-1 text-xs text-slate-400">
|
<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">
|
<div className="mt-6 flex justify-end border-t border-slate-700/70 pt-6">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={actions.handleSubmitAttendanceForm}
|
onClick={actions.handleSubmitStaffBatch}
|
||||||
disabled={saving || studentAttendanceSaveDisabled}
|
disabled={saving || officeStaffUsers.length === 0}
|
||||||
loading={saving}
|
loading={saving}
|
||||||
loadingLabel="Saving..."
|
loadingLabel="Saving..."
|
||||||
leadingIcon={<Save size={14} />}
|
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 = {
|
type AttendanceRosterTableProps = {
|
||||||
users: readonly {
|
users: readonly {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@ -29,43 +29,29 @@ export function IndividualCampusAttendanceView({ state, actions }: IndividualCam
|
|||||||
today,
|
today,
|
||||||
weekStart,
|
weekStart,
|
||||||
weekEnd,
|
weekEnd,
|
||||||
myWeekAvg,
|
|
||||||
myCampusData,
|
|
||||||
myStaffData,
|
myStaffData,
|
||||||
myCampusConfig,
|
myCampusConfig,
|
||||||
roleAccess,
|
roleAccess,
|
||||||
campusInfo,
|
campusInfo,
|
||||||
scopeModel,
|
scopeModel,
|
||||||
} = state;
|
} = state;
|
||||||
const todayRecord = myCampusData.find((record) => record.date === today);
|
|
||||||
const todayStaffRecord = myStaffData.find((record) => record.date === today);
|
const todayStaffRecord = myStaffData.find((record) => record.date === today);
|
||||||
const historyTitle = campusInfo?.fullName || scopeModel.tenantName || 'Current Campus';
|
const historyTitle = campusInfo?.fullName || scopeModel.tenantName || 'Current Campus';
|
||||||
const combinedHistoryRows = buildCombinedAttendanceHistoryRows(myCampusData, myStaffData);
|
const combinedHistoryRows = buildCombinedAttendanceHistoryRows(myStaffData);
|
||||||
const todayStudentTotal = todayRecord?.total_enrolled ?? 0;
|
|
||||||
const todayStaffTotal = todayStaffRecord?.total_staff ?? 0;
|
const todayStaffTotal = todayStaffRecord?.total_staff ?? 0;
|
||||||
const todayStudentPresent = todayRecord?.total_present ?? 0;
|
|
||||||
const todayStaffPresent = todayStaffRecord?.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 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);
|
.filter((date) => date >= weekStart && date <= weekEnd);
|
||||||
const combinedWeekPct = weekDates.length > 0
|
const staffWeekPct = weekDates.length > 0
|
||||||
? Number((weekDates.reduce((sum, date) => {
|
? Number((weekDates.reduce((sum, date) => {
|
||||||
const studentRecord = myCampusData.find((record) => record.date === date) ?? null;
|
|
||||||
const staffRecord = myStaffData.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 total = staffRecord?.total_staff ?? 0;
|
||||||
const present = (studentRecord?.total_present ?? 0) + (staffRecord?.total_present ?? 0);
|
const present = staffRecord?.total_present ?? 0;
|
||||||
return sum + (total > 0 ? (present / total) * 100 : 0);
|
return sum + (total > 0 ? (present / total) * 100 : 0);
|
||||||
}, 0) / weekDates.length).toFixed(2))
|
}, 0) / weekDates.length).toFixed(2))
|
||||||
: null;
|
: 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 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 weekStaffRecords = myStaffData.filter((record) => record.date >= weekStart && record.date <= weekEnd);
|
||||||
const weekStaffAvg = weekStaffRecords.length > 0
|
const weekStaffAvg = weekStaffRecords.length > 0
|
||||||
? Number((weekStaffRecords.reduce((sum, record) => sum + record.attendance_percentage, 0) / weekStaffRecords.length).toFixed(2))
|
? 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">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<AttendanceSummaryCard
|
<AttendanceSummaryCard
|
||||||
label="Today's Attendance"
|
label="Today's Staff Attendance"
|
||||||
value={combinedTodayPct !== null ? `${combinedTodayPct}%` : 'N/A'}
|
value={todayStaffRecord ? `${todayStaffRecord.attendance_percentage}%` : 'N/A'}
|
||||||
helper={`${studentTodayLabel} · ${staffTodayLabel}`}
|
helper={staffTodayLabel}
|
||||||
icon={Calendar}
|
icon={Calendar}
|
||||||
percentage={combinedTodayPct}
|
percentage={todayStaffRecord?.attendance_percentage ?? null}
|
||||||
/>
|
/>
|
||||||
<AttendanceSummaryCard
|
<AttendanceSummaryCard
|
||||||
label="Weekly Average"
|
label="Weekly Average"
|
||||||
value={combinedWeekPct !== null ? `${combinedWeekPct}%` : 'N/A'}
|
value={staffWeekPct !== null ? `${staffWeekPct}%` : 'N/A'}
|
||||||
helper={`${weekStudentLabel} · ${weekStaffLabel}`}
|
helper={weekStaffLabel}
|
||||||
icon={BarChart3}
|
icon={BarChart3}
|
||||||
percentage={combinedWeekPct}
|
percentage={staffWeekPct}
|
||||||
/>
|
/>
|
||||||
<AttendanceSummaryCard
|
<AttendanceSummaryCard
|
||||||
label="People"
|
label="Staff"
|
||||||
value={combinedTodayTotal || 'N/A'}
|
value={todayStaffTotal || 'N/A'}
|
||||||
helper={`${todayStudentTotal} students · ${todayStaffTotal} staff`}
|
helper={`${todayStaffPresent} present today`}
|
||||||
icon={Users}
|
icon={Users}
|
||||||
color="blue"
|
color="blue"
|
||||||
/>
|
/>
|
||||||
@ -143,7 +129,7 @@ export function IndividualCampusAttendanceView({ state, actions }: IndividualCam
|
|||||||
.filter((record) => record.date === today || record.group === 'total')
|
.filter((record) => record.date === today || record.group === 'total')
|
||||||
.slice(0, 45)
|
.slice(0, 45)
|
||||||
.map((record) => {
|
.map((record) => {
|
||||||
const shouldShowDate = record.date !== today || record.group === 'students';
|
const shouldShowDate = record.date !== today || record.group === 'staff';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
|
|||||||
@ -34,8 +34,6 @@ export function SuperintendentAttendanceView({ state, actions }: SuperintendentA
|
|||||||
const { selectedTenant } = useScopeContext();
|
const { selectedTenant } = useScopeContext();
|
||||||
const {
|
const {
|
||||||
today,
|
today,
|
||||||
weekStart,
|
|
||||||
overallStats,
|
|
||||||
combinedStats,
|
combinedStats,
|
||||||
staffSummary,
|
staffSummary,
|
||||||
campusStats,
|
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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<AttendanceSummaryCard
|
<AttendanceSummaryCard
|
||||||
label="Combined Attendance"
|
label="Today's Staff Attendance"
|
||||||
value={combinedStats.combinedTodayPct !== null ? `${combinedStats.combinedTodayPct}%` : 'N/A'}
|
value={combinedStats.combinedTodayPct !== null ? `${combinedStats.combinedTodayPct}%` : 'N/A'}
|
||||||
helper={formatAttendanceDate(today)}
|
helper={formatAttendanceDate(today)}
|
||||||
icon={Calendar}
|
icon={Calendar}
|
||||||
percentage={combinedStats.combinedTodayPct}
|
percentage={combinedStats.combinedTodayPct}
|
||||||
/>
|
/>
|
||||||
<AttendanceSummaryCard
|
<AttendanceSummaryCard
|
||||||
label="Student Attendance"
|
label="Present or Late"
|
||||||
value={combinedStats.studentTodayPct !== null ? `${combinedStats.studentTodayPct}%` : 'N/A'}
|
|
||||||
helper={scopeModel.aggregateHelper}
|
|
||||||
icon={Users}
|
|
||||||
percentage={combinedStats.studentTodayPct}
|
|
||||||
/>
|
|
||||||
<AttendanceSummaryCard
|
|
||||||
label="Staff Attendance"
|
|
||||||
value={combinedStats.staffTodayPct !== null ? `${combinedStats.staffTodayPct}%` : 'N/A'}
|
value={combinedStats.staffTodayPct !== null ? `${combinedStats.staffTodayPct}%` : 'N/A'}
|
||||||
helper={staffAttendanceHelper}
|
helper={staffAttendanceHelper}
|
||||||
icon={UserCheck}
|
icon={UserCheck}
|
||||||
percentage={combinedStats.staffTodayPct}
|
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
|
<AttendanceSummaryCard
|
||||||
label="Staff Records Today"
|
label="Staff Records Today"
|
||||||
value={staffSummary.summary?.recordsCount ?? 'N/A'}
|
value={staffSummary.summary?.recordsCount ?? 'N/A'}
|
||||||
helper={scopeModel.aggregateHelper}
|
helper={formatAttendanceDate(today)}
|
||||||
icon={UserCheck}
|
icon={BarChart3}
|
||||||
color="blue"
|
color="blue"
|
||||||
/>
|
/>
|
||||||
<AttendanceSummaryCard
|
<AttendanceSummaryCard
|
||||||
@ -155,19 +122,19 @@ export function SuperintendentAttendanceView({ state, actions }: SuperintendentA
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{child.todayRecord && (
|
{child.todayStaffRecord && (
|
||||||
<div className="grid grid-cols-3 gap-2 text-center">
|
<div className="grid grid-cols-3 gap-2 text-center">
|
||||||
<div className="bg-slate-700/30 rounded-lg p-2">
|
<div className="bg-slate-700/30 rounded-lg p-2">
|
||||||
<p className="text-xs text-slate-400">Enrolled</p>
|
<p className="text-xs text-slate-400">Staff</p>
|
||||||
<p className="text-sm font-bold text-white">{child.todayRecord.total_enrolled}</p>
|
<p className="text-sm font-bold text-white">{child.todayStaffRecord.total_staff}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-slate-700/30 rounded-lg p-2">
|
<div className="bg-slate-700/30 rounded-lg p-2">
|
||||||
<p className="text-xs text-slate-400">Present</p>
|
<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>
|
||||||
<div className="bg-slate-700/30 rounded-lg p-2">
|
<div className="bg-slate-700/30 rounded-lg p-2">
|
||||||
<p className="text-xs text-slate-400">Absent</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -179,13 +146,13 @@ export function SuperintendentAttendanceView({ state, actions }: SuperintendentA
|
|||||||
{expandedCampus === child.id ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
{expandedCampus === child.id ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
{expandedCampus === child.id ? 'Hide History' : 'View History'}
|
{expandedCampus === child.id ? 'Hide History' : 'View History'}
|
||||||
</button>
|
</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">
|
<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">
|
<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>
|
<span className="text-slate-400">{formatAttendanceDate(record.date)}</span>
|
||||||
<div className="flex items-center gap-3">
|
<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)}`}>
|
<span className={`font-bold ${percentageTextClass(record.attendance_percentage)}`}>
|
||||||
{record.attendance_percentage.toFixed(1)}%
|
{record.attendance_percentage.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -16,6 +16,7 @@ interface ImageUploadProps {
|
|||||||
readonly field: string;
|
readonly field: string;
|
||||||
readonly label?: string;
|
readonly label?: string;
|
||||||
readonly shape?: 'square' | 'circle';
|
readonly shape?: 'square' | 'circle';
|
||||||
|
readonly previewSize?: 'sm' | 'lg';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reusable logo/avatar uploader: pick → upload → report the stored URL. */
|
/** Reusable logo/avatar uploader: pick → upload → report the stored URL. */
|
||||||
@ -26,6 +27,7 @@ export function ImageUpload({
|
|||||||
field,
|
field,
|
||||||
label = 'Image',
|
label = 'Image',
|
||||||
shape = 'square',
|
shape = 'square',
|
||||||
|
previewSize = 'sm',
|
||||||
}: ImageUploadProps) {
|
}: ImageUploadProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
@ -48,11 +50,12 @@ export function ImageUpload({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>{label}</Label>
|
<Label>{label}</Label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col items-start gap-2">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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',
|
'flex shrink-0 items-center justify-center overflow-hidden border border-slate-700/50 bg-slate-800/40 text-slate-500',
|
||||||
shape === 'circle' ? 'rounded-full' : 'rounded-xl',
|
previewSize === 'lg' ? 'h-28 w-28' : 'h-16 w-16',
|
||||||
|
shape === 'circle' ? 'rounded-full' : 'rounded-lg',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{uploading ? (
|
{uploading ? (
|
||||||
@ -63,10 +66,10 @@ export function ImageUpload({
|
|||||||
) : value ? (
|
) : value ? (
|
||||||
<img src={fileAssetUrl(value)} alt={label} className="h-full w-full object-cover" />
|
<img src={fileAssetUrl(value)} alt={label} className="h-full w-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<ImagePlus size={20} />
|
<ImagePlus size={previewSize === 'lg' ? 28 : 20} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { fileDownloadUrl } from '@/business/files/api';
|
import { fileDownloadUrl } from '@/business/files/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@ -24,6 +25,9 @@ export function UserAvatar({
|
|||||||
fallbackClassName,
|
fallbackClassName,
|
||||||
imageClassName,
|
imageClassName,
|
||||||
}: UserAvatarProps) {
|
}: UserAvatarProps) {
|
||||||
|
const [failedAvatarUrl, setFailedAvatarUrl] = useState<string | null>(null);
|
||||||
|
const showImage = Boolean(avatarUrl && failedAvatarUrl !== avatarUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -31,10 +35,11 @@ export function UserAvatar({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{avatarUrl ? (
|
{showImage && avatarUrl ? (
|
||||||
<img
|
<img
|
||||||
src={fileDownloadUrl(avatarUrl)}
|
src={fileDownloadUrl(avatarUrl)}
|
||||||
alt=""
|
alt=""
|
||||||
|
onError={() => setFailedAvatarUrl(avatarUrl)}
|
||||||
className={cn('h-full w-full object-cover', imageClassName)}
|
className={cn('h-full w-full object-cover', imageClassName)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -16,48 +16,55 @@ export function EsaFundingImpactRoles({
|
|||||||
schoolImpactEditor,
|
schoolImpactEditor,
|
||||||
staffRoleEditor,
|
staffRoleEditor,
|
||||||
}: EsaFundingImpactRolesProps) {
|
}: 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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
<section className={`grid grid-cols-1 gap-5 ${showSchoolImpact && showStaffRole ? 'lg:grid-cols-2' : ''}`}>
|
||||||
<div className="bg-gradient-to-br from-violet-500/10 to-violet-600/5 rounded-2xl p-6 border border-violet-500/20">
|
{showSchoolImpact && (
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="bg-gradient-to-br from-violet-500/10 to-violet-600/5 rounded-2xl p-6 border border-violet-500/20">
|
||||||
<Shield size={20} className="text-violet-400" />
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<h3 className="font-bold text-white">Why This Matters for Our School</h3>
|
<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>
|
||||||
<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">
|
{showStaffRole && (
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="bg-gradient-to-br from-amber-500/10 to-amber-600/5 rounded-2xl p-6 border border-amber-500/20">
|
||||||
<Info size={20} className="text-amber-400" />
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<h3 className="font-bold text-white">Your Role as Staff</h3>
|
<Info size={20} className="text-amber-400" />
|
||||||
</div>
|
<h3 className="font-bold text-white">Your Role as Staff</h3>
|
||||||
<div className="space-y-3">
|
</div>
|
||||||
{staffRoleItems.map((item, index) => (
|
<div className="space-y-3">
|
||||||
<div key={item.title} className="flex items-start gap-2.5">
|
{staffRoleItems.map((item, index) => (
|
||||||
<div className="w-6 h-6 rounded-full bg-amber-500/20 flex items-center justify-center flex-shrink-0 mt-0.5">
|
<div key={item.title} className="flex items-start gap-2.5">
|
||||||
<span className="text-xs font-bold text-amber-400">{index + 1}</span>
|
<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>
|
||||||
<div>
|
))}
|
||||||
<span className="text-sm font-semibold text-white">{item.title}</span>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-0.5">{item.description}</p>
|
{staffRoleEditor}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
{staffRoleEditor}
|
)}
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,10 +76,13 @@ function ReadOnlyField({ label, value }: { label: string; value: string }) {
|
|||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { user, profile, refreshUser } = useAuth();
|
const { user, profile, refreshUser } = useAuth();
|
||||||
const capabilitiesQuery = useIamCapabilities();
|
const capabilitiesQuery = useIamCapabilities();
|
||||||
const safetyQuizStatus = useMySafetyQuizStatus(undefined, Boolean(user));
|
const isExternalProfileUser =
|
||||||
const personalityHistoryStatus = useCurrentPersonalityResultHistory(Boolean(user));
|
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 canUseZoneCheckin = canZoneCheckIn(user);
|
||||||
const zoneCheckinStatus = useTodayZoneCheckIn({ enabled: canUseZoneCheckin });
|
const zoneCheckinStatus = useTodayZoneCheckIn({ enabled: canShowQuizResults && canUseZoneCheckin });
|
||||||
const canReadAcknowledgedDocuments = hasPermission(user, 'ACK_POLICY');
|
const canReadAcknowledgedDocuments = hasPermission(user, 'ACK_POLICY');
|
||||||
const policyAcknowledgmentsStatus = usePolicyAcknowledgments(canReadAcknowledgedDocuments);
|
const policyAcknowledgmentsStatus = usePolicyAcknowledgments(canReadAcknowledgedDocuments);
|
||||||
const handbookPoliciesStatus = usePolicies(canReadAcknowledgedDocuments);
|
const handbookPoliciesStatus = usePolicies(canReadAcknowledgedDocuments);
|
||||||
@ -273,7 +276,8 @@ export default function ProfilePage() {
|
|||||||
table="users"
|
table="users"
|
||||||
field="avatar"
|
field="avatar"
|
||||||
label="Avatar"
|
label="Avatar"
|
||||||
shape="circle"
|
shape="square"
|
||||||
|
previewSize="lg"
|
||||||
/>
|
/>
|
||||||
<div className="grid content-start gap-3 sm:grid-cols-2">
|
<div className="grid content-start gap-3 sm:grid-cols-2">
|
||||||
<ReadOnlyField label="Role" value={roleLabel} />
|
<ReadOnlyField label="Role" value={roleLabel} />
|
||||||
@ -404,53 +408,55 @@ export default function ProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className={profileCardClassName}>
|
{canShowQuizResults && (
|
||||||
<CardHeader className="border-b border-slate-700/70 px-4 py-4 md:px-5">
|
<Card className={profileCardClassName}>
|
||||||
<CardTitle className="flex items-center gap-2 text-base text-slate-50">
|
<CardHeader className="border-b border-slate-700/70 px-4 py-4 md:px-5">
|
||||||
<ClipboardList size={16} />
|
<CardTitle className="flex items-center gap-2 text-base text-slate-50">
|
||||||
Quiz results
|
<ClipboardList size={16} />
|
||||||
</CardTitle>
|
Quiz results
|
||||||
</CardHeader>
|
</CardTitle>
|
||||||
<CardContent className="px-4 pb-4 md:px-5">
|
</CardHeader>
|
||||||
<div className={`${formPanelClassName} mt-6`}>
|
<CardContent className="px-4 pb-4 md:px-5">
|
||||||
{safetyQuizStatus.isLoading || personalityHistoryStatus.isLoading || zoneCheckinStatus.isLoading ? (
|
<div className={`${formPanelClassName} mt-6`}>
|
||||||
<p className="text-sm text-slate-300">Loading quiz results...</p>
|
{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>
|
<div className="overflow-hidden rounded-lg border border-slate-700/70">
|
||||||
<TableHeader>
|
<Table>
|
||||||
<TableRow className="border-slate-700/70 bg-slate-950/60 hover:bg-slate-950/60">
|
<TableHeader>
|
||||||
<TableHead className="h-auto p-3 text-slate-400">Quiz</TableHead>
|
<TableRow className="border-slate-700/70 bg-slate-950/60 hover:bg-slate-950/60">
|
||||||
<TableHead className="h-auto p-3 text-slate-400">Category</TableHead>
|
<TableHead className="h-auto p-3 text-slate-400">Quiz</TableHead>
|
||||||
<TableHead className="h-auto p-3 text-slate-400">Result</TableHead>
|
<TableHead className="h-auto p-3 text-slate-400">Category</TableHead>
|
||||||
<TableHead className="h-auto p-3 text-slate-400">Completed</TableHead>
|
<TableHead className="h-auto p-3 text-slate-400">Result</TableHead>
|
||||||
</TableRow>
|
<TableHead className="h-auto p-3 text-slate-400">Completed</TableHead>
|
||||||
</TableHeader>
|
</TableRow>
|
||||||
<TableBody>
|
</TableHeader>
|
||||||
{quizResultRows.map((result) => (
|
<TableBody>
|
||||||
<TableRow
|
{quizResultRows.map((result) => (
|
||||||
key={result.id}
|
<TableRow
|
||||||
className="border-slate-800/80 hover:bg-slate-800/20"
|
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 className="p-3">
|
||||||
</TableCell>
|
<p className="font-semibold text-slate-100">{result.quiz}</p>
|
||||||
<TableCell className="p-3 text-slate-300">{result.category}</TableCell>
|
</TableCell>
|
||||||
<TableCell className={`p-3 font-semibold ${
|
<TableCell className="p-3 text-slate-300">{result.category}</TableCell>
|
||||||
result.status === 'complete' ? 'text-slate-100' : 'text-amber-300'
|
<TableCell className={`p-3 font-semibold ${
|
||||||
}`}>
|
result.status === 'complete' ? 'text-slate-100' : 'text-amber-300'
|
||||||
{result.result}
|
}`}>
|
||||||
</TableCell>
|
{result.result}
|
||||||
<TableCell className="p-3 text-slate-300">{result.completed}</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
<TableCell className="p-3 text-slate-300">{result.completed}</TableCell>
|
||||||
))}
|
</TableRow>
|
||||||
</TableBody>
|
))}
|
||||||
</Table>
|
</TableBody>
|
||||||
</div>
|
</Table>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{canReadAcknowledgedDocuments && (
|
{canReadAcknowledgedDocuments && (
|
||||||
<Card className={profileCardClassName}>
|
<Card className={profileCardClassName}>
|
||||||
|
|||||||
@ -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 { Link, useParams } from 'react-router-dom';
|
||||||
import { useShellOutletContext } from '@/app/shellOutletContext';
|
import { useShellOutletContext } from '@/app/shellOutletContext';
|
||||||
import {
|
import {
|
||||||
@ -8,7 +8,6 @@ import {
|
|||||||
import { formatAttendanceDate } from '@/business/campus-attendance/selectors';
|
import { formatAttendanceDate } from '@/business/campus-attendance/selectors';
|
||||||
import { useStaffAttendanceRecords } from '@/business/staff-attendance/hooks';
|
import { useStaffAttendanceRecords } from '@/business/staff-attendance/hooks';
|
||||||
import { CampusAttendanceLoadingState } from '@/components/campus-attendance/CampusAttendanceStatus';
|
import { CampusAttendanceLoadingState } from '@/components/campus-attendance/CampusAttendanceStatus';
|
||||||
import { percentageTextClass } from '@/components/campus-attendance/styles';
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@ -89,7 +88,6 @@ export default function CampusAttendanceDetailsPage() {
|
|||||||
selectedCampus?.id,
|
selectedCampus?.id,
|
||||||
selectedCampus?.tenantId,
|
selectedCampus?.tenantId,
|
||||||
].filter((id): id is string => Boolean(id)));
|
].filter((id): id is string => Boolean(id)));
|
||||||
const studentRecords = state.attendanceData.filter((record) => selectedCampusIds.has(record.campus_id));
|
|
||||||
const staffRecords = (staffRecordsQuery.data ?? []).filter((record) => (
|
const staffRecords = (staffRecordsQuery.data ?? []).filter((record) => (
|
||||||
record.campusId ? selectedStaffCampusIds.has(record.campusId) : false
|
record.campusId ? selectedStaffCampusIds.has(record.campusId) : false
|
||||||
));
|
));
|
||||||
@ -136,59 +134,13 @@ export default function CampusAttendanceDetailsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-white">{displayName} Attendance Details</h2>
|
<h2 className="text-2xl font-bold text-white">{displayName} Attendance Details</h2>
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<section className="rounded-2xl border border-slate-700/40 bg-slate-800/60">
|
||||||
<div className="border-b border-slate-700/40 p-5">
|
<div className="border-b border-slate-700/40 p-5">
|
||||||
<h3 className="flex items-center gap-2 font-semibold text-white">
|
<h3 className="flex items-center gap-2 font-semibold text-white">
|
||||||
|
|||||||
@ -1,56 +1,117 @@
|
|||||||
|
import type { FormEvent } from 'react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { Label } from '@/components/ui/label';
|
||||||
import { ModuleHeader } from '@/components/ui/module-header';
|
import { ModuleHeader } from '@/components/ui/module-header';
|
||||||
|
import { NativeSelect } from '@/components/ui/native-select';
|
||||||
import { PageSkeleton } from '@/components/ui/page-skeleton';
|
import { PageSkeleton } from '@/components/ui/page-skeleton';
|
||||||
|
import { UserAvatar } from '@/components/common/UserAvatar';
|
||||||
import { useAuth } from '@/contexts/useAuth';
|
import { useAuth } from '@/contexts/useAuth';
|
||||||
|
import { usePermissions } from '@/hooks/usePermissions';
|
||||||
import {
|
import {
|
||||||
|
createUser,
|
||||||
getClass,
|
getClass,
|
||||||
getClassAttendanceSummary,
|
linkGuardianStudent,
|
||||||
listGuardianStudents,
|
listGuardianStudents,
|
||||||
|
listRoles,
|
||||||
listUsers,
|
listUsers,
|
||||||
|
updateUser,
|
||||||
type AdminUserRow,
|
type AdminUserRow,
|
||||||
upsertClassAttendance,
|
|
||||||
} from '@/business/my-class/api';
|
} 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 { getErrorMessage } from '@/shared/errors/errorMessages';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function personName(row: { firstName?: string | null; lastName?: string | null; email?: string }): string {
|
function personName(row: { firstName?: string | null; lastName?: string | null; email?: string }): string {
|
||||||
return [row.firstName, row.lastName].filter(Boolean).join(' ').trim() || row.email || '—';
|
return [row.firstName, row.lastName].filter(Boolean).join(' ').trim() || row.email || '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = () => new Date().toISOString().slice(0, 10);
|
interface StatusMessage {
|
||||||
|
readonly type: 'success' | 'error';
|
||||||
type StudentAttendanceStatus = 'present' | 'late' | 'absent';
|
readonly text: string;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
export default function MyClassPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const permissions = usePermissions();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const classId = user?.classId ?? null;
|
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({
|
const classQuery = useQuery({
|
||||||
queryKey: ['my-class', classId],
|
queryKey: ['my-class', classId],
|
||||||
@ -67,10 +128,10 @@ export default function MyClassPage() {
|
|||||||
queryFn: () => listGuardianStudents(),
|
queryFn: () => listGuardianStudents(),
|
||||||
enabled: Boolean(classId),
|
enabled: Boolean(classId),
|
||||||
});
|
});
|
||||||
const summaryQuery = useQuery({
|
const rolesQuery = useQuery({
|
||||||
queryKey: ['my-class-attendance', classId],
|
queryKey: ['roles'],
|
||||||
queryFn: () => getClassAttendanceSummary(),
|
queryFn: listRoles,
|
||||||
enabled: Boolean(classId),
|
enabled: Boolean(classId) && (permissions.has('CREATE_USERS') || permissions.has('UPDATE_USERS')),
|
||||||
});
|
});
|
||||||
|
|
||||||
const members = useMemo(
|
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.filter((m) => m.app_role?.name === 'teacher' || m.app_role?.name === 'support_staff'),
|
||||||
[members],
|
[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.
|
// studentId → guardian display names.
|
||||||
const guardiansByStudent = useMemo(() => {
|
const guardiansByStudent = useMemo(() => {
|
||||||
@ -98,50 +177,126 @@ export default function MyClassPage() {
|
|||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [guardiansQuery.data]);
|
}, [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 updateStudentForm = (patch: Partial<MyClassStudentFormValues>) => {
|
||||||
const [studentStatusOverrides, setStudentStatusOverrides] = useState<Record<string, StudentAttendanceStatus>>({});
|
setStudentForm((current) => ({ ...current, ...patch }));
|
||||||
const [saving, setSaving] = useState(false);
|
setStudentFormStatus(null);
|
||||||
const [status, setStatus] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
};
|
||||||
const studentStatuses = useMemo(
|
const updateGuardianForm = (guardianKey: string, patch: Partial<MyClassGuardianFormValues>) => {
|
||||||
() => reconcileStudentStatuses(studentStatusOverrides, students),
|
setStudentForm((current) => ({
|
||||||
[studentStatusOverrides, students],
|
...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() {
|
const resetStudentForm = () => {
|
||||||
setStatus(null);
|
setStudentForm(emptyStudentForm());
|
||||||
if (!classId) return;
|
setEditingStudentId(null);
|
||||||
if (students.length === 0) {
|
setIsStudentFormOpen(false);
|
||||||
setStatus({ type: 'error', text: 'No students are available for attendance.' });
|
};
|
||||||
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalPresent = students.filter((student) => (
|
const payload = buildMyClassStudentSaveData(studentForm, classId, studentRoleId);
|
||||||
(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;
|
|
||||||
|
|
||||||
setSaving(true);
|
setStudentFormSaving(true);
|
||||||
try {
|
try {
|
||||||
await upsertClassAttendance(classId, date, {
|
let studentId = editingStudentId;
|
||||||
total_enrolled: students.length,
|
if (editingStudentId) {
|
||||||
total_present: totalPresent,
|
await updateUser(editingStudentId, payload);
|
||||||
total_absent: totalAbsent,
|
} else {
|
||||||
total_tardy: totalTardy,
|
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] });
|
resetStudentForm();
|
||||||
setStatus({ type: 'success', text: 'Attendance saved.' });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus({ type: 'error', text: getErrorMessage(error, 'Could not save attendance') });
|
setStudentFormStatus({ type: 'error', text: getErrorMessage(error, 'Could not save student') });
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setStudentFormSaving(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
if (!classId) {
|
if (!classId) {
|
||||||
return (
|
return (
|
||||||
@ -153,15 +308,18 @@ export default function MyClassPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (classQuery.isLoading || membersQuery.isLoading) {
|
if (classQuery.isLoading || membersQuery.isLoading || rolesQuery.isLoading) {
|
||||||
return <PageSkeleton />;
|
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 (
|
return (
|
||||||
<div className="space-y-6 p-4 md:p-6">
|
<div className="space-y-6 p-4 md:p-6">
|
||||||
<ModuleHeader
|
<ModuleHeader
|
||||||
title={classQuery.data?.name ?? 'My Class'}
|
title={classQuery.data?.name ?? 'My Class'}
|
||||||
description="Your class roster and daily attendance."
|
description="Your class roster and linked guardians."
|
||||||
icon={GraduationCap}
|
icon={GraduationCap}
|
||||||
iconClassName="bg-gradient-to-br from-lime-500 to-lime-700"
|
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">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
<Card className="lg:col-span-2">
|
<Card className="lg:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<Users size={16} />
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
Students ({students.length})
|
<Users size={16} />
|
||||||
</CardTitle>
|
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>
|
</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 ? (
|
{students.length === 0 ? (
|
||||||
<p className="text-sm text-slate-400">No students in this class yet.</p>
|
<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) => {
|
{students.map((s) => {
|
||||||
const guardians = guardiansByStudent.get(s.id) ?? [];
|
const guardians = guardiansByStudent.get(s.id) ?? [];
|
||||||
return (
|
return (
|
||||||
<li key={s.id} className="py-2">
|
<li key={s.id} className="flex items-center justify-between gap-3 py-3">
|
||||||
<p className="text-sm font-medium text-slate-200">{personName(s)}</p>
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<p className="text-xs text-slate-500">
|
<UserAvatar
|
||||||
{guardians.length > 0 ? `Guardians: ${guardians.join(', ')}` : 'No guardian linked'}
|
name={personName(s)}
|
||||||
</p>
|
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>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -218,144 +634,6 @@ export default function MyClassPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { ArrowDown, ArrowUp, ArrowUpDown, 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 { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { ModuleHeader } from '@/components/ui/module-header';
|
||||||
import { NativeSelect } from '@/components/ui/native-select';
|
import { NativeSelect } from '@/components/ui/native-select';
|
||||||
import { PageSkeleton } from '@/components/ui/page-skeleton';
|
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 { TenantParentPicker } from '@/components/tenant-create/TenantParentPicker';
|
||||||
import { ImageUpload } from '@/components/common/ImageUpload';
|
import { ImageUpload } from '@/components/common/ImageUpload';
|
||||||
import { TenantLogo } from '@/components/common/TenantLogo';
|
import { TenantLogo } from '@/components/common/TenantLogo';
|
||||||
|
import { UserAvatar } from '@/components/common/UserAvatar';
|
||||||
import { useScopeContext } from '@/contexts/scope-context';
|
import { useScopeContext } from '@/contexts/scope-context';
|
||||||
import { useAuth } from '@/contexts/useAuth';
|
import { useAuth } from '@/contexts/useAuth';
|
||||||
import { useTenantChildren } from '@/business/scope/queries';
|
import { useTenantChildren } from '@/business/scope/queries';
|
||||||
@ -27,8 +37,8 @@ import {
|
|||||||
createOwnerWithOrganization,
|
createOwnerWithOrganization,
|
||||||
createUser,
|
createUser,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
fileDownloadUrl,
|
|
||||||
linkGuardianStudent,
|
linkGuardianStudent,
|
||||||
|
listGuardianStudents,
|
||||||
listPermissions,
|
listPermissions,
|
||||||
listRoles,
|
listRoles,
|
||||||
listUsers,
|
listUsers,
|
||||||
@ -55,9 +65,9 @@ type UserListSortField =
|
|||||||
| 'name'
|
| 'name'
|
||||||
| 'email'
|
| 'email'
|
||||||
| 'phoneNumber'
|
| 'phoneNumber'
|
||||||
| 'organization'
|
|
||||||
| 'school'
|
| 'school'
|
||||||
| 'campus'
|
| 'campus'
|
||||||
|
| 'class'
|
||||||
| 'role';
|
| 'role';
|
||||||
|
|
||||||
type UserListSortDirection = 'asc' | 'desc';
|
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 {
|
function sortText(value: string | null | undefined): string {
|
||||||
return value?.trim().toLocaleLowerCase() || '';
|
return value?.trim().toLocaleLowerCase() || '';
|
||||||
}
|
}
|
||||||
@ -102,6 +149,16 @@ function permissionLabel(name: string | null | undefined, id: string): string {
|
|||||||
return (name?.trim() || id).replace(/_/g, ' ');
|
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() {
|
export default function UserAdminPage() {
|
||||||
const { tier, ownTenant } = useScopeContext();
|
const { tier, ownTenant } = useScopeContext();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@ -110,12 +167,17 @@ export default function UserAdminPage() {
|
|||||||
const capabilitiesQuery = useIamCapabilities();
|
const capabilitiesQuery = useIamCapabilities();
|
||||||
const rolesQuery = useQuery({ queryKey: ['roles'], queryFn: listRoles });
|
const rolesQuery = useQuery({ queryKey: ['roles'], queryFn: listRoles });
|
||||||
const permissionsQuery = useQuery({ queryKey: ['permissions'], queryFn: listPermissions });
|
const permissionsQuery = useQuery({ queryKey: ['permissions'], queryFn: listPermissions });
|
||||||
|
const guardianStudentsQuery = useQuery({
|
||||||
|
queryKey: ['guardian-students'],
|
||||||
|
queryFn: () => listGuardianStudents(),
|
||||||
|
});
|
||||||
const [usersPage, setUsersPage] = useState(0);
|
const [usersPage, setUsersPage] = useState(0);
|
||||||
const [usersSearchDraft, setUsersSearchDraft] = useState('');
|
const [usersSearchDraft, setUsersSearchDraft] = useState('');
|
||||||
const [usersSearch, setUsersSearch] = useState('');
|
const [usersSearch, setUsersSearch] = useState('');
|
||||||
const [usersSortField, setUsersSortField] = useState<UserListSortField>('name');
|
const [usersSortField, setUsersSortField] = useState<UserListSortField>('name');
|
||||||
const [usersSortDirection, setUsersSortDirection] = useState<UserListSortDirection>('asc');
|
const [usersSortDirection, setUsersSortDirection] = useState<UserListSortDirection>('asc');
|
||||||
const [isUserFormOpen, setIsUserFormOpen] = useState(false);
|
const [isUserFormOpen, setIsUserFormOpen] = useState(false);
|
||||||
|
const [isStudentPickerOpen, setIsStudentPickerOpen] = useState(false);
|
||||||
const usersQuery = useQuery({
|
const usersQuery = useQuery({
|
||||||
queryKey: ['admin-users', usersSearch],
|
queryKey: ['admin-users', usersSearch],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@ -204,6 +266,11 @@ export default function UserAdminPage() {
|
|||||||
[allPermissions, effectivePermissionIds],
|
[allPermissions, effectivePermissionIds],
|
||||||
);
|
);
|
||||||
const tenantInput = selectedRole ? getRoleTenantInput(selectedRole) : 'none';
|
const tenantInput = selectedRole ? getRoleTenantInput(selectedRole) : 'none';
|
||||||
|
const canManageAdvancedPermissions =
|
||||||
|
canEditPermissions
|
||||||
|
&& selectedRole !== 'super_admin'
|
||||||
|
&& selectedRole !== 'student'
|
||||||
|
&& selectedRole !== 'guardian';
|
||||||
const canAutoCreateOwnerOrganization =
|
const canAutoCreateOwnerOrganization =
|
||||||
selectedRole === 'owner' && capabilitiesQuery.data?.canCreateOwnerWithOrganization === true;
|
selectedRole === 'owner' && capabilitiesQuery.data?.canCreateOwnerWithOrganization === true;
|
||||||
const targetLevel =
|
const targetLevel =
|
||||||
@ -224,12 +291,12 @@ export default function UserAdminPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Students for guardian linking (role 'student' in the actor's scope).
|
// Students for guardian linking (role 'student' in the actor's scope).
|
||||||
const studentRoleId = roleIdByName.get('student');
|
|
||||||
const studentsQuery = useQuery({
|
const studentsQuery = useQuery({
|
||||||
queryKey: ['admin-users', 'students', studentRoleId],
|
queryKey: ['admin-users', 'students', 'student'],
|
||||||
queryFn: () => listUsers({ app_role: studentRoleId }),
|
queryFn: () => listUsers({ app_role: 'student' }),
|
||||||
enabled: tenantInput === 'guardian' && Boolean(studentRoleId),
|
enabled: tenantInput === 'guardian',
|
||||||
});
|
});
|
||||||
|
const guardianStudentOptions = studentsQuery.data?.rows ?? [];
|
||||||
|
|
||||||
const handlePickerChange = useCallback((id: string | null) => {
|
const handlePickerChange = useCallback((id: string | null) => {
|
||||||
setPickedTenantId(id);
|
setPickedTenantId(id);
|
||||||
@ -281,6 +348,29 @@ export default function UserAdminPage() {
|
|||||||
}, [rolePermissionIds]);
|
}, [rolePermissionIds]);
|
||||||
|
|
||||||
const fetchedRows = usersQuery.data?.rows;
|
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 sortedRows = useMemo(() => {
|
||||||
const collator = new Intl.Collator(undefined, {
|
const collator = new Intl.Collator(undefined, {
|
||||||
numeric: true,
|
numeric: true,
|
||||||
@ -295,12 +385,12 @@ export default function UserAdminPage() {
|
|||||||
return sortText(row.email);
|
return sortText(row.email);
|
||||||
case 'phoneNumber':
|
case 'phoneNumber':
|
||||||
return sortText(row.phoneNumber);
|
return sortText(row.phoneNumber);
|
||||||
case 'organization':
|
|
||||||
return sortText(locationName(row.organizations));
|
|
||||||
case 'school':
|
case 'school':
|
||||||
return sortText(locationName(row.school));
|
return sortText(locationName(row.school));
|
||||||
case 'campus':
|
case 'campus':
|
||||||
return sortText(locationName(row.campus));
|
return sortText(locationName(row.campus));
|
||||||
|
case 'class':
|
||||||
|
return sortText(classNamesForUser(row, guardianClassNamesById).join(' '));
|
||||||
case 'role':
|
case 'role':
|
||||||
return sortText(
|
return sortText(
|
||||||
row.app_role?.name ? getAuthRoleLabel(row.app_role.name as UserRole) : '—',
|
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)));
|
return collator.compare(sortText(userName(left)), sortText(userName(right)));
|
||||||
});
|
});
|
||||||
}, [fetchedRows, usersSortDirection, usersSortField]);
|
}, [fetchedRows, guardianClassNamesById, usersSortDirection, usersSortField]);
|
||||||
const usersTotal = usersQuery.data?.count ?? sortedRows.length;
|
const usersTotal = usersQuery.data?.count ?? sortedRows.length;
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const start = usersPage * USER_LIST_PAGE_SIZE;
|
const start = usersPage * USER_LIST_PAGE_SIZE;
|
||||||
@ -404,8 +494,8 @@ export default function UserAdminPage() {
|
|||||||
email: email.trim(),
|
email: email.trim(),
|
||||||
avatar,
|
avatar,
|
||||||
app_role: roleId ?? null,
|
app_role: roleId ?? null,
|
||||||
custom_permissions: grantPerms,
|
custom_permissions: canManageAdvancedPermissions ? grantPerms : [],
|
||||||
custom_permissions_filter: excludePerms,
|
custom_permissions_filter: canManageAdvancedPermissions ? excludePerms : [],
|
||||||
...buildTenantPayload(),
|
...buildTenantPayload(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -422,6 +512,7 @@ export default function UserAdminPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
await queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['guardian-students'] });
|
||||||
setUsersPage(0);
|
setUsersPage(0);
|
||||||
setStatus({ type: 'success', text: editingId ? 'User updated.' : 'User created (invite sent).' });
|
setStatus({ type: 'success', text: editingId ? 'User updated.' : 'User created (invite sent).' });
|
||||||
resetForm();
|
resetForm();
|
||||||
@ -523,7 +614,8 @@ export default function UserAdminPage() {
|
|||||||
table="users"
|
table="users"
|
||||||
field="avatar"
|
field="avatar"
|
||||||
label="Avatar"
|
label="Avatar"
|
||||||
shape="circle"
|
shape="square"
|
||||||
|
previewSize="lg"
|
||||||
/>
|
/>
|
||||||
<div className="grid gap-4 sm:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@ -603,6 +695,10 @@ export default function UserAdminPage() {
|
|||||||
setPickedTenantId(null);
|
setPickedTenantId(null);
|
||||||
setClassId('');
|
setClassId('');
|
||||||
setStudentIds([]);
|
setStudentIds([]);
|
||||||
|
if (e.target.value === 'student' || e.target.value === 'guardian') {
|
||||||
|
setGrantPerms([]);
|
||||||
|
setExcludePerms([]);
|
||||||
|
}
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -646,31 +742,69 @@ export default function UserAdminPage() {
|
|||||||
{tenantInput === 'guardian' && !editingId && (
|
{tenantInput === 'guardian' && !editingId && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-slate-100">Students</Label>
|
<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">
|
<Popover open={isStudentPickerOpen} onOpenChange={setIsStudentPickerOpen}>
|
||||||
{(studentsQuery.data?.rows ?? []).length === 0 ? (
|
<PopoverTrigger asChild>
|
||||||
<p className="text-xs text-slate-300">No students found in your scope.</p>
|
<Button
|
||||||
) : (
|
type="button"
|
||||||
(studentsQuery.data?.rows ?? []).map((s) => (
|
variant="outline"
|
||||||
<label key={s.id} className="flex items-center gap-2 text-sm text-slate-100">
|
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"
|
||||||
<input
|
>
|
||||||
type="checkbox"
|
<span className={cn('truncate', studentIds.length === 0 && 'text-slate-400')}>
|
||||||
checked={studentIds.includes(s.id)}
|
{studentPickerLabel(guardianStudentOptions, studentIds)}
|
||||||
onChange={(e) =>
|
</span>
|
||||||
setStudentIds((prev) =>
|
<ChevronDown className="ml-3 h-4 w-4 shrink-0 text-slate-400" />
|
||||||
e.target.checked ? [...prev, s.id] : prev.filter((x) => x !== s.id),
|
</Button>
|
||||||
)
|
</PopoverTrigger>
|
||||||
}
|
<PopoverContent
|
||||||
/>
|
align="start"
|
||||||
{userName(s)}
|
className="w-[var(--radix-popover-trigger-width)] border-slate-700 bg-slate-950 p-0 text-slate-100"
|
||||||
</label>
|
>
|
||||||
))
|
<Command className="bg-slate-950 text-slate-100">
|
||||||
)}
|
<CommandInput placeholder="Search students..." />
|
||||||
</div>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canEditPermissions && selectedRole !== 'super_admin' && (
|
{canManageAdvancedPermissions && (
|
||||||
<details className="rounded-lg border border-slate-600/80 bg-slate-950/45 p-4">
|
<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">
|
<summary className="cursor-pointer text-sm font-semibold text-slate-100">
|
||||||
Advanced permissions (optional)
|
Advanced permissions (optional)
|
||||||
@ -784,7 +918,7 @@ export default function UserAdminPage() {
|
|||||||
<Input
|
<Input
|
||||||
value={usersSearchDraft}
|
value={usersSearchDraft}
|
||||||
onChange={(event) => setUsersSearchDraft(event.target.value)}
|
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"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -817,9 +951,9 @@ export default function UserAdminPage() {
|
|||||||
['name', 'User'],
|
['name', 'User'],
|
||||||
['email', 'Email'],
|
['email', 'Email'],
|
||||||
['phoneNumber', 'Phone'],
|
['phoneNumber', 'Phone'],
|
||||||
['organization', 'Organization'],
|
|
||||||
['school', 'School'],
|
['school', 'School'],
|
||||||
['campus', 'Campus'],
|
['campus', 'Campus'],
|
||||||
|
['class', 'Class'],
|
||||||
['role', 'Role'],
|
['role', 'Role'],
|
||||||
] as const).map(([field, label]) => (
|
] as const).map(([field, label]) => (
|
||||||
<th key={field} className="px-3 py-3 font-medium">
|
<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">
|
<tr key={row.id} className="align-middle">
|
||||||
<td className="px-3 py-2.5">
|
<td className="px-3 py-2.5">
|
||||||
<div className="flex min-w-[180px] items-center gap-3">
|
<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">
|
<UserAvatar
|
||||||
{row.avatar?.[0]?.privateUrl ? (
|
name={userName(row)}
|
||||||
<img
|
avatarUrl={row.avatar?.[0]?.privateUrl ?? null}
|
||||||
src={fileDownloadUrl(row.avatar[0].privateUrl)}
|
className="h-9 w-9 text-[11px]"
|
||||||
alt=""
|
/>
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
userName(row).slice(0, 2).toUpperCase()
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-medium text-slate-100">{userName(row)}</p>
|
<p className="truncate font-medium text-slate-100">{userName(row)}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -868,9 +996,11 @@ export default function UserAdminPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5 text-slate-300 break-all">{row.email || '—'}</td>
|
<td className="px-3 py-2.5 text-slate-300 break-all">{row.email || '—'}</td>
|
||||||
<td className="px-3 py-2.5 text-slate-300">{row.phoneNumber || '—'}</td>
|
<td className="px-3 py-2.5 text-slate-300">{row.phoneNumber || '—'}</td>
|
||||||
<td className="px-3 py-2.5 text-slate-300">{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.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">{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">
|
<td className="px-3 py-2.5 text-slate-300">
|
||||||
{roleName ? getAuthRoleLabel(roleName as UserRole) : '—'}
|
{roleName ? getAuthRoleLabel(roleName as UserRole) : '—'}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -5,7 +5,15 @@ export interface GuardianStudentRow {
|
|||||||
readonly guardianId: string;
|
readonly guardianId: string;
|
||||||
readonly studentId: string;
|
readonly studentId: string;
|
||||||
readonly relationship?: string | null;
|
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;
|
readonly student?: { id: string; firstName?: string | null; lastName?: string | null } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,12 @@ export interface AdminUserRow {
|
|||||||
readonly school?: { id: string; name?: string | null; logo?: string | null } | null;
|
readonly school?: { id: string; name?: string | null; logo?: string | null } | null;
|
||||||
readonly campus?: { id: string; name?: string | null; logo?: string | null } | null;
|
readonly campus?: { id: string; name?: string | null; logo?: string | null } | null;
|
||||||
readonly class?: { 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 campusId?: string | null;
|
||||||
readonly schoolId?: string | null;
|
readonly schoolId?: string | null;
|
||||||
readonly classId?: string | null;
|
readonly classId?: string | null;
|
||||||
@ -54,7 +60,7 @@ export interface ListUsersParams {
|
|||||||
readonly campusId?: string;
|
readonly campusId?: string;
|
||||||
readonly limit?: number;
|
readonly limit?: number;
|
||||||
readonly page?: 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';
|
readonly sort?: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
CampusAttendancePrintInput,
|
CampusAttendancePrintInput,
|
||||||
CampusAttendanceStats,
|
CampusAttendanceStats,
|
||||||
CampusAttendanceSummaryViewModel,
|
|
||||||
StaffAttendanceDailySummaryViewModel,
|
StaffAttendanceDailySummaryViewModel,
|
||||||
} from '@/business/campus-attendance/types';
|
} from '@/business/campus-attendance/types';
|
||||||
import type { StaffAttendanceSummaryViewModel } from '@/business/staff-attendance/types';
|
import type { StaffAttendanceSummaryViewModel } from '@/business/staff-attendance/types';
|
||||||
@ -15,35 +14,8 @@ export const CAMPUS_ATTENDANCE_TEST_SEED = {
|
|||||||
generatedByRole: 'Director & Campus Lead',
|
generatedByRole: 'Director & Campus Lead',
|
||||||
generatedByRoleEscaped: 'Director & Campus Lead',
|
generatedByRoleEscaped: 'Director & Campus Lead',
|
||||||
reportTitleEscaped: 'Attendance & Daily <Report>',
|
reportTitleEscaped: 'Attendance & Daily <Report>',
|
||||||
todayNotesEscaped: 'Needs <support> & follow-up',
|
|
||||||
} as const;
|
} 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 = {
|
export const campusStaffAttendanceTodayRecord: StaffAttendanceDailySummaryViewModel = {
|
||||||
id: 'staff:tigers:2026-06-08',
|
id: 'staff:tigers:2026-06-08',
|
||||||
campus_id: CAMPUS_ATTENDANCE_TEST_SEED.campusId,
|
campus_id: CAMPUS_ATTENDANCE_TEST_SEED.campusId,
|
||||||
@ -89,8 +61,6 @@ export const campusAttendanceStatsSeed: CampusAttendanceStats = {
|
|||||||
todayPct: 90,
|
todayPct: 90,
|
||||||
weekAvg: 85,
|
weekAvg: 85,
|
||||||
config: null,
|
config: null,
|
||||||
recentData: [campusAttendanceTodayRecord, campusAttendanceWeekRecord],
|
|
||||||
todayRecord: campusAttendanceTodayRecord,
|
|
||||||
recentStaffData: [campusStaffAttendanceTodayRecord, campusStaffAttendanceWeekRecord],
|
recentStaffData: [campusStaffAttendanceTodayRecord, campusStaffAttendanceWeekRecord],
|
||||||
todayStaffRecord: campusStaffAttendanceTodayRecord,
|
todayStaffRecord: campusStaffAttendanceTodayRecord,
|
||||||
};
|
};
|
||||||
@ -100,9 +70,6 @@ export const campusAttendancePrintInputSeed: CampusAttendancePrintInput = {
|
|||||||
generatedByName: CAMPUS_ATTENDANCE_TEST_SEED.generatedByName,
|
generatedByName: CAMPUS_ATTENDANCE_TEST_SEED.generatedByName,
|
||||||
generatedByRole: CAMPUS_ATTENDANCE_TEST_SEED.generatedByRole,
|
generatedByRole: CAMPUS_ATTENDANCE_TEST_SEED.generatedByRole,
|
||||||
today: '2026-06-08',
|
today: '2026-06-08',
|
||||||
weekStart: '2026-06-08',
|
|
||||||
campusesToPrint: [campusAttendanceStatsSeed],
|
campusesToPrint: [campusAttendanceStatsSeed],
|
||||||
printTodayRecords: [campusAttendanceTodayRecord],
|
|
||||||
printWeekRecords: [campusAttendanceTodayRecord, campusAttendanceWeekRecord],
|
|
||||||
staffSummary: campusStaffAttendanceSummary,
|
staffSummary: campusStaffAttendanceSummary,
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user