src/app/features/attendance/analysis/attendance-calendar/attendance-calendar.component.ts

Description

Displays a calendar view of attendance events for a given activity, allowing drill-down into details and editing attendance for individual events.

This component functions (almost) as a "display component" and relies on inputs to receive data and updates.

Example

Metadata

Relationships

Depends on

Index

Properties
Methods
Inputs

Constructor

constructor()

Inputs

activity
Type : Entity
highlightForChild
Type : string
records
Type : EventWithAttendance[]
Default value : []

Methods

createNewEvent
createNewEvent()
Returns : void
onRemarksChange
onRemarksChange(remarks: string)
Parameters :
Name Type Optional
remarks string No
Returns : void
Async save
save()
Returns : any
selectDay
selectDay(newDate?: Date)
Parameters :
Name Type Optional
newDate Date Yes
Returns : void
showEventDetails
showEventDetails(selectedEvent: EventWithAttendance)
Parameters :
Name Type Optional
selectedEvent EventWithAttendance No
Returns : void

Properties

Readonly calendar
Type : unknown
Default value : viewChild<MatCalendar<Date>>(MatCalendar)
hasAverage
Type : unknown
Default value : computed(() => { return !Number.isNaN(this.selectedEventStats()?.average); })
highlightDate
Type : unknown
Default value : () => {...}
maxDate
Type : unknown
Default value : computed<Date | undefined>(() => { const dates = this._recordDates(); return dates.length > 0 ? moment.max(dates).endOf("month").toDate() : undefined; })
minDate
Type : unknown
Default value : computed<Date | undefined>(() => { const dates = this._recordDates(); return dates.length > 0 ? moment.min(dates).startOf("month").toDate() : undefined; })
selectedDate
Type : unknown
Default value : signal<moment.Moment | undefined>(undefined)
selectedEvent
Type : unknown
Default value : computed(() => this.records().find((e) => this.selectedDate()?.isSame(e.date, "day")), )
selectedEventAttendance
Type : unknown
Default value : signal<AttendanceItem | undefined>(undefined)
selectedEventParticipantCount
Type : unknown
Default value : computed( () => this.selectedEvent()?.attendanceItems.length ?? 0, )
selectedEventStats
Type : unknown
Default value : computed(() => this.selectedEvent()?.getAttendanceStats(), )
statusControl
Type : unknown
Default value : new FormControl<ConfigurableEnumValue>(null)
statusFieldConfig
Type : FormFieldConfig
Default value : { id: "status", dataType: "configurable-enum", additional: ATTENDANCE_STATUS_CONFIG_ID, }
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  DestroyRef,
  effect,
  inject,
  input,
  signal,
  untracked,
  ViewEncapsulation,
  viewChild,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl, ReactiveFormsModule, FormsModule } from "@angular/forms";
import { Entity } from "#src/app/core/entity/model/entity";
import {
  MatCalendar,
  MatCalendarCellCssClasses,
  MatDatepickerModule,
} from "@angular/material/datepicker";
import moment from "moment";
import { AttendanceItem } from "../../model/attendance-item";
import {
  ATTENDANCE_STATUS_CONFIG_ID,
  AttendanceStatusType,
  NullAttendanceStatusType,
} from "../../model/attendance-status";
import { EntityMapperService } from "#src/app/core/entity/entity-mapper/entity-mapper.service";
import { FormDialogService } from "#src/app/core/form-dialog/form-dialog.service";
import { AttendanceService } from "../../attendance.service";
import { AnalyticsService } from "#src/app/core/analytics/analytics.service";
import { PercentPipe } from "@angular/common";
import { CustomDatePipe } from "#src/app/core/basic-datatypes/date/custom-date.pipe";
import { EditConfigurableEnumComponent } from "#src/app/core/basic-datatypes/configurable-enum/edit-configurable-enum/edit-configurable-enum.component";
import { FormFieldConfig } from "#src/app/core/common-components/entity-form/FormConfig";
import { ConfigurableEnumValue } from "#src/app/core/basic-datatypes/configurable-enum/configurable-enum.types";
import { MatFormFieldModule } from "@angular/material/form-field";
import { MatInputModule } from "@angular/material/input";
import { MatButtonModule } from "@angular/material/button";
import { Angulartics2Module } from "angulartics2";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { EventWithAttendance } from "../../model/event-with-attendance";
import { Logging } from "#src/app/core/logging/logging.service";

