src/app/features/attendance/add-day-attendance/roll-call/roll-call.component.ts

Description

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.

Example

Metadata

Relationships

Used by

No results matching.

Depends on

Index

Properties
Methods
Inputs

Constructor

constructor()

Inputs

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

Methods

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 :
Name Type Optional
newIndex number No
Returns : void
Async includeInactive
includeInactive()
Returns : any
markAttendance
markAttendance(status: AttendanceStatusType)
Parameters :
Name Type Optional
status AttendanceStatusType No
Returns : void
Async saveEvent
saveEvent()
Returns : any
showDetails
showDetails()
Returns : void

Properties

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 currentAttendance
Type : unknown
Default value : computed(() => { const participant = this.currentParticipant(); return participant ? this.attendanceByParticipant()[participant.getId()] : undefined; })

The attendance item of the current participant

Readonly currentIndex
Type : unknown
Default value : signal(0)

The index of the participant currently being processed

Readonly currentParticipant
Type : unknown
Default value : computed(() => { const p = this.participants(); const i = this.currentIndex(); return i < p.length ? p[i] : undefined; })

The participant currently being processed

Readonly event
Type : unknown
Default value : computed<EventWithAttendance | undefined>(() => { return this.eventResource.value(); })

The resolved event, wrapped with typed attendance and date accessors.

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;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""