src/app/features/matching-entities/matching-entities/matching-entities.component.ts

Implements

OnInit

Metadata

Relationships

Used by

No results matching.

Depends on

Index

Properties
Methods
Inputs

Inputs

columns
Type : [ColumnConfig, ColumnConfig][]

Column mapping of property pairs of left and right entity that should be compared side by side.

entity
Type : Entity
leftSide
Type : MatchingSideConfig
matchActionLabel
Type : string
onMatch
Type : NewMatchAction
rightSide
Type : MatchingSideConfig

Methods

applySelectedFilters
applySelectedFilters(side: MatchingSide, filter: DataFilter<Entity>)
Parameters :
Name Type Optional
side MatchingSide No
filter DataFilter<Entity> No
Returns : void
Async createMatch
createMatch()
Returns : any
entityInMapClicked
entityInMapClicked(entity: Entity)
Parameters :
Name Type Optional
entity Entity No
Returns : void
onDisplayedPropertiesChange
onDisplayedPropertiesChange(value: LocationProperties)
Parameters :
Name Type Optional
value LocationProperties No
Returns : void
updateMarkersAndDistances
updateMarkersAndDistances()
Returns : void

Properties

columnsToDisplay
Type : unknown
Default value : computed(() => this.sideDetails() ? ["side-0", "side-1"] : [], )
Readonly comparisonColumns
Type : unknown
Default value : signal<[ColumnConfig, ColumnConfig][]>([])

Columns of the summary comparison view, as a working copy of the configured columns so that generated configs (like the calculated "distance" column) can be filled in without changing the given config.

Static DEFAULT_CONFIG_KEY
Type : string
Default value : "appConfig:matching-entities"
displayedLocationProperties
Type : unknown
Default value : signal<LocationProperties>({})
filteredMapEntities
Type : unknown
Default value : computed(() => { const entities: Entity[] = []; this.sideDetails()?.forEach((side) => { const filterObj = side.filterObj(); if (Object.keys(filterObj).length > 0) { const predicate = this.filterService.getFilterPredicate(filterObj); entities.push( ...(side.dataSource?.allRecords() ?? []).filter(predicate), ); } else { entities.push(...(side.dataSource?.allRecords() ?? [])); } }); return entities; })
getEntityBackgroundColor
Type : unknown
Default value : () => {...}

Get background color for entity rows in matching tables.

lockedMatching
Type : unknown
Default value : signal(false)
mapVisible
Type : unknown
Default value : computed(() => (this.sideDetails() ?? ([] as MatchingSide[])).some( (side) => side.entityType && getLocationProperties(this.entityRegistry.get(side.entityType)).length > 0, ), )
matchComparisonElement
Type : ElementRef
Decorators :
@ViewChild('matchComparison', {static: true})
Readonly resolvedColumns
Type : unknown
Default value : computed<[ColumnConfig, ColumnConfig][]>( () => this.columns() ?? this.config().columns ?? [], )
resolvedMatchActionLabel
Type : unknown
Default value : computed( () => this.matchActionLabel() ?? this.config().matchActionLabel ?? $localize`:Matching button label:create matching`, )
sideDetails
Type : unknown
Default value : signal<[MatchingSide, MatchingSide] | undefined>(undefined)
import { EntityTypePipe } from "#src/app/core/common-components/entity-type/entity-type.pipe";
import { AsyncPipe } from "@angular/common";
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  ElementRef,
  inject,
  Injector,
  input,
  OnInit,
  runInInjectionContext,
  signal,
  ViewChild,
  WritableSignal,
} from "@angular/core";
import { MatButtonModule, MatIconButton } from "@angular/material/button";
import { MatMenuModule } from "@angular/material/menu";
import { MatTableModule } from "@angular/material/table";
import { MatTooltipModule } from "@angular/material/tooltip";
import { ActivatedRoute, RouterLink } from "@angular/router";
import { AblePurePipe } from "@casl/angular";
import {
  FaIconComponent,
  FontAwesomeModule,
} from "@fortawesome/angular-fontawesome";
import { GeoLocation } from "app/features/location/geo-location";
import { toSignal } from "@angular/core/rxjs-interop";
import { BehaviorSubject, map } from "rxjs";
import { EntitiesTableComponent } from "../../../core/common-components/entities-table/entities-table.component";
import {
  ColumnConfig,
  FormFieldConfig,
} from "../../../core/common-components/entity-form/FormConfig";
import { ConfigService } from "../../../core/config/config.service";
import { DynamicComponentConfig } from "../../../core/config/dynamic-components/dynamic-component-config.interface";
import { DynamicComponent } from "../../../core/config/dynamic-components/dynamic-component.decorator";
import { EntityRegistry } from "../../../core/entity/database-entity.decorator";
import { EntityFieldViewComponent } from "../../../core/entity/entity-field-view/entity-field-view.component";
import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service";
import { Entity } from "../../../core/entity/model/entity";
import { FilterService } from "../../../core/filter/filter.service";
import { FilterComponent } from "../../../core/filter/filter/filter.component";
import { DataFilter } from "../../../core/filter/filters/filters";
import { FormDialogService } from "../../../core/form-dialog/form-dialog.service";
import { RouteTarget } from "../../../route-target";
import { FlattenArrayPipe } from "../../../utils/flatten-array/flatten-array.pipe";
import { addAlphaToHexColor } from "../../../utils/style-utils";
import { Coordinates } from "../../location/coordinates";
import {
  getLocationProperties,
  getMinDistanceKm,
} from "../../location/map-utils";
import { LocationProperties } from "../../location/map/map-properties-popup/map-properties-popup.component";
import { MapComponent } from "../../location/map/map.component";
import {
  MatchingEntitiesConfig,
  MatchingSideConfig,
  NewMatchAction,
} from "./matching-entities-config";
import { InMemoryDataSource } from "#src/app/core/common-components/entities-table/data-source/in-memory-data-source";
import { Logging } from "#src/app/core/logging/logging.service";

