src/app/features/dashboard-widgets/entity-count-dashboard-widget/entity-count-dashboard/entity-count-dashboard.component.ts

Metadata

Relationships

Used by

No results matching.

Depends on

Index

Properties
Methods
Inputs

Constructor

constructor()

Inputs

entityType
Type : string
Default value : "Child"
explanation
Type : string
Default value : $localize`:dashboard widget explanation:Counting all "active" records. If configured, you can view different disaggregations by using the arrows below.`
groupBy
Type : string[]
Default value : ["center", "gender"]
subtitle
Type : string

Methods

getNext
getNext()
Returns : void
getPrev
getPrev()
Returns : void
Static getRequiredEntities
getRequiredEntities(config: EntityCountDashboardConfig)
Parameters :
Name Type Optional
config EntityCountDashboardConfig No
Returns : string
goToEntityList
goToEntityList(filterId: string)
Parameters :
Name Type Optional
filterId string No
Returns : void

Properties

currentGroupField
Type : unknown
Default value : computed(() => { const groupBy = this.groupBy(); if (groupBy.length === 0) { return undefined; } return groupBy[this.currentGroupIndex() % groupBy.length]; })
currentGroupIndex
Type : unknown
Default value : signal(0)
entityDefinition
Type : unknown
Default value : computed(() => this.entities.get(this.entityType()))
entityGroupCounts
Type : unknown
Default value : computed<Record<string, GroupCountRow[]>>(() => { const result: Record<string, GroupCountRow[]> = {}; const entityDefinition = this.entityDefinition(); const activeEntities = this.rawEntities().filter( (entity) => !entity.inactive, ); for (const groupByField of this.groupBy()) { result[groupByField] = this.calculateGroupCounts( activeEntities, groupByField, entityDefinition, ); } return result; })
totalEntities
Type : unknown
Default value : computed( () => this.rawEntities().filter((entity) => !entity.inactive).length, )
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  input,
  signal,
  untracked,
} from "@angular/core";
import { Router } from "@angular/router";
import { MatIconButton } from "@angular/material/button";
import { MatTableModule } from "@angular/material/table";
import { MatTooltipModule } from "@angular/material/tooltip";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { Angulartics2Module } from "angulartics2";
import { ConfigurableEnumService } from "app/core/basic-datatypes/configurable-enum/configurable-enum.service";
import { ConfigurableEnumValue } from "app/core/basic-datatypes/configurable-enum/configurable-enum.types";
import { DynamicComponent } from "#src/app/core/config/dynamic-components/dynamic-component.decorator";
import { DashboardListWidgetComponent } from "#src/app/core/dashboard/dashboard-list-widget/dashboard-list-widget.component";
import { EntityRegistry } from "#src/app/core/entity/database-entity.decorator";
import { getEntityRuntimeRoute } from "#src/app/core/entity/entity-config.service";
import { EntityFieldLabelComponent } from "#src/app/core/entity/entity-field-label/entity-field-label.component";
import { EntityMapperService } from "#src/app/core/entity/entity-mapper/entity-mapper.service";
import { Entity, EntityConstructor } from "#src/app/core/entity/model/entity";
import { applyUpdate } from "#src/app/core/entity/model/entity-update";
import { groupBy } from "#src/app/utils/utils";
import { EntityFieldViewComponent } from "#src/app/core/entity/entity-field-view/entity-field-view.component";

/**
 * Configuration (stored in Config document in the DB) for the dashboard widget.
 */
export interface EntityCountDashboardConfig {
  entityType?: string;
  groupBy?: string[];
}

/**
 * Details of one row of disaggregated counts (e.g. for a specific category value) to be displayed.
 */
interface GroupCountRow {
  label: string | undefined;
  id: string;

  /**
   * The count of entities part of this group
   */
  value: number;

  isInvalidOption?: boolean;
  color?: string;

  /**
   * entity with the field value set for display component rendering
   */
  entity?: Entity;

  /**
   * Field name for the display component
   */
  fieldName?: string;
}

