diff --git a/client/src/app/site/base/base-import.service/base-backend-import.service.ts b/client/src/app/site/base/base-import.service/base-backend-import.service.ts index 8d13e6f2f4..ac208716f5 100644 --- a/client/src/app/site/base/base-import.service/base-backend-import.service.ts +++ b/client/src/app/site/base/base-import.service/base-backend-import.service.ts @@ -306,8 +306,7 @@ export abstract class BaseBackendImportService implements BackendImportService { .catch(e => e); if (typeof results !== `boolean`) { - this._currentImportPhaseSubject.next(BackendImportPhase.LOADING_PREVIEW); - this.clearAll(); + this._currentImportPhaseSubject.next(BackendImportPhase.FINISHED); this.matSnackbar.open( this.translate.instant(results.error?.message ?? results?.message ?? results), this.translate.instant(`Ok`) diff --git a/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/components/participant-import-list-preview/participant-import-list-preview.component.html b/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/components/participant-import-list-preview/participant-import-list-preview.component.html new file mode 100644 index 0000000000..28c680e3f8 --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/components/participant-import-list-preview/participant-import-list-preview.component.html @@ -0,0 +1,222 @@ + +
+

Import participants

+
+ + + +
+
+ + @if (dataSource | async) { + @for (column of previewColumns; track column) { +
+ @let entry = row.data; + @let type = column.type; +
{{ getColumnLabel(column.property) }}
+ @if (entry && (entry[def] || type === 'boolean')) { +
+ @if (getHeader(def).is_list && entry[def]) { + @for (item of entry[def]; track item) { + @if (getHeader(def).is_object) { +
+ @if (getHeader(def).type === 'string') { + {{ item.value }} + } + @if (getHeader(def).type === 'number') { + {{ item.value }} + } + @if (getHeader(def).type === 'date') { + {{ item.value }} + } + @if (getHeader(def).type === 'boolean') { + + } +   + + @if (getActionIconRow(item)) { + + {{ getEntryIcon(item) }} + + } +
+ } @else { + @if (getHeader(def).type === 'string') { + {{ item.value }} + } + @if (getHeader(def).type === 'number') { + {{ item.value }} + } + @if (getHeader(def).type === 'decimal') { + {{ this.getShortenedDecimal(entry[def].value) }} + } + @if (getHeader(def).type === 'date') { + {{ item.value }} + } + @if (getHeader(def).type === 'boolean') { + + } +   + } + } + } + @if (!getHeader(def).is_list && entry[def] !== undefined) { + @if (getHeader(def).is_object) { + @let icon = getActionIconEntry(entry[def]); +
+
+ @if (getHeader(def).type === 'string') { + {{ entry[def].value }} + } + @if (getHeader(def).type === 'number') { + {{ entry[def].value }} + } + @if (getHeader(def).type === 'decimal') { + {{ this.getShortenedDecimal(entry[def].value) }} + } + @if (getHeader(def).type === 'date') { + {{ entry[def].value }} + } + @if (getHeader(def).type === 'boolean') { + + } +
+
+ @if (getEntryIcon(entry[def]) || checkChanges(row, column.property)) { + + {{ icon }} + + } +
+
+ } + } +
+ } +
+ } +
+
Action
+ @if (row.state === 'error') { + + {{ getActionIconRow(row) }} + + } + @if (row.state !== 'error' && row.messages.length) { + + warning + + } + @let icon = getActionIconRow(row); + @if (row.state !== 'error' && icon !== '') { + + {{ icon }} + + } +
+ } +
+ +
+ + +
+

+ Importing participants +

+ + + +
+
Import in progress!
+
+ Do not close the tab if you want to see the import status! +
+
+
+
+
+ + +
+
+

Import successful

+ +
+

{{ summary[0]?.value }} participants have been processed

+
+ +
+ @let shortenedSummary = shortenSummary(summary); + @for (row of shortenedSummary; track row) { + @let colInfo = getSummaryInformation(row.name); +
+ + {{ colInfo[0] }} + +
{{ row.value }} {{ row.name }}
+
+ } +
+
+ @let extraInformation = summaryRest(summary); + @for (row of extraInformation; track row) { +
{{ row.value }} {{ row.name }}
+ } +
+
+
diff --git a/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/components/participant-import-list-preview/participant-import-list-preview.component.scss b/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/components/participant-import-list-preview/participant-import-list-preview.component.scss new file mode 100644 index 0000000000..869097fe61 --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/components/participant-import-list-preview/participant-import-list-preview.component.scss @@ -0,0 +1,145 @@ +$os-green: rgb(15, 228, 15); +$os-yellow: rgb(255, 193, 7); + +.os-green { + color: $os-green; +} + +.os-yellow { + color: $os-yellow; +} + +.icon-container { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; +} + +.error-icon { + color: red; + font-weight: 500; +} + +.lower-margin-top { + margin-top: -4px; + padding-left: 2px; +} + +.list-field-entry { + display: inline-flex; + margin-right: 10px; +} + +.cell-wrapper { + display: flex; + align-items: center; + width: 100%; + max-width: 300px; + overflow: hidden; + + .email-cell { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.static-sort-filter-bar { + margin: 0; + height: 100%; + overflow: hidden; +} + +.import-dialog { + padding: 8px; + color: var(--theme-accent-400); + + .import-dialog-title { + color: var(--theme-accent-400); + font-size: 20px; + font-weight: 500; + margin-bottom: 0; + } + + .import-dialog-content { + min-height: fit-content; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + color: var(--theme-accent-400); + text-align: center; + } + + .import-status { + margin-top: 32px; + color: var(--theme-accent-400); + } + + .import-status-title { + font-size: 23px; + font-weight: 400; + margin-bottom: 12px; + } + + .import-status-subtitle { + font-size: 18px; + } +} + +.summary-dialog { + .summary-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + } + + .close-button { + margin-right: 0.5em; + margin-top: 0.5em; + } + + .summary-dialog-title { + font-size: 20px; + font-weight: 500; + margin: 0; + } + + .summary-dialog-content { + display: flex; + font-size: 17px; + color: var(--theme-contrast-400); + text-align: center; + justify-content: center; + } + + .summary-row { + display: flex; + justify-content: start; + gap: 10px; + margin: 6px 0; + } + + .summary-row mat-icon { + font-size: x-large; + } + + .summary-dialog-subtitle { + font-size: 18px; + margin-left: 1.4em; + margin-top: 0; + } + + .summary-dialog-extraInfo { + font-size: 17px; + margin-left: 1.4em; + } + + .summary-dialog-extraInfo:last-child { + margin-bottom: 16px; + } +} diff --git a/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/components/participant-import-list-preview/participant-import-list-preview.component.spec.ts b/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/components/participant-import-list-preview/participant-import-list-preview.component.spec.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/components/participant-import-list-preview/participant-import-list-preview.component.ts b/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/components/participant-import-list-preview/participant-import-list-preview.component.ts new file mode 100644 index 0000000000..3c8a978c7a --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/components/participant-import-list-preview/participant-import-list-preview.component.ts @@ -0,0 +1,934 @@ +import { AsyncPipe, NgClass } from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ContentChild, + ContentChildren, + EventEmitter, + inject, + Input, + OnDestroy, + OnInit, + Output, + QueryList, + TemplateRef +} from '@angular/core'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { MatIcon } from '@angular/material/icon'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MatTabChangeEvent } from '@angular/material/tabs'; +import { MatTooltip } from '@angular/material/tooltip'; +import { Router } from '@angular/router'; +import { Group } from '@app/domain/models/users/group'; +import { ValueLabelCombination } from '@app/infrastructure/utils/import/import-utils'; +import { ActiveMeetingIdService } from '@app/site/pages/meetings/services/active-meeting-id.service'; +import { ViewUser } from '@app/site/pages/meetings/view-models/view-user'; +import { AccountControllerService } from '@app/site/pages/organization/pages/accounts/services/common/account-controller.service'; +import { HeadBarModule } from '@app/ui/modules/head-bar'; +import { ImportListHeaderDefinition } from '@app/ui/modules/import-list'; +import { BackendImportPhase } from '@app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component'; +import { + BackendImportEntryObject, + BackendImportHeader, + BackendImportIdentifiedRow, + BackendImportPreview, + BackendImportState, + BackendImportSummary +} from '@app/ui/modules/import-list/definitions/backend-import-preview'; +import { ImportListFirstTabDirective } from '@app/ui/modules/import-list/directives/import-list-first-tab.directive'; +import { ImportListLastTabDirective } from '@app/ui/modules/import-list/directives/import-list-last-tab.directive'; +import { ImportListStatusTemplateDirective } from '@app/ui/modules/import-list/directives/import-list-status-template.directive'; +import { ListModule } from '@app/ui/modules/list'; +import { ListSearchService } from '@app/ui/modules/list/services/list-search.service'; +import { ScrollingTableCellDefConfig } from '@app/ui/modules/scrolling-table/directives/scrolling-table-cell-config'; +import { END_POSITION, START_POSITION } from '@app/ui/modules/scrolling-table/directives/scrolling-table-cell-position'; +import { _, TranslateService } from '@ngx-translate/core'; +import { map, Observable, of, Subscription } from 'rxjs'; + +import { ViewGroup } from '../../../../modules'; +import { ParticipantControllerService } from '../../../../services/common/participant-controller.service'; +import { ParticipantImportService } from '../../services/participant-import.service/participant-import.service'; +import { ParticipantImportFilterService } from '../../services/participant-import-filter.service'; +import { ParticipantImportPreviewSearchService } from '../../services/participant-import-search.service'; +import { ViewImportedParticipant } from '../../view-models/view-participant-import'; + +@Component({ + selector: `os-participant-import-list-preview`, + templateUrl: `./participant-import-list-preview.component.html`, + styleUrls: [`./participant-import-list-preview.component.scss`], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + HeadBarModule, + ListModule, + MatIcon, + AsyncPipe, + MatTooltip, + MatCheckbox, + NgClass, + MatDialogModule, + MatProgressSpinner + ] +}) +export class ParticipantImportListPreviewComponent implements OnInit, OnDestroy { + public readonly END_POSITION = END_POSITION; + public readonly START_POSITION = START_POSITION; + + protected activeMeetingIdService = inject(ActiveMeetingIdService); + protected accountsControllerService = inject(AccountControllerService); + + @ContentChildren(ImportListFirstTabDirective) + public importListFirstTabs!: QueryList; + + @ContentChildren(ImportListLastTabDirective) + public importListLastTabs!: QueryList; + + @ContentChild(ImportListStatusTemplateDirective, { read: TemplateRef }) + public importListStateTemplate: TemplateRef; + + @Input() + public rowHeight = 20; + + public modelName = `Participant`; + + @Input() + public additionalInfo = ``; + + @Input() + public importer = inject(ParticipantImportService); + + /** + * Define extra filter properties + */ + protected get filterProps(): string[] { + return ViewImportedParticipant.REQUESTABLE_FIELDS; + } + + public filterService = inject(ParticipantImportFilterService); + public alsoFilterByProperties: string[] = [`id`]; + public searchService = inject(ParticipantImportPreviewSearchService); + + @Input() + public searchFieldInput = ``; + + @Output() + public searchFilterUpdated = new EventEmitter(); + + @Output() + public selectedTabChanged = new EventEmitter(); + + @Output() + protected selectedNewFile = new EventEmitter(); + + /** + * Defines all necessary and optional fields, that a .csv-file can contain. + */ + @Input() + public possibleFields: string[] = []; + + protected _totalCountObservable: Observable = null; + + /** + * Whether or not to show the filter bar + */ + public showFilterBar = true; + + /** + * Whether or not to allow horizontal scroll + */ + public horizontalScroll = true; + + /** + * Whether or not to show the header + */ + public showHeader = true; + + /** + * Whether or not to show the CSV-Encoding button + */ + protected csvConfiguration = true; + + /** + * Whether or not to show the CSV-Reload button + */ + protected csvReloadButton = true; // Reload CSV file + + protected userAccounts = this.accountsControllerService.getViewModelList(); + + public readonly Phase = BackendImportPhase; + + /** + * Observable that allows one to monitor the currenty selected file. + */ + public get rawFileObservable(): Observable { + return this.importer?.rawFileObservable || of(null); + } + + /** + * Client-side definition of required/accepted columns. + * Ensures that the client can display information about how the import works. + */ + @Input() + public set defaultColumns(cols: ImportListHeaderDefinition[]) { + this._defaultColumns = cols; + this.setHeaders({ default: cols }); + } + + public get defaultColumns(): ImportListHeaderDefinition[] { + return this._defaultColumns; + } + + /** The header's order according to how they are displayed on the template file */ + private headersOrder = [ + 'title', + 'first_name', + 'last_name', + 'email', + 'member_number', + 'structure_level', + 'groups', + 'number', + 'vote_weight', + 'gender', + 'pronoun', + 'username', + 'default_password', + 'is_active', + 'is_physical_person', + 'is_present', + 'locked_out', + 'saml_id', + 'home_committee', + 'external', + 'comment' + ]; + + /** + * The actual headers of the preview, as they were delivered by the backend. + */ + public get previewColumns(): BackendImportHeader[] { + return this._previewColumns; + } + + /** + * The summary of the preview, as it was delivered by the backend. + */ + public get summary(): BackendImportSummary[] { + return this._summary; + } + + /** + * The rows of the preview, which were delivered by the backend. + * Affixed with fake ids for the purpose of displaying them correctly. + */ + public get rows(): BackendImportIdentifiedRow[] { + return this._rows; + } + + /** + * True if, after the first json-upload, the view is waiting for the user to confirm the import. + */ + public get awaitingConfirm(): boolean { + return this._state === BackendImportPhase.AWAITING_CONFIRM; + } + + /** + * True if the import has successfully finished. + */ + public get finishedSuccessfully(): boolean { + return this._state === BackendImportPhase.FINISHED; + } + + /** + * True if, after an attempted import failed, the view is waiting for the user to confirm the import on the new preview. + */ + public get finishedWithWarning(): boolean { + return this._state === BackendImportPhase.FINISHED_WITH_WARNING; + } + + /** + * True while an import is in progress. + */ + public get isImporting(): boolean { + return this._state === BackendImportPhase.IMPORTING; + } + + /** + * True if the preview can not be imported. + */ + public get hasErrors(): boolean { + return this._state === BackendImportPhase.ERROR; + } + + /** + * Currently selected encoding. Is set and changed by the config's available + * encodings and user mat-select input + */ + public selectedEncoding: string; + + /** + * @returns the encodings available and their labels + */ + public get encodings(): ValueLabelCombination[] { + return this.importer.encodings; + } + + /** + * @returns the available column separators and their labels + */ + public get columnSeparators(): ValueLabelCombination[] { + return this.importer.columnSeparators; + } + + /** + * Currently selected column separator. Is set and changed by the config's available + * column separators and user mat-select input + */ + public selectedColumnSeparator; + + /** + * @eturns the available text separators and their labels + */ + public get textSeparators(): ValueLabelCombination[] { + return this.importer.textSeparators; + } + + /** + * If false there is something wrong with the data. + */ + public get hasRowErrors(): boolean { + return this.importer.previewHasRowErrors; + } + + /** + * Client side information on the required fields of this import. + * Generated from the information in the defaultColumns. + */ + public get requiredFields(): string[] { + return this._requiredFields; + } + + /** + * The Observable from which the views table will be calculated + */ + public get dataSource(): Observable { + return this._dataSource; + } + + private _state: BackendImportPhase = BackendImportPhase.LOADING_PREVIEW; + + private _summary: BackendImportSummary[]; + private _rows: ViewImportedParticipant[]; + private _previewColumns: BackendImportHeader[]; + + private _dataSource: Observable = of([]); + private _requiredFields: string[] = []; + private _defaultColumns: ImportListHeaderDefinition[] = []; + + private _headers: Record = {}; + protected uploadButton: boolean; + private tempPreviewsObservable: Subscription; + + public constructor( + private dialog: MatDialog, + private router: Router, + protected translate: TranslateService, + protected readonly controller: ParticipantControllerService, + private cd: ChangeDetectorRef + ) {} + + /** + * Starts with a clean preview (removing any previously existing import previews) + */ + public ngOnInit(): void { + /* TODO: REMOVE THE MANUAL STATISTICS' CALCULATION */ + this._dataSource = this.importer.previewsObservable.pipe(map(previews => this.calculateRows(previews))); + this._requiredFields = this.createRequiredFields(); + this.importer.currentImportPhaseObservable.subscribe(phase => { + this._state = phase; + }); + this.tempPreviewsObservable = this.importer.previewsObservable.subscribe(previews => { + this._rows = this.calculateRows(previews); + this.uploadButton = previews?.some(preview => preview.state === 'error') ? true : false; + this._totalCountObservable = this._dataSource?.pipe(map(items => items?.length)); + this.fillPreviewData(previews); + this.setHeaders({ preview: this._previewColumns }); + }); + this.searchService = new ListSearchService(this.filterProps, this.alsoFilterByProperties); + } + + /** + * Resets the importer when leaving the view + */ + public ngOnDestroy(): void { + this.importer.clearPreview(); + this.importer.clearFile(); + this.importer.clearAll(); + } + + /** + * Triggers a change in the tab group: Clearing the preview selection + */ + protected onTabChange({ index }: MatTabChangeEvent): void { + this.importer.clearAll(); + this.selectedTabChanged.emit(index); + } + + /** + * True if there are custom tabs. + */ + protected hasSeveralTabs(): boolean { + return this.importListFirstTabs.length + this.importListLastTabs.length > 0; + } + + /** + * triggers the importer's onSelectFile after a file has been chosen + */ + protected onSelectFile(event: any): void { + this.uploadButton = false; + this.importer.onSelectFile(event); + } + + /** + * Gets the relevant backend header information for a property. + */ + protected getHeader(propertyName: string): BackendImportHeader { + return this._headers[propertyName]?.preview; + } + + /** + * Gets the style of the column for the given property. + */ + protected getColumnConfig(propertyName: string): ScrollingTableCellDefConfig { + const defaultHeader = this._headers[propertyName]?.default; + const colWidth = defaultHeader?.width ?? 50; + const def: ScrollingTableCellDefConfig = { minWidth: Math.max(150, colWidth) }; + if (!defaultHeader?.flexible) { + def.width = colWidth; + } + return def; + } + + /** + * Gets the label of the column for the given property. + */ + protected getColumnLabel(propertyName: string): string { + return this._headers[propertyName]?.default?.label ?? propertyName; + } + + /** + * Get the icon for the the item + * @param item a row with a current state + * @return the icon for the item + */ + protected getActionIconRow(item: ViewImportedParticipant): string { + switch (item[`state`]) { + case BackendImportState.Error: // no import possible + return this._state !== BackendImportPhase.FINISHED ? `error_outline` : 'close'; + case BackendImportState.Warning: + return `warning`; + case BackendImportState.New: // item will be imported / has been imported + return this._state !== BackendImportPhase.FINISHED ? `add_circle_outline` : `done`; + case BackendImportState.Done: + return this._state !== BackendImportPhase.FINISHED ? 'autorenew' : 'done'; + case BackendImportState.Referenced: + return this._state !== BackendImportPhase.FINISHED ? 'merge' : `done`; + case BackendImportState.Generated: + return `merge`; + case BackendImportState.Remove: + return `remove`; + case BackendImportState.Unchanged: + return ``; + default: + return `block`; // fallback: Error + } + } + + /** + * Get the icon for the the entry + * @param item an entry with a current state + * @return the icon for the item + */ + protected getActionIconEntry(item: BackendImportEntryObject): string { + switch (item.info) { + case BackendImportState.Error: // no import possible + return `error_outline`; + case BackendImportState.Warning: + return `warning`; + case BackendImportState.New: + return `add_circle_outline`; + case BackendImportState.Done: + return 'autorenew'; + case BackendImportState.Generated: + return `merge`; + case BackendImportState.Remove: + return `remove`; + default: + // ad hoc check for updated structure levels and groups + if ((item.info as string) === 'updated') { + return 'autorenew'; + } + return 'mood_bad'; + } + } + + protected getColorIcon(item: ViewImportedParticipant | BackendImportEntryObject): string { + switch (item[`state`] ?? item[`info`]) { + case BackendImportState.Error: // no import possible + return `red-warning-text`; + case BackendImportState.Warning: + return 'warning'; + case BackendImportState.New: + return 'os-green'; + case BackendImportState.Done: // has been imported / item will be updated + if (this._state === BackendImportPhase.FINISHED) { + return 'os-green'; + } + return 'os-yellow'; + case BackendImportState.Referenced: + if (this._state === BackendImportPhase.FINISHED) { + return 'os-green'; + } + return 'accent'; + case BackendImportState.Generated: + return `accent`; + case BackendImportState.Unchanged: + return ``; + default: + return `block`; // fallback: Error + } + } + + protected getSummaryInformation(item: string): string[] { + return ( + { + total: ['group', 'accent'], + error: ['error_outline', 'red-warning-text'], + warning: ['warning', 'warn'], + created: ['add_circle_outline', 'os-green'], + updated: ['autorenew', 'os-yellow'], + referenced: ['merge', 'accent'], + unchanged: [``, ``] + }[item] ?? ['', ''] + ); + } + + protected getEntryIcon(item: BackendImportEntryObject): string { + if (item.info === BackendImportState.Done || !item) { + return undefined; + } + return this.getActionIconEntry(item); + } + + protected containsError(entry: any, def: string): boolean { + this.cd.markForCheck(); + const value = entry?.[def]; + if (!value) return false; + if (Array.isArray(value)) { + return value.some(icon => this.getEntryIcon(icon) === 'error_outline'); + } + return this.getEntryIcon(value) === 'error_outline'; + } + + /** + * Get the correct tooltip for the item + * @param entry a row with a current state + * @eturn the tooltip for the item + */ + protected getRowTooltip(row: ViewImportedParticipant): string { + switch (row.state) { + case BackendImportState.Error: // no import possible + return ( + this.getErrorDescription(row) ?? + _(`There is an unspecified error in this line, which prevents the import.`) + ); + case BackendImportState.Warning: + return this.getErrorDescription(row) ?? _(`The affected columns will not be imported.`); + case BackendImportState.New: + return ( + this.translate.instant(this.modelName) + + ` ` + + (this._state !== BackendImportPhase.FINISHED + ? this.translate.instant(`will be created`) // item will be updated + : this.translate.instant(`has been created`)) // item has been created + ); + case BackendImportState.Done: + return ( + this.translate.instant(this.modelName) + + ` ` + + (this._state !== BackendImportPhase.FINISHED + ? this.translate.instant(`will be updated`) // item will be updated + : this.translate.instant(`has been updated`)) + ); // item has been updated + case BackendImportState.Referenced: + return ( + this.translate.instant(this.modelName) + + ` ` + + (this._state !== BackendImportPhase.FINISHED + ? this.translate.instant(`will be referenced`) // item will be referenced + : this.translate.instant(`has been referenced`)) // item has been referenced + ); + case BackendImportState.Unchanged: + return ``; + default: + return undefined; + } + } + + public getWarningRowTooltip(row: ViewImportedParticipant): string { + switch (row.state) { + case BackendImportState.Error: // no import possible + return ( + this.getErrorDescription(row) ?? + _(`There is an unspecified error in this line, which prevents the import.`) + ); + default: + return this.getErrorDescription(row) ?? _(`The affected columns will not be imported.`); + } + } + + /** + * A function to trigger the csv example download. + */ + public downloadCsvExample(): void { + this.importer.downloadCsvExample(); + } + + public getCsvReload(event: Event): void { + this.importer.clearFile(); + this.importer.onSelectFile(event); + this.importer.refreshFile(); + } + + /** + * The column separator selection. + */ + protected onColSepChanged(label: string): void { + this.importer.columnSeparator = this.importer.columnSeparators.find(col => col.label === label)?.value; + } + + /** + * The text separator selection + */ + protected onTextSeparatorChanged(value: string): void { + this.importer.textSeparator = value; + } + + /** + * The encoding selection. + */ + protected onEncodingChanged(value: string): void { + this.importer.encoding = value; + } + + /** + * Returns the verbose title for a given summary title. + */ + public getSummaryPointTitle(title: string): string { + return this.importer.getVerboseSummaryPointTitle(title); + } + + public getShortenedDecimal(decimalString: string): string { + while (decimalString?.length && [`0`, `.`].includes(decimalString?.charAt(decimalString?.length - 1))) { + decimalString = decimalString?.substring(0, decimalString?.length - 1); + } + return decimalString; + } + + public isString(value: any): value is string { + return typeof value === `string`; + } + + private setHeaders(data: { default?: ImportListHeaderDefinition[]; preview?: BackendImportHeader[] }): void { + for (const key of Object.keys(data)) { + for (const header of data[key] ?? []) { + if (!this._headers[header.property]) { + this._headers[header.property] = { [key]: header }; + } else { + this._headers[header.property][key] = header; + } + } + } + } + + private getErrorDescription(entry: ViewImportedParticipant): string { + return entry.messages?.map(error => this.translate.instant(this.importer.verbose(error))).join(`\n `); + } + + private fillPreviewData(previews: BackendImportPreview[]): void { + if (!previews || !previews.length) { + this._previewColumns = undefined; + this._summary = undefined; + this._rows = undefined; + } else { + const orderMap = new Map(this.headersOrder.map((property, index) => [property, index])); + this._previewColumns = (previews[0]?.headers ?? this._previewColumns) + .filter(header => !header.is_hidden) + .sort((a, b) => { + const aIndex = orderMap.get(a.property) ?? this.headersOrder.length; + const bIndex = orderMap.get(b.property) ?? this.headersOrder.length; + return aIndex - bIndex; + }); + this.transformSummary(previews); + this.cd.markForCheck(); + } + } + + private transformSummary(previews: BackendImportPreview[]): void { + this._summary = undefined; + this._summary = previews.some(preview => preview.statistics) + ? previews.flatMap(preview => preview.statistics).filter(point => point?.value) + : []; + const countReferenced = this._rows.filter(row => row?.state === BackendImportState.Referenced)?.length | 0; + const countUnchanged = this._rows.filter(row => row?.state === BackendImportState.Unchanged)?.length | 0; + const countUpdated = + (this._summary.find(item => item?.name === 'updated')?.value - countReferenced - countUnchanged) | 0; + const error = this._summary.find(item => item.name === 'error'); + this._summary = this._summary.filter(item => item.name !== 'updated'); + if (countReferenced > 0) { + this._summary.push({ name: 'referenced', value: countReferenced }); + } + if (countUpdated > 0) { + this._summary.push({ name: 'updated', value: countUpdated }); + } + this._summary = this._summary.filter(item => item.name !== 'error'); + this._summary.push({ name: error?.name, value: error?.value }); + } + + private calculateRows(previews: BackendImportPreview[]): ViewImportedParticipant[] { + return previews?.flatMap(preview => + preview.rows.map(row => { + const participant = new ViewImportedParticipant(row.id, row, this.activeMeetingIdService.meetingId); + this.isReferenced(participant); + this.isUnchanged(participant); + return participant; + }) + ); + } + + private createRequiredFields(): string[] { + const definitions = this.defaultColumns; + if (Array.isArray(definitions) && definitions.length > 0) { + return definitions + .filter(definition => definition.isRequired as boolean) + .map(definition => definition.property as string); + } else { + return []; + } + } + + /** + * Summary adapted to the footer. Displays only "created", "updated", "referenced" and "error" columns. + * @param summary + * @returns BackendImportSummary[] + */ + protected shortenSummary(summary: BackendImportSummary[]): BackendImportSummary[] { + return summary?.filter( + col => col.name !== 'structure levels created' && col.name !== 'groups created' && col.name !== 'warning' + ); + } + + protected summaryRest(summary: BackendImportSummary[]): BackendImportSummary[] { + return summary?.filter(col => col.name === 'structure levels created' || col.name === 'groups created'); + } + + protected async importData(dialogTemplate: TemplateRef, summaryDialog: TemplateRef): Promise { + this.tempPreviewsObservable.unsubscribe(); + const customOptions = { + width: `600px`, + disableClose: false, + maxWidth: `90vw`, + maxHeight: `90vh` + }; + const sleep: (ms: number) => Promise = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + const ref = this.dialog.open(dialogTemplate, { + data: this.summary, + ...customOptions, + hasBackdrop: false + }); + try { + await sleep(2000); + if (await this.importer.doImport()) { + // The close() is needed here so dialogs don't overlap if the second one opens + ref.close(); + this.dialog + .open(summaryDialog, { + data: this.summary, + ...customOptions + }) + .afterClosed() + .subscribe(() => this.redirect()); + } + } catch {} + this.cd.detectChanges(); + ref.close(); + } + + private redirect(): void { + this.router.navigateByUrl(this.router.url.replace('/import/preview', '')); + } + + private isReferenced(row: ViewImportedParticipant): boolean { + if (row.state !== 'done') { + return false; + } + for (const user of this.userAccounts) { + if ( + (user.meeting_ids.includes(row.meeting_id) && row.username && row.username === user.username) || + (row.member_number && row.member_number === user.member_number) || + (row.saml_id && row.saml_id === user.saml_id) + ) { + return false; + } + } + row.setState = BackendImportState.Referenced; + return true; + } + + private isUnchanged(item: ViewImportedParticipant): boolean { + if (item.state !== BackendImportState.Done) { + return false; + } + if (this.checkChanges(item) === false) { + item.setState = BackendImportState.Unchanged; + return true; + } + return false; + } + + protected checkChanges(item: ViewImportedParticipant, headerName?: string): false | string { + for (const user of this.userAccounts) { + if ( + (user.meeting_ids.includes(item.meeting_id) && item.username && item.username === user.username) || + (item.member_number && item.member_number === user.member_number) || + (item.saml_id && item.saml_id === user.saml_id) + ) { + const updatedUser = user.getModel(); + const changes: Partial> = {}; + this.compareStructureLevel(item?.structure_levels, user?.structure_level().split(',')); + const userGroups: ViewGroup[] = user?.groups(this.activeMeetingIdService.meetingId) || []; + const changedGroups = this.compareGroups(item?.groups, userGroups); + for (const key of Object.keys(updatedUser) as (keyof ViewUser)[]) { + if (key in item) { + const importedValue = item[key as keyof ViewImportedParticipant]; + if (key === 'id') { + continue; + } + if (updatedUser[key] !== importedValue) { + changes[key] = { + old: updatedUser[key], + new: importedValue + }; + } + } + if (item.is_locked_out !== user.is_locked_out && user.is_locked_out !== undefined) { + changes['locked_out'] = { + old: user.is_locked_out, + new: item.is_locked_out + }; + } + if (item.is_present !== user.isPresentInMeeting() && user.isPresentInMeeting() !== undefined) { + changes['is_present'] = { + old: user.isPresentInMeeting(), + new: item.is_present + }; + } + if (item.saml_id !== user.saml_id) { + changes['saml_id'] = { + old: user.saml_id, + new: item.saml_id + }; + } + if (item.number !== user.number()) { + changes['number'] = { + old: user.number(), + new: item.number + }; + } + if (item.comment !== user.comment(this.activeMeetingIdService.meetingId)) { + changes['comment'] = { + old: user.comment(), + new: item.comment + }; + } + if (item.gender !== user.gender_name) { + changes['gender'] = { + old: user.gender_name, + new: item.gender + }; + } + if ( + this.getShortenedDecimal(item.voteWeight) !== + this.getShortenedDecimal(user.voteWeight.toString()) + ) { + changes['vote_weight'] = { + old: user.voteWeight, + new: item.voteWeight + }; + } + if (item.external !== user.external && user.external !== undefined) { + changes['external'] = { + old: user.external, + new: item.external + }; + } + if (changedGroups.new.length) { + changes['groups'] = { + old: changedGroups.old, + new: changedGroups.new + }; + } + } + if (Object.keys(changes).includes(headerName)) { + return 'autorenew'; + } + if (Object.keys(changes).length === 0) { + return false; + } + } + } + return ''; + } + + private compareStructureLevel( + newSL: string[], + oldSL: string[] + ): { + old: string[]; + new: string[]; + } { + // No need to check new items as they are calculated by the backend + const commonSL = newSL.filter(sl => oldSL.includes(sl['value'])).sort(); + const diffSL = newSL.filter(sl => !oldSL.includes(sl['value']) && sl['id']).sort(); + diffSL.filter(sl => { + sl['info'] = 'updated'; + }); + return { + old: oldSL, + new: commonSL.concat(diffSL) + }; + } + + private compareGroups( + newGroups: string[], + oldGroups: ViewGroup[] + ): { + old: string[]; + new: string[]; + } { + const oldGroupNames = oldGroups.map((g: Group) => g.name).sort(); + const addedGroups = newGroups + .filter(newgroup => !oldGroupNames.includes(newgroup['value'] as string) && newgroup['id']) + .sort(); + addedGroups.filter(g => { + if (g['info'] === 'done') { + g['info'] = 'updated'; + } + }); + return { + old: oldGroupNames, + new: addedGroups + }; + } +} diff --git a/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/components/participant-import-list/participant-import-list.component.html b/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/components/participant-import-list/participant-import-list.component.html index 0962d91e9f..f1a56ece36 100644 --- a/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/components/participant-import-list/participant-import-list.component.html +++ b/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/components/participant-import-list/participant-import-list.component.html @@ -3,19 +3,11 @@

Import participants

- - - @if (canImport) { - - } - { + protected storageKey = `participantImportPreview`; + public constructor(store: ActiveFiltersService) { + super(store); + } + + protected getFilterDefinitions(): OsFilter[] { + return [ + { + property: 'state', + label: _('Participant state'), + options: [ + { condition: STATE_FITERABLE[0], label: STATES[0] }, + { condition: STATE_FITERABLE[1], label: STATES[1] }, + { condition: STATE_FITERABLE[2], label: STATES[2] }, + { condition: STATE_FITERABLE[3], label: STATES[3] } + ] + }, + { + property: `is_active`, + label: _(`Active`), + options: [ + { condition: true, label: _(`Is active`) }, + { condition: [false, null], label: _(`Is not active`) } + ] + }, + { + property: `is_physical_person`, + label: _(`Committee`), + options: [ + { condition: true, label: _(`Is not a committee`) }, + { condition: [false, null], label: _(`Is a committee`) } + ] + }, + { + property: `is_present`, + label: _(`Presence`), + options: [ + { condition: true, label: _(`Is present`) }, + { condition: [false, null], label: _(`Is not present`) } + ] + }, + { + property: `changedVoteWeight`, + label: _(`Vote weight`), + options: [ + { condition: true, label: _(`Has changed vote weight`) }, + { condition: [false, null], label: _(`Has unchanged vote weight`) } + ] + }, + { + property: `is_locked_out`, + label: `Locked out`, + options: [ + { condition: true, label: `Is locked out` }, + { condition: [false, null], label: `Is not locked out` } + ] + }, + { + property: `hasMemberNumber`, + label: _(`Membership number`), + options: [ + { condition: true, label: _(`Has a membership number`) }, + { condition: [false, null], label: _(`Has no membership number`) } + ] + }, + { + property: `hasTitle`, + label: _(`Title`), + options: [ + { condition: true, label: _(`Has a title`) }, + { condition: [false, null], label: _(`Has no title`) } + ] + }, + { + property: `hasSamlId`, + label: _(`SSO`), + options: [ + { condition: true, label: _(`Has SSO identification`) }, + { condition: [false, null], label: _(`Has no SSO identification`) } + ] + }, + { + property: `gender`, + label: _(`Gender`), + options: [ + { condition: GENDER_FITLERABLE[0], label: GENDERS[0] }, + { condition: GENDER_FITLERABLE[1], label: GENDERS[1] }, + { condition: GENDER_FITLERABLE[2], label: GENDERS[2] }, + { condition: GENDER_FITLERABLE[3], label: GENDERS[3] }, + { condition: null, label: _(`not specified`) } + ] + }, + { + property: `hasEmail`, + label: _(`Email address`), + options: [ + { condition: true, label: _(`Has an email address`) }, + { condition: [false, null], label: _(`Has no email address`) } + ] + }, + { + property: `hasPronoun`, + label: _(`Pronoun`), + options: [ + { condition: true, label: _(`Has pronoun`) }, + { condition: [false, null], label: _(`Has no pronoun`) } + ] + }, + { + property: `hasUsername`, + label: _(`Username`), + options: [ + { condition: true, label: _(`Has username`) }, + { condition: [false, null], label: _(`Has no username`) } + ] + }, + { + property: `hasHomeCommittee`, + label: _(`Home committee`), + options: [ + { condition: true, label: _(`Has home committee`) }, + { condition: [false, null], label: _(`Has no home committee`) } + ] + }, + { + property: `hasGroups`, + label: _(`Groups`), + options: [ + { condition: true, label: _(`Has groups`) }, + { condition: [false, null], label: _(`Has no groups`) } + ] + } + ]; + } +} diff --git a/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/services/participant-import-search.service.ts b/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/services/participant-import-search.service.ts new file mode 100644 index 0000000000..328a8a4077 --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/services/participant-import-search.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@angular/core'; +import { ListSearchService } from '@app/ui/modules/list/services/list-search.service'; + +@Injectable({ + providedIn: 'root' +}) +export class ParticipantImportPreviewSearchService extends ListSearchService { + public constructor() { + super( + [ + `short_name`, + `first_name`, + `username`, + `email`, + `saml_id`, + `member_number`, + `externalString`, + `homeCommitteeName`, + `gender`, + `title`, + `structure_level` + ], + [] + ); + } +} diff --git a/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/services/participant-import.service/participant-import.service.ts b/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/services/participant-import.service/participant-import.service.ts index 0113a4d2a0..2ce412bfed 100644 --- a/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/services/participant-import.service/participant-import.service.ts +++ b/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/services/participant-import.service/participant-import.service.ts @@ -33,6 +33,7 @@ export class ParticipantImportService extends BaseBackendImportService { total: _(`Total participants`), created: _(`Participants created`), updated: _(`Participants updated`), + referenced: _(`Participants referenced`), omitted: _(`Participants skipped`), warning: _(`Participants with warnings: affected cells will be skipped`), error: _(`Participants with errors`), diff --git a/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/view-models/view-participant-import.ts b/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/view-models/view-participant-import.ts new file mode 100644 index 0000000000..8e2af5bea7 --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/participants/pages/participant-import/view-models/view-participant-import.ts @@ -0,0 +1,188 @@ +import { Decimal } from '@app/domain/definitions/key-types'; +import { Identifiable } from '@app/domain/interfaces'; +import { + BackendImportEntry, + BackendImportRow, + BackendImportState +} from '@app/ui/modules/import-list/definitions/backend-import-preview'; +import { _ } from '@ngx-translate/core'; + +export const STATES = [ + _(`New participant`), + _(`Updated participant`), + _('Referenced participant'), + _(`Faulty participant`) +]; +export const STATE_FITERABLE = [`new`, `done`, `referenced`, `error`]; + +export class ViewImportedParticipant implements Identifiable, BackendImportRow /* implements Searchable */ { + // This class replaces BackendImportIdentifiedRow + + public meeting_id: number; + public static COLLECTION = `importedParticipant`; + + public state: BackendImportState; + public messages: string[]; + public data: Record; + + public id: number; + public first_name: string; + public last_name: string; + public email: string; + public member_number: string; + public number: string; + public vote_weight: Decimal; + public gender: string; + public pronoun: string; + public username: string; + public default_password: string; + public saml_id: string; + public home_committee: string; + public comment: string; + public title: string; + + public structure_levels: string[]; + public groups: string[]; + + public external: boolean; + public is_active: boolean; + public is_present: boolean; + public is_locked_out: boolean; + public is_physical_person: boolean; + + public constructor(preview_id: number, preview: BackendImportRow, meeting_id: number) { + this.meeting_id = meeting_id; // id of the meeting to import into + this.id = preview_id; + + this.data = preview.data; + this.messages = preview.messages; + this.state = preview.state; + + this.title = this.setValue(this.data?.['title']) as string; + this.first_name = this.setValue(this.data?.['first_name']) as string; + this.last_name = this.setValue(this.data?.['last_name']) as string; + this.email = this.setValue(this.data?.['email']) as string; + this.member_number = this.setValue(this.data?.['member_number']) as string; + this.structure_levels = this.data?.['structure_level'] as string[]; + this.groups = this.data?.['groups'] as string[]; + this.number = this.setValue(this.data?.['number']) as string; + this.vote_weight = this.setValue(this.data?.['vote_weight']) as string; + this.gender = this.setValue(this.data?.['gender']) as string; + this.pronoun = this.setValue(this.data?.['pronoun']) as string; + this.username = this.setValue(this.data?.['username']) as string; + this.default_password = this.setValue(this.data?.['default_password']) as string; + + this.is_active = this.getBooleanValue(this.setValue(this.data?.['is_active']) as boolean); + this.is_physical_person = this.getBooleanValue(this.setValue(this.data?.['is_physical_person']) as boolean); + this.is_present = this.getBooleanValue(this.setValue(this.data?.['is_present']) as boolean); + this.is_locked_out = this.getBooleanValue(this.setValue(this.data?.['locked_out']) as boolean); + this.external = this.getBooleanValue(this.setValue(this.data?.['external'] as boolean)); + + this.saml_id = this.setValue(this.data?.['saml_id']) as string; + this.home_committee = this.setValue(this.data?.['home_committee']) as string; + this.comment = this.setValue(this.data?.['comment']) as string; + } + + public static readonly REQUESTABLE_FIELDS: (keyof ViewImportedParticipant)[] = [ + 'id', + 'first_name', + 'last_name', + 'email', + 'member_number', + 'vote_weight', + 'gender', + 'pronoun', + 'username', + 'default_password', + 'saml_id', + 'home_committee', + 'comment', + 'external', + 'title', + 'structure_levels', + 'groups', + 'is_active', + 'is_present', + 'is_locked_out', + 'is_physical_person' + ]; + + public setValue(field: BackendImportEntry | BackendImportEntry[]): string | number | boolean | undefined { + if (!field) { + return undefined; + } + if (Array.isArray(field)) { + return this.setValue(field[0]); + } + if (typeof field === 'string') { + return field; + } + if (typeof field === 'number') { + return field; + } + if (typeof field === 'boolean') { + return field; + } + return field.value; + } + + protected getDecimalFields(): (keyof ViewImportedParticipant)[] { + return [`vote_weight`]; + } + + public get voteWeight(): Decimal { + return this.vote_weight; + } + + public get getGroups(): string[] { + return [this.groups['value']]; + } + + public get getStructureLevels(): string[] { + return this.structure_levels; + } + + public getBooleanValue(value: string | number | boolean | undefined): boolean { + return !!value; + } + + public get changedVoteWeight(): boolean { + return !this.vote_weight ? true : false; + } + + public get hasMemberNumber(): boolean { + return this.member_number ? true : false; + } + + public get hasTitle(): boolean { + return this.title?.length > 0 ? true : false; + } + + public get hasSamlId(): boolean { + return this.saml_id ? true : false; + } + + public get hasEmail(): boolean { + return this.email?.length > 0 ? true : false; + } + + public get hasUsername(): boolean { + return this.username ? true : false; + } + + public get hasPronoun(): boolean { + return this.pronoun ? true : false; + } + + public get hasHomeCommittee(): boolean { + return this.home_committee?.length > 0 ? true : false; + } + + public get hasGroups(): boolean { + return this.getGroups.length > 0 ? true : false; + } + + public set setState(value: BackendImportState) { + this.state = value; + } +} diff --git a/client/src/app/ui/modules/head-bar/components/head-bar/head-bar.component.ts b/client/src/app/ui/modules/head-bar/components/head-bar/head-bar.component.ts index 59996f1082..5f88731a9c 100644 --- a/client/src/app/ui/modules/head-bar/components/head-bar/head-bar.component.ts +++ b/client/src/app/ui/modules/head-bar/components/head-bar/head-bar.component.ts @@ -1,7 +1,6 @@ import { TemplatePortal } from '@angular/cdk/portal'; import { AfterViewInit, - ChangeDetectionStrategy, Component, EventEmitter, inject, @@ -73,7 +72,7 @@ export const HEAD_BAR_HEIGHT = 50; // height of the head-bar in px. templateUrl: `./head-bar.component.html`, styleUrls: [`./head-bar.component.scss`], encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.Eager, + // changeDetection: ChangeDetectionStrategy.Eager, standalone: false }) export class HeadBarComponent implements OnInit, AfterViewInit { diff --git a/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.html b/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.html index 9bc80a4a3a..e0b9831803 100644 --- a/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.html +++ b/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.html @@ -1,151 +1,245 @@ - - - @if (hasSeveralTabs()) { - - - @for (tab of importListFirstTabs; track tab) { - - {{ tab.label }} - - - } - - - - - - - - @for (tab of importListLastTabs; track tab) { - - {{ tab.label }} - - + + + + + + + + + + + + + +
+
+

{{ 'Import template' | translate }}

+
- - @if (!finishedSuccessfully && (rawFileObservable | async); as file) { -
- {{ file.name }} - -
- }
+
+

{{ 'Description of columns' | translate }}

+
+ +
+
+
+

{{ 'Instructions for import file' | translate }}

+ +
+
+
+
+ + + +
+ + {{ 'Drag files to this area OR click here to select files' | translate }} + +
+ + @if (!finishedSuccessfully && (rawFileObservable | async); as file) { +
+ {{ file.name }} + +
} +
- -@if (dataSource | async) { + +
-

The fields are defined as follows

+

Description of column names

diff --git a/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.scss b/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.scss index 4d41272117..57f96443ae 100644 --- a/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.scss +++ b/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.scss @@ -10,6 +10,15 @@ } } +.instructions { + color: var(--theme-accent-400); + margin-bottom: 12px; +} + +button mat-label { + padding-right: 5px; +} + pbl-ngrid-header-cell, pbl-ngrid-cell { padding-right: 12px; @@ -82,3 +91,41 @@ pbl-ngrid-cell { display: inline-flex; margin-right: 10px; } + +.dropdiv { + width: auto; + height: 100px; + border: dotted 2px var(--theme-accent-400); + margin-bottom: 15px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background-color: #ccc; + } + &:not(:hover) { + background-color: none; + } + .dropdiv-label { + color: var(--theme-accent-400); + } +} + +.dropdiv.processing { + display: none; +} + +.progress { + display: none; + width: inherit; + height: inherit; + background-color: #ccc; +} + +.progress.processing { + display: flex; + height: 100px; + align-items: center; + justify-content: center; +} diff --git a/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.ts b/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.ts index dd2c35257c..9df2f0dc53 100644 --- a/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.ts +++ b/client/src/app/ui/modules/import-list/components/via-backend-import-list/backend-import-list.component.ts @@ -1,5 +1,5 @@ import { - ChangeDetectionStrategy, + ChangeDetectorRef, Component, ContentChild, ContentChildren, @@ -7,7 +7,6 @@ import { EventEmitter, inject, Input, - OnDestroy, OnInit, Output, QueryList, @@ -18,8 +17,10 @@ import { import { MatDialog } from '@angular/material/dialog'; import { MatSelectChange } from '@angular/material/select'; import { MatTabChangeEvent } from '@angular/material/tabs'; +import { Router } from '@angular/router'; import { infoDialogSettings } from '@app/infrastructure/utils/dialog-settings'; import { ValueLabelCombination } from '@app/infrastructure/utils/import/import-utils'; +import { ViewImportedParticipant } from '@app/site/pages/meetings/pages/participants/pages/participant-import/view-models/view-participant-import'; import { BackendImportService } from '@app/ui/base/import-service'; import { _ } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core'; @@ -54,10 +55,9 @@ export enum BackendImportPhase { templateUrl: `./backend-import-list.component.html`, styleUrls: [`./backend-import-list.component.scss`], encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.Eager, standalone: false }) -export class BackendImportListComponent implements OnInit, OnDestroy { +export class BackendImportListComponent implements OnInit { public readonly END_POSITION = END_POSITION; public readonly START_POSITION = START_POSITION; @@ -245,14 +245,24 @@ export class BackendImportListComponent implements OnInit, OnDestroy { private _headers: Record = {}; + public hideOldCard = true; + protected uploadButton: boolean; + protected selectedNewFile; + private dialog = inject(MatDialog); private translate = inject(TranslateService); + public constructor( + private cd: ChangeDetectorRef, + private router: Router + ) {} + /** * Starts with a clean preview (removing any previously existing import previews) */ public ngOnInit(): void { this._importer.clearAll(); + this.uploadButton = true; this._requiredFields = this.createRequiredFields(); this._importer.currentImportPhaseObservable.subscribe(phase => { if (phase === BackendImportPhase.LOADING_PREVIEW && this.fileInput) { @@ -267,13 +277,7 @@ export class BackendImportListComponent implements OnInit, OnDestroy { map(previews => this.calculateRows(previews)), delay(50) ); - } - - /** - * Resets the importer when leaving the view - */ - public ngOnDestroy(): void { - this._importer.clearFile(); + this.cd.detectChanges(); } /** @@ -295,7 +299,8 @@ export class BackendImportListComponent implements OnInit, OnDestroy { /** * triggers the importer's onSelectFile after a file has been chosen */ - public onSelectFile(event: any): void { + public onSelectedFile(event: Event): void { + this.uploadButton = false; this._importer.onSelectFile(event); } @@ -305,6 +310,7 @@ export class BackendImportListComponent implements OnInit, OnDestroy { public removeSelectedFile(clearImporter = true): void { if (this.fileInput) { this.fileInput.nativeElement.value = ``; + this.uploadButton = true; } if (clearImporter) { this._importer.clearFile(); @@ -343,7 +349,7 @@ export class BackendImportListComponent implements OnInit, OnDestroy { * @param item a row or an entry with a current state * @eturn the icon for the item */ - public getActionIcon(item: BackendImportIdentifiedRow | BackendImportEntryObject): string { + public getActionIcon(item: ViewImportedParticipant | BackendImportEntryObject): string { switch (item[`state`] ?? item[`info`]) { case BackendImportState.Error: // no import possible return `block`; @@ -374,7 +380,7 @@ export class BackendImportListComponent implements OnInit, OnDestroy { * @param entry a row with a current state * @eturn the tooltip for the item */ - public getRowTooltip(row: BackendImportIdentifiedRow): string { + public getRowTooltip(row: ViewImportedParticipant): string { switch (row.state) { case BackendImportState.Error: // no import possible return ( @@ -398,7 +404,7 @@ export class BackendImportListComponent implements OnInit, OnDestroy { } } - public getWarningRowTooltip(row: BackendImportIdentifiedRow): string { + public getWarningRowTooltip(row: ViewImportedParticipant): string { switch (row.state) { case BackendImportState.Error: // no import possible return ( @@ -489,7 +495,7 @@ export class BackendImportListComponent implements OnInit, OnDestroy { } } - private getErrorDescription(entry: BackendImportIdentifiedRow): string { + private getErrorDescription(entry: ViewImportedParticipant): string { return entry.messages?.map(error => this.translate.instant(this._importer.verbose(error))).join(`\n `); } @@ -508,7 +514,7 @@ export class BackendImportListComponent implements OnInit, OnDestroy { } } - private calculateRows(previews: BackendImportPreview[]): BackendImportIdentifiedRow[] { + private calculateRows(previews: BackendImportPreview[] | ViewImportedParticipant[]): BackendImportIdentifiedRow[] { return previews?.flatMap(preview => preview.rows); } @@ -522,4 +528,41 @@ export class BackendImportListComponent implements OnInit, OnDestroy { return []; } } + + public onDragOver(event: DragEvent): void { + event.preventDefault(); + event.stopPropagation(); + } + + public onDropSuccess(event: DragEvent): void { + event.preventDefault(); + event.stopPropagation(); + const files = event.dataTransfer?.files; + if (!files || files.length === 0) { + return; + } + const droppedFile = { + target: { + files: files + } + }; + try { + this._importer.onSelectFile(droppedFile); + this.uploadButton = false; + } catch { + this.uploadButton = false; + } + } + + public onChange(event: Event): void { + this._importer.onSelectFile(event); + } + + protected showPreview(): void { + this.router.navigateByUrl(this.router.url.concat('/preview')); + } + + protected sendCsvReload(event: Event): void { + this._importer.onSelectFile(event); + } } diff --git a/client/src/app/ui/modules/import-list/definitions/backend-import-preview.ts b/client/src/app/ui/modules/import-list/definitions/backend-import-preview.ts index 859fbe94c4..b28ca7fa90 100644 --- a/client/src/app/ui/modules/import-list/definitions/backend-import-preview.ts +++ b/client/src/app/ui/modules/import-list/definitions/backend-import-preview.ts @@ -1,4 +1,5 @@ import { Identifiable } from '@app/domain/interfaces'; +import { ViewImportedParticipant } from '@app/site/pages/meetings/pages/participants/pages/participant-import/view-models/view-participant-import'; export enum BackendImportState { Error = `error`, @@ -6,13 +7,15 @@ export enum BackendImportState { New = `new`, Done = `done`, Generated = `generated`, - Remove = `remove` + Remove = `remove`, + Referenced = `referenced`, + Unchanged = `unchanged` // could be expanded later } export interface BackendImportHeader { property: string; // field name/column title - type: `boolean` | `number` | `string` | `date`; // date must be in format yyyy-mm-dd + type: `boolean` | `number` | `string` | `decimal` | `date`; // date must be in format yyyy-mm-dd is_list: boolean; // optional, if not given defaults to `false` is_object: boolean; // optional, if not given defaults to `false` is_hidden: boolean; // optional, if not given defaults to `false` @@ -51,7 +54,7 @@ export interface BackendImportPreview { id: number; // id of action_worker to import state: BackendImportState; // May be `error`, `warning` or `done` headers: BackendImportHeader[]; - rows: BackendImportIdentifiedRow[]; + rows: BackendImportIdentifiedRow[] | ViewImportedParticipant[]; statistics: BackendImportSummary[]; } diff --git a/client/src/app/ui/modules/list/components/sort-filter-bar/sort-filter-bar.component.html b/client/src/app/ui/modules/list/components/sort-filter-bar/sort-filter-bar.component.html index 33ea34c07e..04e6fb2b78 100644 --- a/client/src/app/ui/modules/list/components/sort-filter-bar/sort-filter-bar.component.html +++ b/client/src/app/ui/modules/list/components/sort-filter-bar/sort-filter-bar.component.html @@ -77,12 +77,50 @@ } } + + @if (csvReload) { + + + } + + + @if (csvConfiguration) { + + } @if (hasFilters) {