export interface MatchingSide extends MatchingSideConfig {
  /** pass along filters from app-filter to subrecord component */
  filterObj: WritableSignal<DataFilter<Entity>>;

  dataSource?: InMemoryDataSource<Entity>;
  selectMatch?: (e) => void;
  entityType: string;

  /** whether this allows to select more than one selected match */
  multiSelect: boolean;

  selected: WritableSignal<Entity[]>;

  /** item of `selected` that is currently highlighted */
  highlightedSelected: WritableSignal<Entity | null>;

  distanceColumn: {
    coordinatesProperties: string[];
    compareCoordinates: BehaviorSubject<Coordinates[]>;
  };
}

@RouteTarget("MatchingEntities")
@DynamicComponent("MatchingEntities")
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-matching-entities",
  templateUrl: "./matching-entities.component.html",
  styleUrls: ["./matching-entities.component.scss"],
  imports: [
    MatTableModule,
    FontAwesomeModule,
    MatTooltipModule,
    MatButtonModule,
    EntitiesTableComponent,
    EntityFieldViewComponent,
    MapComponent,
    FilterComponent,
    FlattenArrayPipe,
    MatMenuModule,
    MatIconButton,
    FaIconComponent,
    RouterLink,
    AblePurePipe,
    AsyncPipe,
    EntityTypePipe,
  ],
})
export class MatchingEntitiesComponent implements OnInit {
  private route = inject(ActivatedRoute);
  private formDialog = inject(FormDialogService);
  private entityMapper = inject(EntityMapperService);
  private configService = inject(ConfigService);
  private entityRegistry = inject(EntityRegistry);
  private filterService = inject(FilterService);
  private injector = inject(Injector);
  static DEFAULT_CONFIG_KEY = "appConfig:matching-entities";

  entity = input<Entity>();
  leftSide = input<MatchingSideConfig>();
  rightSide = input<MatchingSideConfig>();
  /**
   * Column mapping of property pairs of left and right entity that should be compared side by side.
   * @param value
   */
  columns = input<[ColumnConfig, ColumnConfig][]>();
  matchActionLabel = input<string>();
  onMatch = input<NewMatchAction>();

  @ViewChild("matchComparison", { static: true })
  matchComparisonElement: ElementRef;

  columnsToDisplay = computed(() =>
    this.sideDetails() ? ["side-0", "side-1"] : [],
  );
  lockedMatching = signal(false);
  sideDetails = signal<[MatchingSide, MatchingSide] | undefined>(undefined);