/**
 * Displays a calendar view of attendance events for a given activity,
 * allowing drill-down into details and editing attendance for individual events.
 *
 * This component functions (almost) as a "display component"
 * and relies on inputs to receive data and updates.
 */
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-attendance-calendar",
  templateUrl: "./attendance-calendar.component.html",
  styleUrls: [
    "./attendance-calendar.component.scss",
    "../../../../core/common-components/dialog-close/dialog-close.component.scss",
  ],
  encapsulation: ViewEncapsulation.None,
  imports: [
    MatDatepickerModule,
    CustomDatePipe,
    EditConfigurableEnumComponent,
    ReactiveFormsModule,
    MatFormFieldModule,
    MatInputModule,
    FormsModule,
    PercentPipe,
    MatButtonModule,
    Angulartics2Module,
    FontAwesomeModule,
  ],
})
export class AttendanceCalendarComponent {
  private entityMapper = inject(EntityMapperService);
  private formDialog = inject(FormDialogService);
  private analyticsService = inject(AnalyticsService);
  private attendanceService = inject(AttendanceService);
  private readonly destroyRef = inject(DestroyRef);

  records = input<EventWithAttendance[]>([]);
  highlightForChild = input<string>();
  activity = input<Entity>();

  readonly calendar = viewChild<MatCalendar<Date>>(MatCalendar);

  private readonly _recordDates = computed(() =>
    this.records()
      .map((e) => e.date)
      .filter((d): d is Date => !!d)
      .map((d) => moment(d)),
  );
  minDate = computed<Date | undefined>(() => {
    const dates = this._recordDates();
    return dates.length > 0
      ? moment.min(dates).startOf("month").toDate()
      : undefined;
  });
  maxDate = computed<Date | undefined>(() => {
    const dates = this._recordDates();
    return dates.length > 0
      ? moment.max(dates).endOf("month").toDate()
      : undefined;
  });

  selectedDate = signal<moment.Moment | undefined>(undefined);
  selectedEvent = computed(() =>
    this.records().find((e) => this.selectedDate()?.isSame(e.date, "day")),
  );
  selectedEventStats = computed(() =>
    this.selectedEvent()?.getAttendanceStats(),
  );
  selectedEventAttendance = signal<AttendanceItem | undefined>(undefined);
  private selectedEventAttendanceOriginal: AttendanceItem | undefined;

  statusControl = new FormControl<ConfigurableEnumValue>(null);
  statusFieldConfig: FormFieldConfig = {
    id: "status",
    dataType: "configurable-enum",
    additional: ATTENDANCE_STATUS_CONFIG_ID,
  };

  constructor() {
    // Update calendar's visible month when date range changes
    effect(() => {
      const maxDate = this.maxDate();
      const calendar = this.calendar();
      if (calendar && maxDate) {
        calendar.activeDate = maxDate;
      }
    });

    // Sync attendance state when selected event or highlighted child changes
    effect(() => {
      const event = this.selectedEvent();
      const childId = this.highlightForChild();
      if (event && childId) {
        const attendance = untracked(() =>
          this.ensureParticipantInAttendance(event, childId),
        );
        this.selectedEventAttendance.set(attendance);
        this.selectedEventAttendanceOriginal = attendance.copy();
        this.statusControl.setValue(attendance.status ?? null, {
          emitEvent: false,
        });
      } else {
        this.selectedEventAttendance.set(undefined);
        this.selectedEventAttendanceOriginal = undefined;
        this.statusControl.setValue(null, { emitEvent: false });
      }
    });

    this.statusControl.valueChanges
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe((status) => {
        const attendance = this.selectedEventAttendance();
        if (attendance) {
          const normalizedStatus: AttendanceStatusType =
            (status as AttendanceStatusType) ?? NullAttendanceStatusType;
          const updatedAttendance = attendance.copy();
          updatedAttendance.status = normalizedStatus;
          this.selectedEventAttendance.set(updatedAttendance);
          this.save().catch((error) =>
            Logging.warn("Could not save attendance status change", error),
          );
        }
      });
  }

  private ensureParticipantInAttendance(
    event: EventWithAttendance,
    participantId: string,
  ): AttendanceItem {
    let item = event.getAttendanceForParticipant(participantId);
    if (!item) {
      item = new AttendanceItem(NullAttendanceStatusType, "", participantId);
      event.attendanceItems = [...event.attendanceItems, item];
    }
    return item;
  }