@DynamicComponent("ChildrenCountDashboard")
@DynamicComponent("EntityCountDashboard")
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-entity-count-dashboard-widget",
  templateUrl: "./entity-count-dashboard.component.html",
  styleUrls: ["./entity-count-dashboard.component.scss"],
  imports: [
    MatTableModule,
    FontAwesomeModule,
    Angulartics2Module,
    DashboardListWidgetComponent,
    MatTooltipModule,
    MatIconButton,
    EntityFieldLabelComponent,
    EntityFieldViewComponent,
  ],
})
export class EntityCountDashboardComponent {
  private readonly entityMapper = inject(EntityMapperService);
  private readonly router = inject(Router);
  private readonly entities = inject(EntityRegistry);
  private readonly configurableEnum = inject(ConfigurableEnumService);

  private rawEntities = signal<Entity[]>([]);
  currentGroupIndex = signal(0);

  entityType = input("Child");
  groupBy = input<string[]>(["center", "gender"]);
  subtitle = input<string>();
  explanation = input<string>(
    $localize`:dashboard widget explanation:Counting all "active" records. If configured, you can view different disaggregations by using the arrows below.`,
  );

  entityDefinition = computed(() => this.entities.get(this.entityType()));
  totalEntities = computed(
    () => this.rawEntities().filter((entity) => !entity.inactive).length,
  );

  entityGroupCounts = computed<Record<string, GroupCountRow[]>>(() => {
    const result: Record<string, GroupCountRow[]> = {};
    const entityDefinition = this.entityDefinition();
    const activeEntities = this.rawEntities().filter(
      (entity) => !entity.inactive,
    );

    for (const groupByField of this.groupBy()) {
      result[groupByField] = this.calculateGroupCounts(
        activeEntities,
        groupByField,
        entityDefinition,
      );
    }

    return result;
  });

  currentGroupField = computed(() => {
    const groupBy = this.groupBy();
    if (groupBy.length === 0) {
      return undefined;
    }

    return groupBy[this.currentGroupIndex() % groupBy.length];
  });

  constructor() {
    effect((onCleanup) => {
      const entityType = this.entityType();
      const entityDefinition = this.entityDefinition();
      let isCurrent = true;

      this.rawEntities.set([]);
      this.currentGroupIndex.set(0);

      untracked(async () => {
        const entities = await this.entityMapper.loadType(entityDefinition);
        if (isCurrent) {
          this.rawEntities.set(entities);
        }
      });

      const subscription = this.entityMapper
        .receiveUpdates(entityType)
        .subscribe((update) => {
          this.rawEntities.update(
            (current) => applyUpdate(current, update) as Entity[],
          );
        });

      onCleanup(() => {
        isCurrent = false;
        subscription.unsubscribe();
      });
    });
  }

  getPrev() {
    const groupBy = this.groupBy();
    if (groupBy.length === 0) {
      return;
    }

    this.currentGroupIndex.update(
      (index) => (index - 1 + groupBy.length) % groupBy.length,
    );
  }

  getNext() {
    const groupBy = this.groupBy();
    if (groupBy.length === 0) {
      return;
    }

    this.currentGroupIndex.update((index) => (index + 1) % groupBy.length);
  }

  static getRequiredEntities(config: EntityCountDashboardConfig) {
    return config?.entityType || "Child";
  }

  private calculateGroupCounts(
    entities: Entity[],
    fieldName: string,
    entityDefinition: EntityConstructor,
  ): GroupCountRow[] {
    const field = entityDefinition.schema.get(fieldName);

    let groupCounts = this.getGroupCounts(
      entities,
      fieldName,
      entityDefinition,
    );
    groupCounts = this.mergeNotDefinedGroups(groupCounts);

    if (field?.dataType === "configurable-enum") {
      return this.getEnumGroupCounts(groupCounts, field);
    }

    return groupCounts;
  }