  mapVisible = computed(() =>
    (this.sideDetails() ?? ([] as MatchingSide[])).some(
      (side) =>
        side.entityType &&
        getLocationProperties(this.entityRegistry.get(side.entityType)).length >
          0,
    ),
  );
  filteredMapEntities = computed(() => {
    const entities: Entity[] = [];
    this.sideDetails()?.forEach((side) => {
      const filterObj = side.filterObj();
      if (Object.keys(filterObj).length > 0) {
        const predicate = this.filterService.getFilterPredicate(filterObj);
        entities.push(
          ...(side.dataSource?.allRecords() ?? []).filter(predicate),
        );
      } else {
        entities.push(...(side.dataSource?.allRecords() ?? []));
      }
    });
    return entities;
  });
  displayedLocationProperties = signal<LocationProperties>({});

  private readonly globalConfig: MatchingEntitiesConfig =
    this.configService.getConfig<MatchingEntitiesConfig>(
      MatchingEntitiesComponent.DEFAULT_CONFIG_KEY,
    ) ?? {};

  private readonly routeConfig = toSignal(
    this.route.data.pipe(
      map(
        (data: DynamicComponentConfig<MatchingEntitiesConfig>) =>
          data?.config as MatchingEntitiesConfig | undefined,
      ),
    ),
  );

  private readonly config = computed<MatchingEntitiesConfig>(() => {
    const routeConf = this.routeConfig();
    if (!routeConf?.leftSide && !routeConf?.rightSide && !routeConf?.columns) {
      return this.globalConfig;
    }
    return { ...this.globalConfig, ...structuredClone(routeConf) };
  });

  /**
   * Columns of the summary comparison view, as a working copy of the configured columns
   * so that generated configs (like the calculated "distance" column) can be filled in
   * without changing the given config.
   */
  readonly comparisonColumns = signal<[ColumnConfig, ColumnConfig][]>([]);

  // TODO: fill selection on hover already?

  async ngOnInit() {
    this.comparisonColumns.set(this.cloneColumns(this.resolvedColumns()));
    const sides: [MatchingSide, MatchingSide] = [
      await this.initSideDetails(this.resolvedLeftSide(), 0),
      await this.initSideDetails(this.resolvedRightSide(), 1),
    ];
    this.sideDetails.set(sides);
    sides.forEach((side, index) => this.initDistanceColumn(side, index));
  }

  /**
   * Generate setup for a side of the matching view template based on the component input properties.
   * @param side
   * @param sideIndex
   * @private
   */
  private async initSideDetails(
    side: MatchingSideConfig,
    sideIndex: number,
  ): Promise<MatchingSide> {
    const newSide = buildMatchingSideConfig(
      side,
      this.comparisonColumns(),
      sideIndex,
    ) as MatchingSide;

    if (!newSide.entityType) {
      const entity = this.entity();
      const initialSelected = entity ? [entity] : [];
      newSide.selected = signal<Entity[]>(initialSelected);
      newSide.highlightedSelected = signal<Entity | null>(
        initialSelected[0] ?? null,
      );
      newSide.entityType = newSide.highlightedSelected()?.getType();
    }

    const resolvedOnMatch = this.resolvedOnMatch();
    newSide.multiSelect = this.checkIfMultiSelect(
      resolvedOnMatch.newEntityType,
      sideIndex === 0
        ? resolvedOnMatch.newEntityMatchPropertyLeft
        : resolvedOnMatch.newEntityMatchPropertyRight,
    );

    if (newSide.multiSelect) {
      newSide.selectMatch = this.getMultiSelectFunction(newSide);
    } else {
      newSide.selectMatch = this.getSingleSelectFunction(newSide);
    }

    if (!newSide.selected) {
      // Currently this component only works with the InMemoryDataSource because all records should be displayed on the map
      runInInjectionContext(
        this.injector,
        () => (newSide.dataSource = new InMemoryDataSource()),
      );
      const records = await this.entityMapper
        .loadType(newSide.entityType)
        .catch((err) => {
          Logging.error("Failed to initialize entities for matching side", {
            entityType: newSide.entityType,
            sideIndex,
            error: err,
          });
          return [];
        });
      newSide.dataSource.allRecords.set(records);
      newSide.availableFilters = newSide.availableFilters ?? [];
      newSide.selected = signal<Entity[]>([]);
      newSide.highlightedSelected = signal<Entity | null>(null);
    }

    newSide.filterObj = signal<DataFilter<Entity>>({
      ...(side.prefilter ?? {}),
    });

    return newSide;
  }

