import { EntityForm } from "#src/app/core/common-components/entity-form/entity-form";
import { EntityFieldEditComponent } from "#src/app/core/entity/entity-field-edit/entity-field-edit.component";
import {
ChangeDetectorRef,
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
inject,
OnInit,
signal,
viewChild,
} from "@angular/core";
import { ReactiveFormsModule } from "@angular/forms";
import { MatButtonModule } from "@angular/material/button";
import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogRef,
} from "@angular/material/dialog";
import { MatError, MatFormFieldModule } from "@angular/material/form-field";
import { MatSlideToggleModule } from "@angular/material/slide-toggle";
import { ConfirmationDialogService } from "app/core/common-components/confirmation-dialog/confirmation-dialog.service";
import { FormFieldConfig } from "app/core/common-components/entity-form/FormConfig";
import { EntityFormService } from "app/core/common-components/entity-form/entity-form.service";
import { Entity, EntityConstructor } from "app/core/entity/model/entity";
import { MergeFieldsComponent } from "./merge-fields/merge-fields.component";
import { WarningNotOptimizedForSmallScreenComponent } from "#src/app/core/common-components/warning-not-optimized-for-small-screen/warning-not-optimized-for-small-screen.component";
import { MergeAccountSectionComponent } from "./merge-account-section/merge-account-section.component";
@Component({
selector: "app-bulk-merge-records",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatDialogActions,
MatDialogContent,
MatButtonModule,
EntityFieldEditComponent,
ReactiveFormsModule,
MatError,
MatDialogClose,
MergeFieldsComponent,
WarningNotOptimizedForSmallScreenComponent,
MatFormFieldModule,
MergeAccountSectionComponent,
MatSlideToggleModule,
],
templateUrl: "./bulk-merge-records.component.html",
styleUrls: ["./bulk-merge-records.component.scss"],
})
export class BulkMergeRecordsComponent<E extends Entity> implements OnInit {
private readonly dialogRef =
inject<MatDialogRef<BulkMergeRecordsComponent<E>>>(MatDialogRef);
private readonly confirmationDialog = inject(ConfirmationDialogService);
private readonly entityFormService = inject(EntityFormService);
private readonly cdr = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
readonly accountSection = viewChild(MergeAccountSectionComponent);
entityConstructor: EntityConstructor;
entitiesToMerge: E[];
mergedEntity: E;
fieldsToMerge = signal<FormFieldConfig[]>([]);
mergeForm: EntityForm<E>;
showOnlyDifferences = signal(false);
filteredFieldsToMerge = computed(() => {
if (!this.showOnlyDifferences()) return this.fieldsToMerge();
return this.fieldsToMerge().filter(
(field) =>
!this.valuesAreIdentical(
this.entitiesToMerge[0][field.id],
this.entitiesToMerge[1][field.id],
),
);
});
/** whether the entitiesToMerge contain some file attachments that would be lost during a merge */
hasDiscardedFileOrPhoto: boolean = false;
constructor() {
const data = inject<{
entityConstructor: EntityConstructor;
entitiesToMerge: E[];
}>(MAT_DIALOG_DATA);
this.entityConstructor = data.entityConstructor;
this.entitiesToMerge = data.entitiesToMerge;
// Use the primary entity's values as the base so form validators (e.g. uniqueness)
// treat existing values as the "default" and don't incorrectly flag them as duplicates.
this.mergedEntity = this.entitiesToMerge[0].copy() as E;
}
async ngOnInit(): Promise<void> {
this.initFieldsToMerge();
this.mergeForm = await this.entityFormService.createEntityForm(
this.fieldsToMerge(),
this.mergedEntity,
this.destroyRef,
false,
true,
false,
);
this.cdr.detectChanges();
}
private initFieldsToMerge(): void {
const fields: FormFieldConfig[] = [];
this.entityConstructor.schema.forEach((field, key) => {
const hasValue = this.entitiesToMerge.some((entity) =>
this.hasValue(entity[key]),
);
const isFileField =
field.dataType === "photo" || field.dataType === "file";
if (isFileField && this.entitiesToMerge[1][key] != null) {
this.hasDiscardedFileOrPhoto = true;
}
if (field.label && hasValue && !isFileField && !field.isInternalField) {
const formField: FormFieldConfig =
this.entityFormService.extendFormFieldConfig(
{ id: key },
this.entityConstructor,
);
fields.push({
...formField,
});
}
});
this.fieldsToMerge.set(fields);
}
private valuesAreIdentical(a: any, b: any): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
/**
* helper method to check whether a value is empty or has a valid value.
*/
hasValue(value: any): boolean {
return !(
value === undefined ||
value === null ||
value === "" ||
(Array.isArray(value) && value.length === 0) ||
value === false
);
}
async confirmAndMergeRecords(): Promise<boolean> {
this.mergeForm.formGroup.markAllAsTouched();
if (this.mergeForm.formGroup.invalid) return false;
const accountDecision =
await this.accountSection()?.validateAndGetDecision();
if (accountDecision === false) {
return false;
}
if (this.hasDiscardedFileOrPhoto) {
const fileIgnoreConfirmed = await this.confirmationDialog.getConfirmation(
$localize`:Merge confirmation title:Warning! Some file attachments will be lost`,
$localize`:Merge confirmation dialog with files/photos:"Record B" contains files or images. Merging currently does not support attachments yet. The merged record will only have the attachments from "record A". Files from "record B" will be lost!`,
);
if (!fileIgnoreConfirmed) {
return false;
}
}
if (
!(await this.confirmationDialog.getConfirmation(
$localize`:Merge confirmation title:Are you sure you want to merge this?`,
$localize`:Merge confirmation dialog:Merging of two records will discard the data that is not selected to be merged. This action cannot be undone. Once the two records are merged, there will be only one record left in the system.`,
))
) {
return false;
}
const primaryIndex = this.accountSection()?.primaryIndex() ?? 0;
this.dialogRef.close({
mergedEntity: Object.assign(
this.entitiesToMerge[primaryIndex].copy(),
this.mergeForm.formGroup.value,
),
entityAccounts: this.accountSection()?.entityAccounts() ?? [],
accountUpdate: accountDecision?.accountUpdate ?? null,
deleteSecondaryAccount: accountDecision?.deleteSecondaryAccount ?? true,
});
}
}
@use "variables/colors";
.merge-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
align-items: start;
> * {
min-width: 0;
}
.header {
font-weight: bold;
background-color: colors.$primary;
color: white;
padding: 0.5rem;
border-bottom: 1px solid #ccc;
text-align: center;
}
app-merge-fields {
grid-column: 2 / 4;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
min-width: 0;
}
.label-cell {
padding: 0.5rem;
}
.preview-cell {
min-width: 0;
overflow: hidden;
mat-form-field,
::ng-deep .mat-mdc-form-field {
width: 100%;
}
}
}
mat-dialog-actions mat-slide-toggle {
margin-left: 0.5rem;
}