  /** Groups entities by field and returns initial groupCounts array */
  private getGroupCounts(
    entities: Entity[],
    fieldName: string,
    entityDefinition: EntityConstructor,
  ): GroupCountRow[] {
    const groups = groupBy(entities, fieldName as keyof Entity);
    return groups.map(([group, groupedEntities]) => {
      const row: GroupCountRow = {
        label: extractHumanReadableLabel(group),
        value: groupedEntities.length,
        id: extractGroupId(group),
        fieldName,
      };

      if (group !== undefined && group !== null && group !== "") {
        const entity = new entityDefinition();
        entity[fieldName] = group;
        row.entity = entity;
      }

      return row;
    });
  }

  /** Merges "" and undefined groups into a single group with label: undefined */
  private mergeNotDefinedGroups(groupCounts: GroupCountRow[]): GroupCountRow[] {
    const notDefinedGroups = groupCounts.filter(
      (group) => group.id === "" || group.id === undefined,
    );
    if (notDefinedGroups.length > 1) {
      const merged = {
        label: undefined,
        value: notDefinedGroups.reduce((sum, group) => sum + group.value, 0),
        id: "",
      };
      return [
        merged,
        ...groupCounts.filter(
          (group) => group.id !== "" && group.id !== undefined,
        ),
      ];
    }

    return groupCounts;
  }

  /** Handles groupCounts for configurable-enum fields, including color and invalid option merging */
  private getEnumGroupCounts(
    groupCounts: GroupCountRow[],
    field: any,
  ): GroupCountRow[] {
    const enumValues = this.configurableEnum.getEnumValues(field.additional);
    const validIds = new Set(enumValues.map((enumValue) => enumValue.id));
    const groupCountsMap = new Map(
      groupCounts.map((aggregate) => [aggregate.id, aggregate]),
    );

    const invalidGroups = groupCounts.filter(
      (group) => group.id && !validIds.has(group.id),
    );
    let invalidOptionRow: GroupCountRow | undefined;
    if (invalidGroups.length > 0) {
      invalidOptionRow = {
        label: undefined,
        value: invalidGroups.reduce((sum, group) => sum + group.value, 0),
        id: "__invalid__",
        isInvalidOption: true,
      };
    }

    let groupCountSorted = enumValues
      .map((enumValue) => {
        const group = groupCountsMap.get(enumValue.id);
        if (group) {
          return enumValue.color !== undefined
            ? { ...group, color: enumValue.color }
            : { ...group };
        }
        return undefined;
      })
      .filter(Boolean);

    if (groupCountsMap.has("")) {
      const mergedGroup = groupCountsMap.get("");
      groupCountSorted.unshift(mergedGroup);
    }

    if (invalidOptionRow) {
      groupCountSorted = [invalidOptionRow, ...groupCountSorted];
    }

    return groupCountSorted;
  }

  goToEntityList(filterId: string) {
    const field = this.currentGroupField();
    if (!field) {
      return;
    }

    const params = {};
    params[field] = encodeURIComponent(filterId);

    this.router.navigate([getEntityRuntimeRoute(this.entityDefinition())], {
      queryParams: params,
    });
  }
}

/**
 * Get a human-readable string from the given value as a label.
 * @param value
 */
function extractHumanReadableLabel(
  value: string | ConfigurableEnumValue | any,
): string | undefined {
  if (value === undefined || value === null || value === "") {
    return undefined;
  }
  if (typeof value === "string") {
    return value;
  }
  if (value?.label) {
    return value.label;
  }

  return String(value);
}

/**
 * Extract a group ID from a group value (string, object, etc.)
 */
function extractGroupId(group: any): string {
  if (group === undefined || group === null || group === "") {
    return "";
  }
  if (typeof group === "object" && "id" in group) {
    return group.id;
  }
  return group;
}
<app-dashboard-list-widget
  [icon]="entityDefinition().icon"
  theme="child"
  [title]="totalEntities()"
  [subtitle]="subtitle() ?? entityDefinition().labelPlural"
  [explanation]="explanation()"
  [entries]="
    currentGroupField()
      ? (entityGroupCounts()[currentGroupField() ?? ''] ?? [])
      : []
  "
  [paginationPageSize]="4"