  private checkIfMultiSelect(
    onMatchEntityType: string,
    onMatchProperty: string,
  ) {
    if (!onMatchEntityType || !onMatchProperty) {
      return false;
    }

    try {
      const schemaField = this.entityRegistry
        .get(onMatchEntityType)
        .schema.get(onMatchProperty);
      return !!schemaField?.isArray;
    } catch {
      return false;
    }
  }

  private getMultiSelectFunction(newSide: MatchingSide) {
    return (e: Entity) => {
      const currentSelected = newSide.selected();
      if (currentSelected.includes(e)) {
        // unselect
        this.highlightSelectedRow(e, true);
        const newSelected = currentSelected.filter((s) => s !== e);
        newSide.selected.set(newSelected);
        if (newSide.highlightedSelected() === e) {
          newSide.highlightedSelected.set(newSelected[0] ?? null);
        }
      } else {
        this.highlightSelectedRow(e);
        newSide.selected.set([...currentSelected, e]);
        newSide.highlightedSelected.set(e);
      }

      this.matchComparisonElement.nativeElement.scrollIntoView();
      this.updateDistanceColumn(newSide);
    };
  }

  private getSingleSelectFunction(newSide: MatchingSide) {
    return (e: Entity) => {
      if (newSide.selected()[0] === e) {
        // Deselect if already selected
        this.highlightSelectedRow(e, true);
        newSide.selected.set([]);
        newSide.highlightedSelected.set(null);
      } else {
        this.highlightSelectedRow(e);
        const prev = newSide.highlightedSelected();
        if (prev) {
          this.highlightSelectedRow(prev, true);
        }
        newSide.selected.set([e]);
        newSide.highlightedSelected.set(e);
      }

      this.matchComparisonElement.nativeElement.scrollIntoView();
      this.updateDistanceColumn(newSide);
    };
  }

  private highlightSelectedRow(newSelectedEntity: Entity, unHighlight = false) {
    if (unHighlight) {
      newSelectedEntity.getColor =
        newSelectedEntity.getConstructor().prototype.getColor;
    } else {
      newSelectedEntity.getColor = () =>
        addAlphaToHexColor(
          Entity.getColorWithConditions(newSelectedEntity),
          0.2,
        );
    }
  }

  /**
   * Get background color for entity rows in matching tables.
   */
  getEntityBackgroundColor = (entity: Entity): string => {
    const isSelected = this.sideDetails()?.some((side) =>
      side.selected().some((selected) => selected.getId() === entity.getId()),
    );
    if (isSelected) {
      const color = Entity.getColorWithConditions(entity);
      return addAlphaToHexColor(color, 0.2);
    }

    return "";
  };

  async createMatch() {
    const onMatch = this.resolvedOnMatch();
    if (
      !onMatch?.newEntityType ||
      !onMatch.newEntityMatchPropertyLeft ||
      !onMatch.newEntityMatchPropertyRight
    ) {
      return;
    }
    const newMatchEntity = new (this.entityRegistry.get(
      onMatch.newEntityType,
    ))();

    const sides = this.sideDetails();
    const leftMatch = sides[0].selected();
    const rightMatch = sides[1].selected();

    newMatchEntity[onMatch.newEntityMatchPropertyLeft] = sides[0].multiSelect
      ? leftMatch.map((e) => e.getId())
      : leftMatch[0].getId();
    newMatchEntity[onMatch.newEntityMatchPropertyRight] = sides[1].multiSelect
      ? rightMatch.map((e) => e.getId())
      : rightMatch[0].getId();

    // best guess properties (if they do not exist on the specific entity, the values will be discarded during save
    newMatchEntity["date"] = new Date();
    newMatchEntity["start"] = new Date();
    newMatchEntity["name"] =
      newMatchEntity.getConstructor().label +
      " " +
      leftMatch.map((e) => e.toString()).join(", ") +
      " - " +
      rightMatch.map((e) => e.toString()).join(", ");

    if (onMatch.columnsToReview) {
      this.formDialog
        .openFormPopup(newMatchEntity, onMatch.columnsToReview)
        .afterClosed()
        .subscribe((result) => {
          if (result instanceof newMatchEntity.getConstructor()) {
            this.resetMatchingSelection();
          }
        });
    } else {
      await this.entityMapper.save(newMatchEntity);
      this.resetMatchingSelection();
    }
  }