  selectedEventParticipantCount = computed(
    () => this.selectedEvent()?.attendanceItems.length ?? 0,
  );

  highlightDate = (cellDate: Date): MatCalendarCellCssClasses => {
    const cellMoment = moment(cellDate);
    const classes = {
      "attendance-calendar-date-general": true,
    };

    if (this.selectedDate()) {
      classes["attendance-calendar-date-selected"] = cellMoment.isSame(
        this.selectedDate(),
        "day",
      );
    }

    const event = this.records().find((e) => cellMoment.isSame(e.date, "day"));
    if (event && this.highlightForChild()) {
      // coloring for individual child
      const eventAttendance = event.getAttendanceForParticipant(
        this.highlightForChild(),
      );

      if (!eventAttendance?.status?.id) {
        classes[
          "attendance-calendar-date-has-participants-with-unknown-status"
        ] = true;
      } else {
        classes["attendance-status-" + eventAttendance.status.id] = true;
      }

      classes["attendance-calendar-date-has-remarks"] =
        eventAttendance?.remarks && eventAttendance?.remarks !== "";
    }

    if (event && !this.highlightForChild()) {
      // coloring based on averages across all children
      const stats = event.getAttendanceStats();

      if (isNaN(stats.average)) {
        classes["attendance-calendar-date-no-data"] = true;
      } else {
        const percentageSlab = Math.round(stats.average * 10) * 10;
        classes["w-" + percentageSlab] = true;
      }

      classes["attendance-calendar-date-has-participants-with-unknown-status"] =
        stats.excludedUnknown > 0;
    }

    return classes;
  };

  hasAverage = computed(() => {
    return !Number.isNaN(this.selectedEventStats()?.average);
  });

  selectDay(newDate?: Date) {
    this.selectedDate.set(newDate ? moment(newDate) : undefined);

    if (newDate) {
      this.analyticsService.eventTrack("calendar_select_date", {
        category: "Attendance",
        label: this.selectedEvent() ? "with event" : "without event",
      });
    }

    this.calendar()?.updateTodaysDate();
  }

  async save() {
    const attendance = this.selectedEventAttendance();
    const original = this.selectedEventAttendanceOriginal;
    if (
      attendance?.status === original?.status &&
      attendance?.remarks === original?.remarks
    ) {
      // don't write unchanged object
      return;
    }

    await this.entityMapper.save(this.selectedEvent().entity);

    this.analyticsService.eventTrack("calendar_save_event_changes", {
      category: "Attendance",
    });
  }

  onRemarksChange(remarks: string) {
    const att = this.selectedEventAttendance();
    if (!att) return;
    const updatedAttendance = att.copy();
    updatedAttendance.remarks = remarks;
    this.selectedEventAttendance.set(updatedAttendance);
  }

  createNewEvent() {
    const activity = this.activity();
    if (!activity) {
      return;
    }
    this.attendanceService
      .createEventForActivity(activity, this.selectedDate().toDate())
      .then((note) => {
        this.formDialog.openView(note.entity);
      });
  }

  showEventDetails(selectedEvent: EventWithAttendance) {
    this.formDialog.openView(selectedEvent.entity);
  }
}
<mat-calendar
  (selectedChange)="selectDay($event)"
  [dateClass]="highlightDate"
  [minDate]="minDate()"
  [maxDate]="maxDate()"
>
</mat-calendar>