>
  @if (currentGroupField(); as groupField) {
    <div class="flex-row navigation-controls">
      <button
        mat-icon-button
        matTooltip="Previous Grouping"
        i18n-matTooltip
        i18n-caption
        caption="Previous"
        i18n-aria-label
        aria-label="Previous grouping"
        (click)="getPrev()"
        [style.visibility]="groupBy().length > 1 ? 'visible' : 'hidden'"
        [style.transform]="'scale(0.75)'"
      >
        <fa-icon icon="circle-chevron-left" size="xs"></fa-icon>
      </button>

      <span
        class="flex-grow groupby-label"
        i18n="category of disagregation in EntityCountDashboard"
      >
        by
        <app-entity-field-label
          [entityType]="entityDefinition()"
          [field]="groupField"
        >
        </app-entity-field-label>
        :
      </span>

      <button
        mat-icon-button
        matTooltip="Next Grouping"
        i18n-matTooltip
        i18n-caption
        caption="Next"
        i18n-aria-label
        aria-label="Next grouping"
        (click)="getNext()"
        [style.visibility]="groupBy().length > 1 ? 'visible' : 'hidden'"
        [style.transform]="'scale(0.75)'"
      >
        <fa-icon icon="circle-chevron-right"></fa-icon>
      </button>
    </div>

    <div class="label-separator"></div>
  }

  <div class="table-wrapper">
    <table
      mat-table
      i18n-aria-label="Label for children count dashboard"
      aria-label="Table showing disaggregation of the beneficiaries"
    >
      <ng-container matColumnDef="label">
        <td *matCellDef="let group" class="label-cell">
          @if (group.isInvalidOption) {
            <span
              class="invalid-label"
              matTooltip="records with a value that is not matching any available dropdown option"
              i18n-matTooltip
              i18n
            >
              [invalid option]
            </span>
          } @else if (group.label === undefined) {
            <span
              class="not-defined-label"
              matTooltip="records where this field is empty"
              i18n-matTooltip
              i18n
              >not defined</span
            >
          } @else if (group.entity && group.fieldName) {
            <app-entity-field-view
              [entity]="group.entity"
              [field]="group.fieldName"
            ></app-entity-field-view>
          }
        </td>
      </ng-container>
      <ng-container matColumnDef="value">
        <td *matCellDef="let group" class="text-align-end padding-left-regular">
          {{ group.value }}
        </td>
      </ng-container>
      <ng-container matColumnDef="link">
        <td *matCellDef class="text-align-end">
          <fa-icon icon="external-link-square-alt"></fa-icon>
        </td>
      </ng-container>

      <!-- Table header only for assistive technologies like screen readers -->
      <tr class="visually-hidden">
        <th scope="col" i18n="The category the count is grouped by">
          Category
        </th>
        <th scope="col" i18n="The count of records in the category">Count</th>
        <th scope="col" i18n="A link to the filtered list for that category">
          Link
        </th>
      </tr>
      <tr
        mat-row
        *matRowDef="let row; let i = index; columns: ['label', 'value', 'link']"
        (click)="goToEntityList(row.id)"
        class="pointer"
        angulartics2On="click"
        angularticsCategory="Navigation"
        angularticsAction="dashboard_children_count_school_link"
        [angularticsLabel]="'list-entry-' + i"
      ></tr>
    </table>
  </div>
</app-dashboard-list-widget>

./entity-count-dashboard.component.scss

@use "../../../../core/dashboard/dashboard-widget-base";
@use "../../../../core/common-components/special-option-labels.scss";

.groupby-label {
  margin: auto;
  text-align: center;
  overflow: hidden;
  text-overflow: ellipsis;
}

.label-separator {
  border-top: 1px solid #ccc;
  margin: 8px 0 0 0;
}

.mat-mdc-table {
  .mat-mdc-row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto auto;
    align-items: center;
    column-gap: 8px;
  }
}

.label-cell ::ng-deep app-entity-block.display-entity {
  display: block;
  min-width: 0;
}

.label-cell ::ng-deep app-entity-block .truncate-text {
  display: block;
  width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.navigation-controls {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 10px;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""