  private resetMatchingSelection() {
    this.lockedMatching.set(false);

    for (const side of this.sideDetails()) {
      if (!side.dataSource?.allRecords()) {
        continue;
      }

      side
        .selected()
        .forEach((selectedEntity) =>
          this.highlightSelectedRow(selectedEntity, true),
        );
      side.selected.set([]);
      side.highlightedSelected.set(null);
      this.updateDistanceColumn(side);
    }
  }

  onDisplayedPropertiesChange(value: LocationProperties) {
    this.displayedLocationProperties.set(value);
    this.updateMarkersAndDistances();
  }

  applySelectedFilters(side: MatchingSide, filter: DataFilter<Entity>) {
    side.filterObj.set({ ...side.prefilter, ...filter });
  }

  entityInMapClicked(entity: Entity) {
    const side = this.sideDetails()?.find(
      (s) => s.entityType === entity.getType(),
    );
    if (side) {
      side.selectMatch(entity);
    }
  }

  /**
   * Initialize distance column for columns of side and columns of EntitySubrecord
   * @param side
   * @param index of the side
   * @private
   */
  private initDistanceColumn(side: MatchingSide, index: number) {
    const sideIndex = side.columns.findIndex((col) =>
      typeof col === "string" ? col === "distance" : col.id === "distance",
    );

    if (sideIndex !== -1) {
      const columnConfig = this.getDistanceColumnConfig(side);
      side.columns[sideIndex] = columnConfig;
      side.distanceColumn = columnConfig.additional;
      this.setDistanceValuesForSide(side);
      const columns = this.comparisonColumns();
      const colIndex = columns.findIndex((row) => {
        const col = row[index];
        return typeof col === "string"
          ? col === "distance"
          : col?.id === "distance";
      });
      if (colIndex !== -1) {
        const updatedColumns = [...columns];
        updatedColumns[colIndex] = [...updatedColumns[colIndex]] as [
          ColumnConfig,
          ColumnConfig,
        ];
        updatedColumns[colIndex][index] = columnConfig;
        this.comparisonColumns.set(updatedColumns);
      }
    }
  }

  /**
   * Build the display-only distance column configuration for matching tables.
   */
  private getDistanceColumnConfig(side: MatchingSide): FormFieldConfig {
    return {
      id: "distance",
      label: $localize`:Matching View column name:Distance`,
      viewComponent: "DisplayDistance",
      dataType: "number",
      additional: {
        coordinatesProperties:
          this.displayedLocationProperties()[side.entityType],
        compareCoordinates: new BehaviorSubject<Coordinates[]>([]),
      },
    };
  }

  /**
   * Pre-calculate and attach a non-enumerable "distance" value on each entity
   * so the table can sort without custom sort functions.
   */
  private setDistanceValuesForSide(side: MatchingSide) {
    if (!side.dataSource?.allRecords()?.length || !side.distanceColumn) {
      return;
    }

    for (const entity of side.dataSource.allRecords()) {
      this.defineDistanceValue(entity, side);
    }
  }

  /**
   * Define a non-enumerable distance value on the entity for table sorting.
   */
  private defineDistanceValue(entity: Entity, side: MatchingSide) {
    const existingDescriptor = Object.getOwnPropertyDescriptor(
      entity,
      "distance",
    );
    if (existingDescriptor && existingDescriptor.configurable === false) {
      return;
    }

    const distanceValue = getMinDistanceKm(
      entity,
      side.distanceColumn?.coordinatesProperties ?? [],
      side.distanceColumn?.compareCoordinates?.value ?? [],
    );

    Object.defineProperty(entity, "distance", {
      configurable: true,
      enumerable: false,
      value: distanceValue,
      writable: true,
    });
  }

