src/app/features/attendance/analysis/activity-attendance-section/activity-attendance-section.component.ts

Description

Displays attendance analysis for a given "recurring activity" either related to a specific participant or the overall attendance.

Example

Metadata

Relationships

Depends on

Index

Properties
Methods
Inputs

Constructor

constructor()

Inputs

entity
Type : Entity
forChild
Type : string

Methods

showDetails
showDetails(activity: ActivityAttendance)
Parameters :
Name Type Optional
activity ActivityAttendance No
Returns : void

Properties

attendanceData
Type : unknown
Default value : resource({ params: () => ({ entity: this.entity(), loadAll: this.loadAll() }), loader: async ({ params: { entity, loadAll } }) => { const emptyResult = { records: [] as ActivityAttendance[], isFallbackToOlder: false, hasMoreRecords: false, }; if (!entity) { return emptyResult; } if (loadAll) { return { ...emptyResult, records: await this.attendanceService.getActivityAttendances(entity), }; } let from = moment().startOf("month").subtract(6, "months").toDate(); let records = await this.attendanceService.getActivityAttendances( entity, from, ); let isFallbackToOlder = false; if (records.length === 0) { // fall back to the most recent month with data (if any) const latestEventDate = await this.attendanceService.getLatestEventDate(entity); if (latestEventDate) { from = moment(latestEventDate).startOf("month").toDate(); records = await this.attendanceService.getActivityAttendances( entity, from, ); isFallbackToOlder = records.length > 0; } } return { records, isFallbackToOlder, hasMoreRecords: await this.hasRecordsBefore(entity, from), }; }, })
columns
Type : FormFieldConfig[]
Default value : [ { id: "periodFrom", label: $localize`:The month something took place:Month`, viewComponent: "DisplayMonth", }, { id: "presentEvents", label: $localize`:How many children are present at a meeting|Title of table column:Present`, viewComponent: "ReadonlyFunction", additional: (e: ActivityAttendance) => this.forChild() ? e.countEventsPresent(this.forChild()) : e.countTotalPresent(), }, { id: "totalEvents", label: $localize`:Events of an attendance:Events`, viewComponent: "ReadonlyFunction", additional: (e: ActivityAttendance) => e.countEventsTotal(), }, { id: "attendancePercentage", label: $localize`:Percentage of people that attended an event:Attended`, viewComponent: "ReadonlyFunction", additional: (e: ActivityAttendance) => { const pct = this.forChild() ? e.getAttendancePercentage(this.forChild()) : e.getAttendancePercentageAverage(); return pct !== undefined ? formatPercent(pct, this.locale, "1.0-0") : "-"; }, }, ]
combinedAttendance
Type : unknown
Default value : computed(() => { const combined = new ActivityAttendance(); combined.activity = this.entity(); this.allRecords().forEach((record) => { combined.events.push(...record.events); if ( !combined.periodFrom || moment(record.periodFrom).isBefore(combined.periodFrom, "day") ) { combined.periodFrom = record.periodFrom; } if ( !combined.periodTo || moment(record.periodTo).isAfter(combined.periodTo, "day") ) { combined.periodTo = record.periodTo; } }); return combined; })
Readonly dataSource
Type : unknown
Default value : new InMemoryDataSource<ActivityAttendance>()
entityCtr
Type : unknown
Default value : ActivityAttendance
getBackgroundColor
Type : unknown
Default value : computed( () => (rec: ActivityAttendance) => rec.getColor(this.forChild()), )
hasMoreRecords
Type : unknown
Default value : computed( () => this.attendanceData.value()?.hasMoreRecords ?? false, )

Whether events older than the currently displayed records exist and can be loaded.

includeWithoutParticipation
Type : unknown
Default value : signal(false)
isFallbackToOlder
Type : unknown
Default value : computed( () => this.attendanceData.value()?.isFallbackToOlder ?? false, )

Whether older records are displayed because the default time range had none.

loadAll
Type : unknown
Default value : linkedSignal({ source: () => this.entity()?.getId(), computation: () => false, })

Resets to false whenever entity changes; set to true by "Load all records" button.

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  input,
  linkedSignal,
  LOCALE_ID,
  resource,
  signal,
} from "@angular/core";
import { Entity } from "#src/app/core/entity/model/entity";
import { AttendanceDetailsComponent } from "../attendance-details/attendance-details.component";
import { AttendanceService } from "../../attendance.service";
import { formatPercent } from "@angular/common";
import { ActivityAttendance } from "../../model/activity-attendance";
import moment from "moment";
import { FormFieldConfig } from "#src/app/core/common-components/entity-form/FormConfig";
import { DynamicComponent } from "#src/app/core/config/dynamic-components/dynamic-component.decorator";
import { EntityMapperService } from "#src/app/core/entity/entity-mapper/entity-mapper.service";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { debounceTime, merge } from "rxjs";
import { MatProgressBarModule } from "@angular/material/progress-bar";
import { MatSlideToggleModule } from "@angular/material/slide-toggle";
import { MatTooltipModule } from "@angular/material/tooltip";
import { MatButtonModule } from "@angular/material/button";
import { AttendanceCalendarComponent } from "../attendance-calendar/attendance-calendar.component";
import { AttendanceSummaryComponent } from "../attendance-summary/attendance-summary.component";
import { MatDialog } from "@angular/material/dialog";
import { EntitiesTableComponent } from "#src/app/core/common-components/entities-table/entities-table.component";
import { InMemoryDataSource } from "#src/app/core/common-components/entities-table/data-source/in-memory-data-source";
import { FaIconComponent } from "@fortawesome/angular-fontawesome";

/**
 * Displays attendance analysis for a given "recurring activity"
 * either related to a specific participant or the overall attendance.
 */
@DynamicComponent("ActivityAttendanceSection")
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-activity-attendance-section",
  templateUrl: "./activity-attendance-section.component.html",
  imports: [
    MatProgressBarModule,
    EntitiesTableComponent,
    MatSlideToggleModule,
    MatTooltipModule,
    MatButtonModule,
    AttendanceCalendarComponent,
    AttendanceSummaryComponent,
    FaIconComponent,
  ],
})
@UntilDestroy()
export class ActivityAttendanceSectionComponent {
  private attendanceService = inject(AttendanceService);
  private entityMapper = inject(EntityMapperService);
  private locale = inject(LOCALE_ID);
  private dialog = inject(MatDialog);

  entity = input<Entity>();
  forChild = input<string>();

  /** Resets to false whenever entity changes; set to true by "Load all records" button. */
  loadAll = linkedSignal({
    source: () => this.entity()?.getId(),
    computation: () => false,
  });
  includeWithoutParticipation = signal(false);

  attendanceData = resource({
    params: () => ({ entity: this.entity(), loadAll: this.loadAll() }),
    loader: async ({ params: { entity, loadAll } }) => {
      const emptyResult = {
        records: [] as ActivityAttendance[],
        isFallbackToOlder: false,
        hasMoreRecords: false,
      };
      if (!entity) {
        return emptyResult;
      }
      if (loadAll) {
        return {
          ...emptyResult,
          records: await this.attendanceService.getActivityAttendances(entity),
        };
      }

      let from = moment().startOf("month").subtract(6, "months").toDate();
      let records = await this.attendanceService.getActivityAttendances(
        entity,
        from,
      );
      let isFallbackToOlder = false;

      if (records.length === 0) {
        // fall back to the most recent month with data (if any)
        const latestEventDate =
          await this.attendanceService.getLatestEventDate(entity);
        if (latestEventDate) {
          from = moment(latestEventDate).startOf("month").toDate();
          records = await this.attendanceService.getActivityAttendances(
            entity,
            from,
          );
          isFallbackToOlder = records.length > 0;
        }
      }

      return {
        records,
        isFallbackToOlder,
        hasMoreRecords: await this.hasRecordsBefore(entity, from),
      };
    },
  });

  /** Whether any events exist before the given date (i.e. more records can be loaded). */
  private async hasRecordsBefore(entity: Entity, date: Date) {
    const earliestEventDate =
      await this.attendanceService.getEarliestEventDate(entity);
    return (
      !!earliestEventDate && moment(earliestEventDate).isBefore(date, "day")
    );
  }

  private readonly allRecords = computed(
    () => this.attendanceData.value()?.records ?? [],
  );

  /** Whether older records are displayed because the default time range had none. */
  isFallbackToOlder = computed(
    () => this.attendanceData.value()?.isFallbackToOlder ?? false,
  );

  /** Whether events older than the currently displayed records exist and can be loaded. */
  hasMoreRecords = computed(
    () => this.attendanceData.value()?.hasMoreRecords ?? false,
  );

  entityCtr = ActivityAttendance;

  readonly dataSource = new InMemoryDataSource<ActivityAttendance>();

  combinedAttendance = computed(() => {
    const combined = new ActivityAttendance();
    combined.activity = this.entity();
    this.allRecords().forEach((record) => {
      combined.events.push(...record.events);
      if (
        !combined.periodFrom ||
        moment(record.periodFrom).isBefore(combined.periodFrom, "day")
      ) {
        combined.periodFrom = record.periodFrom;
      }
      if (
        !combined.periodTo ||
        moment(record.periodTo).isAfter(combined.periodTo, "day")
      ) {
        combined.periodTo = record.periodTo;
      }
    });
    return combined;
  });

  columns: FormFieldConfig[] = [
    {
      id: "periodFrom",
      label: $localize`:The month something took place:Month`,
      viewComponent: "DisplayMonth",
    },
    {
      id: "presentEvents",
      label: $localize`:How many children are present at a meeting|Title of table column:Present`,
      viewComponent: "ReadonlyFunction",
      additional: (e: ActivityAttendance) =>
        this.forChild()
          ? e.countEventsPresent(this.forChild())
          : e.countTotalPresent(),
    },
    {
      id: "totalEvents",
      label: $localize`:Events of an attendance:Events`,
      viewComponent: "ReadonlyFunction",
      additional: (e: ActivityAttendance) => e.countEventsTotal(),
    },
    {
      id: "attendancePercentage",
      label: $localize`:Percentage of people that attended an event:Attended`,
      viewComponent: "ReadonlyFunction",
      additional: (e: ActivityAttendance) => {
        const pct = this.forChild()
          ? e.getAttendancePercentage(this.forChild())
          : e.getAttendancePercentageAverage();
        return pct !== undefined
          ? formatPercent(pct, this.locale, "1.0-0")
          : "-";
      },
    },
  ];

  constructor() {
    this.subscribeToEventUpdates();

    effect(() => {
      const forChild = this.forChild();
      let records: ActivityAttendance[];
      if (this.includeWithoutParticipation() || !forChild) {
        records = [...this.allRecords()];
      } else {
        records = this.allRecords().filter(
          (r) =>
            r.countEventsAbsent(forChild) + r.countEventsPresent(forChild) > 0,
        );
      }
      if (records?.length > 0) {
        records.sort((a, b) => b.periodFrom.getTime() - a.periodFrom.getTime());
      }
      this.dataSource.allRecords.set(records);
    });
  }

  private subscribeToEventUpdates() {
    const eventTypes = this.attendanceService.eventTypes();
    if (eventTypes.length === 0) {
      return;
    }
    merge(...eventTypes.map((type) => this.entityMapper.receiveUpdates(type)))
      .pipe(debounceTime(500), untilDestroyed(this))
      .subscribe((update) => {
        const wrapped = this.attendanceService.wrapEventEntity(update.entity);
        if (wrapped.activityId === this.entity()?.getId()) {
          this.attendanceData.reload();
        }
      });
  }

  showDetails(activity: ActivityAttendance) {
    this.dialog.open(AttendanceDetailsComponent, {
      data: {
        forChild: this.forChild(),
        attendance: activity,
      },
    });
  }

  getBackgroundColor = computed(
    () => (rec: ActivityAttendance) => rec.getColor(this.forChild()),
  );
}
@if (attendanceData.isLoading()) {
  <div>
    <mat-progress-bar mode="indeterminate"></mat-progress-bar>
  </div>
} @else {
  @if (isFallbackToOlder()) {
    <div
      class="flex-row flex-wrap align-center gap-small margin-bottom-regular margin-top-regular"
    >
      <fa-icon class="color-error" icon="triangle-exclamation"></fa-icon>
      <span
        class="color-error"
        i18n="
          attendance fallback hint|explains that older records are shown because
          there are no records within the default recent time range
        "
        >No records within the last 6 months. Showing the most recent records
        instead.</span
      >
      @if (hasMoreRecords()) {
        <button
          i18n="
            load-all button|load all records, not only the ones from the last 6
            months
          "
          mat-flat-button
          color="accent"
          (click)="loadAll.set(true)"
        >
          Load all records
        </button>
      }
    </div>
  }
  <div class="flex-row flex-wrap gap-large">
    <app-attendance-calendar
      class="flex-grow"
      [records]="combinedAttendance()?.events ?? []"
      [highlightForChild]="forChild()"
      [activity]="entity()"
    ></app-attendance-calendar>
    <app-attendance-summary
      [attendance]="combinedAttendance()"
      [forChild]="forChild()"
      [columns]="columns"
      class="margin-top-regular flex-grow"
    ></app-attendance-summary>
  </div>
}

<app-entities-table
  [entityType]="entityCtr"
  [recordsDataSource]="dataSource"
  [customColumns]="columns"
  clickMode="none"
  (entityClick)="showDetails($event)"
  [getBackgroundColor]="getBackgroundColor()"
  [editable]="false"
>
</app-entities-table>

<div class="margin-top-regular">
  <mat-slide-toggle
    i18n="
      slider|show unrelated attendance-entries for an activity that are not
      linked to the child of interest
    "
    (change)="includeWithoutParticipation.set($event.checked)"
    i18n-matTooltip="
      Show unrelated tooltip|Tooltip that will appear when hovered over the
      show-unrelated button
    "
    matTooltip="Activate to also show entries for the activity that do not have any events with actual participation of this person"
  >
    Also show unrelated
  </mat-slide-toggle>
  &nbsp;
  @if (!isFallbackToOlder() && hasMoreRecords()) {
    <button
      i18n="
        load-all button|load all records, not only the ones from the last 6
        months
      "
      mat-stroked-button
      (click)="loadAll.set(true)"
    >
      Load all records
    </button>
  }
</div>
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""