diff --git a/apps/red-ui/src/app/guards/trash-dossiers.guard.ts b/apps/red-ui/src/app/guards/trash-dossiers.guard.ts deleted file mode 100644 index d1a8c1a62..000000000 --- a/apps/red-ui/src/app/guards/trash-dossiers.guard.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Injectable } from '@angular/core'; -import { CanActivate } from '@angular/router'; -import { firstValueFrom } from 'rxjs'; -import { TrashDossiersService } from '@services/entity-services/trash-dossiers.service'; - -@Injectable({ providedIn: 'root' }) -export class TrashDossiersGuard implements CanActivate { - constructor(private readonly _trashDossiersService: TrashDossiersService) {} - - async canActivate(): Promise { - await firstValueFrom(this._trashDossiersService.loadAll()); - return true; - } -} diff --git a/apps/red-ui/src/app/modules/admin/admin-routing.module.ts b/apps/red-ui/src/app/modules/admin/admin-routing.module.ts index 8595b149b..5065145c8 100644 --- a/apps/red-ui/src/app/modules/admin/admin-routing.module.ts +++ b/apps/red-ui/src/app/modules/admin/admin-routing.module.ts @@ -22,7 +22,8 @@ import { DICTIONARY_TYPE, DOSSIER_TEMPLATE_ID } from '@utils/constants'; import { DossierTemplateExistsGuard } from '@guards/dossier-template-exists.guard'; import { DictionaryExistsGuard } from '@guards/dictionary-exists.guard'; import { DossierStatesListingScreenComponent } from './screens/dossier-states-listing/dossier-states-listing-screen.component'; -import { TrashDossiersGuard } from '@guards/trash-dossiers.guard'; +import { DossiersGuard } from '../../guards/dossiers.guard'; +import { ACTIVE_DOSSIERS_SERVICE } from '../../tokens'; const routes: Routes = [ { path: '', redirectTo: 'dossier-templates', pathMatch: 'full' }, @@ -200,8 +201,9 @@ const routes: Routes = [ component: TrashScreenComponent, canActivate: [CompositeRouteGuard], data: { - routeGuards: [AuthGuard, RedRoleGuard, TrashDossiersGuard], + routeGuards: [AuthGuard, RedRoleGuard, DossiersGuard], requiredRoles: ['RED_MANAGER'], + dossiersService: ACTIVE_DOSSIERS_SERVICE, }, }, ]; diff --git a/apps/red-ui/src/app/modules/admin/admin.module.ts b/apps/red-ui/src/app/modules/admin/admin.module.ts index d3e083a57..03e0332fa 100644 --- a/apps/red-ui/src/app/modules/admin/admin.module.ts +++ b/apps/red-ui/src/app/modules/admin/admin.module.ts @@ -48,6 +48,7 @@ import { DossierStatesListingScreenComponent } from './screens/dossier-states-li import { AddEditDossierStateDialogComponent } from './dialogs/add-edit-dossier-state-dialog/add-edit-dossier-state-dialog.component'; import { A11yModule } from '@angular/cdk/a11y'; import { ConfirmDeleteDossierStateDialogComponent } from './dialogs/confirm-delete-dossier-state-dialog/confirm-delete-dossier-state-dialog.component'; +import { TrashTableItemComponent } from './screens/trash/trash-table-item/trash-table-item.component'; const dialogs = [ AddEditDossierTemplateDialogComponent, @@ -100,6 +101,7 @@ const components = [ DossierStatesListingScreenComponent, AddEditDossierStateDialogComponent, ConfirmDeleteDossierStateDialogComponent, + TrashTableItemComponent, ], providers: [AdminDialogService, AuditService, DigitalSignatureService, LicenseReportService, RulesService, SmtpConfigService], imports: [ diff --git a/apps/red-ui/src/app/modules/admin/screens/dossier-states-listing/dossier-states-listing-screen.component.ts b/apps/red-ui/src/app/modules/admin/screens/dossier-states-listing/dossier-states-listing-screen.component.ts index 8411daf27..1c30861ff 100644 --- a/apps/red-ui/src/app/modules/admin/screens/dossier-states-listing/dossier-states-listing-screen.component.ts +++ b/apps/red-ui/src/app/modules/admin/screens/dossier-states-listing/dossier-states-listing-screen.component.ts @@ -9,7 +9,9 @@ import { } from '@iqser/common-ui'; import { DossierState, IDossierState } from '@red/domain'; import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; -import { firstValueFrom, map, Observable } from 'rxjs'; +import { ActiveDossiersService } from '@services/dossiers/active-dossiers.service'; +import { DossierStateService } from '@services/entity-services/dossier-state.service'; +import { firstValueFrom } from 'rxjs'; import { AdminDialogService } from '../../services/admin-dialog.service'; import { DoughnutChartConfig } from '@shared/components/simple-doughnut-chart/simple-doughnut-chart.component'; import { ActivatedRoute } from '@angular/router'; diff --git a/apps/red-ui/src/app/modules/admin/screens/trash/trash-screen.component.html b/apps/red-ui/src/app/modules/admin/screens/trash/trash-screen.component.html index 35c955de3..1f641e133 100644 --- a/apps/red-ui/src/app/modules/admin/screens/trash/trash-screen.component.html +++ b/apps/red-ui/src/app/modules/admin/screens/trash/trash-screen.component.html @@ -42,58 +42,5 @@ -
-
-
- {{ entity.dossierName }} -
-
-
- - {{ entity.memberIds.length }} -
-
- - {{ entity.date | date: 'mediumDate' }} -
-
- - {{ entity.dueDate | date: 'mediumDate' }} -
-
-
- -
- -
- -
- - {{ entity.softDeletedTime | date: 'exactDate' }} - -
- -
-
- {{ entity.restoreDate | date: 'timeFromNow' }} -
-
- - - -
-
-
+
diff --git a/apps/red-ui/src/app/modules/admin/screens/trash/trash-screen.component.ts b/apps/red-ui/src/app/modules/admin/screens/trash/trash-screen.component.ts index 188ad23c7..335e5c128 100644 --- a/apps/red-ui/src/app/modules/admin/screens/trash/trash-screen.component.ts +++ b/apps/red-ui/src/app/modules/admin/screens/trash/trash-screen.component.ts @@ -1,9 +1,8 @@ -import { ChangeDetectionStrategy, Component, forwardRef, Injector } from '@angular/core'; +import { ChangeDetectionStrategy, Component, forwardRef, Injector, OnInit } from '@angular/core'; import { CircleButtonTypes, ConfirmationDialogInput, - DefaultListingServicesTmp, - EntitiesService, + DefaultListingServices, ListingComponent, LoadingService, SortingOrders, @@ -12,49 +11,49 @@ import { } from '@iqser/common-ui'; import { AdminDialogService } from '../../services/admin-dialog.service'; import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; -import { Observable } from 'rxjs'; +import { firstValueFrom, Observable } from 'rxjs'; import { distinctUntilChanged, map } from 'rxjs/operators'; import { RouterHistoryService } from '@services/router-history.service'; -import { TrashDossier } from '@red/domain'; -import { TrashDossiersService } from '@services/entity-services/trash-dossiers.service'; +import { TrashItem } from '@red/domain'; +import { TrashService } from '@services/entity-services/trash.service'; +import { FilesService } from '@services/entity-services/files.service'; +import { ActiveDossiersService } from '@services/dossiers/active-dossiers.service'; +import { DossierTemplatesService } from '@services/entity-services/dossier-templates.service'; @Component({ templateUrl: './trash-screen.component.html', styleUrls: ['./trash-screen.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - providers: [ - ...DefaultListingServicesTmp, - { - provide: EntitiesService, - useExisting: TrashDossiersService, - }, - { provide: ListingComponent, useExisting: forwardRef(() => TrashScreenComponent) }, - ], + providers: [...DefaultListingServices, { provide: ListingComponent, useExisting: forwardRef(() => TrashScreenComponent) }], }) -export class TrashScreenComponent extends ListingComponent { +export class TrashScreenComponent extends ListingComponent implements OnInit { readonly circleButtonTypes = CircleButtonTypes; readonly tableHeaderLabel = _('trash.table-header.title'); readonly canRestoreSelected$ = this._canRestoreSelected$; readonly canHardDeleteSelected$ = this._canHardDeleteSelected$; - readonly tableColumnConfigs: TableColumnConfig[] = [ - { label: _('trash.table-col-names.name'), sortByKey: 'searchKey' }, + readonly tableColumnConfigs: TableColumnConfig[] = [ + { label: _('trash.table-col-names.name') }, { label: _('trash.table-col-names.owner'), class: 'user-column' }, - { label: _('trash.table-col-names.deleted-on'), sortByKey: 'softDeletedTime' }, - { label: _('trash.table-col-names.time-to-restore'), sortByKey: 'softDeletedTime' }, + { label: _('trash.table-col-names.dossier') }, + { label: _('trash.table-col-names.deleted-on') }, + { label: _('trash.table-col-names.time-to-restore') }, ]; constructor( protected readonly _injector: Injector, private readonly _loadingService: LoadingService, - private readonly _trashDossiersService: TrashDossiersService, + private readonly _trashService: TrashService, + private readonly _activeDossiersService: ActiveDossiersService, + private readonly _filesService: FilesService, readonly routerHistoryService: RouterHistoryService, private readonly _adminDialogService: AdminDialogService, + private readonly _dossierTemplatesService: DossierTemplatesService, ) { super(_injector); this.sortingService.setSortingOption({ - column: 'softDeletedTime', - order: SortingOrders.desc, + column: 'type', + order: SortingOrders.asc, }); } @@ -72,26 +71,38 @@ export class TrashScreenComponent extends ListingComponent { ); } - disabledFn = (dossier: TrashDossier) => !dossier.canRestore; + async ngOnInit(): Promise { + this._loadingService.start(); + await firstValueFrom(this._dossierTemplatesService.loadAll()); + const entities: TrashItem[] = await firstValueFrom(this._trashService.all()); + this.entitiesService.setEntities(entities); + this._loadingService.stop(); + } - hardDelete(dossiers = this.listingService.selected): void { + disabledFn = (dossier: TrashItem) => !dossier.canRestore; + + hardDelete(items = this.listingService.selected): void { const data = new ConfirmationDialogInput({ - title: _('confirmation-dialog.delete-dossier.title'), + title: _('confirmation-dialog.delete-items.title'), titleColor: TitleColors.WARN, - question: _('confirmation-dialog.delete-dossier.question'), + question: _('confirmation-dialog.delete-items.question'), translateParams: { - dossierName: dossiers[0].dossierName, - dossiersCount: dossiers.length, + name: items[0].name, + itemsCount: items.length, }, }); - this._adminDialogService.openDialog('confirm', null, data, () => { - const dossierIds: string[] = dossiers.map(d => d.id); - this._loadingService.loadWhile(this._trashDossiersService.hardDelete(dossierIds)); + this._adminDialogService.openDialog('confirm', null, data, async () => { + this._loadingService.start(); + await firstValueFrom(this._trashService.hardDelete(items)); + items.forEach(item => this.entitiesService.remove(item.id)); + this._loadingService.stop(); }); } - restore(dossiers = this.listingService.selected): void { - const dossierIds: string[] = dossiers.map(d => d.id); - this._loadingService.loadWhile(this._trashDossiersService.restore(dossierIds)); + async restore(items = this.listingService.selected): Promise { + this._loadingService.start(); + await firstValueFrom(this._trashService.restore(items)); + items.forEach(item => this.entitiesService.remove(item.id)); + this._loadingService.stop(); } } diff --git a/apps/red-ui/src/app/modules/admin/screens/trash/trash-table-item/trash-table-item.component.html b/apps/red-ui/src/app/modules/admin/screens/trash/trash-table-item/trash-table-item.component.html new file mode 100644 index 000000000..669a54d8a --- /dev/null +++ b/apps/red-ui/src/app/modules/admin/screens/trash/trash-table-item/trash-table-item.component.html @@ -0,0 +1,71 @@ +
+ +
+ + + +
+ {{ dossier.name }} +
+
+
+ + {{ dossier.memberIds.length }} +
+
+ + {{ dossier.date | date: 'mediumDate' }} +
+
+ + {{ dossier.dueDate | date: 'mediumDate' }} +
+
+
+
+
+ +
+ +
+ +
+ + {{ fileDossier.dossierName }} + + + - +
+ +
+ + {{ item.softDeletedTime | date: 'exactDate' }} + +
+ +
+
+ {{ item.restoreDate | date: 'timeFromNow' }} +
+
+ + + +
+
diff --git a/apps/red-ui/src/app/modules/admin/screens/trash/trash-table-item/trash-table-item.component.scss b/apps/red-ui/src/app/modules/admin/screens/trash/trash-table-item/trash-table-item.component.scss new file mode 100644 index 000000000..8445bc1fe --- /dev/null +++ b/apps/red-ui/src/app/modules/admin/screens/trash/trash-table-item/trash-table-item.component.scss @@ -0,0 +1,13 @@ +.filename { + flex-direction: row; + align-items: center; + justify-content: flex-start; + + > mat-icon { + min-width: 24px; + + &.low-opacity { + opacity: 0.2; + } + } +} diff --git a/apps/red-ui/src/app/modules/admin/screens/trash/trash-table-item/trash-table-item.component.ts b/apps/red-ui/src/app/modules/admin/screens/trash/trash-table-item/trash-table-item.component.ts new file mode 100644 index 000000000..97f4e4f8b --- /dev/null +++ b/apps/red-ui/src/app/modules/admin/screens/trash/trash-table-item/trash-table-item.component.ts @@ -0,0 +1,33 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; +import { Dossier, TrashDossier, TrashFile, TrashItem } from '@red/domain'; +import { CircleButtonTypes } from '@iqser/common-ui'; +import { ActiveDossiersService } from '@services/dossiers/active-dossiers.service'; + +@Component({ + selector: 'redaction-trash-table-item [item]', + templateUrl: './trash-table-item.component.html', + styleUrls: ['./trash-table-item.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TrashTableItemComponent implements OnChanges { + readonly circleButtonTypes = CircleButtonTypes; + + @Input() item: TrashItem; + @Output() restore = new EventEmitter(); + @Output() hardDelete = new EventEmitter(); + fileDossier: Dossier; + + constructor(private readonly _activeDossiersService: ActiveDossiersService) {} + + file(item: TrashItem): TrashFile { + return item as TrashFile; + } + + dossier(item: TrashItem): TrashDossier { + return item as TrashDossier; + } + + ngOnChanges(): void { + this.fileDossier = this._activeDossiersService.find(this.dossier(this.item).dossierId); + } +} diff --git a/apps/red-ui/src/app/modules/archive/components/table-item/table-item.component.html b/apps/red-ui/src/app/modules/archive/components/table-item/table-item.component.html index aabfd8229..e259f3edc 100644 --- a/apps/red-ui/src/app/modules/archive/components/table-item/table-item.component.html +++ b/apps/red-ui/src/app/modules/archive/components/table-item/table-item.component.html @@ -1,5 +1,5 @@
- +
{{ dossier.archivedTime | date: 'd MMM. yyyy' }}
diff --git a/apps/red-ui/src/app/modules/dossier-overview/components/dossier-details-stats/dossier-details-stats.component.html b/apps/red-ui/src/app/modules/dossier-overview/components/dossier-details-stats/dossier-details-stats.component.html index ce35f1ea8..85500bec8 100644 --- a/apps/red-ui/src/app/modules/dossier-overview/components/dossier-details-stats/dossier-details-stats.component.html +++ b/apps/red-ui/src/app/modules/dossier-overview/components/dossier-details-stats/dossier-details-stats.component.html @@ -20,33 +20,34 @@ {{ 'dossier-overview.dossier-details.stats.analysed-pages' | translate: { count: stats.numberOfPages | number } }} + +
+ + {{ 'dossier-overview.dossier-details.stats.created-on' | translate: { date: date } }} +
+ +
+ + {{ 'dossier-overview.dossier-details.stats.due-date' | translate: { date: dueDate } }} +
+ +
+ + {{ dossierTemplateName }} +
+ + + + +
+ + {{ 'dossier-overview.dossier-details.stats.deleted' | translate: { count: stats.numberOfSoftDeletedFiles } }} +
-
- - {{ 'dossier-overview.dossier-details.stats.created-on' | translate: { date: date } }} -
- -
- - {{ 'dossier-overview.dossier-details.stats.due-date' | translate: { date: dueDate } }} -
- -
- - {{ dossierTemplateName }} -
- - - - -
this.sortingService.defaultSort(entities))); sortedEntities$.pipe(take(1)).subscribe(entities => { const fileName = this.dossier.dossierName + '.export.csv'; - const mapper = (file?: IFile) => ({ + const mapper = (file?: File) => ({ ...file, assignee: this._userService.getNameForId(file.assignee) || '-', primaryAttribute: this._primaryFileAttributeService.getPrimaryFileAttributeValue(file, this.dossier.dossierTemplateId), diff --git a/apps/red-ui/src/app/modules/dossier-overview/dossier-overview.module.ts b/apps/red-ui/src/app/modules/dossier-overview/dossier-overview.module.ts index 747d9bba6..9a0011201 100644 --- a/apps/red-ui/src/app/modules/dossier-overview/dossier-overview.module.ts +++ b/apps/red-ui/src/app/modules/dossier-overview/dossier-overview.module.ts @@ -11,11 +11,9 @@ import { DossierDetailsStatsComponent } from './components/dossier-details-stats import { TableItemComponent } from './components/table-item/table-item.component'; import { SharedDossiersModule } from '../dossier/shared/shared-dossiers.module'; import { FileWorkloadComponent } from './components/table-item/file-workload/file-workload.component'; -import { FileStatsComponent } from './components/file-stats/file-stats.component'; import { WorkflowItemComponent } from './components/workflow-item/workflow-item.component'; import { DossierOverviewScreenHeaderComponent } from './components/screen-header/dossier-overview-screen-header.component'; import { ViewModeSelectionComponent } from './components/view-mode-selection/view-mode-selection.component'; -import { FileNameColumnComponent } from './components/table-item/file-name-column/file-name-column.component'; const routes: Routes = [ { @@ -36,11 +34,9 @@ const routes: Routes = [ DossierDetailsStatsComponent, FileWorkloadComponent, TableItemComponent, - FileStatsComponent, WorkflowItemComponent, DossierOverviewScreenHeaderComponent, ViewModeSelectionComponent, - FileNameColumnComponent, ], imports: [RouterModule.forChild(routes), CommonModule, SharedModule, SharedDossiersModule, IqserIconsModule, TranslateModule], }) diff --git a/apps/red-ui/src/app/modules/dossier/dialogs/edit-dossier-dialog/deleted-documents/edit-dossier-deleted-documents.component.html b/apps/red-ui/src/app/modules/dossier/dialogs/edit-dossier-dialog/deleted-documents/edit-dossier-deleted-documents.component.html deleted file mode 100644 index ce1c54ddc..000000000 --- a/apps/red-ui/src/app/modules/dossier/dialogs/edit-dossier-dialog/deleted-documents/edit-dossier-deleted-documents.component.html +++ /dev/null @@ -1,94 +0,0 @@ - - - -
-
- - - - - - - - -
-
- {{ file.filename }} -
- -
-
- - {{ file.numberOfPages }} -
-
- -
- -
- -
- -
- -
- {{ file.softDeleted | date: 'exactDate' }} -
- -
-
{{ file.restoreDate | date: 'timeFromNow' }}
-
- - - -
-
-
-
diff --git a/apps/red-ui/src/app/modules/dossier/dialogs/edit-dossier-dialog/deleted-documents/edit-dossier-deleted-documents.component.scss b/apps/red-ui/src/app/modules/dossier/dialogs/edit-dossier-dialog/deleted-documents/edit-dossier-deleted-documents.component.scss deleted file mode 100644 index 305ab6377..000000000 --- a/apps/red-ui/src/app/modules/dossier/dialogs/edit-dossier-dialog/deleted-documents/edit-dossier-deleted-documents.component.scss +++ /dev/null @@ -1,16 +0,0 @@ -@use 'variables'; -@use 'common-mixins'; - -.instructions { - color: variables.$grey-7; - flex: 1; - text-align: end; -} - -:host ::ng-deep iqser-table cdk-virtual-scroll-viewport { - height: calc(100% - 81px) !important; -} - -.cell.filename span { - @include common-mixins.line-clamp(1); -} diff --git a/apps/red-ui/src/app/modules/dossier/dialogs/edit-dossier-dialog/deleted-documents/edit-dossier-deleted-documents.component.ts b/apps/red-ui/src/app/modules/dossier/dialogs/edit-dossier-dialog/deleted-documents/edit-dossier-deleted-documents.component.ts deleted file mode 100644 index 3f6b69cde..000000000 --- a/apps/red-ui/src/app/modules/dossier/dialogs/edit-dossier-dialog/deleted-documents/edit-dossier-deleted-documents.component.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { Component, forwardRef, Injector, Input, OnInit } from '@angular/core'; -import { EditDossierSaveResult, EditDossierSectionInterface } from '../edit-dossier-section.interface'; -import { Dossier, File, IFile } from '@red/domain'; -import { - CircleButtonTypes, - ConfirmationDialogInput, - DefaultListingServices, - getLeftDateTime, - IListable, - IRouterPath, - ListingComponent, - LoadingService, - SortingOrders, - TableColumnConfig, - TitleColors, -} from '@iqser/common-ui'; -import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; -import * as moment from 'moment'; -import { ConfigService } from '@services/config.service'; -import { firstValueFrom, Observable, of } from 'rxjs'; -import { distinctUntilChanged, map } from 'rxjs/operators'; -import { DossiersDialogService } from '../../../services/dossiers-dialog.service'; -import { FilesService } from '@services/entity-services/files.service'; -import { FileManagementService } from '@services/entity-services/file-management.service'; -import { workflowFileStatusTranslations } from '../../../../../translations/file-status-translations'; -import { PermissionsService } from '@services/permissions.service'; -import { UserService } from '@services/user.service'; - -interface FileListItem extends IFile, IListable, IRouterPath { - readonly canHardDelete: boolean; - readonly canRestore: boolean; - readonly restoreDate: string; -} - -@Component({ - selector: 'redaction-edit-dossier-deleted-documents', - templateUrl: './edit-dossier-deleted-documents.component.html', - styleUrls: ['./edit-dossier-deleted-documents.component.scss'], - providers: [ - ...DefaultListingServices, - { provide: ListingComponent, useExisting: forwardRef(() => EditDossierDeletedDocumentsComponent) }, - ], -}) -export class EditDossierDeletedDocumentsComponent extends ListingComponent implements EditDossierSectionInterface, OnInit { - readonly fileStatusTranslations = workflowFileStatusTranslations; - @Input() dossier: Dossier; - readonly changed = false; - readonly valid = false; - readonly canRestoreSelected$ = this._canRestoreSelected$; - readonly canDeleteSelected$ = this._canDeleteSelected$; - disabled: boolean; - readonly tableColumnConfigs: TableColumnConfig[] = [ - { label: _('edit-dossier-dialog.deleted-documents.table-col-names.name'), width: '3fr' }, - { label: _('edit-dossier-dialog.deleted-documents.table-col-names.pages') }, - { label: _('edit-dossier-dialog.deleted-documents.table-col-names.assignee'), class: 'user-column' }, - { label: _('edit-dossier-dialog.deleted-documents.table-col-names.status') }, - { label: _('edit-dossier-dialog.deleted-documents.table-col-names.deleted-on'), sortByKey: 'softDeleted', width: '2fr' }, - { label: _('edit-dossier-dialog.deleted-documents.table-col-names.time-to-restore'), sortByKey: 'softDeleted', width: '2fr' }, - ]; - readonly tableHeaderLabel = _('edit-dossier-dialog.deleted-documents.table-header.label'); - readonly circleButtonTypes = CircleButtonTypes; - readonly deleteRetentionHours = this._configService.values.DELETE_RETENTION_HOURS; - - constructor( - protected readonly _injector: Injector, - private readonly _fileManagementService: FileManagementService, - private readonly _filesService: FilesService, - private readonly _loadingService: LoadingService, - private readonly _configService: ConfigService, - private readonly _dialogService: DossiersDialogService, - private readonly _permissionsService: PermissionsService, - private readonly _userService: UserService, - ) { - super(_injector); - } - - private get _canRestoreSelected$(): Observable { - return this.listingService.selectedEntities$.pipe( - map(entities => entities.length && !entities.find(file => !file.canRestore)), - distinctUntilChanged(), - ); - } - - private get _canDeleteSelected$(): Observable { - return this.listingService.selectedEntities$.pipe( - map(entities => entities.length && !entities.find(file => !file.canHardDelete)), - distinctUntilChanged(), - ); - } - - hardDelete(files = this.listingService.selected) { - const data = new ConfirmationDialogInput({ - title: _('confirmation-dialog.permanently-delete-file.title'), - titleColor: TitleColors.WARN, - question: _('confirmation-dialog.permanently-delete-file.question'), - confirmationText: _('confirmation-dialog.permanently-delete-file.confirmation-text'), - requireInput: true, - denyText: _('confirmation-dialog.permanently-delete-file.deny-text'), - translateParams: { - fileName: files[0].filename, - filesCount: files.length, - }, - }); - this._dialogService.openDialog('confirm', null, data, () => { - this._loadingService.loadWhile(this._hardDelete(files)); - }); - } - - async ngOnInit() { - this._loadingService.start(); - const files = await firstValueFrom(this._filesService.getDeletedFilesFor(this.dossier.id)); - this.entitiesService.setEntities(this._toListItems(files)); - this.sortingService.setSortingOption({ - column: 'softDeleted', - order: SortingOrders.desc, - }); - this._loadingService.stop(); - } - - revert() {} - - save(): EditDossierSaveResult { - return firstValueFrom(of({ success: true })); - } - - restore(files = this.listingService.selected) { - this._loadingService.loadWhile(this._restore(files)); - } - - disabledFn = (file: FileListItem) => !file.canRestore; - - private async _restore(files: FileListItem[]): Promise { - const fileIds = files.map(f => f.id); - await firstValueFrom(this._fileManagementService.restore(files, this.dossier.id)); - this._removeFromList(fileIds); - } - - private async _hardDelete(files: FileListItem[]) { - const fileIds = files.map(f => f.id); - await firstValueFrom(this._fileManagementService.hardDelete(this.dossier.id, fileIds)); - this._removeFromList(fileIds); - } - - private _removeFromList(ids: string[]): void { - const entities = this.entitiesService.all.filter(e => !ids.includes(e.fileId)); - this.entitiesService.setEntities(entities); - } - - private _toListItems(files: IFile[]): FileListItem[] { - return files.map(file => this._toListItem(file)); - } - - private _toListItem(_file: IFile): FileListItem { - const file = new File(_file, this._userService.getNameForId(_file.assignee), this.dossier.routerPath); - const restoreDate = this._getRestoreDate(_file.softDeleted); - return { - id: file.fileId, - ...file, - restoreDate, - searchKey: file.filename, - canRestore: this._canRestore(file, restoreDate), - canHardDelete: this._canPerformActions(file), - }; - } - - private _canPerformActions(file: File): boolean { - return ( - this._permissionsService.canHardDeleteOrRestore(this.dossier) && - (this._userService.currentUser.isManager || this._permissionsService.canDeleteFile(file)) - ); - } - - private _canRestore(file: File, restoreDate: string): boolean { - const { daysLeft, hoursLeft, minutesLeft } = getLeftDateTime(restoreDate); - return this._canPerformActions(file) && daysLeft + hoursLeft + minutesLeft > 0; - } - - private _getRestoreDate(softDeletedTime: string): string { - return moment(softDeletedTime).add(this.deleteRetentionHours, 'hours').toISOString(); - } -} diff --git a/apps/red-ui/src/app/modules/dossier/dialogs/edit-dossier-dialog/edit-dossier-dialog.component.html b/apps/red-ui/src/app/modules/dossier/dialogs/edit-dossier-dialog/edit-dossier-dialog.component.html index 22872775f..946d699e7 100644 --- a/apps/red-ui/src/app/modules/dossier/dialogs/edit-dossier-dialog/edit-dossier-dialog.component.html +++ b/apps/red-ui/src/app/modules/dossier/dialogs/edit-dossier-dialog/edit-dossier-dialog.component.html @@ -43,11 +43,6 @@ *ngIf="activeNav === 'dossierAttributes'" [dossier]="dossier" > - -
diff --git a/apps/red-ui/src/app/modules/dossier/dialogs/edit-dossier-dialog/edit-dossier-dialog.component.ts b/apps/red-ui/src/app/modules/dossier/dialogs/edit-dossier-dialog/edit-dossier-dialog.component.ts index dafce681c..2a0c7bcaa 100644 --- a/apps/red-ui/src/app/modules/dossier/dialogs/edit-dossier-dialog/edit-dossier-dialog.component.ts +++ b/apps/red-ui/src/app/modules/dossier/dialogs/edit-dossier-dialog/edit-dossier-dialog.component.ts @@ -9,7 +9,6 @@ import { EditDossierDictionaryComponent } from './dictionary/edit-dossier-dictio import { EditDossierAttributesComponent } from './attributes/edit-dossier-attributes.component'; import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; -import { EditDossierDeletedDocumentsComponent } from './deleted-documents/edit-dossier-deleted-documents.component'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; import { EditDossierTeamComponent } from './edit-dossier-team/edit-dossier-team.component'; @@ -18,7 +17,7 @@ import { UserService } from '@services/user.service'; import { DossiersService } from '@services/dossiers/dossiers.service'; import { dossiersServiceProvider } from '@services/entity-services/dossiers.service.provider'; -type Section = 'dossierInfo' | 'downloadPackage' | 'dossierDictionary' | 'members' | 'dossierAttributes' | 'deletedDocuments'; +type Section = 'dossierInfo' | 'downloadPackage' | 'dossierDictionary' | 'members' | 'dossierAttributes'; interface NavItem { key: Section; @@ -43,7 +42,6 @@ export class EditDossierDialogComponent extends BaseDialogComponent implements A @ViewChild(EditDossierDictionaryComponent) dictionaryComponent: EditDossierDictionaryComponent; @ViewChild(EditDossierTeamComponent) membersComponent: EditDossierTeamComponent; @ViewChild(EditDossierAttributesComponent) attributesComponent: EditDossierAttributesComponent; - @ViewChild(EditDossierDeletedDocumentsComponent) deletedDocumentsComponent: EditDossierDeletedDocumentsComponent; private _dossier: Dossier; @@ -83,22 +81,21 @@ export class EditDossierDialogComponent extends BaseDialogComponent implements A dossierDictionary: this.dictionaryComponent, members: this.membersComponent, dossierAttributes: this.attributesComponent, - deletedDocuments: this.deletedDocumentsComponent, }[this.activeNav]; } get noPaddingTab(): boolean { - return ['dossierAttributes', 'deletedDocuments'].includes(this.activeNav); + return ['dossierAttributes'].includes(this.activeNav); } get showHeading(): boolean { - return !['dossierAttributes', 'dossierDictionary', 'deletedDocuments'].includes(this.activeNav); + return !['dossierAttributes', 'dossierDictionary'].includes(this.activeNav); } get showActionButtons(): boolean { return ( (['members'].includes(this.activeNav) && this._userService.currentUser.isManager) || - (!['deletedDocuments'].includes(this.activeNav) && this._permissionsService.canEditDossier(this._dossier)) + this._permissionsService.canEditDossier(this._dossier) ); } @@ -184,10 +181,6 @@ export class EditDossierDialogComponent extends BaseDialogComponent implements A title: _('edit-dossier-dialog.nav-items.dossier-attributes'), readonly: !this._permissionsService.canEditDossierAttributes(this._dossier), }, - { - key: 'deletedDocuments', - sideNavTitle: _('edit-dossier-dialog.nav-items.deleted-documents'), - }, ]; } } diff --git a/apps/red-ui/src/app/modules/dossier/dialogs/edit-dossier-dialog/general-info/edit-dossier-general-info.component.ts b/apps/red-ui/src/app/modules/dossier/dialogs/edit-dossier-dialog/general-info/edit-dossier-general-info.component.ts index 4f8180d37..e4a295344 100644 --- a/apps/red-ui/src/app/modules/dossier/dialogs/edit-dossier-dialog/general-info/edit-dossier-general-info.component.ts +++ b/apps/red-ui/src/app/modules/dossier/dialogs/edit-dossier-dialog/general-info/edit-dossier-general-info.component.ts @@ -16,7 +16,7 @@ import { firstValueFrom } from 'rxjs'; import { DOSSIER_TEMPLATE_ID } from '@utils/constants'; import { TranslateService } from '@ngx-translate/core'; import { DossiersService } from '@services/dossiers/dossiers.service'; -import { TrashDossiersService } from '@services/entity-services/trash-dossiers.service'; +import { TrashService } from '@services/entity-services/trash.service'; import { ArchivedDossiersService } from '@services/dossiers/archived-dossiers.service'; import { DossierStatesMapService } from '@services/entity-services/dossier-states-map.service'; @@ -42,7 +42,7 @@ export class EditDossierGeneralInfoComponent implements OnInit, EditDossierSecti private readonly _dossierStatesMapService: DossierStatesMapService, private readonly _dossierTemplatesService: DossierTemplatesService, private readonly _dossiersService: DossiersService, - private readonly _trashDossiersService: TrashDossiersService, + private readonly _trashService: TrashService, private readonly _dossierStatsService: DossierStatsService, private readonly _formBuilder: FormBuilder, private readonly _dialogService: DossiersDialogService, @@ -137,7 +137,7 @@ export class EditDossierGeneralInfoComponent implements OnInit, EditDossierSecti }); this._dialogService.openDialog('confirm', null, data, async () => { this._loadingService.start(); - await firstValueFrom(this._trashDossiersService.delete(this.dossier)); + await firstValueFrom(this._trashService.deleteDossier(this.dossier)); this._editDossierDialogRef.close(); await this._router.navigate(['main', 'dossiers']); this._loadingService.stop(); diff --git a/apps/red-ui/src/app/modules/dossier/screens/dossier-overview/components/dossier-details-stats/dossier-details-stats.component.ts b/apps/red-ui/src/app/modules/dossier/screens/dossier-overview/components/dossier-details-stats/dossier-details-stats.component.ts new file mode 100644 index 000000000..43a969833 --- /dev/null +++ b/apps/red-ui/src/app/modules/dossier/screens/dossier-overview/components/dossier-details-stats/dossier-details-stats.component.ts @@ -0,0 +1,41 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { Dossier, DossierAttributeWithValue, DossierStats } from '@red/domain'; +import { DossiersDialogService } from '../../../../services/dossiers-dialog.service'; +import { DossierTemplatesService } from '@services/entity-services/dossier-templates.service'; +import { FilesService } from '@services/entity-services/files.service'; +import { firstValueFrom, Observable } from 'rxjs'; +import { DossierStatsService } from '@services/dossiers/dossier-stats.service'; + +@Component({ + selector: 'redaction-dossier-details-stats', + templateUrl: './dossier-details-stats.component.html', + styleUrls: ['./dossier-details-stats.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DossierDetailsStatsComponent implements OnInit { + @Input() dossierAttributes: DossierAttributeWithValue[]; + @Input() dossier: Dossier; + + attributesExpanded = false; + dossierTemplateName: string; + dossierStats$: Observable; + + constructor( + private readonly _dossierTemplatesService: DossierTemplatesService, + private readonly _dialogService: DossiersDialogService, + private readonly _filesService: FilesService, + private readonly _dossierStatsService: DossierStatsService, + ) {} + + ngOnInit() { + this.dossierStats$ = this._dossierStatsService.watch$(this.dossier.dossierId); + this.dossierTemplateName = this._dossierTemplatesService.find(this.dossier.dossierTemplateId)?.name || '-'; + } + + openEditDossierDialog(section: string): void { + const data = { dossierId: this.dossier.dossierId, section }; + this._dialogService.openDialog('editDossier', null, data, async () => { + await firstValueFrom(this._filesService.loadAll(this.dossier.dossierId, this.dossier.routerPath)); + }); + } +} diff --git a/apps/red-ui/src/app/modules/dossier/screens/dossiers-listing/components/dossiers-listing-details/dossiers-listing-details.component.ts b/apps/red-ui/src/app/modules/dossier/screens/dossiers-listing/components/dossiers-listing-details/dossiers-listing-details.component.ts new file mode 100644 index 000000000..72d2d8988 --- /dev/null +++ b/apps/red-ui/src/app/modules/dossier/screens/dossiers-listing/components/dossiers-listing-details/dossiers-listing-details.component.ts @@ -0,0 +1,88 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { DoughnutChartConfig } from '@shared/components/simple-doughnut-chart/simple-doughnut-chart.component'; +import { FilterService, mapEach } from '@iqser/common-ui'; +import { ActiveDossiersService } from '@services/dossiers/active-dossiers.service'; +import { combineLatest, Observable } from 'rxjs'; +import { DossierStats, FileCountPerWorkflowStatus, StatusSorter } from '@red/domain'; +import { workflowFileStatusTranslations } from '../../../../../../translations/file-status-translations'; +import { TranslateChartService } from '@services/translate-chart.service'; +import { filter, map, switchMap } from 'rxjs/operators'; +import { DossierStatsService } from '@services/dossiers/dossier-stats.service'; +import { DossierStateService } from '@services/entity-services/dossier-state.service'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + selector: 'redaction-dossiers-listing-details', + templateUrl: './dossiers-listing-details.component.html', + styleUrls: ['./dossiers-listing-details.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DossiersListingDetailsComponent { + readonly documentsChartData$: Observable; + readonly dossiersChartData$: Observable; + + constructor( + readonly filterService: FilterService, + readonly activeDossiersService: ActiveDossiersService, + private readonly _dossierStatsMap: DossierStatsService, + private readonly _translateChartService: TranslateChartService, + private readonly _dossierStateService: DossierStateService, + private readonly _translateService: TranslateService, + ) { + this.documentsChartData$ = this.activeDossiersService.all$.pipe( + mapEach(dossier => _dossierStatsMap.watch$(dossier.dossierId)), + switchMap(stats$ => combineLatest(stats$)), + filter(stats => !stats.some(s => s === undefined)), + map(stats => this._toChartData(stats)), + ); + + this.dossiersChartData$ = this.activeDossiersService.all$.pipe(map(() => this._toDossierChartData())); + } + + private _toDossierChartData(): DoughnutChartConfig[] { + this._dossierStateService.all.forEach( + state => (state.dossierCount = this.activeDossiersService.getCountWithState(state.dossierStatusId)), + ); + const configArray: DoughnutChartConfig[] = [ + ...this._dossierStateService.all + .reduce((acc, { color, dossierCount, name }) => { + const key = name + '-' + color; + const item = acc.get(key) ?? Object.assign({}, { value: 0, label: name, color: color }); + + return acc.set(key, { ...item, value: item.value + dossierCount }); + }, new Map()) + .values(), + ]; + + const notAssignedLength = this.activeDossiersService.all.length - configArray.map(v => v.value).reduce((acc, val) => acc + val, 0); + configArray.push({ + value: notAssignedLength, + label: this._translateService.instant('edit-dossier-dialog.general-info.form.dossier-status.placeholder'), + color: '#E2E4E9', + }); + + return configArray; + } + + private _toChartData(stats: DossierStats[]) { + const chartData: FileCountPerWorkflowStatus = {}; + stats.forEach(stat => { + const statuses: FileCountPerWorkflowStatus = stat.fileCountPerWorkflowStatus; + Object.keys(statuses).forEach(status => { + chartData[status] = chartData[status] ? (chartData[status] as number) + (statuses[status] as number) : statuses[status]; + }); + }); + + const documentsChartData = Object.keys(chartData).map( + status => + ({ + value: chartData[status], + color: status, + label: workflowFileStatusTranslations[status], + key: status, + } as DoughnutChartConfig), + ); + documentsChartData.sort((a, b) => StatusSorter.byStatus(a.key, b.key)); + return this._translateChartService.translateStatus(documentsChartData); + } +} diff --git a/apps/red-ui/src/app/modules/dossier/screens/dossiers-listing/config.service.ts b/apps/red-ui/src/app/modules/dossier/screens/dossiers-listing/config.service.ts new file mode 100644 index 000000000..d4d510284 --- /dev/null +++ b/apps/red-ui/src/app/modules/dossier/screens/dossiers-listing/config.service.ts @@ -0,0 +1,266 @@ +import { Injectable, TemplateRef } from '@angular/core'; +import { ButtonConfig, IFilterGroup, INestedFilter, keyChecker, NestedFilter, TableColumnConfig } from '@iqser/common-ui'; +import { Dossier, StatusSorter, User } from '@red/domain'; +import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; +import { TranslateService } from '@ngx-translate/core'; +import { UserPreferenceService } from '@services/user-preference.service'; +import { UserService } from '@services/user.service'; +import { workflowFileStatusTranslations } from '../../../../translations/file-status-translations'; +import { dossierMemberChecker, dossierStateChecker, dossierTemplateChecker, RedactionFilterSorter } from '@utils/index'; +import { workloadTranslations } from '../../translations/workload-translations'; +import { DossierTemplatesService } from '@services/entity-services/dossier-templates.service'; +import { DossierStatsService } from '@services/dossiers/dossier-stats.service'; +import { DossierStateService } from '@services/entity-services/dossier-state.service'; + +@Injectable() +export class ConfigService { + constructor( + private readonly _translateService: TranslateService, + private readonly _userPreferenceService: UserPreferenceService, + private readonly _userService: UserService, + private readonly _dossierTemplatesService: DossierTemplatesService, + private readonly _dossierStatsService: DossierStatsService, + private readonly _dossierStateService: DossierStateService, + ) {} + + get tableConfig(): TableColumnConfig[] { + return [ + { label: _('dossier-listing.table-col-names.name'), sortByKey: 'searchKey', width: '2fr' }, + // { label: _('dossier-listing.table-col-names.last-modified') }, + { label: _('dossier-listing.table-col-names.needs-work') }, + { label: _('dossier-listing.table-col-names.owner'), class: 'user-column' }, + { label: _('dossier-listing.table-col-names.documents-status'), class: 'flex-end', width: 'auto' }, + { label: _('dossier-listing.table-col-names.dossier-status'), class: 'flex-end' }, + ]; + } + + get _currentUser(): User { + return this._userService.currentUser; + } + + _myDossiersChecker = (dw: Dossier) => dw.ownerId === this._currentUser.id; + + _toApproveChecker = (dw: Dossier) => dw.approverIds.includes(this._currentUser.id); + + _toReviewChecker = (dw: Dossier) => dw.memberIds.includes(this._currentUser.id); + + _otherChecker = (dw: Dossier) => !dw.memberIds.includes(this._currentUser.id); + + buttonsConfig(addDossier: () => void): ButtonConfig[] { + return [ + { + label: _('dossier-listing.add-new'), + action: addDossier, + hide: !this._currentUser.isManager, + icon: 'iqser:plus', + type: 'primary', + helpModeKey: 'new_dossier_button', + }, + ]; + } + + filterGroups(entities: Dossier[], needsWorkFilterTemplate: TemplateRef) { + const allDistinctFileStatus = new Set(); + const allDistinctPeople = new Set(); + const allDistinctNeedsWork = new Set(); + const allDistinctDossierTemplates = new Set(); + const allDistinctDossierStates = new Set(); + + const filterGroups: IFilterGroup[] = []; + + entities?.forEach(entry => { + entry.memberIds.forEach(f => allDistinctPeople.add(f)); + allDistinctDossierTemplates.add(entry.dossierTemplateId); + if (entry.dossierStatusId) { + allDistinctDossierStates.add(entry.dossierStatusId); + } + const stats = this._dossierStatsService.get(entry.dossierId); + + if (!stats) { + return; + } + + Object.keys(stats?.fileCountPerWorkflowStatus).forEach(status => allDistinctFileStatus.add(status)); + + if (stats.hasHintsNoRedactionsFilePresent) { + allDistinctNeedsWork.add('hint'); + } + if (stats.hasRedactionsFilePresent) { + allDistinctNeedsWork.add('redaction'); + } + if (stats.hasSuggestionsFilePresent) { + allDistinctNeedsWork.add('suggestion'); + } + if (stats.hasNoFlagsFilePresent) { + allDistinctNeedsWork.add('none'); + } + }); + + const dossierStatesFilters = [...allDistinctDossierStates].map( + id => + new NestedFilter({ + id: id, + label: this._dossierStateService.find(id).name, + }), + ); + + filterGroups.push({ + slug: 'dossierStatesFilters', + label: this._translateService.instant('filters.dossier-status'), + icon: 'red:status', + hide: dossierStatesFilters.length <= 1, + filters: dossierStatesFilters, + checker: dossierStateChecker, + }); + + const statusFilters = [...allDistinctFileStatus].map( + status => + new NestedFilter({ + id: status, + label: this._translateService.instant(workflowFileStatusTranslations[status]), + }), + ); + + filterGroups.push({ + slug: 'statusFilters', + label: this._translateService.instant('filters.documents-status'), + icon: 'red:status', + filters: statusFilters.sort((a, b) => StatusSorter[a.id] - StatusSorter[b.id]), + checker: (dossier: Dossier, filter: INestedFilter) => this._dossierStatusChecker(dossier, filter), + }); + + const peopleFilters = [...allDistinctPeople].map( + userId => + new NestedFilter({ + id: userId, + label: this._userService.getNameForId(userId), + }), + ); + + filterGroups.push({ + slug: 'peopleFilters', + label: this._translateService.instant('filters.people'), + icon: 'red:user', + filters: peopleFilters, + checker: dossierMemberChecker, + }); + + const needsWorkFilters = [...allDistinctNeedsWork].map( + type => + new NestedFilter({ + id: type, + label: workloadTranslations[type], + }), + ); + + filterGroups.push({ + slug: 'needsWorkFilters', + label: this._translateService.instant('filters.needs-work'), + icon: 'red:needs-work', + filterTemplate: needsWorkFilterTemplate, + filters: needsWorkFilters.sort((a, b) => RedactionFilterSorter[a.id] - RedactionFilterSorter[b.id]), + checker: (dossier: Dossier, filter: INestedFilter) => this._annotationFilterChecker(dossier, filter), + matchAll: true, + }); + + const dossierTemplateFilters = [...allDistinctDossierTemplates].map( + id => + new NestedFilter({ + id: id, + label: this._dossierTemplatesService.find(id)?.name || '-', + }), + ); + + filterGroups.push({ + slug: 'dossierTemplateFilters', + label: this._translateService.instant('filters.dossier-templates'), + icon: 'red:template', + hide: dossierTemplateFilters.length <= 1, + filters: dossierTemplateFilters, + checker: dossierTemplateChecker, + }); + + filterGroups.push({ + slug: 'quickFilters', + filters: this._quickFilters(entities), + checker: (dw: Dossier, filter: NestedFilter) => filter.checked && filter.checker(dw), + }); + + const dossierFilters = entities.map( + dossier => + new NestedFilter({ + id: dossier.dossierName, + label: dossier.dossierName, + }), + ); + filterGroups.push({ + slug: 'dossierNameFilter', + label: this._translateService.instant('dossier-listing.filters.label'), + icon: 'red:folder', + filters: dossierFilters, + filterceptionPlaceholder: this._translateService.instant('dossier-listing.filters.search'), + checker: keyChecker('dossierName'), + }); + + return filterGroups; + } + + private _quickFilters(entities: Dossier[]): NestedFilter[] { + const myDossiersLabel = this._translateService.instant('dossier-listing.quick-filters.my-dossiers'); + const filters = [ + { + id: 'my-dossiers', + label: myDossiersLabel, + checker: this._myDossiersChecker, + disabled: entities.filter(this._myDossiersChecker).length === 0, + helpModeKey: 'dossiers_quickfilter_my_dossiers', + }, + { + id: 'to-approve', + label: this._translateService.instant('dossier-listing.quick-filters.to-approve'), + checker: this._toApproveChecker, + disabled: entities.filter(this._toApproveChecker).length === 0, + }, + { + id: 'to-review', + label: this._translateService.instant('dossier-listing.quick-filters.to-review'), + checker: this._toReviewChecker, + disabled: entities.filter(this._toReviewChecker).length === 0, + }, + { + id: 'other', + label: this._translateService.instant('dossier-listing.quick-filters.other'), + checker: this._otherChecker, + disabled: entities.filter(this._otherChecker).length === 0, + }, + ].map(filter => new NestedFilter(filter)); + + return filters.filter(f => f.label === myDossiersLabel || this._userPreferenceService.areDevFeaturesEnabled); + } + + private _dossierStatusChecker = (dossier: Dossier, filter: INestedFilter) => { + const stats = this._dossierStatsService.get(dossier.dossierId); + return stats?.fileCountPerWorkflowStatus[filter.id]; + }; + + private _annotationFilterChecker = (dossier: Dossier, filter: INestedFilter) => { + const stats = this._dossierStatsService.get(dossier.dossierId); + switch (filter.id) { + // case 'analysis': { + // return stats.reanalysisRequired; + // } + case 'suggestion': { + return stats.hasSuggestionsFilePresent; + } + case 'redaction': { + return stats.hasRedactionsFilePresent; + } + case 'hint': { + return stats.hasHintsNoRedactionsFilePresent; + } + case 'none': { + return stats.hasNoFlagsFilePresent; + } + } + }; +} diff --git a/apps/red-ui/src/app/modules/dossier/screens/file-preview-screen/components/user-management/user-management.component.ts b/apps/red-ui/src/app/modules/dossier/screens/file-preview-screen/components/user-management/user-management.component.ts new file mode 100644 index 000000000..3cd5b87fa --- /dev/null +++ b/apps/red-ui/src/app/modules/dossier/screens/file-preview-screen/components/user-management/user-management.component.ts @@ -0,0 +1,115 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { Dossier, File, StatusBarConfigs, User } from '@red/domain'; +import { List, LoadingService, Toaster } from '@iqser/common-ui'; +import { PermissionsService } from '@services/permissions.service'; +import { FileAssignService } from '../../../../shared/services/file-assign.service'; +import { workflowFileStatusTranslations } from '../../../../../../translations/file-status-translations'; +import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; +import { UserService } from '@services/user.service'; +import { FilesService } from '@services/entity-services/files.service'; +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject, combineLatest, firstValueFrom, Observable, switchMap } from 'rxjs'; +import { FilePreviewStateService } from '../../services/file-preview-state.service'; +import { distinctUntilChanged, map } from 'rxjs/operators'; +import { ActiveDossiersService } from '@services/dossiers/active-dossiers.service'; + +@Component({ + selector: 'redaction-user-management', + templateUrl: './user-management.component.html', + styleUrls: ['./user-management.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UserManagementComponent { + readonly translations = workflowFileStatusTranslations; + readonly statusBarConfig$: Observable; + readonly assignTooltip$: Observable; + readonly canAssignReviewer$: Observable; + readonly canAssignToSelf$: Observable; + readonly editingReviewer$ = new BehaviorSubject(false); + readonly canAssignOrUnassign$: Observable; + readonly canAssign$: Observable; + readonly usersOptions$: Observable; + private readonly _dossier$: Observable; + private readonly _canAssignUser$: Observable; + private readonly _canUnassignUser$: Observable; + + constructor( + readonly fileAssignService: FileAssignService, + readonly permissionsService: PermissionsService, + readonly userService: UserService, + readonly filesService: FilesService, + readonly toaster: Toaster, + readonly loadingService: LoadingService, + readonly translateService: TranslateService, + readonly stateService: FilePreviewStateService, + private readonly _activeDossiersService: ActiveDossiersService, + ) { + this._dossier$ = this.stateService.file$.pipe(switchMap(file => this._activeDossiersService.getEntityChanged$(file.dossierId))); + this.statusBarConfig$ = this.stateService.file$.pipe(map(file => [{ length: 1, color: file.workflowStatus }])); + this.assignTooltip$ = this.stateService.file$.pipe( + map(file => + file.isUnderApproval + ? this.translateService.instant(_('dossier-overview.assign-approver')) + : file.assignee + ? this.translateService.instant(_('file-preview.change-reviewer')) + : this.translateService.instant(_('file-preview.assign-reviewer')), + ), + ); + + this.canAssignToSelf$ = this.stateService.file$.pipe( + map(file => this.permissionsService.canAssignToSelf(file)), + distinctUntilChanged(), + ); + this._canAssignUser$ = this.stateService.file$.pipe( + map(file => this.permissionsService.canAssignUser(file)), + distinctUntilChanged(), + ); + this._canUnassignUser$ = this.stateService.file$.pipe( + map(file => this.permissionsService.canUnassignUser(file)), + distinctUntilChanged(), + ); + + this.canAssignOrUnassign$ = combineLatest([this._canAssignUser$, this._canUnassignUser$]).pipe( + map(([canAssignUser, canUnassignUser]) => canAssignUser || canUnassignUser), + distinctUntilChanged(), + ); + + this.canAssign$ = combineLatest([this.canAssignToSelf$, this.canAssignOrUnassign$]).pipe( + map(([canAssignToSelf, canAssignOrUnassign]) => canAssignToSelf || canAssignOrUnassign), + distinctUntilChanged(), + ); + + this.canAssignReviewer$ = combineLatest([this.stateService.file$, this._canAssignUser$, this._dossier$]).pipe( + map(([file, canAssignUser, dossier]) => !file.assignee && canAssignUser && dossier.hasReviewers), + distinctUntilChanged(), + ); + + this.usersOptions$ = combineLatest([this._canUnassignUser$, this.stateService.file$, this._dossier$]).pipe( + map(([canUnassignUser, file, dossier]) => { + const unassignUser = canUnassignUser ? [undefined] : []; + return file.isUnderApproval ? [...dossier.approverIds, ...unassignUser] : [...dossier.memberIds, ...unassignUser]; + }), + ); + } + + async assignReviewer(file: File, user: User | string) { + const assigneeId = typeof user === 'string' ? user : user?.id; + const reviewerName = this.userService.getNameForId(assigneeId); + + const { dossierId, filename } = file; + this.loadingService.start(); + + if (!assigneeId) { + await firstValueFrom(this.filesService.setUnassigned([file], dossierId)); + } else if (file.isNew || file.isUnderReview) { + await firstValueFrom(this.filesService.setReviewerFor([file], dossierId, assigneeId)); + } else if (file.isUnderApproval) { + await firstValueFrom(this.filesService.setUnderApprovalFor([file], dossierId, assigneeId)); + } + + this.loadingService.stop(); + + this.toaster.info(_('assignment.reviewer'), { params: { reviewerName, filename } }); + this.editingReviewer$.next(false); + } +} diff --git a/apps/red-ui/src/app/modules/dossier/screens/file-preview-screen/file-preview-screen.component.ts b/apps/red-ui/src/app/modules/dossier/screens/file-preview-screen/file-preview-screen.component.ts new file mode 100644 index 000000000..5a80b46bf --- /dev/null +++ b/apps/red-ui/src/app/modules/dossier/screens/file-preview-screen/file-preview-screen.component.ts @@ -0,0 +1,703 @@ +import { ChangeDetectorRef, Component, HostListener, NgZone, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core'; +import { ActivatedRoute, ActivatedRouteSnapshot, NavigationExtras, Router } from '@angular/router'; +import { Core } from '@pdftron/webviewer'; +import { + AutoUnsubscribe, + CircleButtonTypes, + CustomError, + Debounce, + ErrorService, + FilterService, + LoadingService, + NestedFilter, + OnAttach, + OnDetach, + processFilters, + shareDistinctLast, +} from '@iqser/common-ui'; +import { MatDialogRef, MatDialogState } from '@angular/material/dialog'; +import { ManualRedactionEntryWrapper } from '@models/file/manual-redaction-entry.wrapper'; +import { AnnotationWrapper } from '@models/file/annotation.wrapper'; +import { ManualAnnotationResponse } from '@models/file/manual-annotation-response'; +import { AnnotationDrawService } from './services/annotation-draw.service'; +import { AnnotationProcessingService } from '../../services/annotation-processing.service'; +import { File, ViewMode } from '@red/domain'; +import { PermissionsService } from '@services/permissions.service'; +import { combineLatest, firstValueFrom, Observable, of, timer } from 'rxjs'; +import { UserPreferenceService } from '@services/user-preference.service'; +import { PdfViewerDataService } from '../../services/pdf-viewer-data.service'; +import { download } from '@utils/file-download-utils'; +import { FileWorkloadComponent } from './components/file-workload/file-workload.component'; +import { DossiersDialogService } from '../../services/dossiers-dialog.service'; +import { clearStamps, stampPDFPage } from '@utils/page-stamper'; +import { TranslateService } from '@ngx-translate/core'; +import { handleFilterDelta } from '@utils/filter-utils'; +import { FilesService } from '@services/entity-services/files.service'; +import { FileManagementService } from '@services/entity-services/file-management.service'; +import { catchError, map, switchMap, tap } from 'rxjs/operators'; +import { FilesMapService } from '@services/entity-services/files-map.service'; +import { WatermarkService } from '@shared/services/watermark.service'; +import { ExcludedPagesService } from './services/excluded-pages.service'; +import { ViewModeService } from './services/view-mode.service'; +import { MultiSelectService } from './services/multi-select.service'; +import { DocumentInfoService } from './services/document-info.service'; +import { ReanalysisService } from '../../../../services/reanalysis.service'; +import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; +import { SkippedService } from './services/skipped.service'; +import { FilePreviewStateService } from './services/file-preview-state.service'; +import { FileDataModel } from '../../../../models/file/file-data.model'; +import { filePreviewScreenProviders } from './file-preview-providers'; +import { ManualAnnotationService } from '../../services/manual-annotation.service'; +import { DossiersService } from '@services/dossiers/dossiers.service'; +import { PageRotationService } from './services/page-rotation.service'; +import { ComponentCanDeactivate } from '../../../../guards/can-deactivate.guard'; +import { PdfViewer } from './services/pdf-viewer.service'; +import Annotation = Core.Annotations.Annotation; +import PDFNet = Core.PDFNet; + +const ALL_HOTKEY_ARRAY = ['Escape', 'F', 'f', 'ArrowUp', 'ArrowDown']; + +@Component({ + templateUrl: './file-preview-screen.component.html', + styleUrls: ['./file-preview-screen.component.scss'], + providers: filePreviewScreenProviders, +}) +export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnInit, OnDestroy, OnAttach, OnDetach, ComponentCanDeactivate { + readonly circleButtonTypes = CircleButtonTypes; + + dialogRef: MatDialogRef; + fullScreen = false; + selectedAnnotations: AnnotationWrapper[] = []; + displayPdfViewer = false; + activeViewerPage: number = null; + readonly canPerformAnnotationActions$: Observable; + readonly fileId = this.stateService.fileId; + readonly dossierId = this.stateService.dossierId; + readonly file$ = this.stateService.file$.pipe(tap(file => this._fileUpdated(file))); + ready = false; + private _lastPage: string; + + @ViewChild('fileWorkloadComponent') private readonly _workloadComponent: FileWorkloadComponent; + @ViewChild('annotationFilterTemplate', { + read: TemplateRef, + static: false, + }) + private readonly _filterTemplate: TemplateRef; + + constructor( + readonly permissionsService: PermissionsService, + readonly userPreferenceService: UserPreferenceService, + readonly stateService: FilePreviewStateService, + private readonly _watermarkService: WatermarkService, + private readonly _changeDetectorRef: ChangeDetectorRef, + private readonly _activatedRoute: ActivatedRoute, + private readonly _dialogService: DossiersDialogService, + private readonly _router: Router, + private readonly _annotationProcessingService: AnnotationProcessingService, + private readonly _annotationDrawService: AnnotationDrawService, + private readonly _pdfViewerDataService: PdfViewerDataService, + private readonly _filesService: FilesService, + private readonly _ngZone: NgZone, + private readonly _fileManagementService: FileManagementService, + private readonly _loadingService: LoadingService, + private readonly _filterService: FilterService, + private readonly _translateService: TranslateService, + private readonly _filesMapService: FilesMapService, + private readonly _dossiersService: DossiersService, + private readonly _reanalysisService: ReanalysisService, + private readonly _errorService: ErrorService, + private readonly _pageRotationService: PageRotationService, + private readonly _skippedService: SkippedService, + private readonly _pdf: PdfViewer, + private readonly _manualAnnotationService: ManualAnnotationService, + readonly excludedPagesService: ExcludedPagesService, + private readonly _viewModeService: ViewModeService, + readonly multiSelectService: MultiSelectService, + readonly documentInfoService: DocumentInfoService, + ) { + super(); + this.canPerformAnnotationActions$ = this._canPerformAnnotationActions$; + + document.documentElement.addEventListener('fullscreenchange', () => { + if (!document.fullscreenElement) { + this.fullScreen = false; + } + }); + } + + get changed() { + return this._pageRotationService.hasRotations(); + } + + get visibleAnnotations(): AnnotationWrapper[] { + return this._fileData ? this._fileData.getVisibleAnnotations(this._viewModeService.viewMode) : []; + } + + get allAnnotations(): AnnotationWrapper[] { + return this._fileData ? this._fileData.allAnnotations : []; + } + + private get _fileData(): FileDataModel { + return this.stateService.fileData; + } + + private get _canPerformAnnotationActions$() { + const viewMode$ = this._viewModeService.viewMode$.pipe(tap(() => this.#deactivateMultiSelect())); + + return combineLatest([this.stateService.file$, viewMode$, this._viewModeService.compareMode$]).pipe( + map(([file, viewMode]) => this.permissionsService.canPerformAnnotationActions(file) && viewMode === 'STANDARD'), + shareDistinctLast(), + ); + } + + async save() { + await this._pageRotationService.applyRotation(); + } + + async updateViewMode(): Promise { + if (!this._pdf.ready) { + return; + } + + const textHighlightAnnotationIds = this._fileData.textHighlightAnnotations.map(a => a.id); + const textHighlightAnnotations = this._pdf.getAnnotations((a: Core.Annotations.Annotation) => + textHighlightAnnotationIds.includes(a.Id), + ); + + this._pdf.deleteAnnotations(textHighlightAnnotations); + + const ocrAnnotationIds = this._fileData.allAnnotations.filter(a => a.isOCR).map(a => a.id); + const annotations = this._pdf.getAnnotations(a => a.getCustomData('redact-manager')); + const redactions = annotations.filter(a => a.getCustomData('redaction')); + + switch (this._viewModeService.viewMode) { + case 'STANDARD': { + this._setAnnotationsColor(redactions, 'annotationColor'); + const standardEntries = annotations + .filter(a => a.getCustomData('changeLogRemoved') === 'false') + .filter(a => !ocrAnnotationIds.includes(a.Id)); + const nonStandardEntries = annotations.filter(a => a.getCustomData('changeLogRemoved') === 'true'); + this._setAnnotationsOpacity(standardEntries, true); + this._pdf.showAnnotations(standardEntries); + this._pdf.hideAnnotations(nonStandardEntries); + break; + } + case 'DELTA': { + const changeLogEntries = annotations.filter(a => a.getCustomData('changeLog') === 'true'); + const nonChangeLogEntries = annotations.filter(a => a.getCustomData('changeLog') === 'false'); + this._setAnnotationsColor(redactions, 'annotationColor'); + this._setAnnotationsOpacity(changeLogEntries, true); + this._pdf.showAnnotations(changeLogEntries); + this._pdf.hideAnnotations(nonChangeLogEntries); + break; + } + case 'REDACTED': { + const nonRedactionEntries = annotations.filter(a => a.getCustomData('redaction') === 'false'); + this._setAnnotationsOpacity(redactions); + this._setAnnotationsColor(redactions, 'redactionColor'); + this._pdf.showAnnotations(redactions); + this._pdf.hideAnnotations(nonRedactionEntries); + break; + } + case 'TEXT_HIGHLIGHTS': { + this._loadingService.start(); + const textHighlights = await firstValueFrom(this._pdfViewerDataService.loadTextHighlightsFor(this.dossierId, this.fileId)); + this._pdf.hideAnnotations(annotations); + this._fileData.textHighlights = textHighlights; + await this._annotationDrawService.drawAnnotations(this._fileData.textHighlightAnnotations); + this._loadingService.stop(); + } + } + + await this._stampPDF(); + this.rebuildFilters(); + } + + ngOnDetach(): void { + this._pageRotationService.clearRotations(); + this.displayPdfViewer = false; + super.ngOnDetach(); + this._changeDetectorRef.markForCheck(); + } + + async ngOnAttach(previousRoute: ActivatedRouteSnapshot): Promise { + const file = await this.stateService.file; + if (!file.canBeOpened) { + return this._router.navigate([this._dossiersService.find(this.dossierId)?.routerLink]); + } + this._viewModeService.compareMode = false; + this._viewModeService.switchToStandard(); + + await this.ngOnInit(); + this._lastPage = previousRoute.queryParams.page; + this._changeDetectorRef.markForCheck(); + } + + async ngOnInit(): Promise { + this.ready = false; + this._loadingService.start(); + await this.userPreferenceService.saveLastOpenedFileForDossier(this.dossierId, this.fileId); + this._subscribeToFileUpdates(); + + const file = await this.stateService.file; + if (file?.analysisRequired && !file.excludedFromAutomaticAnalysis) { + const reanalyzeFiles = this._reanalysisService.reanalyzeFilesForDossier([file], this.dossierId, { force: true }); + await firstValueFrom(reanalyzeFiles); + } + + this.displayPdfViewer = true; + } + + rebuildFilters(deletePreviousAnnotations = false): void { + const startTime = new Date().getTime(); + if (deletePreviousAnnotations) { + this._pdf.deleteAnnotations(); + + console.log(`[REDACTION] Delete previous annotations time: ${new Date().getTime() - startTime} ms`); + } + const processStartTime = new Date().getTime(); + + const annotationFilters = this._annotationProcessingService.getAnnotationFilter(this.visibleAnnotations); + const primaryFilters = this._filterService.getGroup('primaryFilters')?.filters; + this._filterService.addFilterGroup({ + slug: 'primaryFilters', + filterTemplate: this._filterTemplate, + filters: processFilters(primaryFilters, annotationFilters), + }); + const secondaryFilters = this._filterService.getGroup('secondaryFilters')?.filters; + this._filterService.addFilterGroup({ + slug: 'secondaryFilters', + filterTemplate: this._filterTemplate, + filters: processFilters(secondaryFilters, AnnotationProcessingService.secondaryAnnotationFilters(this._fileData?.viewedPages)), + }); + console.log(`[REDACTION] Process time: ${new Date().getTime() - processStartTime} ms`); + console.log(`[REDACTION] Filter rebuild time: ${new Date().getTime() - startTime}`); + } + + handleAnnotationSelected(annotationIds: string[]) { + this.selectedAnnotations = annotationIds + .map(id => this.visibleAnnotations.find(annotationWrapper => annotationWrapper.id === id)) + .filter(ann => ann !== undefined); + if (this.selectedAnnotations.length > 1) { + this.multiSelectService.activate(); + } + this._workloadComponent.scrollToSelectedAnnotation(); + this._changeDetectorRef.markForCheck(); + } + + @Debounce(10) + selectAnnotations(annotations?: AnnotationWrapper[]) { + if (annotations) { + const annotationsToSelect = this.multiSelectService.isActive ? [...this.selectedAnnotations, ...annotations] : annotations; + this._pdf.selectAnnotations(annotationsToSelect, this.multiSelectService.isActive); + } else { + this._pdf.deselectAllAnnotations(); + } + } + + deselectAnnotations(annotations: AnnotationWrapper[]) { + this._pdf.deselectAnnotations(annotations); + } + + selectPage(pageNumber: number) { + this._pdf.navigateToPage(pageNumber); + this._workloadComponent?.scrollAnnotationsToPage(pageNumber, 'always'); + this._lastPage = pageNumber.toString(); + } + + openManualAnnotationDialog(manualRedactionEntryWrapper: ManualRedactionEntryWrapper) { + this._ngZone.run(() => { + this.dialogRef = this._dialogService.openDialog( + 'manualAnnotation', + null, + { manualRedactionEntryWrapper, dossierId: this.dossierId }, + async (entryWrapper: ManualRedactionEntryWrapper) => { + const addAnnotation$ = this._manualAnnotationService + .addAnnotation(entryWrapper.manualRedactionEntry, this.dossierId, this.fileId) + .pipe(catchError(() => of(undefined))); + const addAnnotationResponse = await firstValueFrom(addAnnotation$); + const response = new ManualAnnotationResponse(entryWrapper, addAnnotationResponse); + + if (response?.annotationId) { + const annotation = this._pdf.annotationManager.getAnnotationById(response.manualRedactionEntryWrapper.rectId); + this._pdf.deleteAnnotations([annotation]); + const distinctPages = manualRedactionEntryWrapper.manualRedactionEntry.positions + .map(p => p.page) + .filter((item, pos, self) => self.indexOf(item) === pos); + for (const page of distinctPages) { + await this._reloadAnnotationsForPage(page); + } + await this.updateViewMode(); + } + }, + ); + }); + } + + toggleFullScreen() { + this.fullScreen = !this.fullScreen; + if (this.fullScreen) { + this._openFullScreen(); + } else { + this.closeFullScreen(); + } + } + + handleArrowEvent($event: KeyboardEvent): void { + if (['ArrowUp', 'ArrowDown'].includes($event.key)) { + if (this.selectedAnnotations.length === 1) { + this._workloadComponent.navigateAnnotations($event); + } + } + } + + @HostListener('window:keyup', ['$event']) + handleKeyEvent($event: KeyboardEvent) { + if (this._router.url.indexOf('/file/') < 0) { + return; + } + + if (!ALL_HOTKEY_ARRAY.includes($event.key) || this.dialogRef?.getState() === MatDialogState.OPEN) { + return; + } + + if (['Escape'].includes($event.key)) { + this.fullScreen = false; + this.closeFullScreen(); + this._changeDetectorRef.markForCheck(); + } + + if (['f', 'F'].includes($event.key)) { + // if you type in an input, don't toggle full-screen + if ($event.target instanceof HTMLInputElement || $event.target instanceof HTMLTextAreaElement) { + return; + } + this.toggleFullScreen(); + return; + } + } + + async viewerPageChanged($event: any) { + if (typeof $event !== 'number') { + return; + } + + this._scrollViews(); + this.multiSelectService.deactivate(); + + // Add current page in URL query params + const extras: NavigationExtras = { + queryParams: { page: $event }, + queryParamsHandling: 'merge', + replaceUrl: true, + }; + await this._router.navigate([], extras); + + this.activeViewerPage = this._pdf.currentPage; + this._changeDetectorRef.markForCheck(); + } + + @Debounce() + async viewerReady() { + this.ready = true; + this._pdf.ready = true; + + await this._reloadAnnotations(); + this._setExcludedPageStyles(); + + this._pdf.documentViewer.addEventListener('pageComplete', () => { + this._setExcludedPageStyles(); + }); + + // Go to initial page from query params + const pageNumber: string = this._lastPage || this._activatedRoute.snapshot.queryParams.page; + if (pageNumber) { + setTimeout(() => { + this.selectPage(parseInt(pageNumber, 10)); + this.activeViewerPage = this._pdf.currentPage; + this._scrollViews(); + this._changeDetectorRef.markForCheck(); + this._loadingService.stop(); + }); + } else { + this._loadingService.stop(); + } + this._changeDetectorRef.markForCheck(); + } + + async annotationsChangedByReviewAction(annotation?: AnnotationWrapper) { + this.multiSelectService.deactivate(); + const file = await this.stateService.file; + const fileReloaded = await firstValueFrom(this._filesService.reload(this.dossierId, file)); + if (!fileReloaded) { + await this._reloadAnnotationsForPage(annotation?.pageNumber ?? this.activeViewerPage); + } + } + + closeFullScreen() { + if (!!document.fullscreenElement && document.exitFullscreen) { + document.exitFullscreen().then(); + } + } + + async switchView(viewMode: ViewMode) { + this._viewModeService.viewMode = viewMode; + await this.updateViewMode(); + this._scrollViews(); + } + + async downloadOriginalFile(file: File) { + const originalFile = this._fileManagementService.downloadOriginalFile( + this.dossierId, + this.fileId, + 'response', + file.cacheIdentifier, + ); + download(await firstValueFrom(originalFile), file.filename); + } + + #deactivateMultiSelect(): void { + this.multiSelectService.deactivate(); + this._pdf.deselectAllAnnotations(); + this.handleAnnotationSelected([]); + } + + private _setExcludedPageStyles() { + const file = this._filesMapService.get(this.dossierId, this.fileId); + setTimeout(() => { + const iframeDoc = this._pdf.UI.iframeWindow.document; + const pageContainer = iframeDoc.getElementById(`pageWidgetContainer${this.activeViewerPage}`); + if (pageContainer) { + if (file.excludedPages.includes(this.activeViewerPage)) { + pageContainer.classList.add('excluded-page'); + } else { + pageContainer.classList.remove('excluded-page'); + } + } + }, 100); + } + + private async _stampPDF() { + const pdfDoc = await this._pdf.documentViewer.getDocument().getPDFDoc(); + const file = await this.stateService.file; + const allPages = [...Array(file.numberOfPages).keys()].map(page => page + 1); + + if (!pdfDoc || !this._pdf.ready) { + return; + } + + await clearStamps(pdfDoc, this._pdf.PDFNet, allPages); + + if (this._viewModeService.isRedacted) { + const dossier = await this.stateService.dossier; + if (dossier.watermarkPreviewEnabled) { + await this._stampPreview(pdfDoc, dossier.dossierTemplateId); + } + } else { + await this._stampExcludedPages(pdfDoc, file.excludedPages); + } + this._pdf.documentViewer.refreshAll(); + this._pdf.documentViewer.updateView([this.activeViewerPage], this.activeViewerPage); + this._changeDetectorRef.markForCheck(); + } + + private async _stampPreview(document: PDFNet.PDFDoc, dossierTemplateId: string) { + const watermark = await this._watermarkService.getWatermark(dossierTemplateId).toPromise(); + await stampPDFPage( + document, + this._pdf.PDFNet, + watermark.text, + watermark.fontSize, + watermark.fontType, + watermark.orientation, + watermark.opacity, + watermark.hexColor, + Array.from({ length: await document.getPageCount() }, (x, i) => i + 1), + ); + } + + private async _stampExcludedPages(document: PDFNet.PDFDoc, excludedPages: number[]) { + if (excludedPages && excludedPages.length > 0) { + await stampPDFPage( + document, + this._pdf.PDFNet, + this._translateService.instant('file-preview.excluded-from-redaction') as string, + 17, + 'courier', + 'TOP_LEFT', + 50, + '#dd4d50', + excludedPages, + ); + } + } + + private async _fileUpdated(file: File): Promise { + await this._loadFileData(file); + await this._reloadAnnotations(); + } + + private _subscribeToFileUpdates(): void { + this.addActiveScreenSubscription = timer(0, 5000) + .pipe( + switchMap(() => this.stateService.file$), + switchMap(file => this._filesService.reload(this.dossierId, file)), + ) + .subscribe(); + + this.addActiveScreenSubscription = this._dossiersService + .getEntityDeleted$(this.dossierId) + .pipe(tap(() => this._handleDeletedDossier())) + .subscribe(); + + this.addActiveScreenSubscription = this._filesMapService + .watchDeleted$(this.fileId) + .pipe(tap(() => this._handleDeletedFile())) + .subscribe(); + + this.addActiveScreenSubscription = this._skippedService.hideSkipped$ + .pipe(tap(hideSkipped => this._handleIgnoreAnnotationsDrawing(hideSkipped))) + .subscribe(); + } + + private _handleDeletedDossier(): void { + this._errorService.set( + new CustomError(_('error.deleted-entity.file-dossier.label'), _('error.deleted-entity.file-dossier.action'), 'iqser:expand'), + ); + } + + private _handleDeletedFile(): void { + this._errorService.set( + new CustomError(_('error.deleted-entity.file.label'), _('error.deleted-entity.file.action'), 'iqser:expand'), + ); + } + + private async _loadFileData(file: File): Promise { + if (!file || file.isError) { + const dossier = await this.stateService.dossier; + return this._router.navigate([dossier.routerLink]); + } + + if (file.isUnprocessed) { + return; + } + + this.stateService.fileData = await firstValueFrom(this._pdfViewerDataService.loadDataFor(file)); + } + + @Debounce(0) + private _scrollViews() { + this._workloadComponent?.scrollQuickNavigation(); + this._workloadComponent?.scrollAnnotations(); + } + + private async _reloadAnnotations() { + this._deleteAnnotations(); + await this._cleanupAndRedrawAnnotations(); + await this.updateViewMode(); + } + + private async _reloadAnnotationsForPage(page: number) { + const file = await this.stateService.file; + // if this action triggered a re-processing, + // we don't want to redraw for this page since they will get redrawn as soon as processing ends; + if (file.isProcessing) { + return; + } + + const currentPageAnnotations = this.visibleAnnotations.filter(a => a.pageNumber === page); + this._fileData.redactionLog = await firstValueFrom(this._pdfViewerDataService.loadRedactionLogFor(this.dossierId, this.fileId)); + + this._deleteAnnotations(currentPageAnnotations); + await this._cleanupAndRedrawAnnotations(annotation => annotation.pageNumber === page); + } + + private _deleteAnnotations(annotationsToDelete?: AnnotationWrapper[]) { + if (!this._pdf.ready) { + return; + } + + if (!annotationsToDelete) { + this._pdf.deleteAnnotations(); + } + annotationsToDelete?.forEach(annotation => { + this._findAndDeleteAnnotation(annotation.id); + }); + } + + private async _cleanupAndRedrawAnnotations(newAnnotationsFilter?: (annotation: AnnotationWrapper) => boolean) { + if (!this._pdf.ready) { + return; + } + + const currentFilters = this._filterService.getGroup('primaryFilters')?.filters || []; + this.rebuildFilters(); + + const startTime = new Date().getTime(); + const annotations = this._fileData.allAnnotations; + const newAnnotations = newAnnotationsFilter ? annotations.filter(newAnnotationsFilter) : annotations; + + if (currentFilters) { + this._handleDeltaAnnotationFilters(currentFilters, this.visibleAnnotations); + } + + await this._annotationDrawService.drawAnnotations(newAnnotations); + console.log(`[REDACTION] Annotations redraw time: ${new Date().getTime() - startTime} ms for ${newAnnotations.length} annotations`); + } + + private _handleDeltaAnnotationFilters(currentFilters: NestedFilter[], newAnnotations: AnnotationWrapper[]) { + const primaryFilterGroup = this._filterService.getGroup('primaryFilters'); + const primaryFilters = primaryFilterGroup.filters; + const secondaryFilters = this._filterService.getGroup('secondaryFilters').filters; + const hasAnyFilterSet = [...primaryFilters, ...secondaryFilters].find(f => f.checked || f.indeterminate); + + if (!hasAnyFilterSet) { + return; + } + + const newPageSpecificFilters = this._annotationProcessingService.getAnnotationFilter(newAnnotations); + + handleFilterDelta(currentFilters, newPageSpecificFilters, primaryFilters); + this._filterService.addFilterGroup({ + ...primaryFilterGroup, + filters: primaryFilters, + }); + } + + private _findAndDeleteAnnotation(id: string) { + const viewerAnnotation = this._pdf.annotationManager.getAnnotationById(id); + if (viewerAnnotation) { + this._pdf.deleteAnnotations([viewerAnnotation]); + } + } + + private _openFullScreen() { + const documentElement = document.documentElement; + if (documentElement.requestFullscreen) { + documentElement.requestFullscreen().then(); + } + } + + private _handleIgnoreAnnotationsDrawing(hideSkipped: boolean): void { + const ignored = this._pdf.getAnnotations(a => a.getCustomData('skipped')); + if (hideSkipped) { + this._pdf.hideAnnotations(ignored); + } else { + this._pdf.showAnnotations(ignored); + } + } + + private _setAnnotationsOpacity(annotations: Annotation[], restoreToOriginal: boolean = false) { + annotations.forEach(annotation => { + annotation['Opacity'] = restoreToOriginal ? parseFloat(annotation.getCustomData('opacity')) : 1; + }); + } + + private _setAnnotationsColor(annotations: Annotation[], customData: string) { + annotations.forEach(annotation => { + const color = this._annotationDrawService.convertColor(annotation.getCustomData(customData)); + annotation['StrokeColor'] = color; + annotation['FillColor'] = color; + }); + } +} diff --git a/apps/red-ui/src/app/modules/dossier/screens/file-preview-screen/services/annotation-actions.service.ts b/apps/red-ui/src/app/modules/dossier/screens/file-preview-screen/services/annotation-actions.service.ts new file mode 100644 index 000000000..6dc483356 --- /dev/null +++ b/apps/red-ui/src/app/modules/dossier/screens/file-preview-screen/services/annotation-actions.service.ts @@ -0,0 +1,600 @@ +import { EventEmitter, Inject, Injectable, NgZone } from '@angular/core'; +import { PermissionsService } from '@services/permissions.service'; +import { ManualAnnotationService } from '../../../services/manual-annotation.service'; +import { AnnotationWrapper } from '@models/file/annotation.wrapper'; +import { Observable } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; +import { getFirstRelevantTextPart } from '@utils/functions'; +import { AnnotationPermissions } from '@models/file/annotation.permissions'; +import { DossiersDialogService } from '../../../services/dossiers-dialog.service'; +import { BASE_HREF } from '../../../../../tokens'; +import { UserService } from '@services/user.service'; +import { Core } from '@pdftron/webviewer'; +import { Dossier, IAddRedactionRequest, ILegalBasisChangeRequest, IRectangle, IResizeRequest } from '@red/domain'; +import { toPosition } from '../../../utils/pdf-calculation.utils'; +import { AnnotationDrawService } from './annotation-draw.service'; +import { translateQuads } from '@utils/pdf-coordinates'; +import { ActiveDossiersService } from '@services/dossiers/active-dossiers.service'; +import { + AcceptRecommendationData, + AcceptRecommendationDialogComponent, + AcceptRecommendationReturnType, +} from '../dialogs/accept-recommendation-dialog/accept-recommendation-dialog.component'; +import { defaultDialogConfig } from '@iqser/common-ui'; +import { filter } from 'rxjs/operators'; +import { MatDialog } from '@angular/material/dialog'; +import { FilePreviewStateService } from './file-preview-state.service'; +import { PdfViewer } from './pdf-viewer.service'; +import Annotation = Core.Annotations.Annotation; + +@Injectable() +export class AnnotationActionsService { + constructor( + @Inject(BASE_HREF) private readonly _baseHref: string, + private readonly _ngZone: NgZone, + private readonly _userService: UserService, + private readonly _permissionsService: PermissionsService, + private readonly _manualAnnotationService: ManualAnnotationService, + private readonly _translateService: TranslateService, + private readonly _dialogService: DossiersDialogService, + private readonly _dialog: MatDialog, + private readonly _pdf: PdfViewer, + private readonly _annotationDrawService: AnnotationDrawService, + private readonly _activeDossiersService: ActiveDossiersService, + private readonly _screenStateService: FilePreviewStateService, + ) {} + + private get _dossier(): Dossier { + return this._activeDossiersService.find(this._screenStateService.dossierId); + } + + acceptSuggestion($event: MouseEvent, annotations: AnnotationWrapper[], annotationsChanged: EventEmitter) { + $event?.stopPropagation(); + const { dossierId, fileId } = this._screenStateService; + annotations.forEach(annotation => { + this._processObsAndEmit( + this._manualAnnotationService.approve(annotation.id, dossierId, fileId, annotation.isModifyDictionary), + annotation, + annotationsChanged, + ); + }); + } + + rejectSuggestion($event: MouseEvent, annotations: AnnotationWrapper[], annotationsChanged: EventEmitter) { + $event?.stopPropagation(); + const { dossierId, fileId } = this._screenStateService; + annotations.forEach(annotation => { + this._processObsAndEmit( + this._manualAnnotationService.declineOrRemoveRequest(annotation, dossierId, fileId), + annotation, + annotationsChanged, + ); + }); + } + + forceAnnotation( + $event: MouseEvent, + annotations: AnnotationWrapper[], + annotationsChanged: EventEmitter, + hint: boolean = false, + ) { + const { dossierId, fileId } = this._screenStateService; + const data = { dossier: this._dossier, annotations, hint }; + this._dialogService.openDialog('forceAnnotation', $event, data, (request: ILegalBasisChangeRequest) => { + annotations.forEach(annotation => { + this._processObsAndEmit( + this._manualAnnotationService.force( + { + ...request, + annotationId: annotation.id, + }, + dossierId, + fileId, + ), + annotation, + annotationsChanged, + ); + }); + }); + } + + changeLegalBasis($event: MouseEvent, annotations: AnnotationWrapper[], annotationsChanged: EventEmitter) { + const { dossierId, fileId } = this._screenStateService; + this._dialogService.openDialog( + 'changeLegalBasis', + $event, + { annotations, dossier: this._dossier }, + (data: { comment: string; legalBasis: string; section: string; value: string }) => { + annotations.forEach(annotation => { + this._processObsAndEmit( + this._manualAnnotationService.changeLegalBasis( + annotation.annotationId, + dossierId, + fileId, + data.section, + data.value, + data.legalBasis, + data.comment, + ), + annotation, + annotationsChanged, + ); + }); + }, + ); + } + + removeOrSuggestRemoveAnnotation( + $event: MouseEvent, + annotations: AnnotationWrapper[], + removeFromDictionary: boolean, + annotationsChanged: EventEmitter, + ) { + const data = { + annotationsToRemove: annotations, + removeFromDictionary, + dossier: this._dossier, + hint: annotations[0].hintDictionary, + }; + const { dossierId, fileId } = this._screenStateService; + this._dialogService.openDialog('removeAnnotations', $event, data, (result: { comment: string }) => { + annotations.forEach(annotation => { + this._processObsAndEmit( + this._manualAnnotationService.removeOrSuggestRemoveAnnotation( + annotation, + dossierId, + fileId, + result.comment, + removeFromDictionary, + ), + annotation, + annotationsChanged, + ); + }); + }); + } + + markAsFalsePositive($event: MouseEvent, annotations: AnnotationWrapper[], annotationsChanged: EventEmitter) { + annotations.forEach(annotation => { + this._markAsFalsePositive($event, annotation, this._getFalsePositiveText(annotation), annotationsChanged); + }); + } + + recategorizeImages($event: MouseEvent, annotations: AnnotationWrapper[], annotationsChanged: EventEmitter) { + const data = { annotations, dossier: this._dossier }; + const { dossierId, fileId } = this._screenStateService; + this._dialogService.openDialog('recategorizeImage', $event, data, (res: { type: string; comment: string }) => { + annotations.forEach(annotation => { + this._processObsAndEmit( + this._manualAnnotationService.recategorizeImg(annotation.annotationId, dossierId, fileId, res.type, res.comment), + annotation, + annotationsChanged, + ); + }); + }); + } + + undoDirectAction($event: MouseEvent, annotations: AnnotationWrapper[], annotationsChanged: EventEmitter) { + $event?.stopPropagation(); + + const { dossierId, fileId } = this._screenStateService; + annotations.forEach(annotation => { + this._processObsAndEmit( + this._manualAnnotationService.undoRequest(annotation, dossierId, fileId), + annotation, + annotationsChanged, + ); + }); + } + + convertRecommendationToAnnotation( + $event: any, + recommendations: AnnotationWrapper[], + annotationsChanged: EventEmitter, + ) { + $event?.stopPropagation(); + + const { dossierId, fileId } = this._screenStateService; + const dialogRef = this._dialog.open( + AcceptRecommendationDialogComponent, + { ...defaultDialogConfig, autoFocus: true, data: { annotations: recommendations, dossierId } }, + ); + const dialogClosed = dialogRef.afterClosed().pipe(filter(value => !!value && !!value.annotations)); + dialogClosed.subscribe(({ annotations, comment: commentText }) => { + const comment = commentText ? { text: commentText } : undefined; + annotations.forEach(annotation => { + this._processObsAndEmit( + this._manualAnnotationService.addRecommendation(annotation, dossierId, fileId, comment), + annotation, + annotationsChanged, + ); + }); + }); + } + + getViewerAvailableActions( + dossier: Dossier, + annotations: AnnotationWrapper[], + annotationsChanged: EventEmitter, + ): Record[] { + const availableActions = []; + const annotationPermissions = annotations.map(annotation => ({ + annotation, + permissions: AnnotationPermissions.forUser( + this._permissionsService.isApprover(dossier), + this._userService.currentUser, + annotation, + ), + })); + + // you can only resize one annotation at a time + const canResize = annotationPermissions.length === 1 && annotationPermissions[0].permissions.canResizeAnnotation; + if (canResize) { + const firstAnnotation = annotations[0]; + // if we already entered resize-mode previously + if (firstAnnotation.resizing) { + availableActions.push({ + type: 'actionButton', + img: this._convertPath('/assets/icons/general/check.svg'), + title: this._translateService.instant('annotation-actions.resize-accept.label'), + onClick: () => + this._ngZone.run(() => { + this.acceptResize(null, firstAnnotation, annotationsChanged); + }), + }); + availableActions.push({ + type: 'actionButton', + img: this._convertPath('/assets/icons/general/close.svg'), + title: this._translateService.instant('annotation-actions.resize-cancel.label'), + onClick: () => + this._ngZone.run(() => { + this.cancelResize(null, firstAnnotation, annotationsChanged); + }), + }); + return availableActions; + } + + availableActions.push({ + type: 'actionButton', + img: this._convertPath('/assets/icons/general/resize.svg'), + title: this._translateService.instant('annotation-actions.resize.label'), + onClick: () => this._ngZone.run(() => this.resize(null, annotations[0])), + }); + } + + const canChangeLegalBasis = annotationPermissions.reduce((acc, next) => acc && next.permissions.canChangeLegalBasis, true); + if (canChangeLegalBasis) { + availableActions.push({ + type: 'actionButton', + img: this._convertPath('/assets/icons/general/edit.svg'), + title: this._translateService.instant('annotation-actions.edit-reason.label'), + onClick: () => + this._ngZone.run(() => { + this.changeLegalBasis(null, annotations, annotationsChanged); + }), + }); + } + + const canRecategorizeImage = annotationPermissions.reduce((acc, next) => acc && next.permissions.canRecategorizeImage, true); + if (canRecategorizeImage) { + availableActions.push({ + type: 'actionButton', + img: this._convertPath('/assets/icons/general/thumb-down.svg'), + title: this._translateService.instant('annotation-actions.recategorize-image'), + onClick: () => + this._ngZone.run(() => { + this.recategorizeImages(null, annotations, annotationsChanged); + }), + }); + } + + const canRemoveOrSuggestToRemoveFromDictionary = annotationPermissions.reduce( + (acc, next) => acc && next.permissions.canRemoveOrSuggestToRemoveFromDictionary, + true, + ); + if (canRemoveOrSuggestToRemoveFromDictionary) { + availableActions.push({ + type: 'actionButton', + img: this._convertPath('/assets/icons/general/remove-from-dict.svg'), + title: this._translateService.instant('annotation-actions.remove-annotation.remove-from-dict'), + onClick: () => + this._ngZone.run(() => { + this.removeOrSuggestRemoveAnnotation(null, annotations, true, annotationsChanged); + }), + }); + } + + const canAcceptRecommendation = annotationPermissions.reduce((acc, next) => acc && next.permissions.canAcceptRecommendation, true); + if (canAcceptRecommendation) { + availableActions.push({ + type: 'actionButton', + img: this._convertPath('/assets/icons/general/check.svg'), + title: this._translateService.instant('annotation-actions.accept-recommendation.label'), + onClick: () => + this._ngZone.run(() => { + this.convertRecommendationToAnnotation(null, annotations, annotationsChanged); + }), + }); + } + + const canAcceptSuggestion = annotationPermissions.reduce((acc, next) => acc && next.permissions.canAcceptSuggestion, true); + if (canAcceptSuggestion) { + availableActions.push({ + type: 'actionButton', + img: this._convertPath('/assets/icons/general/check.svg'), + title: this._translateService.instant('annotation-actions.accept-suggestion.label'), + onClick: () => + this._ngZone.run(() => { + this.acceptSuggestion(null, annotations, annotationsChanged); + }), + }); + } + + const canUndo = annotationPermissions.reduce((acc, next) => acc && next.permissions.canUndo, true); + if (canUndo) { + availableActions.push({ + type: 'actionButton', + img: this._convertPath('/assets/icons/general/undo.svg'), + title: this._translateService.instant('annotation-actions.undo'), + onClick: () => + this._ngZone.run(() => { + this.undoDirectAction(null, annotations, annotationsChanged); + }), + }); + } + + const canMarkAsFalsePositive = annotationPermissions.reduce((acc, next) => acc && next.permissions.canMarkAsFalsePositive, true); + if (canMarkAsFalsePositive) { + availableActions.push({ + type: 'actionButton', + img: this._convertPath('/assets/icons/general/thumb-down.svg'), + title: this._translateService.instant('annotation-actions.remove-annotation.false-positive'), + onClick: () => + this._ngZone.run(() => { + this.markAsFalsePositive(null, annotations, annotationsChanged); + }), + }); + } + + const canForceRedaction = annotationPermissions.reduce((acc, next) => acc && next.permissions.canForceRedaction, true); + if (canForceRedaction) { + availableActions.push({ + type: 'actionButton', + img: this._convertPath('/assets/icons/general/thumb-up.svg'), + title: this._translateService.instant('annotation-actions.force-redaction.label'), + onClick: () => + this._ngZone.run(() => { + this.forceAnnotation(null, annotations, annotationsChanged); + }), + }); + } + + const canForceHint = annotationPermissions.reduce((acc, next) => acc && next.permissions.canForceHint, true); + if (canForceHint) { + availableActions.push({ + type: 'actionButton', + img: this._convertPath('/assets/icons/general/thumb-up.svg'), + title: this._translateService.instant('annotation-actions.force-hint.label'), + onClick: () => + this._ngZone.run(() => { + this.forceAnnotation(null, annotations, annotationsChanged, true); + }), + }); + } + + const canRejectSuggestion = annotationPermissions.reduce((acc, next) => acc && next.permissions.canRejectSuggestion, true); + if (canRejectSuggestion) { + availableActions.push({ + type: 'actionButton', + img: this._convertPath('/assets/icons/general/close.svg'), + title: this._translateService.instant('annotation-actions.reject-suggestion'), + onClick: () => + this._ngZone.run(() => { + this.rejectSuggestion(null, annotations, annotationsChanged); + }), + }); + } + + const canRemoveOrSuggestToRemoveOnlyHere = annotationPermissions.reduce( + (acc, next) => acc && next.permissions.canRemoveOrSuggestToRemoveOnlyHere, + true, + ); + if (canRemoveOrSuggestToRemoveOnlyHere) { + availableActions.push({ + type: 'actionButton', + img: this._convertPath('/assets/icons/general/trash.svg'), + title: this._translateService.instant('annotation-actions.remove-annotation.only-here'), + onClick: () => + this._ngZone.run(() => { + this.removeOrSuggestRemoveAnnotation(null, annotations, false, annotationsChanged); + }), + }); + } + + return availableActions; + } + + updateHiddenAnnotation(annotations: AnnotationWrapper[], viewerAnnotations: Annotation[], hidden: boolean) { + const annotationId = viewerAnnotations[0].Id; + const annotationToBeUpdated = annotations.find((a: AnnotationWrapper) => a.annotationId === annotationId); + annotationToBeUpdated.hidden = hidden; + } + + resize($event: MouseEvent, annotationWrapper: AnnotationWrapper) { + $event?.stopPropagation(); + + annotationWrapper.resizing = true; + + const viewerAnnotation = this._pdf.annotationManager.getAnnotationById(annotationWrapper.id); + viewerAnnotation.ReadOnly = false; + viewerAnnotation.Hidden = false; + viewerAnnotation.disableRotationControl(); + this._pdf.annotationManager.redrawAnnotation(viewerAnnotation); + this._pdf.annotationManager.selectAnnotation(viewerAnnotation); + + this._annotationDrawService.annotationToQuads(viewerAnnotation); + } + + acceptResize($event: MouseEvent, annotationWrapper: AnnotationWrapper, annotationsChanged?: EventEmitter) { + const data = { dossier: this._dossier }; + const fileId = this._screenStateService.fileId; + this._dialogService.openDialog('resizeAnnotation', $event, data, async (result: { comment: string }) => { + const textAndPositions = await this._extractTextAndPositions(annotationWrapper.id); + const text = + annotationWrapper.value === 'Rectangle' ? 'Rectangle' : annotationWrapper.isImage ? 'Image' : textAndPositions.text; + + const resizeRequest: IResizeRequest = { + annotationId: annotationWrapper.id, + comment: result.comment, + positions: textAndPositions.positions, + value: text, + }; + + this._processObsAndEmit( + this._manualAnnotationService.resizeOrSuggestToResize(annotationWrapper, data.dossier.dossierId, fileId, resizeRequest), + annotationWrapper, + annotationsChanged, + ); + }); + } + + cancelResize($event: MouseEvent, annotationWrapper: AnnotationWrapper, annotationsChanged: EventEmitter) { + $event?.stopPropagation(); + + annotationWrapper.resizing = false; + + const viewerAnnotation = this._pdf.annotationManager.getAnnotationById(annotationWrapper.id); + viewerAnnotation.ReadOnly = false; + this._pdf.annotationManager.redrawAnnotation(viewerAnnotation); + this._pdf.annotationManager.deselectAllAnnotations(); + annotationsChanged.emit(annotationWrapper); + } + + private _processObsAndEmit( + obs: Observable, + annotation: AnnotationWrapper, + annotationsChanged: EventEmitter, + ) { + obs.subscribe({ + next: () => { + annotationsChanged.emit(annotation); + }, + error: () => { + annotationsChanged.emit(); + }, + }); + } + + private _getFalsePositiveText(annotation: AnnotationWrapper) { + if (annotation.canBeMarkedAsFalsePositive) { + let text: string; + if (annotation.hasTextAfter) { + text = getFirstRelevantTextPart(annotation.textAfter, 'FORWARD'); + return text ? (annotation.value + text).trim() : annotation.value; + } + if (annotation.hasTextAfter) { + text = getFirstRelevantTextPart(annotation.textBefore, 'BACKWARD'); + return text ? (text + annotation.value).trim() : annotation.value; + } else { + return annotation.value; + } + } + } + + private _markAsFalsePositive( + $event: MouseEvent, + annotation: AnnotationWrapper, + text: string, + annotationsChanged: EventEmitter, + ) { + $event?.stopPropagation(); + + const falsePositiveRequest: IAddRedactionRequest = {}; + falsePositiveRequest.reason = annotation.id; + falsePositiveRequest.value = text; + falsePositiveRequest.type = 'false_positive'; + falsePositiveRequest.positions = annotation.positions; + falsePositiveRequest.addToDictionary = true; + falsePositiveRequest.comment = { text: 'False Positive' }; + const { dossierId, fileId } = this._screenStateService; + + this._processObsAndEmit( + this._manualAnnotationService.addAnnotation(falsePositiveRequest, dossierId, fileId), + annotation, + annotationsChanged, + ); + } + + private _convertPath(path: string): string { + return this._baseHref + path; + } + + private async _extractTextAndPositions(annotationId: string) { + const viewerAnnotation = this._pdf.annotationManager.getAnnotationById(annotationId); + + const document = await this._pdf.documentViewer.getDocument().getPDFDoc(); + const page = await document.getPage(viewerAnnotation.getPageNumber()); + if (viewerAnnotation instanceof this._pdf.Annotations.TextHighlightAnnotation) { + const words = []; + const rectangles: IRectangle[] = []; + for (const quad of viewerAnnotation.Quads) { + const rect = toPosition( + viewerAnnotation.getPageNumber(), + this._pdf.documentViewer.getPageHeight(viewerAnnotation.getPageNumber()), + this._translateQuads(viewerAnnotation.getPageNumber(), quad), + ); + rectangles.push(rect); + + // TODO: this is an educated guess for lines that are close together + // TODO: so that we do not extract text from line above/line below + const percentHeightOffset = rect.height / 10; + + const pdfNetRect = new this._pdf.instance.Core.PDFNet.Rect( + rect.topLeft.x, + rect.topLeft.y + percentHeightOffset, + rect.topLeft.x + rect.width, + rect.topLeft.y + rect.height - percentHeightOffset, + ); + const quadWords = await this._extractTextFromRect(page, pdfNetRect); + words.push(...quadWords); + } + + console.log(words.join(' ')); + + return { + text: words.join(' '), + positions: rectangles, + }; + } else { + const rect = toPosition( + viewerAnnotation.getPageNumber(), + this._pdf.documentViewer.getPageHeight(viewerAnnotation.getPageNumber()), + this._annotationDrawService.annotationToQuads(viewerAnnotation), + ); + return { + positions: [rect], + text: null, + }; + } + } + + private _translateQuads(page: number, quads: any) { + const rotation = this._pdf.documentViewer.getCompleteRotation(page); + return translateQuads(page, rotation, quads); + } + + private async _extractTextFromRect(page: Core.PDFNet.Page, rect: Core.PDFNet.Rect) { + const txt = await this._pdf.PDFNet.TextExtractor.create(); + txt.begin(page, rect); // Read the page. + + const words: string[] = []; + // Extract words one by one. + let line = await txt.getFirstLine(); + for (; await line.isValid(); line = await line.getNextLine()) { + for (let word = await line.getFirstWord(); await word.isValid(); word = await word.getNextWord()) { + words.push(await word.getString()); + } + } + return words; + } +} diff --git a/apps/red-ui/src/app/modules/dossier/shared/shared-dossiers.module.ts b/apps/red-ui/src/app/modules/dossier/shared/shared-dossiers.module.ts index cdb1bbc83..57cb8d531 100644 --- a/apps/red-ui/src/app/modules/dossier/shared/shared-dossiers.module.ts +++ b/apps/red-ui/src/app/modules/dossier/shared/shared-dossiers.module.ts @@ -13,7 +13,6 @@ import { EditDossierDownloadPackageComponent } from '../dialogs/edit-dossier-dia import { EditDossierDictionaryComponent } from '../dialogs/edit-dossier-dialog/dictionary/edit-dossier-dictionary.component'; import { EditDossierAttributesComponent } from '../dialogs/edit-dossier-dialog/attributes/edit-dossier-attributes.component'; import { EditDossierTeamComponent } from '../dialogs/edit-dossier-dialog/edit-dossier-team/edit-dossier-team.component'; -import { EditDossierDeletedDocumentsComponent } from '../dialogs/edit-dossier-dialog/deleted-documents/edit-dossier-deleted-documents.component'; import { DateColumnComponent } from './components/date-column/date-column.component'; const components = [ @@ -23,7 +22,6 @@ const components = [ EditDossierDictionaryComponent, EditDossierAttributesComponent, EditDossierTeamComponent, - EditDossierDeletedDocumentsComponent, FileActionsComponent, DateColumnComponent, ]; diff --git a/apps/red-ui/src/app/modules/dossiers-listing/components/table-item/table-item.component.html b/apps/red-ui/src/app/modules/dossiers-listing/components/table-item/table-item.component.html index d84c0c53f..0ed998e63 100644 --- a/apps/red-ui/src/app/modules/dossiers-listing/components/table-item/table-item.component.html +++ b/apps/red-ui/src/app/modules/dossiers-listing/components/table-item/table-item.component.html @@ -1,6 +1,6 @@
- +
diff --git a/apps/red-ui/src/app/modules/shared/components/dossiers-listing-dossier-name/dossiers-listing-dossier-name.component.html b/apps/red-ui/src/app/modules/shared/components/dossier-name-column/dossier-name-column.component.html similarity index 100% rename from apps/red-ui/src/app/modules/shared/components/dossiers-listing-dossier-name/dossiers-listing-dossier-name.component.html rename to apps/red-ui/src/app/modules/shared/components/dossier-name-column/dossier-name-column.component.html diff --git a/apps/red-ui/src/app/modules/shared/components/dossiers-listing-dossier-name/dossiers-listing-dossier-name.component.scss b/apps/red-ui/src/app/modules/shared/components/dossier-name-column/dossier-name-column.component.scss similarity index 100% rename from apps/red-ui/src/app/modules/shared/components/dossiers-listing-dossier-name/dossiers-listing-dossier-name.component.scss rename to apps/red-ui/src/app/modules/shared/components/dossier-name-column/dossier-name-column.component.scss diff --git a/apps/red-ui/src/app/modules/shared/components/dossiers-listing-dossier-name/dossiers-listing-dossier-name.component.ts b/apps/red-ui/src/app/modules/shared/components/dossier-name-column/dossier-name-column.component.ts similarity index 64% rename from apps/red-ui/src/app/modules/shared/components/dossiers-listing-dossier-name/dossiers-listing-dossier-name.component.ts rename to apps/red-ui/src/app/modules/shared/components/dossier-name-column/dossier-name-column.component.ts index fab9ad4de..d65488e93 100644 --- a/apps/red-ui/src/app/modules/shared/components/dossiers-listing-dossier-name/dossiers-listing-dossier-name.component.ts +++ b/apps/red-ui/src/app/modules/shared/components/dossier-name-column/dossier-name-column.component.ts @@ -1,18 +1,27 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; -import { Dossier, DossierStats } from '@red/domain'; +import { DossierStats } from '@red/domain'; import { DossierTemplatesService } from '@services/entity-services/dossier-templates.service'; import * as moment from 'moment'; +import { List } from '@iqser/common-ui'; const DUE_DATE_WARN_DAYS = 14; +interface PartialDossier { + readonly dossierName: string; + readonly dossierTemplateId: string; + readonly dueDate?: string; + readonly date?: string; + readonly memberIds: List; +} + @Component({ - selector: 'redaction-dossiers-listing-dossier-name', - templateUrl: './dossiers-listing-dossier-name.component.html', - styleUrls: ['./dossiers-listing-dossier-name.component.scss'], + selector: 'redaction-dossier-name-column', + templateUrl: './dossier-name-column.component.html', + styleUrls: ['./dossier-name-column.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class DossiersListingDossierNameComponent { - @Input() dossier: Dossier; +export class DossierNameColumnComponent { + @Input() dossier: PartialDossier; @Input() dossierStats: DossierStats; constructor(private readonly _dossierTemplatesService: DossierTemplatesService) {} diff --git a/apps/red-ui/src/app/modules/shared/components/file-name-column/file-name-column.component.html b/apps/red-ui/src/app/modules/shared/components/file-name-column/file-name-column.component.html new file mode 100644 index 000000000..f6b15ff1b --- /dev/null +++ b/apps/red-ui/src/app/modules/shared/components/file-name-column/file-name-column.component.html @@ -0,0 +1,21 @@ +
+
+ {{ file.filename }} +
+
+ +
+
+ + {{ primaryAttribute }} + +
+
+ + diff --git a/apps/red-ui/src/app/modules/shared/components/file-name-column/file-name-column.component.scss b/apps/red-ui/src/app/modules/shared/components/file-name-column/file-name-column.component.scss new file mode 100644 index 000000000..6d6ea372d --- /dev/null +++ b/apps/red-ui/src/app/modules/shared/components/file-name-column/file-name-column.component.scss @@ -0,0 +1,18 @@ +@use 'common-mixins'; + +.table-item-title { + max-width: 25vw; + + &.error { + color: var(--iqser-red-1); + } + + &.initial-processing { + color: var(--iqser-disabled); + } +} + +.primary-attribute { + padding-top: 6px; + @include common-mixins.line-clamp(1); +} diff --git a/apps/red-ui/src/app/modules/shared/components/file-name-column/file-name-column.component.ts b/apps/red-ui/src/app/modules/shared/components/file-name-column/file-name-column.component.ts new file mode 100644 index 000000000..c2e92358e --- /dev/null +++ b/apps/red-ui/src/app/modules/shared/components/file-name-column/file-name-column.component.ts @@ -0,0 +1,31 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core'; +import { PrimaryFileAttributeService } from '@services/primary-file-attribute.service'; +import { FileAttributes } from '@red/domain'; + +interface PartialFile { + readonly isError: boolean; + readonly isInitialProcessing: boolean; + readonly filename: string; + readonly numberOfPages: number; + readonly excludedPages: number[]; + readonly lastOCRTime?: string; + readonly fileAttributes: FileAttributes; +} + +@Component({ + selector: 'redaction-file-name-column', + templateUrl: './file-name-column.component.html', + styleUrls: ['./file-name-column.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FileNameColumnComponent implements OnChanges { + @Input() file: PartialFile; + @Input() dossierTemplateId: string; + primaryAttribute: string; + + constructor(private readonly _primaryFileAttributeService: PrimaryFileAttributeService) {} + + ngOnChanges() { + this.primaryAttribute = this._primaryFileAttributeService.getPrimaryFileAttributeValue(this.file, this.dossierTemplateId); + } +} diff --git a/apps/red-ui/src/app/modules/shared/components/file-stats/file-stats.component.html b/apps/red-ui/src/app/modules/shared/components/file-stats/file-stats.component.html new file mode 100644 index 000000000..0078aa46a --- /dev/null +++ b/apps/red-ui/src/app/modules/shared/components/file-stats/file-stats.component.html @@ -0,0 +1,14 @@ +
+
+ + {{ file.numberOfPages }} +
+
+ + {{ file.excludedPages.length }} +
+
+ + {{ file.lastOCRTime | date: 'mediumDate' }} +
+
diff --git a/apps/red-ui/src/app/modules/shared/components/file-stats/file-stats.component.scss b/apps/red-ui/src/app/modules/shared/components/file-stats/file-stats.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/apps/red-ui/src/app/modules/shared/components/file-stats/file-stats.component.ts b/apps/red-ui/src/app/modules/shared/components/file-stats/file-stats.component.ts new file mode 100644 index 000000000..e2cd30c5d --- /dev/null +++ b/apps/red-ui/src/app/modules/shared/components/file-stats/file-stats.component.ts @@ -0,0 +1,11 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; + +@Component({ + selector: 'redaction-file-stats', + templateUrl: './file-stats.component.html', + styleUrls: ['./file-stats.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FileStatsComponent { + @Input() file: { numberOfPages: number; excludedPages: number[]; lastOCRTime?: string }; +} diff --git a/apps/red-ui/src/app/modules/shared/services/dictionary.service.ts b/apps/red-ui/src/app/modules/shared/services/dictionary.service.ts index 1ec02ea3b..740d8e21b 100644 --- a/apps/red-ui/src/app/modules/shared/services/dictionary.service.ts +++ b/apps/red-ui/src/app/modules/shared/services/dictionary.service.ts @@ -185,7 +185,7 @@ export class DictionaryService extends EntitiesService for (const dictionary of this._dictionariesMapService.get(dossierTemplateId)) { if (!dictionary.virtual && dictionary.addToDictionaryAction) { - possibleDictionaries.push(dictionary as Dictionary); + possibleDictionaries.push(dictionary); } } diff --git a/apps/red-ui/src/app/modules/shared/shared.module.ts b/apps/red-ui/src/app/modules/shared/shared.module.ts index 4305bfbd8..6098db04e 100644 --- a/apps/red-ui/src/app/modules/shared/shared.module.ts +++ b/apps/red-ui/src/app/modules/shared/shared.module.ts @@ -29,6 +29,8 @@ import { ExpandableFileActionsComponent } from './components/expandable-file-act import { ProcessingIndicatorComponent } from '@shared/components/processing-indicator/processing-indicator.component'; import { DossierStateComponent } from '@shared/components/dossier-state/dossier-state.component'; import { DossiersListingDossierNameComponent } from '@shared/components/dossiers-listing-dossier-name/dossiers-listing-dossier-name.component'; +import { FileStatsComponent } from './components/file-stats/file-stats.component'; +import { FileNameColumnComponent } from '@shared/components/file-name-column/file-name-column.component'; const buttons = [FileDownloadBtnComponent, UserButtonComponent]; @@ -47,6 +49,8 @@ const components = [ ProcessingIndicatorComponent, DossierStateComponent, DossiersListingDossierNameComponent, + FileStatsComponent, + FileNameColumnComponent, ...buttons, ]; diff --git a/apps/red-ui/src/app/services/entity-services/dossier-templates.service.ts b/apps/red-ui/src/app/services/entity-services/dossier-templates.service.ts index 471459d81..9547bfa4a 100644 --- a/apps/red-ui/src/app/services/entity-services/dossier-templates.service.ts +++ b/apps/red-ui/src/app/services/entity-services/dossier-templates.service.ts @@ -1,7 +1,7 @@ import { EntitiesService, List, mapEach, RequiredParam, Toaster, Validate } from '@iqser/common-ui'; import { DossierTemplate, IDossierTemplate } from '@red/domain'; import { Injectable, Injector } from '@angular/core'; -import { forkJoin, Observable, of } from 'rxjs'; +import { forkJoin, Observable, of, throwError } from 'rxjs'; import { FileAttributesService } from './file-attributes.service'; import { catchError, mapTo, switchMap, tap } from 'rxjs/operators'; import { DossierTemplateStatsService } from '@services/entity-services/dossier-template-stats.service'; diff --git a/apps/red-ui/src/app/services/entity-services/dossiers.service.provider.ts b/apps/red-ui/src/app/services/entity-services/dossiers.service.provider.ts index 0b176f0d7..0acf1c071 100644 --- a/apps/red-ui/src/app/services/entity-services/dossiers.service.provider.ts +++ b/apps/red-ui/src/app/services/entity-services/dossiers.service.provider.ts @@ -3,8 +3,11 @@ import { Injector, ProviderToken } from '@angular/core'; import { DossiersService } from '../dossiers/dossiers.service'; export const dossiersServiceResolver = (injector: Injector) => { - const route = injector.get(ActivatedRoute); - const token: ProviderToken = (route.firstChild || route).snapshot.data.dossiersService; + let route: ActivatedRoute = injector.get(ActivatedRoute); + while (route.firstChild) { + route = route.firstChild; + } + const token: ProviderToken = route.snapshot.data.dossiersService; return injector.get(token); }; diff --git a/apps/red-ui/src/app/services/entity-services/file-management.service.ts b/apps/red-ui/src/app/services/entity-services/file-management.service.ts index b900de21c..0d26bb4cd 100644 --- a/apps/red-ui/src/app/services/entity-services/file-management.service.ts +++ b/apps/red-ui/src/app/services/entity-services/file-management.service.ts @@ -26,21 +26,6 @@ export class FileManagementService extends GenericService { return super._post(fileIds, `delete/${dossierId}`).pipe(switchMap(() => this._filesService.loadAll(dossierId, routerPath))); } - @Validate() - hardDelete(@RequiredParam() dossierId: string, @RequiredParam() fileIds: List) { - const queryParams = fileIds.map(id => ({ key: 'fileIds', value: id })); - return super - .delete({}, `delete/hard-delete/${dossierId}`, queryParams) - .pipe(switchMap(() => this._dossierStatsService.getFor([dossierId]))); - } - - @Validate() - restore(@RequiredParam() files: List, @RequiredParam() dossierId: string) { - const fileIds = files.map(f => f.id); - const routerPath: string = files[0].routerPath; - return this._post(fileIds, `delete/restore/${dossierId}`).pipe(switchMap(() => this._filesService.loadAll(dossierId, routerPath))); - } - @Validate() rotatePage(@RequiredParam() body: IPageRotationRequest, @RequiredParam() dossierId: string, @RequiredParam() fileId: string) { return this._post(body, `rotate/${dossierId}/${fileId}`); diff --git a/apps/red-ui/src/app/services/entity-services/files.service.ts b/apps/red-ui/src/app/services/entity-services/files.service.ts index 7f58fadcd..06685575a 100644 --- a/apps/red-ui/src/app/services/entity-services/files.service.ts +++ b/apps/red-ui/src/app/services/entity-services/files.service.ts @@ -99,12 +99,4 @@ export class FilesService extends EntitiesService { switchMap(() => this.loadAll(dossierId, routerPath)), ); } - - /** - * Gets the deleted files for a dossier. - */ - @Validate() - getDeletedFilesFor(@RequiredParam() dossierId: string): Observable { - return this.getAll(`${this._defaultModelPath}/softdeleted/${dossierId}`); - } } diff --git a/apps/red-ui/src/app/services/entity-services/trash-dossiers.service.ts b/apps/red-ui/src/app/services/entity-services/trash-dossiers.service.ts deleted file mode 100644 index 158ff48d5..000000000 --- a/apps/red-ui/src/app/services/entity-services/trash-dossiers.service.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Injectable, Injector } from '@angular/core'; -import { EntitiesService, mapEach, QueryParam, RequiredParam, Toaster, Validate } from '@iqser/common-ui'; -import { Dossier, IDossier, TrashDossier } from '@red/domain'; -import { catchError, switchMap, tap } from 'rxjs/operators'; -import { firstValueFrom, Observable, of } from 'rxjs'; -import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; -import * as moment from 'moment'; -import { ConfigService } from '../config.service'; -import { PermissionsService } from '../permissions.service'; -import { ActiveDossiersService } from '@services/dossiers/active-dossiers.service'; - -export interface IDossiersStats { - totalPeople: number; - totalAnalyzedPages: number; -} - -@Injectable({ - providedIn: 'root', -}) -export class TrashDossiersService extends EntitiesService { - constructor( - protected readonly _injector: Injector, - private readonly _toaster: Toaster, - private readonly _configService: ConfigService, - private readonly _permissionsService: PermissionsService, - private readonly _activeDossiersService: ActiveDossiersService, - ) { - super(_injector, TrashDossier, 'dossier'); - } - - loadAll(): Observable { - return this.#getDeleted().pipe( - mapEach( - dossier => new TrashDossier(dossier, this.#getRestoreDate(dossier), this._permissionsService.canDeleteDossier(dossier)), - ), - tap(dossiers => this.setEntities(dossiers)), - ); - } - - delete(dossier: Dossier): Observable { - const showToast = () => { - this._toaster.error(_('dossier-listing.delete.delete-failed'), { params: dossier }); - return of({}); - }; - return super.delete(dossier.dossierId).pipe( - switchMap(() => this._activeDossiersService.loadAll()), - catchError(showToast), - ); - } - - @Validate() - restore(@RequiredParam() dossierIds: string[]): Promise { - return firstValueFrom( - this._post(dossierIds, 'deleted-dossiers/restore').pipe( - switchMap(() => this._activeDossiersService.loadAll()), - tap(() => this.#removeDossiers(dossierIds)), - ), - ); - } - - @Validate() - hardDelete(@RequiredParam() dossierIds: string[]): Promise { - const body = dossierIds.map(id => ({ key: 'dossierId', value: id })); - return firstValueFrom( - super.delete(body, 'deleted-dossiers/hard-delete', body).pipe( - switchMap(() => this._activeDossiersService.loadAll()), - tap(() => this.#removeDossiers(dossierIds)), - ), - ); - } - - #getRestoreDate(dossier: IDossier): string { - return moment(dossier.softDeletedTime).add(this._configService.values.DELETE_RETENTION_HOURS, 'hours').toISOString(); - } - - #getDeleted(): Observable { - return this.getAll('deleted-dossiers'); - } - - #removeDossiers(dossierIds: string[]): void { - this.setEntities(this.all.filter(dossier => !dossierIds.includes(dossier.id))); - } -} diff --git a/apps/red-ui/src/app/services/entity-services/trash.service.ts b/apps/red-ui/src/app/services/entity-services/trash.service.ts new file mode 100644 index 000000000..c9d968106 --- /dev/null +++ b/apps/red-ui/src/app/services/entity-services/trash.service.ts @@ -0,0 +1,138 @@ +import { Injectable, Injector } from '@angular/core'; +import { GenericService, List, mapEach, QueryParam, RequiredParam, Toaster, Validate } from '@iqser/common-ui'; +import { Dossier, File, IDossier, IFile, TrashDossier, TrashFile, TrashItem } from '@red/domain'; +import { catchError, switchMap, take } from 'rxjs/operators'; +import { forkJoin, map, Observable, of } from 'rxjs'; +import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; +import { ConfigService } from '../config.service'; +import { PermissionsService } from '../permissions.service'; +import { ActiveDossiersService } from '@services/dossiers/active-dossiers.service'; +import { UserService } from '@services/user.service'; +import { flatMap } from 'lodash'; +import { DossierStatsService } from '@services/dossiers/dossier-stats.service'; +import { FilesService } from '@services/entity-services/files.service'; + +export interface IDossiersStats { + totalPeople: number; + totalAnalyzedPages: number; +} + +@Injectable({ + providedIn: 'root', +}) +export class TrashService extends GenericService { + constructor( + protected readonly _injector: Injector, + private readonly _toaster: Toaster, + private readonly _configService: ConfigService, + private readonly _permissionsService: PermissionsService, + private readonly _activeDossiersService: ActiveDossiersService, + private readonly _userService: UserService, + private readonly _dossierStatsService: DossierStatsService, + private readonly _filesService: FilesService, + ) { + super(_injector, ''); + } + + deleteDossier(dossier: Dossier): Observable { + const showToast = () => { + this._toaster.error(_('dossier-listing.delete.delete-failed'), { params: dossier }); + return of({}); + }; + return this.delete(dossier.dossierId, 'dossier').pipe( + switchMap(() => this._activeDossiersService.loadAll()), + catchError(showToast), + ); + } + + @Validate() + restore(@RequiredParam() items: TrashItem[]): Observable { + return this.#executeAction( + items, + (dossierIds: string[]) => this._restoreDossiers(dossierIds), + (dossierId: string, fileIds: string[]) => this._restoreFiles(dossierId, fileIds), + ); + } + + @Validate() + hardDelete(@RequiredParam() items: TrashItem[]): Observable { + return this.#executeAction( + items, + (dossierIds: string[]) => this._hardDeleteDossiers(dossierIds), + (dossierId: string, fileIds: string[]) => this._hardDeleteFiles(dossierId, fileIds), + ); + } + + getDossiers(): Observable { + return this.getAll('deleted-dossiers').pipe( + mapEach( + dossier => + new TrashDossier( + dossier, + this._configService.values.DELETE_RETENTION_HOURS as number, + this._permissionsService.canDeleteDossier(dossier), + ), + ), + // TODO: API to include deleted dossiers + // switchMap(dossiers => this._dossierStatsService.getFor(dossiers.map(d => d.id)).pipe(mapTo(dossiers))), + ); + } + + getFiles(dossierIds = this._activeDossiersService.all.map(d => d.id)): Observable { + return this._post>(dossierIds, 'status/softdeleted').pipe( + map(res => flatMap(Object.values(res))), + mapEach(file => new File(file, this._userService.getNameForId(file.assignee), this._activeDossiersService.routerPath)), + mapEach(file => { + const dossierTemplateId = this._activeDossiersService.find(file.dossierId).dossierTemplateId; + return new TrashFile( + file, + dossierTemplateId, + this._configService.values.DELETE_RETENTION_HOURS as number, + this._permissionsService.canDeleteFile(file), + ); + }), + ); + } + + all(): Observable { + return forkJoin([this.getDossiers(), this.getFiles()]).pipe(map(result => flatMap(result))); + } + + #executeAction(items: TrashItem[], dossiersFn: (...args) => Observable, filesFn: (...args) => Observable) { + const requests$ = []; + + const dossierIds = items.filter(i => i.isDossier).map(i => i.id); + if (dossierIds.length) { + requests$.push(dossiersFn(dossierIds)); + } + + const files = items.filter(i => i.isFile) as TrashFile[]; + const dossierId: string = files.length ? files[0].dossierId : ''; + const fileIds = files.map(i => i.id); + if (files.length) { + requests$.push(filesFn(dossierId, fileIds)); + } + + return forkJoin(requests$.map(r => r.pipe(take(1)))); + } + + private _restoreDossiers(@RequiredParam() dossierIds: string[]): Observable { + return this._post(dossierIds, 'deleted-dossiers/restore').pipe(switchMap(() => this._activeDossiersService.loadAll())); + } + + private _hardDeleteDossiers(@RequiredParam() dossierIds: string[]): Observable { + const body = dossierIds.map(id => ({ key: 'dossierId', value: id })); + return this.delete(body, 'deleted-dossiers/hard-delete', body); + } + + private _hardDeleteFiles(@RequiredParam() dossierId: string, @RequiredParam() fileIds: List) { + const queryParams = fileIds.map(id => ({ key: 'fileIds', value: id })); + return super + .delete({}, `delete/hard-delete/${dossierId}`, queryParams) + .pipe(switchMap(() => this._dossierStatsService.getFor([dossierId]))); + } + + private _restoreFiles(@RequiredParam() dossierId: string, @RequiredParam() fileIds: List) { + return this._post(fileIds, `delete/restore/${dossierId}`).pipe(switchMap(() => this._filesService.loadAll(dossierId, 'dossiers'))); + } +} diff --git a/apps/red-ui/src/app/services/primary-file-attribute.service.ts b/apps/red-ui/src/app/services/primary-file-attribute.service.ts index 5a8acdc18..c0f334af0 100644 --- a/apps/red-ui/src/app/services/primary-file-attribute.service.ts +++ b/apps/red-ui/src/app/services/primary-file-attribute.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { FileAttributesService } from './entity-services/file-attributes.service'; -import { IFile } from '@red/domain'; +import { FileAttributes } from '@red/domain'; @Injectable({ providedIn: 'root', @@ -8,7 +8,7 @@ import { IFile } from '@red/domain'; export class PrimaryFileAttributeService { constructor(private readonly _fileAttributesService: FileAttributesService) {} - getPrimaryFileAttributeValue(file: IFile, dossierTemplateId: string) { + getPrimaryFileAttributeValue(file: { fileAttributes: FileAttributes }, dossierTemplateId: string) { const fileAttributesConfig = this._fileAttributesService.getFileAttributeConfig(dossierTemplateId); let primaryAttribute; diff --git a/apps/red-ui/src/assets/i18n/de.json b/apps/red-ui/src/assets/i18n/de.json index 83b76d6e1..9e1394b31 100644 --- a/apps/red-ui/src/assets/i18n/de.json +++ b/apps/red-ui/src/assets/i18n/de.json @@ -517,17 +517,15 @@ "question": "Möchten Sie fortfahren?", "title": "Dokument löschen" }, + "delete-items": { + "question": "", + "title": "" + }, "delete-justification": { "question": "Möchten Sie {count, plural, one{diese Begründung} other{diese Begründung}} wirklich löschen?", "title": "{count, plural, one{{justificationName}} other{ausgewählte Begründungen}} löschen" }, "input-label": "Bitte geben Sie unten Folgendes ein, um fortzufahren", - "permanently-delete-file": { - "confirmation-text": "{filesCount, plural, one{Document} other{Documents}} löschen", - "deny-text": "{filesCount, plural, one{Dokument} other{Dokumente}} behalten", - "question": "Möchten Sie {filesCount, plural, one{dieses Dokument} other{diese Dokumente}} wirklich löschen?", - "title": "{filesCount, plural, one{{fileName}} other{ausgewählte Dokumente}} löschen" - }, "report-template-same-name": { "confirmation-text": "Ja. Hochladen fortsetzen", "deny-text": "Nein. Hochladen abbrechen", @@ -1026,31 +1024,6 @@ }, "change-successful": "Dossier wurde aktualisiert.", "delete-successful": "Dossier wurde gelöscht.", - "deleted-documents": { - "action": { - "delete": "Endgültig löschen", - "restore": "Wiederherstellen" - }, - "bulk": { - "delete": "Ausgewählte Dokumente endgültig löschen", - "restore": "Ausgewählte Dokumente wiederherstellen" - }, - "instructions": "Gelöschte Objekte können bis zu {hours} Stunden nach ihrer Löschung wiederhergestellt werden", - "no-data": { - "title": "Es sind keine gelöschten Dokumente vorhanden." - }, - "table-col-names": { - "assignee": "Bevollmächtigter", - "deleted-on": "Gelöscht am", - "name": "Name", - "pages": "Seiten", - "status": "Status", - "time-to-restore": "Verbleibende Zeit für Wiederherstellung" - }, - "table-header": { - "label": "{length} {length, plural, one{gelöschtes Dokument} other{gelöschte Dokumente}}" - } - }, "dictionary": { "display-name": { "cancel": "Abbrechen", @@ -1087,7 +1060,6 @@ "missing-owner": "", "nav-items": { "choose-download": "Wählen Sie die Dokumente für Ihr Download-Paket aus:", - "deleted-documents": "Gelöschte Dokumente", "dictionary": "Wörterbuch", "dossier-attributes": "Dossier-Attribute", "dossier-dictionary": "Dossier-Wörterbuch", @@ -1851,6 +1823,7 @@ }, "table-col-names": { "deleted-on": "Gelöscht am", + "dossier": "", "name": "Name", "owner": "Eigentümer", "time-to-restore": "Verbleibende Zeit für Wiederherstellung" diff --git a/apps/red-ui/src/assets/i18n/en.json b/apps/red-ui/src/assets/i18n/en.json index 36ed8b809..4787e37d3 100644 --- a/apps/red-ui/src/assets/i18n/en.json +++ b/apps/red-ui/src/assets/i18n/en.json @@ -517,17 +517,15 @@ "question": "Do you wish to proceed?", "title": "Delete Document" }, + "delete-items": { + "question": "Are you sure you want to delete {itemsCount, plural, one{this item} other{these items}}?", + "title": "Delete {itemsCount, plural, one{{name}} other{Selected Items}}" + }, "delete-justification": { "question": "Are you sure you want to delete {count, plural, one{this justification} other{these justifications}}?", "title": "Delete {count, plural, one{{justificationName}} other{Selected Justifications}}" }, "input-label": "To proceed please type below", - "permanently-delete-file": { - "confirmation-text": "Delete {filesCount, plural, one{Document} other{Documents}}", - "deny-text": "Keep {filesCount, plural, one{Document} other{Documents}}", - "question": "Are you sure you want to delete {filesCount, plural, one{this document} other{these documents}}?", - "title": "Delete {filesCount, plural, one{{fileName}} other{Selected Documents}}" - }, "report-template-same-name": { "confirmation-text": "Yes. Continue upload", "deny-text": "No. Cancel Upload", @@ -1026,31 +1024,6 @@ }, "change-successful": "Dossier {dossierName} was updated.", "delete-successful": "Dossier {dossierName} was deleted.", - "deleted-documents": { - "action": { - "delete": "Delete Forever", - "restore": "Restore" - }, - "bulk": { - "delete": "Forever Delete Selected Documents", - "restore": "Restore Selected Documents" - }, - "instructions": "Deleted items can be restored up to {hours} hours from their deletion", - "no-data": { - "title": "There are no deleted documents." - }, - "table-col-names": { - "assignee": "Assignee", - "deleted-on": "Deleted On", - "name": "Name", - "pages": "Pages", - "status": "Status", - "time-to-restore": "Time To Restore" - }, - "table-header": { - "label": "{length} deleted {length, plural, one{document} other{documents}}" - } - }, "dictionary": { "display-name": { "cancel": "Cancel", @@ -1087,7 +1060,6 @@ "missing-owner": "You cannot edit the dossier because the owner is missing!", "nav-items": { "choose-download": "Choose what is included at download:", - "deleted-documents": "Deleted Documents", "dictionary": "Dictionary", "dossier-attributes": "Dossier Attributes", "dossier-dictionary": "Dossier Dictionary", @@ -1839,24 +1811,25 @@ "restore": "Restore" }, "bulk": { - "delete": "Forever Delete Selected Dossiers", - "restore": "Restore Selected Dossiers" + "delete": "Forever Delete Selected Items", + "restore": "Restore Selected Items" }, "label": "Trash", "no-data": { - "title": "There are no dossiers yet." + "title": "There are no deleted items yet." }, "no-match": { - "title": "No dossiers match your current filters." + "title": "No items match your current filters." }, "table-col-names": { "deleted-on": "Deleted on", + "dossier": "Dossier", "name": "Name", - "owner": "Owner", + "owner": "Owner/assignee", "time-to-restore": "Time to restore" }, "table-header": { - "title": "{length} deleted {length, plural, one{dossier} other{dossiers}}" + "title": "{length} deleted {length, plural, one{item} other{items}}" } }, "type": "Type", diff --git a/libs/red-domain/src/index.ts b/libs/red-domain/src/index.ts index e1b7070bc..99077ba8e 100644 --- a/libs/red-domain/src/index.ts +++ b/libs/red-domain/src/index.ts @@ -21,5 +21,5 @@ export * from './lib/signature'; export * from './lib/legal-basis'; export * from './lib/dossier-stats'; export * from './lib/dossier-state'; -export * from './lib/trash-dossier'; +export * from './lib/trash'; export * from './lib/text-highlight'; diff --git a/libs/red-domain/src/lib/dossier-stats/dossier-stats.model.ts b/libs/red-domain/src/lib/dossier-stats/dossier-stats.model.ts index 7460d50b9..142f9af37 100644 --- a/libs/red-domain/src/lib/dossier-stats/dossier-stats.model.ts +++ b/libs/red-domain/src/lib/dossier-stats/dossier-stats.model.ts @@ -43,6 +43,7 @@ export class DossierStats implements IDossierStats { readonly numberOfFiles: number; readonly numberOfProcessingFiles: number; readonly processingStats: ProcessingStats; + readonly numberOfSoftDeletedFiles: number; readonly hasFiles: boolean; @@ -58,6 +59,7 @@ export class DossierStats implements IDossierStats { this.hasUpdatesFilePresent = stats.hasUpdatesFilePresent; this.numberOfPages = stats.numberOfPages; this.numberOfFiles = stats.numberOfFiles; + this.numberOfSoftDeletedFiles = stats.numberOfSoftDeletedFiles; this.numberOfProcessingFiles = Object.entries(this.fileCountPerProcessingStatus) .filter(([key]) => isProcessingStatuses.includes(key as ProcessingFileStatus)) .reduce((count, [, value]) => count + value, 0); diff --git a/libs/red-domain/src/lib/dossier-stats/dossier-stats.ts b/libs/red-domain/src/lib/dossier-stats/dossier-stats.ts index 09a07870a..ae80a4921 100644 --- a/libs/red-domain/src/lib/dossier-stats/dossier-stats.ts +++ b/libs/red-domain/src/lib/dossier-stats/dossier-stats.ts @@ -12,4 +12,5 @@ export interface IDossierStats { hasUpdatesFilePresent: boolean; numberOfPages: number; numberOfFiles: number; + numberOfSoftDeletedFiles: number; } diff --git a/libs/red-domain/src/lib/files/file.model.ts b/libs/red-domain/src/lib/files/file.model.ts index 60b2911f1..df6ac4929 100644 --- a/libs/red-domain/src/lib/files/file.model.ts +++ b/libs/red-domain/src/lib/files/file.model.ts @@ -16,7 +16,7 @@ export class File extends Entity implements IFile, IRouterPath { readonly dossierId: string; readonly excluded: boolean; readonly excludedFromAutomaticAnalysis: boolean; - readonly fileAttributes?: FileAttributes; + readonly fileAttributes: FileAttributes; readonly fileId: string; readonly filename: string; readonly hasAnnotationComments: boolean; @@ -25,6 +25,7 @@ export class File extends Entity implements IFile, IRouterPath { readonly hasRedactions: boolean; readonly hasUpdates: boolean; readonly lastOCRTime?: string; + readonly softDeletedTime?: string; readonly lastProcessed?: string; readonly lastReviewer?: string; readonly lastApprover?: string; @@ -32,7 +33,7 @@ export class File extends Entity implements IFile, IRouterPath { readonly lastUploaded?: string; readonly legalBasisVersion?: number; readonly numberOfAnalyses: number; - readonly numberOfPages?: number; + readonly numberOfPages: number; readonly rulesVersion?: number; readonly uploader?: string; readonly excludedPages: number[]; @@ -72,7 +73,6 @@ export class File extends Entity implements IFile, IRouterPath { this.dossierId = file.dossierId; this.excluded = !!file.excluded; this.excludedFromAutomaticAnalysis = !!file.excludedFromAutomaticAnalysis; - this.fileAttributes = file.fileAttributes; this.fileId = file.fileId; this.filename = file.filename; this.hasAnnotationComments = !!file.hasAnnotationComments; @@ -81,6 +81,7 @@ export class File extends Entity implements IFile, IRouterPath { this.hasRedactions = !!file.hasRedactions; this.hasUpdates = !!file.hasUpdates; this.lastOCRTime = file.lastOCRTime; + this.softDeletedTime = file.softDeletedTime; this.lastProcessed = file.lastProcessed; this.lastReviewer = file.lastReviewer; this.lastApprover = file.lastApprover; @@ -115,9 +116,8 @@ export class File extends Entity implements IFile, IRouterPath { this.canBeOpened = !this.isError && !this.isUnprocessed && this.numberOfAnalyses > 0; this.canBeOCRed = !this.excluded && !this.lastOCRTime && (this.isNew || this.isUnderReview || this.isUnderApproval); - if (!this.fileAttributes || !this.fileAttributes.attributeIdToValue) { - this.fileAttributes = { attributeIdToValue: {} }; - } + this.fileAttributes = + file.fileAttributes && file.fileAttributes.attributeIdToValue ? file.fileAttributes : { attributeIdToValue: {} }; } get id(): string { diff --git a/libs/red-domain/src/lib/files/file.ts b/libs/red-domain/src/lib/files/file.ts index 26dac2ec5..99426dcd7 100644 --- a/libs/red-domain/src/lib/files/file.ts +++ b/libs/red-domain/src/lib/files/file.ts @@ -137,7 +137,7 @@ export interface IFile { /** * Shows if the file is soft deleted. */ - readonly softDeleted?: string; + readonly softDeletedTime?: string; /** * The ID of the user who uploaded the file. */ diff --git a/libs/red-domain/src/lib/trash-dossier/index.ts b/libs/red-domain/src/lib/trash-dossier/index.ts deleted file mode 100644 index a90f3227f..000000000 --- a/libs/red-domain/src/lib/trash-dossier/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './trash-dossier.model'; diff --git a/libs/red-domain/src/lib/trash-dossier/trash-dossier.model.ts b/libs/red-domain/src/lib/trash-dossier/trash-dossier.model.ts deleted file mode 100644 index aca8adf9d..000000000 --- a/libs/red-domain/src/lib/trash-dossier/trash-dossier.model.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { getLeftDateTime, List } from '@iqser/common-ui'; -import { DownloadFileType } from '../shared'; -import { DossierStatus, IDossier } from '../dossiers'; - -export class TrashDossier implements IDossier { - readonly dossierId: string; - readonly dossierTemplateId: string; - readonly ownerId: string; - readonly memberIds: List; - readonly approverIds: List; - readonly reportTemplateIds: List; - readonly dossierName: string; - readonly dossierStatusId: string; - readonly date: string; - readonly dueDate?: string; - readonly description?: string; - readonly downloadFileTypes?: List; - readonly hardDeletedTime?: string; - readonly softDeletedTime?: string; - readonly startDate?: string; - readonly status: DossierStatus; - readonly watermarkEnabled: boolean; - readonly watermarkPreviewEnabled: boolean; - readonly archivedTime: string; - readonly hasReviewers: boolean; - readonly canRestore: boolean; - - constructor(dossier: IDossier, readonly restoreDate: string, readonly canHardDelete: boolean) { - this.dossierId = dossier.dossierId; - this.approverIds = dossier.approverIds; - this.date = dossier.date; - this.description = dossier.description; - this.dossierName = dossier.dossierName; - this.dossierStatusId = dossier.dossierStatusId; - this.dossierTemplateId = dossier.dossierTemplateId; - this.downloadFileTypes = dossier.downloadFileTypes; - this.dueDate = dossier.dueDate; - this.hardDeletedTime = dossier.hardDeletedTime; - this.memberIds = dossier.memberIds; - this.ownerId = dossier.ownerId; - this.reportTemplateIds = dossier.reportTemplateIds; - this.softDeletedTime = dossier.softDeletedTime; - this.startDate = dossier.startDate; - this.status = dossier.status; - this.watermarkEnabled = dossier.watermarkEnabled; - this.watermarkPreviewEnabled = dossier.watermarkPreviewEnabled; - this.archivedTime = dossier.archivedTime; - this.hasReviewers = !!this.memberIds && this.memberIds.length > 1; - - this.canRestore = this.#canRestoreDossier(restoreDate); - // Because of migrations, for some this is not set - this.softDeletedTime = dossier.softDeletedTime || '-'; - } - - get id(): string { - return this.dossierId; - } - - get searchKey(): string { - return this.dossierName; - } - - #canRestoreDossier(restoreDate: string): boolean { - const { daysLeft, hoursLeft, minutesLeft } = getLeftDateTime(restoreDate); - - return daysLeft >= 0 && hoursLeft >= 0 && minutesLeft > 0; - } -} diff --git a/libs/red-domain/src/lib/trash/index.ts b/libs/red-domain/src/lib/trash/index.ts new file mode 100644 index 000000000..a0d146cc8 --- /dev/null +++ b/libs/red-domain/src/lib/trash/index.ts @@ -0,0 +1,3 @@ +export * from './trash.item'; +export * from './trash-dossier.model'; +export * from './trash-file.model'; diff --git a/libs/red-domain/src/lib/trash/trash-dossier.model.ts b/libs/red-domain/src/lib/trash/trash-dossier.model.ts new file mode 100644 index 000000000..c26d101de --- /dev/null +++ b/libs/red-domain/src/lib/trash/trash-dossier.model.ts @@ -0,0 +1,43 @@ +import { List } from '@iqser/common-ui'; +import { IDossier } from '../dossiers'; +import { TrashItem } from './trash.item'; + +export class TrashDossier extends TrashItem { + readonly type = 'dossier'; + readonly icon = 'red:folder'; + + readonly dossierId: string; + readonly dossierTemplateId: string; + readonly dossierName: string; + readonly memberIds: List; + readonly date: string; + readonly dueDate?: string; + readonly ownerId: string; + readonly softDeletedTime: string; + + constructor(dossier: IDossier, protected readonly _retentionHours: number, readonly canHardDelete: boolean) { + super(_retentionHours, dossier.softDeletedTime, canHardDelete); + this.dossierId = dossier.dossierId; + this.dossierTemplateId = dossier.dossierTemplateId; + this.date = dossier.date; + this.dossierName = dossier.dossierName; + this.dueDate = dossier.dueDate; + this.memberIds = dossier.memberIds; + this.ownerId = dossier.ownerId; + + // Because of migrations, for some this is not set + this.softDeletedTime = dossier.softDeletedTime || '-'; + } + + get id(): string { + return this.dossierId; + } + + get searchKey(): string { + return this.dossierName; + } + + get name(): string { + return this.dossierName; + } +} diff --git a/libs/red-domain/src/lib/trash/trash-file.model.ts b/libs/red-domain/src/lib/trash/trash-file.model.ts new file mode 100644 index 000000000..9e1d3b8d2 --- /dev/null +++ b/libs/red-domain/src/lib/trash/trash-file.model.ts @@ -0,0 +1,56 @@ +import { TrashItem } from './trash.item'; +import { File, IFile } from '../files'; +import { FileAttributes } from '../file-attributes'; + +export class TrashFile extends TrashItem implements Partial { + readonly type = 'file'; + readonly icon = 'iqser:document'; + + readonly dossierId: string; + readonly fileId: string; + readonly filename: string; + readonly assignee?: string; + + readonly numberOfPages: number; + readonly excludedPages: number[]; + readonly lastOCRTime?: string; + readonly fileAttributes: FileAttributes; + + readonly isError: boolean; + readonly isInitialProcessing: boolean; + + constructor( + file: File, + readonly dossierTemplateId: string, + protected readonly _retentionHours: number, + readonly canHardDelete: boolean, + ) { + super(_retentionHours, file.softDeletedTime, canHardDelete); + this.fileId = file.fileId; + this.dossierId = file.dossierId; + this.filename = file.filename; + this.assignee = file.assignee; + this.numberOfPages = file.numberOfPages || 0; + this.excludedPages = file.excludedPages || []; + this.lastOCRTime = file.lastOCRTime; + this.fileAttributes = file.fileAttributes; + this.isError = file.isError; + this.isInitialProcessing = file.isInitialProcessing; + } + + get id(): string { + return this.fileId; + } + + get searchKey(): string { + return this.filename; + } + + get name(): string { + return this.filename; + } + + get ownerId(): string | undefined { + return this.assignee; + } +} diff --git a/libs/red-domain/src/lib/trash/trash.item.ts b/libs/red-domain/src/lib/trash/trash.item.ts new file mode 100644 index 000000000..3676937f1 --- /dev/null +++ b/libs/red-domain/src/lib/trash/trash.item.ts @@ -0,0 +1,43 @@ +import { getLeftDateTime } from '@iqser/common-ui'; +import * as moment from 'moment'; + +export abstract class TrashItem { + abstract readonly type: 'dossier' | 'file'; + abstract readonly ownerId?: string; + abstract readonly dossierId: string; + abstract readonly icon: string; + readonly canRestore: boolean; + readonly restoreDate: string; + + protected constructor( + protected readonly _retentionHours: number, + readonly softDeletedTime: string | undefined, + readonly canHardDelete: boolean, + ) { + this.restoreDate = this.#restoreDate; + this.canRestore = this.#canRestore; + } + + abstract get id(): string; + + abstract get searchKey(): string; + + abstract get name(): string; + + get isDossier(): boolean { + return this.type === 'dossier'; + } + + get isFile(): boolean { + return this.type === 'file'; + } + + get #canRestore(): boolean { + const { daysLeft, hoursLeft, minutesLeft } = getLeftDateTime(this.restoreDate); + return daysLeft >= 0 && hoursLeft >= 0 && minutesLeft > 0; + } + + get #restoreDate(): string { + return moment(this.softDeletedTime).add(this._retentionHours, 'hours').toISOString(); + } +}