  updateMarkersAndDistances() {
    this.sideDetails()?.forEach((side) => {
      const sideProperties =
        this.displayedLocationProperties()[side.entityType];
      if (side.distanceColumn) {
        side.distanceColumn.coordinatesProperties = sideProperties;
        const lastValue = side.distanceColumn.compareCoordinates.value;
        side.distanceColumn.compareCoordinates.next(lastValue);
      }
      if (side.highlightedSelected()) {
        this.updateDistanceColumn(side);
      }
      this.setDistanceValuesForSide(side);
    });
  }

  private updateDistanceColumn(side: MatchingSide) {
    const highlighted = side.highlightedSelected();
    const locationProperties =
      this.displayedLocationProperties()[highlighted?.getType()];
    const sides = this.sideDetails();
    const otherIndex = sides[0] === side ? 1 : 0;
    const distanceColumn = sides[otherIndex].distanceColumn;
    if (locationProperties && distanceColumn) {
      const coordinates: Coordinates[] = locationProperties.map(
        (prop) => (highlighted[prop] as GeoLocation)?.geoLookup,
      );
      distanceColumn.compareCoordinates.next(coordinates);
    }
    const distanceSide = sides[otherIndex];
    this.setDistanceValuesForSide(distanceSide);
  }

  resolvedMatchActionLabel = computed(
    () =>
      this.matchActionLabel() ??
      this.config().matchActionLabel ??
      $localize`:Matching button label:create matching`,
  );

  private readonly resolvedLeftSide = computed<MatchingSideConfig>(
    () => this.leftSide() ?? this.config().leftSide ?? {},
  );

  private readonly resolvedRightSide = computed<MatchingSideConfig>(
    () => this.rightSide() ?? this.config().rightSide ?? {},
  );

  readonly resolvedColumns = computed<[ColumnConfig, ColumnConfig][]>(
    () => this.columns() ?? this.config().columns ?? [],
  );

  private readonly resolvedOnMatch = computed<NewMatchAction>(
    () =>
      this.onMatch() ??
      this.config().onMatch ?? {
        newEntityType: "",
        newEntityMatchPropertyLeft: "",
        newEntityMatchPropertyRight: "",
      },
  );

  private cloneColumns(
    columns: [ColumnConfig, ColumnConfig][],
  ): [ColumnConfig, ColumnConfig][] {
    return (columns ?? []).map(([left, right]) => [
      this.cloneColumn(left),
      this.cloneColumn(right),
    ]);
  }

  private cloneColumn(column: ColumnConfig): ColumnConfig {
    if (typeof column === "string" || !column) {
      return column;
    }
    return { ...column };
  }
}

/**
 * Create a MatchingSideConfig object
 * and fill it with columns from comparison array if necessary
 * @param side Base side configuration
 * @param columns columns array from the overall matching configuration
 * @param sideIndex index (0 or 1) of the side in the columns array
 */
export function buildMatchingSideConfig(
  side: MatchingSideConfig,
  columns: [ColumnConfig, ColumnConfig][],
  sideIndex: number,
): MatchingSideConfig {
  const newSide = Object.assign({}, side) as MatchingSide; // we are transforming it into this type here
  newSide.columns =
    newSide.columns ??
    (columns ?? []).map((p) => p[sideIndex]).filter((c) => !!c);
  return newSide;
}
<div class="actions-container">
  @if ("update" | ablePure: "Config" | async) {
    <button mat-icon-button color="primary" [matMenuTriggerFor]="additional">
      <fa-icon icon="ellipsis-v"></fa-icon>
    </button>
    <mat-menu #additional>
      <button mat-menu-item [routerLink]="['/admin/matching']">
        <fa-icon
          class="standard-icon-with-text color-accent"
          icon="tools"
        ></fa-icon>
        <span i18n>Edit Matching Entities</span>
      </button>
    </mat-menu>
  }
</div>

