src/app/features/attendance/add-day-attendance/roll-call/roll-call.component.ts
Displays the participants of the given event one by one to mark attendance status. Can be used as a standalone routed view (via route param :id) or embedded with eventEntity input.
| changeDetection | ChangeDetectionStrategy.OnPush |
| selector | app-roll-call |
| standalone | true |
| imports |
MatProgressBarModule
MatButtonModule
FontAwesomeModule
MatTooltipModule
|
| styleUrls | ./roll-call.component.scss |
| templateUrl | ./roll-call.component.html |
No results matching.
MatProgressBarModule
MatButtonModule
FontAwesomeModule
EntityBlockComponent
RollCallTabComponent
MatTooltipModule
ViewTitleComponent
Properties |
|
Methods |
Inputs |
constructor()
|
| eventEntity | |
Type : EventWithAttendance
|
|
|
The event to be displayed and edited. Can be set directly when used as an embedded component, or loaded from DB via id. |
|
| id | |
Type : string
|
|
|
Entity ID from route param, mapped by RoutedViewComponent. Supports real entity IDs, or "new" for creating a new event. |
|
| sortParticipantsBy | |
Type : string
|
|
|
(optional) property name of the participant entities by which they are sorted |
|
| exit |
exit()
|
|
Handle navigating back, showing save/discard dialog if there are unsaved changes.
Returns :
void
|
| finish |
finish()
|
|
Returns :
void
|
| goToParticipantWithIndex | ||||||
goToParticipantWithIndex(newIndex: number)
|
||||||
|
Parameters :
Returns :
void
|
| Async includeInactive |
includeInactive()
|
|
Returns :
any
|
| markAttendance | ||||||
markAttendance(status: AttendanceStatusType)
|
||||||
|
Parameters :
Returns :
void
|
| Async saveEvent |
saveEvent()
|
|
Returns :
any
|
| showDetails |
showDetails()
|
|
Returns :
void
|
| Readonly attendanceByParticipant |
Type : unknown
|
Default value : signal<Record<string, AttendanceItem>>({})
|
|
Lookup object for attendance items by participant ID, built during loadParticipants |
| Readonly availableStatus |
Type : unknown
|
Default value : signal<AttendanceStatusType[]>([])
|
|
Options available for selecting an attendance status |
| Readonly currentIndex |
Type : unknown
|
Default value : signal(0)
|
|
The index of the participant currently being processed |
| Readonly eventResource |
Type : ResourceRef<EventWithAttendance | undefined>
|
Default value : resource({
params: () => ({ entity: this.eventEntity(), id: this.id() }),
loader: async ({ params: { entity, id } }) => {
if (entity) return entity;
if (!id) return undefined;
if (id === "new") return this.createEventFromRoute();
return this.loadExistingEvent(id);
},
})
|
|
Loads the event entity from the provided input or by ID. Unifies the two input paths (direct entity vs route-based loading). |
| Readonly inactiveParticipants |
Type : unknown
|
Default value : signal<Entity[]>([])
|
| Readonly isDirty |
Type : unknown
|
Default value : signal(false)
|
|
Whether any changes have been made to the model |
| Readonly isFinished |
Type : unknown
|
Default value : computed(
() =>
!!this.event() &&
!this.eventResource.isLoading() &&
!this.isInitializing() &&
this.currentIndex() >= this.participants().length,
)
|
| Readonly isFirst |
Type : unknown
|
Default value : computed(() => this.currentIndex() === 0)
|
| Readonly isInitializing |
Type : unknown
|
Default value : signal(false)
|
| Readonly isLast |
Type : unknown
|
Default value : computed(
() => this.currentIndex() === this.participants().length - 1,
)
|
| Readonly participants |
Type : unknown
|
Default value : signal<Entity[]>([])
|
import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
effect,
inject,
input,
resource,
ResourceRef,
signal,
untracked,
} from "@angular/core";
import { Location } from "@angular/common";
import { animate, style, transition, trigger } from "@angular/animations";
import {
ATTENDANCE_STATUS_CONFIG_ID,
AttendanceStatusType,
} from "../../model/attendance-status";
import { AttendanceItem } from "../../model/attendance-item";
import { EntityMapperService } from "#src/app/core/entity/entity-mapper/entity-mapper.service";
import { Entity } from "#src/app/core/entity/model/entity";
import { Logging } from "#src/app/core/logging/logging.service";
import { sortByAttribute } from "#src/app/utils/utils";
import { EventWithAttendance } from "../../model/event-with-attendance";
import { FormDialogService } from "#src/app/core/form-dialog/form-dialog.service";
import { MatProgressBarModule } from "@angular/material/progress-bar";
import { MatButtonModule } from "@angular/material/button";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { RollCallTabComponent } from "./roll-call-tab/roll-call-tab.component";
import { ConfigurableEnumService } from "#src/app/core/basic-datatypes/configurable-enum/configurable-enum.service";
import { MatTooltipModule } from "@angular/material/tooltip";
import { ConfirmationDialogService } from "#src/app/core/common-components/confirmation-dialog/confirmation-dialog.service";
import { EntityBlockComponent } from "#src/app/core/basic-datatypes/entity/entity-block/entity-block.component";
import { ActivatedRoute, Router } from "@angular/router";
import { AttendanceService } from "../../attendance.service";
import { UnsavedChangesService } from "#src/app/core/entity-details/form/unsaved-changes.service";
import {
ConfirmationDialogButton,
OkButton,
} from "#src/app/core/common-components/confirmation-dialog/confirmation-dialog/confirmation-dialog.component";
import { ViewTitleComponent } from "#src/app/core/common-components/view-title/view-title.component";
import { RouteTarget } from "#src/app/route-target";
/**
* Displays the participants of the given event one by one to mark attendance status.
* Can be used as a standalone routed view (via route param :id) or embedded with eventEntity input.
*/
@RouteTarget("RollCall")
@Component({
selector: "app-roll-call",
templateUrl: "./roll-call.component.html",
styleUrls: ["./roll-call.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger("completeRollCall", [
transition("void => *", [
style({ backgroundColor: "transparent" }),
animate(1000),
]),
]),
],
imports: [
MatProgressBarModule,
MatButtonModule,
FontAwesomeModule,
EntityBlockComponent,
RollCallTabComponent,
MatTooltipModule,
ViewTitleComponent,
],
})
export class RollCallComponent {
private readonly enumService = inject(ConfigurableEnumService);
private readonly entityMapper = inject(EntityMapperService);
private readonly formDialog = inject(FormDialogService);
private readonly confirmationDialog = inject(ConfirmationDialogService);
private readonly router = inject(Router);
private readonly location = inject(Location);
private readonly route = inject(ActivatedRoute);
private readonly attendanceService = inject(AttendanceService);
private readonly unsavedChanges = inject(UnsavedChangesService);
/**
* Entity ID from route param, mapped by RoutedViewComponent.
* Supports real entity IDs, or "new" for creating a new event.
*/
readonly id = input<string>();
/**
* The event to be displayed and edited.
* Can be set directly when used as an embedded component, or loaded from DB via id.
*/
readonly eventEntity = input<EventWithAttendance>();
/**
* (optional) property name of the participant entities by which they are sorted
*/
readonly sortParticipantsBy = input<string>();
/**
* Loads the event entity from the provided input or by ID.
* Unifies the two input paths (direct entity vs route-based loading).
*/
readonly eventResource: ResourceRef<EventWithAttendance | undefined> =
resource({
params: () => ({ entity: this.eventEntity(), id: this.id() }),
loader: async ({ params: { entity, id } }) => {
if (entity) return entity;
if (!id) return undefined;
if (id === "new") return this.createEventFromRoute();
return this.loadExistingEvent(id);
},
});
/** The resolved event, wrapped with typed attendance and date accessors. */
readonly event = computed<EventWithAttendance | undefined>(() => {
return this.eventResource.value();
});
/** The index of the participant currently being processed */
readonly currentIndex = signal(0);
/** The participant currently being processed */
readonly currentParticipant = computed(() => {
const p = this.participants();
const i = this.currentIndex();
return i < p.length ? p[i] : undefined;
});
/** The attendance item of the current participant */
readonly currentAttendance = computed(() => {
const participant = this.currentParticipant();
return participant
? this.attendanceByParticipant()[participant.getId()]
: undefined;
});
/** Whether any changes have been made to the model */
readonly isDirty = signal(false);
/** Lookup object for attendance items by participant ID, built during loadParticipants */
readonly attendanceByParticipant = signal<Record<string, AttendanceItem>>({});
/** Options available for selecting an attendance status */
readonly availableStatus = signal<AttendanceStatusType[]>([]);
readonly participants = signal<Entity[]>([]);
readonly inactiveParticipants = signal<Entity[]>([]);
readonly isInitializing = signal(false);
readonly isFirst = computed(() => this.currentIndex() === 0);
readonly isLast = computed(
() => this.currentIndex() === this.participants().length - 1,
);
readonly isFinished = computed(
() =>
!!this.event() &&
!this.eventResource.isLoading() &&
!this.isInitializing() &&
this.currentIndex() >= this.participants().length,
);
constructor() {
// clear this component's unsaved-changes state when it is destroyed
inject(DestroyRef).onDestroy(() =>
this.unsavedChanges.setUnsavedChanges(this, false),
);
// Initialize participants when event is loaded/resolved
effect(() => {
const event = this.event();
if (event) {
untracked(() => this.initializeForEvent());
}
});
// React to sort configuration changes
effect(() => {
this.sortParticipantsBy();
untracked(() => this.sortParticipants());
});
}
/**
* Initialize participant data for the current event entity.
*/
private async initializeForEvent() {
this.isInitializing.set(true);
try {
this.loadAttendanceStatusTypes();
await this.loadParticipants();
this.setInitialIndex();
} finally {
this.isInitializing.set(false);
}
}
/**
* Create or load a new event based on route query params.
*/
private async createEventFromRoute(): Promise<
EventWithAttendance | undefined
> {
const activityId = this.route.snapshot.queryParamMap.get("activity");
const dateStr = this.route.snapshot.queryParamMap.get("date");
const date = dateStr ? this.parseDateOnly(dateStr) : new Date();
if (activityId) {
return this.attendanceService.createEventForActivity(activityId, date);
}
// in the future we may implement a UI to create a one-time event on the fly
return undefined;
}
/**
* Parse a "YYYY-MM-DD" date string as a local date.
* (new Date("YYYY-MM-DD") would parse as UTC midnight, shifting the date for negative offsets.)
*/
private parseDateOnly(value: string): Date {
const [year, month, day] = value.split("-").map(Number);
return new Date(year, month - 1, day);
}
private async loadExistingEvent(
id: string,
): Promise<EventWithAttendance | undefined> {
let event: EventWithAttendance | undefined;
try {
const entityType = Entity.extractTypeFromId(id);
const entity = await this.entityMapper.load(entityType, id);
event = this.attendanceService.wrapEventEntity(entity);
} catch (e) {
Logging.warn("Could not load event", { eventId: id, error: e });
void this.router.navigate(["/404"]);
return undefined;
}
return event;
}
/**
* Set the index of the first participant that expects user input.
* This is the first entry of the list, if the user has never recorded attendance
* for this event. Else it is the first participant without any attendance information
* (i.e. got skipped or the user left at this participant)
*/
private setInitialIndex() {
const participantsList = this.participants();
const attendanceMap = this.attendanceByParticipant();
let index = 0;
for (const entry of participantsList) {
if (!attendanceMap[entry.getId()]?.status?.id) {
break;
}
index += 1;
}
// do not jump to end - if all participants are recorded, start with first instead
if (index >= participantsList.length) {
index = 0;
}
this.goToParticipantWithIndex(index);
}
private loadAttendanceStatusTypes() {
this.availableStatus.set(
this.enumService.getEnumValues<AttendanceStatusType>(
ATTENDANCE_STATUS_CONFIG_ID,
),
);
}
private async loadParticipants() {
const event = this.event();
if (!event) return;
const attendanceItems: AttendanceItem[] = event.attendanceItems;
const active: Entity[] = [];
const inactive: Entity[] = [];
const attendanceMap: Record<string, AttendanceItem> = {};
const validAttendanceItems: AttendanceItem[] = [];
for (const attendanceItem of attendanceItems) {
const participantId = attendanceItem.participant;
let participant: Entity;
try {
participant = await this.entityMapper.load(
Entity.extractTypeFromId(participantId),
participantId,
);
} catch (e) {
Logging.debug(
"Could not find participant " +
participantId +
" for event " +
event.entity.getId(),
);
continue;
}
attendanceMap[participantId] = attendanceItem;
if (!participant.inactive) {
active.push(participant);
validAttendanceItems.push(attendanceItem);
} else {
inactive.push(participant);
}
}
event.attendanceItems = validAttendanceItems;
this.participants.set(active);
this.inactiveParticipants.set(inactive);
this.attendanceByParticipant.set(attendanceMap);
this.sortParticipants();
}
private sortParticipants() {
const sortBy = this.sortParticipantsBy();
if (!sortBy) {
return;
}
this.participants.update((participants) =>
[...participants].sort(sortByAttribute<any>(sortBy, "asc")),
);
// also sort the participants in the entity itself for display in details view later
const event = this.event();
if (event) {
const sortedIds = this.participants().map((e) => e.getId());
const attendance = event.attendanceItems;
attendance.sort(
(a, b) =>
sortedIds.indexOf(a.participant) - sortedIds.indexOf(b.participant),
);
event.attendanceItems = attendance;
}
}
markAttendance(status: AttendanceStatusType) {
const attendance = this.currentAttendance();
if (attendance) {
attendance.status = status;
}
this.isDirty.set(true);
this.unsavedChanges.setUnsavedChanges(this, true);
this.goToParticipantWithIndex(this.currentIndex() + 1);
}
goToParticipantWithIndex(newIndex: number) {
this.currentIndex.set(
Math.max(0, Math.min(newIndex, this.participants().length)),
);
if (this.isFinished()) {
void this.saveEvent();
}
}
finish() {
this.location.back();
}
/**
* Handle navigating back, showing save/discard dialog if there are unsaved changes.
*/
exit() {
if (this.isDirty()) {
this.confirmationDialog.getConfirmation(
$localize`:Exit from the current screen:Exit`,
$localize`Do you want to save your progress before going back?`,
[
{
text: $localize`Save`,
click: (): boolean => {
void this.saveEvent().then(() => this.location.back());
return true;
},
},
{
text: $localize`:Discard changes made to a form:Discard`,
click: (): boolean => {
this.isDirty.set(false);
this.unsavedChanges.setUnsavedChanges(this, false);
this.location.back();
return false;
},
},
] as ConfirmationDialogButton[],
true,
);
} else {
this.location.back();
}
}
async saveEvent() {
const entity = this.event()?.entity;
if (entity) {
try {
await this.entityMapper.save(entity);
this.isDirty.set(false);
this.unsavedChanges.setUnsavedChanges(this, false);
} catch (e) {
Logging.warn("Could not save attendance event", e);
this.confirmationDialog.getConfirmation(
$localize`:Error message when saving failed:Error trying to save`,
$localize`An error occurred while saving the event. Please try again.`,
OkButton,
);
}
}
}
showDetails() {
const entity = this.event()?.entity;
if (!entity) return;
this.formDialog.openView(entity);
}
async includeInactive() {
const confirmation = await this.confirmationDialog.getConfirmation(
$localize`Also include archived participants?`,
$localize`This event has some participants who are "archived". We automatically remove them from the attendance list for you. Do you want to also include archived participants for this event?`,
);
if (confirmation) {
const event = this.event();
if (event) {
const inactiveItems = this.inactiveParticipants()
.map((p) => this.attendanceByParticipant()[p.getId()])
.filter((item): item is AttendanceItem => item !== undefined);
event.attendanceItems = [...event.attendanceItems, ...inactiveItems];
}
this.participants.update((p) => [...p, ...this.inactiveParticipants()]);
this.inactiveParticipants.set([]);
this.sortParticipants();
}
}
}
<app-view-title
[disableBackButton]="true"
i18n="Record Attendance|Title when recording attendance for an event"
>
Record Attendance
</app-view-title>
<div class="flex-row align-center relative-left margin-bottom-small">
<button mat-icon-button (click)="exit()" matTooltip="Back" i18n-matTooltip>
<fa-icon icon="arrow-left"></fa-icon>
</button>
@if (event(); as event) {
<h2 class="remove-margin-bottom">{{ event.entity.toString() }}</h2>
}
</div>
@if (eventResource.isLoading() || isInitializing()) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
@if (!eventResource.isLoading() && !event()) {
<p i18n="Error message when attendance event could not be loaded">
Event could not be loaded. Please go back and try again.
</p>
<button
mat-raised-button
(click)="finish()"
i18n="Back button after event load failure"
>
Back to Overview
</button>
}
<!-- Individual Student's Page -->
@if (participants().length > 0) {
<div class="flex-column gap-regular">
<mat-progress-bar
mode="determinate"
[value]="(currentIndex() / participants().length) * 100"
></mat-progress-bar>
<div class="progress-nav flex-row">
<div style="margin-left: -10px">
<button
mat-icon-button
(click)="goToParticipantWithIndex(0)"
[disabled]="isFirst()"
class="button-skip"
>
<fa-icon icon="angle-double-left"></fa-icon>
</button>
<button
mat-icon-button
(click)="goToParticipantWithIndex(currentIndex() - 1)"
[disabled]="isFirst()"
class="button-skip"
>
<fa-icon icon="angle-left"></fa-icon>
</button>
</div>
<div
class="progress-label flex-grow"
[style.visibility]="
currentIndex() < participants().length ? 'visible' : 'hidden'
"
>
{{ currentIndex() + 1 }} / {{ participants().length }}
</div>
<div>
<button
mat-icon-button
(click)="goToParticipantWithIndex(currentIndex() + 1)"
[disabled]="isFinished()"
class="button-skip"
>
<fa-icon icon="angle-right"></fa-icon>
</button>
<button
mat-icon-button
(click)="goToParticipantWithIndex(participants().length)"
[disabled]="isFinished()"
class="button-skip"
>
<fa-icon icon="angle-double-right"></fa-icon>
</button>
</div>
@if (inactiveParticipants().length > 0) {
<div style="margin-right: -10px">
<button
mat-icon-button
(click)="includeInactive()"
[disabled]="isFinished()"
color="warn"
matTooltip="Excluded some archived participants. Click to include."
i18n-matTooltip
>
<fa-icon icon="warning"></fa-icon>
</button>
</div>
}
</div>
@if (!isFinished()) {
<app-entity-block
class="margin-small"
[entity]="currentParticipant()"
[linkDisabled]="true"
>
</app-entity-block>
}
@if (!isFinished() && currentAttendance()) {
<div class="tab-wrapper">
@for (
participant of participants();
track participant.getId();
let i = $index
) {
<app-roll-call-tab
class="tab-body"
[class.tab-body-active]="currentIndex() === i"
[position]="i - currentIndex()"
>
<div>
@for (option of availableStatus(); track option.id) {
<div
role="button"
class="group-select-option"
(click)="markAttendance(option)"
[style.background-color]="
attendanceByParticipant()[participant.getId()]?.status
?.id === option.id
? option.color
: null
"
>
<div
style="
display: flex;
flex-direction: row;
padding: 16px;
gap: 16px;
"
>
@if (
attendanceByParticipant()[participant.getId()]?.status
?.id === option.id
) {
<fa-icon icon="check"></fa-icon>
}
<p style="margin: 0">{{ option.label }}</p>
</div>
</div>
}
</div>
</app-roll-call-tab>
}
</div>
}
</div>
}
<!-- Completion Page -->
@if (isFinished()) {
<div class="flex-column gap-regular margin-top-regular">
<div class="roll-call-complete" @completeRollCall>
<fa-icon icon="check-circle" size="3x"></fa-icon>
<div
i18n="
Attendance completed|shows when the user has registered the attendance
of all children
"
(click)="finish()"
>
Attendance completed.
</div>
<button
(click)="showDetails()"
class="finished-screen-button"
mat-raised-button
color="accent"
i18n="Open details of recorded event for review"
>
Review Details
</button>
</div>
<button
(click)="finish()"
color="primary"
mat-raised-button
i18n="Back to overview button after finishing a roll call"
>
Back to Overview
</button>
</div>
}
./roll-call.component.scss
@use "variables/sizes";
@use "variables/colors";
@use "sass:color";
.group-select-option {
width: 100%;
cursor: pointer;
box-sizing: border-box;
border: 1px solid;
border-top: 0;
&:first-child {
border-top: 1px solid;
}
}
.roll-call-complete {
display: flex;
flex-direction: column;
place-content: center;
place-items: center;
height: 200px;
gap: sizes.$small;
background-color: color.adjust(colors.$success, $alpha: -0.5);
border-radius: 4px;
}
.progress-label {
display: flex;
place-content: center;
}
.attendance-progress-bar {
display: flex;
flex-direction: column;
margin-top: sizes.$small;
margin-bottom: sizes.$small;
}
.progress-nav {
margin-top: -4px;
justify-content: space-between;
align-items: center;
}
.tab-body {
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 0;
display: flex;
overflow-x: hidden;
}
.tab-body-active {
position: relative;
flex-grow: 1;
place-content: center;
z-index: 1;
}
.tab-wrapper {
display: flex;
position: relative;
overflow-x: hidden;
}