@if (selectedDate()) {
  <div class="attendance-calendar-details-form">
    <h3 class="remove-margin-top">
      {{ selectedDate()!.toDate() | customDate: "shortDate" }}
    </h3>
    <button
      mat-icon-button
      class="overlay-close-button"
      (click)="selectDay()"
      type="button"
    >
      <fa-icon icon="times"></fa-icon>
    </button>
    @if (selectedEvent(); as selectedEvent) {
      @if (highlightForChild()) {
        <div>
          <div>
            <mat-form-field>
              <mat-label i18n="Attendance Status field label">Status</mat-label>
              <app-edit-configurable-enum
                [formControl]="statusControl"
                [formFieldConfig]="statusFieldConfig"
              ></app-edit-configurable-enum>
            </mat-form-field>
          </div>
          <div>
            <mat-form-field class="full-width">
              <textarea
                matInput
                i18n-placeholder="
                  Placeholder if no remarks for the attendance of a child are
                  given
                "
                placeholder="Remarks"
                [ngModel]="selectedEventAttendance()?.remarks"
                (ngModelChange)="onRemarksChange($event)"
                (blur)="save()"
              ></textarea>
            </mat-form-field>
          </div>
          <button
            class="margin-top-regular"
            i18n="
              Show Details Button|Allows the user to see details of an event
              that took place at a particular day
            "
            mat-stroked-button
            (click)="showEventDetails(selectedEvent)"
            angulartics2On="click"
            angularticsCategory="Attendance"
            angularticsAction="calendar_show_event_details"
          >
            Details
          </button>
        </div>
      } @else {
        <div>
          @if (!hasAverage()) {
            <div
              i18n="
                Event participants|How many participants attended at an event
                (in percent)
              "
            >
              All excused (out of
              {{ selectedEventParticipantCount() }}
              {selectedEventParticipantCount(), plural,
                one {participant}
                other {participants}
              }
              )
            </div>
          }
          @if (hasAverage()) {
            <div i18n>
              {{ selectedEventStats()!.average | percent: "1.0-0" }}
              attended (of
              {{ selectedEventParticipantCount() }}
              {selectedEventParticipantCount(), plural,
                one {participant}
                other {participants}

              })
            </div>
          }
          @if (selectedEventStats()!.excludedUnknown > 0) {
            <div
              i18n="Unknown status|How many children are without a status"
              class="unknown-status-notice"
            >
              {{ selectedEventStats()!.excludedUnknown }}
              {selectedEventStats()!.excludedUnknown, plural,
                one {participant}
                other {participants}
              }
              without recorded status
            </div>
          }
          <button
            class="margin-top-regular"
            i18n="
              Show Details Button|Allows the user to see details of an event
              that took place at a particular day
            "
            mat-stroked-button
            (click)="showEventDetails(selectedEvent)"
            angulartics2On="click"
            angularticsCategory="Attendance"
            angularticsAction="calendar_show_event_details"
          >
            Details
          </button>
        </div>
      }
    } @else {
      <em
        i18n="
          No Events|Informs the user that there are no events at a particular
          date
        "
      >
        no events on this date
      </em>
      <button
        mat-stroked-button
        (click)="createNewEvent()"
        i18n="
          Add New Event Button|Allows the user to create a new event for the
          selected date
        "
        angulartics2On="click"
        angularticsCategory="Attendance"
        angularticsAction="calendar_create_event"
        style="margin-top: 12px; display: block"
      >
        Add new event
      </button>
    }
  </div>
}

./attendance-calendar.component.scss

@use "variables/colors";
@use "functions/gradient-helpers";
@use "variables/sizes";

/* Per-status calendar cell background colors (referenced via highlightDate in the component) */
.attendance-status-PRESENT {
  background-color: #c8e6c9 !important;
}
.attendance-status-ABSENT {
  background-color: #ff8a65 !important;
}
.attendance-status-LATE {
  background-color: #ffecb3 !important;
}
.attendance-status-EXCUSED {
  background-color: #d7ccc8 !important;
}

mat-calendar {
  min-width: 200px;
  max-width: 300px;
}

.unknown-status-notice {
  font-style: italic;
  color: colors.$error;
}

.attendance-calendar-date-general {
  &.attendance-calendar-date-selected {
    font-weight: 900;
    font-size: 115%;
  }

  &.attendance-calendar-date-has-remarks {
    background: gradient-helpers.top-right-triangle(#5f5f5f);
  }

  &.attendance-calendar-date-no-data {
    background: gradient-helpers.top-right-triangle(grey);
  }

  &.attendance-calendar-date-has-participants-with-unknown-status {
    background: gradient-helpers.top-right-triangle(red);
  }
}

.attendance-calendar-details-form {
  padding: 10px;
  max-width: 300px;
  position: relative;

  border: 5px solid black;
  box-sizing: border-box;
  margin-bottom: sizes.$regular;
}

../../../../core/common-components/dialog-close/dialog-close.component.scss

/**
 * Positions the 'x' button that is shown for an overlay to
 * the top-right of the overlay
 */
.overlay-close-button {
  position: absolute;
  top: 0;
  right: 0;
  border-radius: 0 4px 0 4px;
}

:host ::ng-deep .mat-mdc-icon-button .mat-mdc-button-persistent-ripple {
  border-radius: 0 4px 0 4px;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""