<div class="margin-bottom-small" #matchComparison>
  <table
    mat-table
    [dataSource]="comparisonColumns()"
    class="match-comparison-table mat-elevation-z1"
  >
    @for (side of sideDetails(); track $index; let i = $index) {
      <ng-container [matColumnDef]="'side-' + i">
        <th
          mat-header-cell
          *matHeaderCellDef
          class="comparison-header"
          [style.color]="(side.entityType | entityType)?.color ?? 'black'"
        >
          <em class="comparison-header-type"
            >{{ (side.entityType | entityType)?.label }}:</em
          >
          &nbsp;
          @for (s of side.selected(); track s.getId(); let i = $index) {
            <span
              [class.highlighted-name]="s === side.highlightedSelected()"
              (click)="side.highlightedSelected.set(s)"
              >{{ s.toString() }}</span
            >
            @if (i < side.selected().length - 1) {
              ,
            }
          }
          &nbsp;
          @if (!side.dataSource?.allRecords() || lockedMatching()) {
            <fa-icon
              icon="lock"
              matTooltip="These details of the record are displayed to make comparison easier. You cannot select a different record here."
              i18n-matTooltip="
                tooltip explaining that this entity in matching is locked
              "
              class="icon"
            ></fa-icon>
          }
        </th>
        <td mat-cell *matCellDef="let property">
          @if (side.highlightedSelected() && property[i]) {
            <app-entity-field-view
              [entity]="side.highlightedSelected()"
              [field]="property[i]"
              showLabel="above"
            ></app-entity-field-view>
          } @else {
            -
          }
        </td>
      </ng-container>
    }

    <tr mat-header-row *matHeaderRowDef="columnsToDisplay()"></tr>
    <tr mat-row *matRowDef="let row; columns: columnsToDisplay()"></tr>
  </table>
</div>

<div class="margin-bottom-large flex-column gap-small">
  <button
    mat-raised-button
    color="accent"
    style="width: 100%"
    (click)="createMatch()"
    [disabled]="
      !(
        sideDetails()?.[0].selected().length > 0 &&
        sideDetails()?.[1].selected().length > 0
      ) || lockedMatching()
    "
  >
    {{ resolvedMatchActionLabel() }}
  </button>
  @if (mapVisible()) {
    <app-map
      class="flex-grow"
      [expandable]="true"
      [showMapOnly]="true"
      [entities]="filteredMapEntities()"
      [highlightedEntities]="
        [sideDetails()?.[0].selected(), sideDetails()?.[1].selected()]
          | flattenArray
      "
      (entityClick)="entityInMapClicked($event)"
      [displayedProperties]="displayedLocationProperties()"
      (displayedPropertiesChange)="onDisplayedPropertiesChange($event)"
    ></app-map>
  }
</div>

@if (!lockedMatching()) {
  <div class="flex-row flex-wrap gap-large">
    @for (side of sideDetails(); track $index) {
      @if (side.dataSource?.allRecords()) {
        <div class="flex-grow">
          <h3
            [style.color]="(side.entityType | entityType)?.color ?? 'black'"
            class="selection-header"
            i18n="header of section with entities available for selection"
          >
            Select {{ (side.entityType | entityType)?.label }}
          </h3>

          @if (side.multiSelect) {
            <div class="selection-table-hint margin-bottom-regular" i18n>
              Click on multiple rows below to match a group.
            </div>
          }

          <app-filter
            class="flex-row gap-regular flex-wrap"
            [filterConfig]="side.availableFilters"
            [entityType]="side.entityType | entityType"
            [entities]="side.dataSource.allRecords()"
            (filterObjChange)="applySelectedFilters(side, $event)"
          ></app-filter>

          <app-entities-table
            [entityType]="side.entityType | entityType"
            [recordsDataSource]="side.dataSource"
            [customColumns]="side.columns"
            [editable]="false"
            clickMode="none"
            (entityClick)="side.selectMatch($event)"
            [filter]="side.filterObj()"
            [getBackgroundColor]="getEntityBackgroundColor"
          ></app-entities-table>
        </div>
      }
    }
  </div>
}

./matching-entities.component.scss

@use "mixins/grid-layout";
@use "variables/sizes";

.overall-layout {
  @include grid-layout.adaptive(380px, 100%);
}

.map-placeholder {
  background-color: lightgrey;
  height: 100px;
  padding: 2em;
}

.match-comparison-table {
  table-layout: fixed;
  width: 100%;
  border-spacing: sizes.$large 0;
  margin: auto;
}

.comparison-header {
  font-size: medium;
}
.comparison-header-type {
  font-weight: bold;
}
.selection-header {
  font-weight: 500;
  font-size: medium;
}

.highlighted-name {
  text-decoration: underline;
}

.actions-container {
  position: absolute;
  right: 0;
}

.selection-table-hint {
  font-size: 0.9em;
  font-style: italic;

  // replace the margin of the heading above
  margin-top: -16px;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""