Merge branch 'master' into release/4.839.x

This commit is contained in:
Dan Percic 2024-10-31 10:32:42 +02:00
commit 8f541081ae
173 changed files with 5008 additions and 3686 deletions

View File

@ -4,38 +4,40 @@ variables:
PROJECT: red-ui
DOCKERFILELOCATION: 'docker/$PROJECT/Dockerfile'
include:
- project: 'gitlab/gitlab'
ref: 'main'
file: 'ci-templates/docker_build_nexus_v2.yml'
rules:
- if: $CI_PIPELINE_SOURCE != "schedule"
- if: $CI_PIPELINE_SOURCE != "schedule"
localazy update:
image: node:20.5
cache:
- key:
files:
- yarn.lock
paths:
- .yarn-cache/
script:
- git config user.email "${CI_EMAIL}"
- git config user.name "${CI_USERNAME}"
- git remote add gitlab_origin https://${CI_USERNAME}:${CI_ACCESS_TOKEN}@gitlab.knecon.com/redactmanager/red-ui.git
- cd tools/localazy
- yarn install --cache-folder .yarn-cache
- yarn start
- cd ../..
- git add .
- |-
CHANGES=$(git status --porcelain | wc -l)
if [ "$CHANGES" -gt "0" ]
then
git status
git commit -m "push back localazy update"
git push gitlab_origin HEAD:${CI_COMMIT_REF_NAME}
fi
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
image: node:20.5
cache:
- key:
files:
- yarn.lock
paths:
- .yarn-cache/
script:
# - git config user.email "${CI_EMAIL}"
# - git config user.name "${CI_USERNAME}"
# - git remote add gitlab_origin https://${CI_USERNAME}:${CI_ACCESS_TOKEN}@gitlab.knecon.com/redactmanager/red-ui.git
- git push https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.knecon.com/redactmanager/red-ui.git
- cd tools/localazy
- yarn install --cache-folder .yarn-cache
- yarn start
- cd ../..
- git add .
- |-
CHANGES=$(git status --porcelain | wc -l)
if [ "$CHANGES" -gt "0" ]
then
git status
git commit -m "push back localazy update"
git push gitlab_origin HEAD:${CI_COMMIT_REF_NAME}
# git push https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.knecon.com/redactmanager/red-ui.git
# git push
fi
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"

View File

@ -22,7 +22,7 @@
}
.mat-mdc-menu-item.notification {
padding: 8px 26px 10px 8px;
padding: 8px 26px 10px 8px !important;
margin: 2px 0 0 0;
height: fit-content;
position: relative;

View File

@ -9,7 +9,7 @@
.container {
padding: 32px;
width: 900px;
width: 1000px;
max-width: 100%;
box-sizing: border-box;
}

View File

@ -28,7 +28,7 @@ export const canRemoveOnlyHere = (annotation: AnnotationWrapper, canAddRedaction
(annotation.isRedacted || (annotation.isHint && !annotation.isImage));
export const canRemoveFromDictionary = (annotation: AnnotationWrapper, autoAnalysisDisabled: boolean) =>
annotation.isModifyDictionary &&
(annotation.isModifyDictionary || annotation.engines.includes('DICTIONARY') || annotation.engines.includes('DOSSIER_DICTIONARY')) &&
(annotation.isRedacted || annotation.isSkipped || annotation.isHint || (annotation.isIgnoredHint && !annotation.isRuleBased)) &&
(autoAnalysisDisabled || !annotation.pending) &&
annotation.isDictBased;

View File

@ -26,6 +26,12 @@ import {
} from '@red/domain';
import { annotationTypesTranslations } from '@translations/annotation-types-translations';
interface AnnotationContent {
translation: string;
params: { [key: string]: string };
untranslatedContent: string;
}
export class AnnotationWrapper implements IListable {
id: string;
superType: SuperType;
@ -36,7 +42,7 @@ export class AnnotationWrapper implements IListable {
numberOfComments = 0;
firstTopLeftPoint: IPoint;
shortContent: string;
content: string;
content: AnnotationContent;
value: string;
pageNumber: number;
dictionaryOperation = false;
@ -279,7 +285,7 @@ export class AnnotationWrapper implements IListable {
);
const content = this.#createContent(annotationWrapper, logEntry, isDocumine);
annotationWrapper.shortContent = this.#getShortContent(annotationWrapper, legalBasisList) || content;
annotationWrapper.shortContent = this.#getShortContent(annotationWrapper, legalBasisList) || content.untranslatedContent;
annotationWrapper.content = content;
const lastRelevantManualChange = logEntry.manualChanges?.at(-1);
@ -311,39 +317,57 @@ export class AnnotationWrapper implements IListable {
}
static #createContent(annotationWrapper: AnnotationWrapper, logEntry: IEntityLogEntry, isDocumine: boolean) {
let content = '';
let untranslatedContent = '';
const params: { [key: string]: string } = {};
if (logEntry.matchedRule) {
content += `Rule ${logEntry.matchedRule} matched${isDocumine ? ':' : ''} \n\n`;
params['hasRule'] = 'true';
params['matchedRule'] = logEntry.matchedRule.replace(/(^[, ]*)|([, ]*$)/g, '');
params['ruleSymbol'] = isDocumine ? ':' : '';
untranslatedContent += `Rule ${logEntry.matchedRule} matched${isDocumine ? ':' : ''} \n\n`;
}
if (logEntry.reason) {
params['hasReason'] = 'true';
if (isDocumine && logEntry.reason.slice(-1) === '.') {
logEntry.reason = logEntry.reason.slice(0, -1);
}
content += logEntry.reason + '\n\n';
if (!params['hasRule']) {
params['reason'] = logEntry.reason.substring(0, 1).toUpperCase() + logEntry.reason.substring(1);
} else {
params['reason'] = logEntry.reason;
}
params['reason'] = params['reason'].replace(/(^[, ]*)|([, ]*$)/g, '');
untranslatedContent += logEntry.reason + '\n\n';
//remove leading and trailing commas and whitespaces
content = content.replace(/(^[, ]*)|([, ]*$)/g, '');
content = content.substring(0, 1).toUpperCase() + content.substring(1);
untranslatedContent = untranslatedContent.replace(/(^[, ]*)|([, ]*$)/g, '');
untranslatedContent = untranslatedContent.substring(0, 1).toUpperCase() + untranslatedContent.substring(1);
}
if (annotationWrapper.legalBasis && !isDocumine) {
content += 'Legal basis: ' + annotationWrapper.legalBasis + '\n\n';
params['hasLb'] = 'true';
params['legalBasis'] = annotationWrapper.legalBasis;
untranslatedContent += 'Legal basis: ' + annotationWrapper.legalBasis + '\n\n';
}
if (annotationWrapper.hasBeenRemovedByManualOverride) {
content += 'Removed by manual override';
params['hasOverride'] = 'true';
untranslatedContent += 'Removed by manual override';
}
if (logEntry.section) {
params['hasSection'] = 'true';
params['sectionSymbol'] = isDocumine ? '' : ':';
params['shouldLower'] = untranslatedContent.length.toString();
params['section'] = logEntry.section;
let prefix = `In section${isDocumine ? '' : ':'} `;
if (content.length) {
if (untranslatedContent.length) {
prefix = ` ${prefix.toLowerCase()}`;
}
content += `${prefix} "${logEntry.section}"`;
untranslatedContent += `${prefix} "${logEntry.section}"`;
}
return content;
return { translation: _('annotation-content'), params: params, untranslatedContent: untranslatedContent };
}
static #getShortContent(annotationWrapper: AnnotationWrapper, legalBasisList: ILegalBasis[]) {

View File

@ -20,6 +20,7 @@
<label [translate]="'top-bar.navigation-items.my-account.children.language.label'"></label>
<mat-form-field>
<mat-select formControlName="language">
<mat-select-trigger>{{ languageSelectLabel() | translate }}</mat-select-trigger>
<mat-option *ngFor="let language of languages" [value]="language">
{{ translations[language] | translate }}
</mat-option>
@ -41,7 +42,7 @@
<div class="dialog-actions">
<iqser-icon-button
[disabled]="form.invalid || !(profileChanged || languageChanged || themeChanged)"
[disabled]="disabled"
[label]="'user-profile-screen.actions.save' | translate"
[submit]="true"
[type]="iconButtonTypes.primary"

View File

@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { ReactiveFormsModule, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, computed } from '@angular/core';
import { FormGroup, ReactiveFormsModule, UntypedFormBuilder, Validators } from '@angular/forms';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import {
BaseFormComponent,
@ -19,21 +19,49 @@ import { firstValueFrom } from 'rxjs';
import { UserProfileDialogService } from '../services/user-profile-dialog.service';
import { NgForOf, NgIf } from '@angular/common';
import { MatFormField } from '@angular/material/form-field';
import { MatOption, MatSelect } from '@angular/material/select';
import { MatOption, MatSelect, MatSelectTrigger } from '@angular/material/select';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { PdfViewer } from '../../../../pdf-viewer/services/pdf-viewer.service';
import { formControlToSignal } from '@utils/functions';
import { AsControl } from '@common-ui/utils';
interface UserProfileForm {
email: string;
firstName: string;
lastName: string;
language: string;
darkTheme: boolean;
}
@Component({
templateUrl: './user-profile-screen.component.html',
styleUrls: ['./user-profile-screen.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ReactiveFormsModule, NgIf, MatFormField, MatSelect, MatOption, NgForOf, TranslateModule, MatSlideToggle, IconButtonComponent],
imports: [
ReactiveFormsModule,
NgIf,
MatFormField,
MatSelect,
MatOption,
NgForOf,
TranslateModule,
MatSlideToggle,
IconButtonComponent,
MatSelectTrigger,
],
})
export class UserProfileScreenComponent extends BaseFormComponent implements OnInit {
#profileModel: IProfile;
export class UserProfileScreenComponent extends BaseFormComponent {
readonly form: FormGroup<AsControl<UserProfileForm>> = this.#getForm();
initialFormValue = this.form.getRawValue();
readonly translations = languagesTranslations;
readonly devMode = this._userPreferenceService.isIqserDevMode;
readonly profileKeys = ['email', 'firstName', 'lastName'];
readonly languages = this._translateService.langs;
readonly language = formControlToSignal<UserProfileForm['language']>(this.form.controls.language);
readonly languageSelectLabel = computed(() => this.translations[this.language()]);
constructor(
private readonly _userService: UserService,
private readonly _loadingService: LoadingService,
@ -45,43 +73,29 @@ export class UserProfileScreenComponent extends BaseFormComponent implements OnI
protected readonly _userPreferenceService: UserPreferenceService,
private readonly _changeRef: ChangeDetectorRef,
private readonly _toaster: Toaster,
private readonly _pdfViewer: PdfViewer,
) {
super();
this._loadingService.start();
if (!this._permissionsService.has(Roles.updateMyProfile)) {
this.form.disable();
}
this._loadingService.stop();
}
get languageChanged(): boolean {
return this.#profileModel['language'] !== this.form.get('language').value;
return this.initialFormValue['language'] !== this.form.controls.language.value;
}
get themeChanged(): boolean {
return this.#profileModel['darkTheme'] !== this.form.get('darkTheme').value;
return this.initialFormValue['darkTheme'] !== this.form.controls.darkTheme.value;
}
get emailChanged(): boolean {
return this.#profileModel['email'] !== this.form.get('email').value;
return this.initialFormValue['email'] !== this.form.controls.email.value;
}
get profileChanged(): boolean {
const keys = Object.keys(this.form.getRawValue());
keys.splice(keys.indexOf('language'), 1);
keys.splice(keys.indexOf('darkTheme'), 1);
for (const key of keys) {
if (this.#profileModel[key] !== this.form.get(key).value) {
return true;
}
}
return false;
}
get languages(): string[] {
return this._translateService.langs;
}
ngOnInit() {
this._initializeForm();
return this.profileKeys.some(key => this.initialFormValue[key] !== this.form.get(key).value);
}
async save(): Promise<void> {
@ -106,15 +120,17 @@ export class UserProfileScreenComponent extends BaseFormComponent implements OnI
}
if (this.languageChanged) {
await this._languageService.change(this.form.get('language').value);
await this._languageService.change(this.form.controls.language.value);
await this._pdfViewer.instance?.UI.setLanguage(this._languageService.currentLanguage);
}
if (this.themeChanged) {
await this._userPreferenceService.saveTheme(this.form.get('darkTheme').value ? 'dark' : 'light');
await this._userPreferenceService.saveTheme(this.form.controls.darkTheme.value ? 'dark' : 'light');
}
this._initializeForm();
this.initialFormValue = this.form.getRawValue();
this._changeRef.markForCheck();
this._loadingService.stop();
this._toaster.success(_('user-profile-screen.update.success'));
} catch (e) {
this._loadingService.stop();
@ -125,35 +141,13 @@ export class UserProfileScreenComponent extends BaseFormComponent implements OnI
await this._userService.createResetPasswordAction();
}
private _getForm(): UntypedFormGroup {
#getForm() {
return this._formBuilder.group({
email: ['', [Validators.required, Validators.email]],
firstName: [''],
lastName: [''],
language: [''],
darkTheme: [false],
email: [this._userService.currentUser.email ?? '', [Validators.required, Validators.email]],
firstName: [this._userService.currentUser.firstName ?? ''],
lastName: [this._userService.currentUser.lastName ?? ''],
language: [this._userPreferenceService.getLanguage()],
darkTheme: [this._userPreferenceService.getTheme() === 'dark'],
});
}
private _initializeForm(): void {
try {
this.form = this._getForm();
if (!this._permissionsService.has(Roles.updateMyProfile)) {
this.form.disable();
}
this.#profileModel = {
email: this._userService.currentUser.email ?? '',
firstName: this._userService.currentUser.firstName ?? '',
lastName: this._userService.currentUser.lastName ?? '',
language: this._languageService.currentLanguage ?? '',
darkTheme: this._userPreferenceService.getTheme() === 'dark',
};
this.form.patchValue(this.#profileModel, { emitEvent: false });
this.initialFormValue = this.form.getRawValue();
} catch (e) {
} finally {
this._loadingService.stop();
this._changeRef.markForCheck();
}
}
}

View File

@ -2,11 +2,18 @@ import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { addHintTranslations } from '@translations/add-hint-translations';
import { redactTextTranslations } from '@translations/redact-text-translations';
import { removeRedactionTranslations } from '@translations/remove-redaction-translations';
import { RedactOrHintOptions, RemoveRedactionOptions } from '../../file-preview/utils/dialog-types';
import {
ForceAnnotationOptions,
RectangleRedactOptions,
RedactOrHintOptions,
RemoveRedactionOptions,
} from '../../file-preview/utils/dialog-types';
export const SystemDefaults = {
RECTANGLE_REDACT_DEFAULT: RectangleRedactOptions.ONLY_THIS_PAGE,
ADD_REDACTION_DEFAULT: RedactOrHintOptions.IN_DOSSIER,
ADD_HINT_DEFAULT: RedactOrHintOptions.IN_DOSSIER,
FORCE_REDACTION_DEFAULT: ForceAnnotationOptions.ONLY_HERE,
REMOVE_REDACTION_DEFAULT: RemoveRedactionOptions.ONLY_HERE,
REMOVE_HINT_DEFAULT: RemoveRedactionOptions.ONLY_HERE,
REMOVE_RECOMMENDATION_DEFAULT: RemoveRedactionOptions.DO_NOT_RECOMMEND,
@ -28,6 +35,10 @@ export const redactionAddOptions = [
label: redactTextTranslations.onlyHere.label,
value: RedactOrHintOptions.ONLY_HERE,
},
{
label: redactTextTranslations.inDocument.label,
value: RedactOrHintOptions.IN_DOCUMENT,
},
{
label: redactTextTranslations.inDossier.label,
value: RedactOrHintOptions.IN_DOSSIER,

View File

@ -23,7 +23,7 @@
<div class="mt-44">
<redaction-donut-chart
[config]="chartConfig"
[config]="chartConfig()"
[radius]="63"
[strokeWidth]="15"
[subtitles]="['user-stats.chart.users' | translate]"

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Component, EventEmitter, input, Input, output, Output } from '@angular/core';
import { DonutChartConfig } from '@red/domain';
import { CircleButtonComponent } from '@iqser/common-ui';
import { TranslateModule } from '@ngx-translate/core';
@ -12,6 +12,6 @@ import { DonutChartComponent } from '@shared/components/donut-chart/donut-chart.
imports: [CircleButtonComponent, TranslateModule, DonutChartComponent],
})
export class UsersStatsComponent {
@Output() toggleCollapse = new EventEmitter();
@Input() chartConfig: DonutChartConfig[];
readonly chartConfig = input.required<DonutChartConfig[]>();
readonly toggleCollapse = output();
}

View File

@ -1,6 +1,6 @@
<div
[translateParams]="{
type: user ? 'edit' : 'create'
type: !!user() ? 'edit' : 'create',
}"
[translate]="'add-edit-user.title'"
class="dialog-header heading-l"
@ -37,9 +37,17 @@
</div>
</div>
@if (!user()) {
<div class="iqser-input-group">
<label [translate]="'add-edit-user.form.account-setup'"></label>
<mat-checkbox formControlName="sendSetPasswordMail">{{ 'add-edit-user.form.send-email' | translate }}</mat-checkbox>
<span [translate]="'add-edit-user.form.send-email-explanation'" class="hint"></span>
</div>
}
<div
(click)="toggleResetPassword.emit()"
*ngIf="!!user"
*ngIf="!!user()"
[translate]="'add-edit-user.form.reset-password'"
class="mt-24 fit-content link-action"
></div>
@ -48,14 +56,14 @@
<div class="dialog-actions">
<iqser-icon-button
[disabled]="form.invalid || !changed"
[label]="(user ? 'add-edit-user.actions.save-changes' : 'add-edit-user.actions.save') | translate"
[label]="(user() ? 'add-edit-user.actions.save-changes' : 'add-edit-user.actions.save') | translate"
[submit]="true"
[type]="iconButtonTypes.primary"
></iqser-icon-button>
<iqser-icon-button
(action)="delete()"
*ngIf="user && !disabledDelete(user)"
*ngIf="user() && !disabledDelete(user())"
[label]="'add-edit-user.actions.delete' | translate"
[type]="iconButtonTypes.dark"
icon="iqser:trash"

View File

@ -5,3 +5,7 @@
margin-top: 8px;
width: 300px;
}
.hint {
margin-left: 23px;
}

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import { Component, input, OnInit, output } from '@angular/core';
import { ReactiveFormsModule, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { AdminDialogService } from '../../../services/admin-dialog.service';
import { BaseFormComponent, IconButtonComponent, LoadingService, Toaster } from '@iqser/common-ui';
@ -20,15 +20,15 @@ import { NgForOf, NgIf } from '@angular/common';
standalone: true,
imports: [TranslateModule, ReactiveFormsModule, MatCheckbox, NgForOf, IconButtonComponent, NgIf],
})
export class UserDetailsComponent extends BaseFormComponent implements OnChanges {
/** e.g. a RED_ADMIN is automatically a RED_USER_ADMIN => can't disable RED_USER_ADMIN as long as RED_ADMIN is checked */
private readonly _ROLE_REQUIREMENTS = { RED_MANAGER: 'RED_USER', RED_ADMIN: 'RED_USER_ADMIN' };
@Input() user: User;
@Output() readonly toggleResetPassword = new EventEmitter();
@Output() readonly closeDialog = new EventEmitter();
@Output() readonly cancel = new EventEmitter();
export class UserDetailsComponent extends BaseFormComponent implements OnInit {
user = input<User>();
readonly toggleResetPassword = output();
readonly closeDialog = output<boolean>();
readonly cancel = output();
readonly ROLES = ['RED_USER', 'RED_MANAGER', 'RED_USER_ADMIN', 'RED_ADMIN'];
readonly translations = rolesTranslations;
/** e.g. a RED_ADMIN is automatically a RED_USER_ADMIN => can't disable RED_USER_ADMIN as long as RED_ADMIN is checked */
readonly #ROLE_REQUIREMENTS = { RED_MANAGER: 'RED_USER', RED_ADMIN: 'RED_USER_ADMIN' };
constructor(
private readonly _formBuilder: UntypedFormBuilder,
@ -49,13 +49,17 @@ export class UserDetailsComponent extends BaseFormComponent implements OnChanges
}, []);
}
private get _rolesControls(): any {
get sendSetPasswordMail() {
return !this.form.controls.sendSetPasswordMail.value;
}
get #rolesControls() {
return this.ROLES.reduce(
(prev, role) => ({
...prev,
[role]: [
{
value: this.user && this.user.has(role),
value: this.user() && this.user().has(role),
disabled: this.shouldBeDisabled(role),
},
],
@ -64,20 +68,20 @@ export class UserDetailsComponent extends BaseFormComponent implements OnChanges
);
}
ngOnChanges() {
this.form = this._getForm();
ngOnInit() {
this.form = this.#getForm();
this.initialFormValue = this.form.getRawValue();
}
shouldBeDisabled(role: string): boolean {
const isCurrentAdminUser = this.user && this.user.isAdmin && this.user.id === this._userService.currentUser.id;
const isCurrentAdminUser = this.user() && this.user().isAdmin && this.user().id === this._userService.currentUser.id;
return (
// RED_ADMIN can't remove own RED_ADMIN role
(role === 'RED_ADMIN' && isCurrentAdminUser) ||
// only RED_ADMINs can edit RED_ADMIN roles
(role === 'RED_ADMIN' && !this._userService.currentUser.isAdmin) ||
Object.keys(this._ROLE_REQUIREMENTS).reduce(
(value, key) => value || (role === this._ROLE_REQUIREMENTS[key] && this.user?.roles.includes(key)),
Object.keys(this.#ROLE_REQUIREMENTS).reduce(
(value, key) => value || (role === this.#ROLE_REQUIREMENTS[key] && this.user()?.roles.includes(key)),
false,
)
);
@ -85,38 +89,38 @@ export class UserDetailsComponent extends BaseFormComponent implements OnChanges
async save() {
this._loadingService.start();
const userData: IProfileUpdateRequest = { ...this.form.getRawValue(), roles: this.activeRoles };
const userData: IProfileUpdateRequest = {
...this.form.getRawValue(),
roles: this.activeRoles,
sendSetPasswordMail: this.sendSetPasswordMail,
};
if (!this.user) {
if (!this.user()) {
await firstValueFrom(this._userService.create(userData))
.then(() => {
this.closeDialog.emit(true);
})
.catch(error => {
if (error.status === HttpStatusCode.Conflict) {
this._toaster.error(_('add-edit-user.error.email-already-used'));
} else {
this._toaster.error(_('add-edit-user.error.generic'));
}
this._toaster.error(null, { error });
this._loadingService.stop();
});
} else {
await firstValueFrom(this._userService.updateProfile(userData, this.user.id));
await firstValueFrom(this._userService.updateProfile(userData, this.user().id));
this.closeDialog.emit(true);
}
}
delete() {
this._dialogService.deleteUsers([this.user.id], () => this.closeDialog.emit(true));
this._dialogService.deleteUsers([this.user().id], () => this.closeDialog.emit(true));
}
setRolesRequirements(checked: boolean, role: string): void {
if (Object.keys(this._ROLE_REQUIREMENTS).includes(role)) {
if (Object.keys(this.#ROLE_REQUIREMENTS).includes(role)) {
if (checked) {
this.form.patchValue({ [this._ROLE_REQUIREMENTS[role]]: true });
this.form.controls[this._ROLE_REQUIREMENTS[role]].disable();
this.form.patchValue({ [this.#ROLE_REQUIREMENTS[role]]: true });
this.form.controls[this.#ROLE_REQUIREMENTS[role]].disable();
} else {
this.form.controls[this._ROLE_REQUIREMENTS[role]].enable();
this.form.controls[this.#ROLE_REQUIREMENTS[role]].enable();
}
}
}
@ -127,18 +131,19 @@ export class UserDetailsComponent extends BaseFormComponent implements OnChanges
return user.id === this._userService.currentUser.id || (userAdmin && !currentUserAdmin);
}
private _getForm(): UntypedFormGroup {
#getForm(): UntypedFormGroup {
return this._formBuilder.group({
firstName: [this.user?.firstName, Validators.required],
lastName: [this.user?.lastName, Validators.required],
firstName: [this.user()?.firstName, Validators.required],
lastName: [this.user()?.lastName, Validators.required],
email: [
{
value: this.user?.email,
disabled: !!this.user?.email,
value: this.user()?.email,
disabled: !!this.user()?.email,
},
[Validators.required, Validators.email],
],
...this._rolesControls,
...this.#rolesControls,
sendSetPasswordMail: [false],
});
}
}

View File

@ -17,7 +17,7 @@ import { RouterHistoryService } from '@services/router-history.service';
import { auditCategoriesTranslations } from '@translations/audit-categories-translations';
import { Roles } from '@users/roles';
import { applyIntervalConstraints } from '@utils/date-inputs-utils';
import { Dayjs } from 'dayjs';
import dayjs, { Dayjs } from 'dayjs';
import { firstValueFrom } from 'rxjs';
import { AdminDialogService } from '../../services/admin-dialog.service';
import { AuditService } from '../../services/audit.service';
@ -139,16 +139,9 @@ export class AuditScreenComponent extends ListingComponent<Audit> implements OnI
const promises = [];
const category = this.form.get('category').value;
const userId = this.form.get('userId').value;
const from = this.form.get('from').value;
let to = this.form.get('to').value;
if (to) {
const hoursLeft = new Date(to).getHours();
const minutesLeft = new Date(to).getMinutes();
to = to
.clone()
.add(24 - hoursLeft - 1, 'h')
.add(60 - minutesLeft - 1);
}
const from = this.form.get('from').value ? dayjs(this.form.get('from').value).startOf('day').toISOString() : null;
const to = this.form.get('to').value ? dayjs(this.form.get('to').value).endOf('day').toISOString() : null;
const logsRequestBody: IAuditSearchRequest = {
pageSize: PAGE_SIZE,
page: page,

View File

@ -42,6 +42,15 @@
type="text"
/>
</div>
<div class="iqser-input-group required w-150">
<label translate="add-edit-component-mapping.form.quote-char"></label>
<input
[placeholder]="'add-edit-component-mapping.form.quote-char-placeholder' | translate"
formControlName="quoteChar"
name="quoteChar"
type="text"
/>
</div>
<div class="iqser-input-group required w-150">
<label translate="add-edit-component-mapping.form.encoding-type"></label>

View File

@ -17,12 +17,14 @@ interface DialogData {
dossierTemplateId: string;
mapping: IComponentMapping;
}
interface DialogResult {
id: string;
name: string;
file: Blob;
encoding: string;
delimiter: string;
quoteChar: string;
fileName?: string;
}
@ -72,14 +74,14 @@ export class AddEditComponentMappingDialogComponent
const file = new Blob([fileContent.body as Blob], { type: 'text/csv' });
this.form.get('file').setValue(file);
this.initialFormValue = this.form.getRawValue();
this.#disableEncodingAndDelimiter();
this.#disableEncodingAndQuoteCharAndDelimiter();
}
}
changeFile(file: File) {
this.form.get('file').setValue(file);
this.form.get('fileName').setValue(file?.name);
this.#enableEncodingAndDelimiter();
this.#enableEncodingAndQuoteCharAndDelimiter();
}
save() {
@ -93,16 +95,19 @@ export class AddEditComponentMappingDialogComponent
fileName: [this.data?.mapping?.fileName, Validators.required],
encoding: this.encodingTypeOptions.find(e => e === this.data?.mapping?.encoding) ?? this.encodingTypeOptions[0],
delimiter: [this.data?.mapping?.delimiter ?? ',', Validators.required],
quoteChar: [this.data?.mapping?.quoteChar ?? '"', Validators.required],
});
}
#disableEncodingAndDelimiter() {
#disableEncodingAndQuoteCharAndDelimiter() {
this.form.get('encoding').disable();
this.form.get('delimiter').disable();
this.form.get('quoteChar').disable();
}
#enableEncodingAndDelimiter() {
#enableEncodingAndQuoteCharAndDelimiter() {
this.form.get('encoding').enable();
this.form.get('delimiter').enable();
this.form.get('quoteChar').enable();
}
}

View File

@ -99,8 +99,8 @@ export default class ComponentMappingsScreenComponent extends ListingComponent<C
const result = await dialog.result();
if (result) {
this._loadingService.start();
const { id, name, encoding, delimiter, fileName } = result;
const newMapping = { id, name, encoding, delimiter, fileName };
const { id, name, encoding, delimiter, fileName, quoteChar } = result;
const newMapping = { id, name, encoding, delimiter, fileName, quoteChar };
await firstValueFrom(
this._componentMappingService.createUpdateComponentMapping(this.#dossierTemplateId, newMapping, result.file),
);

View File

@ -91,7 +91,7 @@ export class AddEditDossierAttributeDialogComponent extends BaseDialogComponent
const createOrUpdate = this._dossierAttributesService.createOrUpdate(attribute, this.data.dossierTemplateId);
const result = await createOrUpdate.catch((error: HttpErrorResponse) => {
this._loadingService.stop();
this._toaster.error(_('add-edit-dossier-attribute.error.generic'), { error });
this._toaster.rawError(error.error.message);
return undefined;
});

View File

@ -47,6 +47,7 @@
<div class="dialog-actions">
<iqser-icon-button
(action)="save()"
[buttonId]="'save-dossier-state'"
[disabled]="disabled"
[label]="'add-edit-dossier-state.save' | translate"
[submit]="true"

View File

@ -4,12 +4,12 @@
</div>
<div class="dialog-content">
<div [innerHTML]="'confirm-delete-dossier-state.warning' | translate : translateArgs" class="heading"></div>
<div [innerHTML]="'confirm-delete-dossier-state.warning' | translate: translateArgs" class="heading"></div>
<form *ngIf="data.dossierCount !== 0 && data.otherStates.length > 0" [formGroup]="form" class="mt-16">
<div class="iqser-input-group">
<mat-checkbox color="primary" formControlName="replace">
{{ 'confirm-delete-dossier-state.question' | translate : { count: data.dossierCount } }}
{{ 'confirm-delete-dossier-state.question' | translate: { count: data.dossierCount } }}
</mat-checkbox>
</div>
@ -30,7 +30,12 @@
</div>
<div class="dialog-actions">
<iqser-icon-button (action)="save()" [label]="label | translate" [type]="iconButtonTypes.primary"></iqser-icon-button>
<iqser-icon-button
(action)="save()"
[buttonId]="'confirm-delete-dossier-state'"
[label]="label | translate"
[type]="iconButtonTypes.primary"
></iqser-icon-button>
<div [translate]="'confirm-delete-dossier-state.cancel'" class="all-caps-label cancel" mat-dialog-close></div>
</div>

View File

@ -13,18 +13,20 @@
<span class="small-label">{{ state.dossierCount }}</span>
</div>
<div class="cell">
<div [id]="'dossier_' + (state.name | snakeCase)" class="cell">
<div *ngIf="permissionsService.canPerformDossierStatesActions()" class="action-buttons">
<div [attr.help-mode-key]="'edit_delete_dossier_state'">
<iqser-circle-button
(action)="openEditStateDialog(state)"
[tooltip]="'dossier-states-listing.action.edit' | translate"
[buttonId]="'dossier-state-edit-button'"
icon="iqser:edit"
></iqser-circle-button>
<iqser-circle-button
(action)="openConfirmDeleteStateDialog(state)"
[tooltip]="'dossier-states-listing.action.delete' | translate"
[buttonId]="'dossier-state-delete-button'"
icon="iqser:trash"
></iqser-circle-button>
</div>

View File

@ -14,13 +14,14 @@ import {
import { MatTooltip } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { NgIf } from '@angular/common';
import { SnakeCasePipe } from '@common-ui/pipes/snake-case.pipe';
@Component({
selector: 'redaction-dossier-states-table-item',
templateUrl: './dossier-states-table-item.component.html',
styleUrls: ['./dossier-states-table-item.component.scss'],
standalone: true,
imports: [MatTooltip, CircleButtonComponent, TranslateModule, NgIf],
imports: [MatTooltip, CircleButtonComponent, TranslateModule, NgIf, SnakeCasePipe],
})
export class DossierStatesTableItemComponent {
readonly #dialog = inject(MatDialog);

View File

@ -1,6 +1,5 @@
<div class="dialog-header">
<div class="heading-l" translate="general-config-screen.general.title"></div>
<div translate="general-config-screen.general.subtitle"></div>
</div>
<form (submit)="save()" *ngIf="form" [formGroup]="form">
<div class="dialog-content">

View File

@ -11,7 +11,12 @@
<div class="dialog mt-24 mb-0">
<redaction-system-preferences-form></redaction-system-preferences-form>
</div>
<div class="dialog mt-24">
<redaction-smtp-form></redaction-smtp-form>
</div>
@if (smtpLicenseFeatureEnabled) {
<div class="dialog mt-24">
<redaction-smtp-form></redaction-smtp-form>
</div>
} @else {
<div style="visibility: hidden" class="dialog mt-24"></div>
}
</div>

View File

@ -6,6 +6,8 @@ import { BaseFormComponent, IqserListingModule } from '@iqser/common-ui';
import { SystemPreferencesFormComponent } from './system-preferences-form/system-preferences-form.component';
import { RouterHistoryService } from '@services/router-history.service';
import { TranslateModule } from '@ngx-translate/core';
import { LicenseService } from '@services/license.service';
import { ILicenseFeature } from '@red/domain';
@Component({
selector: 'redaction-general-config-screen',
@ -17,15 +19,17 @@ import { TranslateModule } from '@ngx-translate/core';
export class GeneralConfigScreenComponent extends BaseFormComponent implements AfterViewInit {
readonly currentUser = inject(UserService).currentUser;
readonly routerHistoryService = inject(RouterHistoryService);
readonly licenseService = inject(LicenseService);
@ViewChild(GeneralConfigFormComponent) generalConfigFormComponent: GeneralConfigFormComponent;
@ViewChild(SystemPreferencesFormComponent) systemPreferencesFormComponent: SystemPreferencesFormComponent;
@ViewChild(SmtpFormComponent) smtpFormComponent: SmtpFormComponent;
children: BaseFormComponent[];
smtpLicenseFeatureEnabled: boolean;
get changed(): boolean {
for (const child of this.children) {
if (child.changed) {
if (child?.changed) {
return true;
}
}
@ -43,6 +47,8 @@ export class GeneralConfigScreenComponent extends BaseFormComponent implements A
ngAfterViewInit() {
this.children = [this.generalConfigFormComponent, this.systemPreferencesFormComponent, this.smtpFormComponent];
let licenseFeature: ILicenseFeature = this.licenseService.getFeature('configurableSMTPServer');
this.smtpLicenseFeatureEnabled = licenseFeature != null && licenseFeature.value === true;
}
async save(): Promise<void> {

View File

@ -28,7 +28,7 @@ import { MatIcon } from '@angular/material/icon';
import { SelectComponent } from '@shared/components/select/select.component';
import { MatSuffix } from '@angular/material/form-field';
const downloadTypes = ['ORIGINAL', 'PREVIEW', 'DELTA_PREVIEW', 'REDACTED'].map(type => ({
const downloadTypes = ['ORIGINAL', 'PREVIEW', 'OPTIMIZED_PREVIEW', 'DELTA_PREVIEW', 'REDACTED'].map(type => ({
key: type,
label: downloadTypesTranslations[type],
}));

View File

@ -17,8 +17,17 @@
type="text"
/>
</div>
<div class="iqser-input-group">
<label translate="add-edit-entity.form.technical-name"></label>
<div class="technical-name">{{ this.technicalName() || '-' }}</div>
<span
[translateParams]="{ type: data.justification ? 'edit' : 'create' }"
[translate]="'add-edit-entity.form.technical-name-hint'"
class="hint"
></span>
</div>
<div class="iqser-input-group required w-400">
<div class="iqser-input-group w-400">
<label translate="add-edit-justification.form.reason"></label>
<input
[placeholder]="'add-edit-justification.form.reason-placeholder' | translate"
@ -28,7 +37,7 @@
/>
</div>
<div class="iqser-input-group required w-400">
<div class="iqser-input-group w-400">
<label translate="add-edit-justification.form.description"></label>
<textarea
[placeholder]="'add-edit-justification.form.description-placeholder' | translate"

View File

@ -1,11 +1,13 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, Inject, untracked } from '@angular/core';
import { ReactiveFormsModule, UntypedFormGroup, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Justification } from '@red/domain';
import { JustificationsService } from '@services/entity-services/justifications.service';
import { BaseDialogComponent, CircleButtonComponent, IconButtonComponent } from '@iqser/common-ui';
import { BaseDialogComponent, CircleButtonComponent, HasScrollbarDirective, IconButtonComponent } from '@iqser/common-ui';
import { firstValueFrom } from 'rxjs';
import { TranslateModule } from '@ngx-translate/core';
import { formControlToSignal } from '@utils/functions';
import { toSignal } from '@angular/core/rxjs-interop';
interface DialogData {
justification?: Justification;
@ -16,9 +18,29 @@ interface DialogData {
templateUrl: './add-edit-justification-dialog.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TranslateModule, ReactiveFormsModule, IconButtonComponent, CircleButtonComponent],
imports: [TranslateModule, ReactiveFormsModule, IconButtonComponent, CircleButtonComponent, HasScrollbarDirective],
})
export class AddEditJustificationDialogComponent extends BaseDialogComponent {
readonly form = this.#getForm();
readonly name = formControlToSignal(this.form.controls['name']);
readonly allJustifications = toSignal(this._justificationService.all$);
readonly technicalName = computed(() => {
if (this.data.justification) {
return this.data.justification.technicalName;
}
if (!this.name()) {
return null;
}
let currentTechnicalName = Justification.toTechnicalName(this.name());
const existingTechnicalNames = untracked(this.allJustifications).map(justification => justification.technicalName);
let suffix = 1;
while (existingTechnicalNames.includes(currentTechnicalName)) {
currentTechnicalName =
currentTechnicalName === '_' ? `${currentTechnicalName}${suffix++}` : [currentTechnicalName, suffix++].join('_');
}
return currentTechnicalName;
});
constructor(
private readonly _justificationService: JustificationsService,
protected readonly _dialogRef: MatDialogRef<AddEditJustificationDialogComponent>,
@ -26,7 +48,6 @@ export class AddEditJustificationDialogComponent extends BaseDialogComponent {
) {
super(_dialogRef, !!data.justification);
this.form = this._getForm();
this.initialFormValue = this.form.getRawValue();
}
@ -34,7 +55,8 @@ export class AddEditJustificationDialogComponent extends BaseDialogComponent {
const dossierTemplateId = this.data.dossierTemplateId;
this._loadingService.start();
try {
await firstValueFrom(this._justificationService.createOrUpdate(this.form.getRawValue() as Justification, dossierTemplateId));
const formValue = { ...this.form.getRawValue(), technicalName: this.technicalName() };
await firstValueFrom(this._justificationService.createOrUpdate(formValue as Justification, dossierTemplateId));
await firstValueFrom(this._justificationService.loadAll(dossierTemplateId));
this._dialogRef.close(true);
} catch (error) {
@ -43,11 +65,12 @@ export class AddEditJustificationDialogComponent extends BaseDialogComponent {
this._loadingService.stop();
}
private _getForm(): UntypedFormGroup {
#getForm(): UntypedFormGroup {
return this._formBuilder.group({
name: [{ value: this.data.justification?.name, disabled: !!this.data.justification }, Validators.required],
reason: [this.data.justification?.reason, Validators.required],
description: [this.data.justification?.description, Validators.required],
reason: [this.data.justification?.reason],
description: [this.data.justification?.description],
technicalName: [this.data.justification?.technicalName ?? null],
});
}
}

View File

@ -3,7 +3,7 @@ import { LicenseService } from '@services/license.service';
import { map } from 'rxjs/operators';
import { ChartDataset } from 'chart.js';
import { ChartBlue, ChartGreen, ChartRed } from '../../utils/constants';
import { getDataUntilCurrentMonth, getLabelsFromLicense, getLineConfig, isCurrentMonthAndYear } from '../../utils/functions';
import { getDataUntilCurrentMonth, getLabelsFromLicense, getLineConfig, isCurrentMonth } from '../../utils/functions';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { size } from '@iqser/common-ui/lib/utils';
@ -43,7 +43,7 @@ export class LicenseAnalysisCapacityUsageComponent {
#getCapacityDatasets(): ChartDataset[] {
const monthlyData = [...this.licenseService.selectedLicenseReport.monthlyData];
const dataUntilCurrentMonth = getDataUntilCurrentMonth(monthlyData);
if (monthlyData.length === 1 || isCurrentMonthAndYear(monthlyData[0].startDate)) {
if (monthlyData.length === 1 || isCurrentMonth(monthlyData[0].startDate)) {
const empty = { analysedFilesBytes: null } as ILicenseData;
dataUntilCurrentMonth.splice(0, 0, empty);
monthlyData.splice(0, 0, empty);
@ -60,11 +60,8 @@ export class LicenseAnalysisCapacityUsageComponent {
},
{
data: dataUntilCurrentMonth.map((month, monthIndex) =>
month.analysedFilesBytes
? month.analysedFilesBytes +
monthlyData.slice(0, monthIndex).reduce((acc, curr) => acc + (curr.analysedFilesBytes ?? 0), 0)
: 0,
data: dataUntilCurrentMonth.map((_, monthIndex) =>
monthlyData.slice(0, monthIndex + 1).reduce((acc, curr) => acc + (curr.analysedFilesBytes ?? 0), 0),
),
label: this._translateService.instant('license-info-screen.analysis-capacity-usage.analyzed-cumulative'),
yAxisID: 'y1',

View File

@ -3,7 +3,7 @@ import { LicenseService } from '@services/license.service';
import { map } from 'rxjs/operators';
import { ChartDataset } from 'chart.js';
import { ChartBlue, ChartGreen, ChartRed } from '../../utils/constants';
import { getDataUntilCurrentMonth, getLabelsFromLicense, getLineConfig, isCurrentMonthAndYear } from '../../utils/functions';
import { getDataUntilCurrentMonth, getLabelsFromLicense, getLineConfig, isCurrentMonth } from '../../utils/functions';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { ILicenseData } from '@red/domain';
@ -40,7 +40,7 @@ export class LicensePageUsageComponent {
#getPagesDatasets(): ChartDataset[] {
const monthlyData = [...this.licenseService.selectedLicenseReport.monthlyData];
const dataUntilCurrentMonth = getDataUntilCurrentMonth(monthlyData);
if (monthlyData.length === 1 || isCurrentMonthAndYear(monthlyData[0].startDate)) {
if (monthlyData.length === 1 || isCurrentMonth(monthlyData[0].startDate)) {
const empty = { numberOfAnalyzedPages: null } as ILicenseData;
dataUntilCurrentMonth.splice(0, 0, empty);
monthlyData.splice(0, 0, empty);
@ -63,11 +63,8 @@ export class LicensePageUsageComponent {
order: 1,
},
{
data: dataUntilCurrentMonth.map((month, monthIndex) =>
month.numberOfAnalyzedPages
? month.numberOfAnalyzedPages +
monthlyData.slice(0, monthIndex).reduce((acc, curr) => acc + (curr.numberOfAnalyzedPages ?? 0), 0)
: 0,
data: dataUntilCurrentMonth.map((_, monthIndex) =>
monthlyData.slice(0, monthIndex + 1).reduce((acc, curr) => acc + (curr.numberOfAnalyzedPages ?? 0), 0),
),
label: this._translateService.instant('license-info-screen.page-usage.cumulative-pages'),
yAxisID: 'y1',

View File

@ -78,9 +78,9 @@ export class LicenseRetentionCapacityComponent {
return [
{
data: monthlyData.flatMap(d => d.activeFilesUploadedBytes),
label: this._translateService.instant('license-info-screen.retention-capacity-usage.active-documents'),
...getLineConfig(ChartGreen, false, 'origin'),
data: monthlyData.flatMap(d => d.trashFilesUploadedBytes),
label: this._translateService.instant('license-info-screen.retention-capacity-usage.trash-documents'),
...getLineConfig(ChartRed, false, 'origin'),
stack: 'storage',
},
{
@ -90,9 +90,9 @@ export class LicenseRetentionCapacityComponent {
stack: 'storage',
},
{
data: monthlyData.flatMap(d => d.trashFilesUploadedBytes),
label: this._translateService.instant('license-info-screen.retention-capacity-usage.trash-documents'),
...getLineConfig(ChartRed, false, '-1'),
data: monthlyData.flatMap(d => d.activeFilesUploadedBytes),
label: this._translateService.instant('license-info-screen.retention-capacity-usage.active-documents'),
...getLineConfig(ChartGreen, false, 'origin'),
stack: 'storage',
},
{

View File

@ -6,7 +6,6 @@ import { ComplexFillTarget } from 'chart.js/dist/types';
const monthNames = dayjs.monthsShort();
const currentMonth = dayjs(Date.now()).month();
const currentYear = dayjs(Date.now()).year();
export const verboseDate = (date: Dayjs) => `${monthNames[date.month()]} ${date.year()}`;
@ -45,7 +44,7 @@ export const getLabelsFromLicense = (license: ILicenseReport) => {
monthIterator = monthIterator.add(1, 'month');
}
if (startMonth.month() === endMonth.month() || startMonth.month() === currentMonth) {
if (startMonth.isSame(endMonth, 'month') || isCurrentMonth(startMonth.toDate())) {
result.splice(0, 0, '');
}
@ -53,9 +52,9 @@ export const getLabelsFromLicense = (license: ILicenseReport) => {
};
export const getDataUntilCurrentMonth = (monthlyData: ILicenseData[]) => {
return monthlyData.filter(data => dayjs(data.startDate).month() <= currentMonth && dayjs(data.startDate).year() <= currentYear);
return monthlyData.filter(data => dayjs(data.startDate).isSameOrBefore(dayjs(Date.now()), 'month'));
};
export const isCurrentMonthAndYear = (date: Date | string) => {
return dayjs(date).month() === currentMonth && dayjs(date).year() === currentYear;
export const isCurrentMonth = (date: Date | string) => {
return dayjs(date).isSame(dayjs(Date.now()), 'month');
};

View File

@ -2,64 +2,74 @@
<div [translate]="'reports-screen.title'" class="heading-xl"></div>
<div [translate]="'reports-screen.setup'" class="description"></div>
<div *ngIf="!isDocumine" [translate]="'reports-screen.description'" class="description"></div>
<div *ngIf="!isDocumine && placeholders$ | async as placeholders" class="placeholders">
<div [translate]="'reports-screen.table-header.placeholders'" class="all-caps-label"></div>
<div [translate]="'reports-screen.table-header.description'" class="all-caps-label"></div>
<ng-container *ngFor="let placeholder of placeholders">
<div class="placeholder">{{ placeholder.placeholder }}</div>
<div
[innerHTML]="placeholder.descriptionTranslation | translate: { attribute: placeholder.attributeName }"
class="description"
></div>
</ng-container>
</div>
@if (!isDocumine) {
<div [translate]="'reports-screen.description'" class="description"></div>
}
@if (!isDocumine && placeholders$ | async; as placeholders) {
<div class="placeholders">
<div [translate]="'reports-screen.table-header.placeholders'" class="all-caps-label"></div>
<div [translate]="'reports-screen.table-header.description'" class="all-caps-label"></div>
@for (placeholder of placeholders; track placeholder.placeholder) {
<div class="placeholder">{{ placeholder.placeholder }}</div>
<div
[innerHTML]="placeholder.descriptionTranslation | translate: { attribute: placeholder.attributeName }"
class="description"
></div>
}
</div>
}
</div>
<div *ngIf="availableTemplates$ | async as availableTemplates" class="right-container" iqserHasScrollbar>
<div class="header">
<div [translate]="'reports-screen.report-documents'" class="heading"></div>
<iqser-circle-button
(action)="fileInput.click()"
*allow="roles.reportTemplates.upload; if: currentUser.isAdmin"
[tooltip]="'reports-screen.upload-document' | translate"
[attr.help-mode-key]="'upload_report'"
icon="iqser:upload"
></iqser-circle-button>
</div>
<div
(click)="fileInput.click()"
*allow="roles.reportTemplates.upload; if: currentUser.isAdmin && !availableTemplates?.length"
[translate]="'reports-screen.upload-document'"
class="template upload-button"
></div>
<div *ngFor="let template of availableTemplates" class="template">
<div class="name">
{{ template.fileName }} {{ template.multiFileReport ? ('reports-screen.multi-file-report' | translate) : '' }}
</div>
<div class="actions">
<iqser-circle-button
(action)="download(template)"
*allow="roles.reportTemplates.download"
[iconSize]="12"
[size]="18"
icon="iqser:download"
></iqser-circle-button>
@if (availableTemplates$ | async; as availableTemplates) {
<div class="right-container" iqserHasScrollbar>
<div class="header">
<div [translate]="'reports-screen.report-documents'" class="heading"></div>
<iqser-circle-button
(action)="deleteTemplate(template)"
*allow="roles.reportTemplates.delete; if: currentUser.isAdmin"
[iconSize]="12"
[size]="18"
icon="iqser:trash"
(action)="fileInput.click()"
*allow="roles.reportTemplates.upload; if: currentUser.isAdmin"
[tooltip]="'reports-screen.upload-document' | translate"
[attr.help-mode-key]="'upload_report'"
[buttonId]="'upload_report'"
icon="iqser:upload"
></iqser-circle-button>
</div>
<div
(click)="fileInput.click()"
*allow="roles.reportTemplates.upload; if: currentUser.isAdmin && !availableTemplates?.length"
[translate]="'reports-screen.upload-document'"
class="template upload-button"
></div>
@for (template of availableTemplates; track template.templateId) {
<div [id]="template.fileName | snakeCase" class="template">
<div class="name">
{{ template.fileName }}
</div>
<div class="actions">
<iqser-circle-button
(action)="download(template)"
*allow="roles.reportTemplates.download"
[buttonId]="(template.fileName | snakeCase) + '-download-button'"
[iconSize]="12"
[size]="18"
icon="iqser:download"
></iqser-circle-button>
<iqser-circle-button
(action)="deleteTemplate(template)"
*allow="roles.reportTemplates.delete; if: currentUser.isAdmin"
[buttonId]="(template.fileName | snakeCase) + '-delete-button'"
[iconSize]="12"
[size]="18"
icon="iqser:trash"
></iqser-circle-button>
</div>
</div>
}
</div>
</div>
}
<input #fileInput (change)="uploadTemplate($event)" hidden type="file" />

View File

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, ElementRef, OnInit, ViewChild } from '@angular/core';
import { ChangeDetectionStrategy, Component, ElementRef, inject, OnInit, viewChild } from '@angular/core';
import { DOSSIER_TEMPLATE_ID, IPlaceholdersResponse, IReportTemplate, User } from '@red/domain';
import { download } from '@utils/file-download-utils';
import {
@ -23,8 +23,9 @@ import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { Roles } from '@users/roles';
import { getCurrentUser } from '@iqser/common-ui/lib/users';
import { getParam } from '@iqser/common-ui/lib/utils';
import { AsyncPipe, NgForOf, NgIf } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { AsyncPipe } from '@angular/common';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { SnakeCasePipe } from '@common-ui/pipes/snake-case.pipe';
interface Placeholder {
placeholder: string;
@ -41,15 +42,16 @@ const placeholderTypes: PlaceholderType[] = ['generalPlaceholders', 'fileAttribu
styleUrls: ['./reports-screen.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [HasScrollbarDirective, NgIf, NgForOf, AsyncPipe, TranslateModule, CircleButtonComponent, IqserAllowDirective],
imports: [HasScrollbarDirective, AsyncPipe, TranslateModule, CircleButtonComponent, IqserAllowDirective, SnakeCasePipe],
})
export default class ReportsScreenComponent implements OnInit {
@ViewChild('fileInput') private readonly _fileInput: ElementRef;
readonly placeholders$ = new BehaviorSubject<Placeholder[]>([]);
readonly availableTemplates$ = new BehaviorSubject<IReportTemplate[]>([]);
readonly currentUser = getCurrentUser<User>();
readonly roles = Roles;
readonly isDocumine = getConfig().IS_DOCUMINE;
readonly #translateService = inject(TranslateService);
private readonly _fileInput = viewChild<ElementRef>('fileInput');
readonly #dossierTemplateId = getParam(DOSSIER_TEMPLATE_ID);
constructor(
@ -62,8 +64,8 @@ export default class ReportsScreenComponent implements OnInit {
async ngOnInit() {
this._loadingService.start();
await this._loadReportTemplates();
await this._loadPlaceholders();
await this.#loadReportTemplates();
await this.#loadPlaceholders();
this._loadingService.stop();
}
@ -83,14 +85,14 @@ export default class ReportsScreenComponent implements OnInit {
deleteTemplate(template: IReportTemplate) {
this._dialogService.openDialog('confirm', null, () => {
this._loadingService.loadWhile(this._deleteTemplate(template));
this._loadingService.loadWhile(this.#deleteTemplate(template));
});
}
uploadTemplate($event) {
const file: File = $event.target.files[0];
if (!this._isValidFile(file)) {
if (!this.#isValidFile(file)) {
this._toaster.error(_('reports-screen.invalid-upload'));
return;
}
@ -114,27 +116,39 @@ export default class ReportsScreenComponent implements OnInit {
template => template.fileName === file.name && template.multiFileReport === multiFileReport,
)
) {
await this._openOverwriteConfirmationDialog(file, multiFileReport);
await this.#openOverwriteConfirmationDialog(file, multiFileReport);
} else {
await this._uploadTemplateForm(file, multiFileReport);
await this.#uploadTemplateForm(file, multiFileReport);
}
}
});
this._fileInput.nativeElement.value = null;
this._fileInput().nativeElement.value = null;
}
private _getAttributeName(placeholder: string): string {
#getAttributeName(placeholder: string): string {
return removeBraces(placeholder).split('.').pop();
}
private _getPlaceholderDescriptionTranslation(type: PlaceholderType, placeholder: string): string {
#getPlaceholderDescriptionTranslation(type: PlaceholderType, placeholder: string): string {
return type === 'generalPlaceholders'
? generalPlaceholdersDescriptionsTranslations[removeBraces(placeholder)]
: placeholdersDescriptionsTranslations[type];
}
private async _openOverwriteConfirmationDialog(file: File, multiFileReport: boolean): Promise<void> {
#getTemplateFilename(template: IReportTemplate): string {
const extensionIndex = template.fileName.lastIndexOf('.');
const hasExtension = extensionIndex !== -1;
const baseName = hasExtension ? template.fileName.substring(0, extensionIndex) : template.fileName;
const extension = hasExtension ? template.fileName.substring(extensionIndex) : '';
const multiFileSuffix = template.multiFileReport ? ` ${this.#translateService.instant(_('reports-screen.multi-file-report'))}` : '';
return `${baseName}${multiFileSuffix}${extension}`.trim();
}
async #openOverwriteConfirmationDialog(file: File, multiFileReport: boolean): Promise<void> {
const data: IConfirmationDialogData = {
title: _('confirmation-dialog.report-template-same-name.title'),
question: _('confirmation-dialog.report-template-same-name.question'),
@ -147,29 +161,34 @@ export default class ReportsScreenComponent implements OnInit {
this._dialogService.openDialog('confirm', data, null, async result => {
if (result) {
await this._uploadTemplateForm(file, multiFileReport);
await this.#uploadTemplateForm(file, multiFileReport);
}
});
}
private async _uploadTemplateForm(file: File, multiFileReport: boolean): Promise<void> {
async #uploadTemplateForm(file: File, multiFileReport: boolean): Promise<void> {
this._loadingService.start();
await firstValueFrom(this._reportTemplateService.uploadTemplateForm(this.#dossierTemplateId, multiFileReport, file));
await this._loadReportTemplates();
await this.#loadReportTemplates();
this._loadingService.stop();
}
private async _deleteTemplate(template: IReportTemplate) {
async #deleteTemplate(template: IReportTemplate) {
await firstValueFrom(this._reportTemplateService.delete(template.dossierTemplateId, template.templateId));
await this._loadReportTemplates();
await this.#loadReportTemplates();
}
private async _loadReportTemplates() {
async #loadReportTemplates() {
const reportTemplates = await this._reportTemplateService.getAvailableReportTemplates(this.#dossierTemplateId);
this.availableTemplates$.next(reportTemplates);
this.availableTemplates$.next(
reportTemplates.map(template => ({
...template,
fileName: this.#getTemplateFilename(template),
})),
);
}
private async _loadPlaceholders() {
async #loadPlaceholders() {
const placeholdersResponse: IPlaceholdersResponse = await firstValueFrom(
this._reportTemplateService.getAvailablePlaceholders(this.#dossierTemplateId),
);
@ -177,25 +196,25 @@ export default class ReportsScreenComponent implements OnInit {
placeholderTypes.flatMap(type =>
placeholdersResponse[type].map(placeholder => ({
placeholder,
descriptionTranslation: this._getPlaceholderDescriptionTranslation(type, placeholder),
attributeName: this._getAttributeName(placeholder),
descriptionTranslation: this.#getPlaceholderDescriptionTranslation(type, placeholder),
attributeName: this.#getAttributeName(placeholder),
})),
),
);
}
private _isValidFile(file: File): boolean {
return this._isExcelFile(file) || this._isWordFile(file);
#isValidFile(file: File): boolean {
return this.#isExcelFile(file) || this.#isWordFile(file);
}
private _isExcelFile(file: File): boolean {
#isExcelFile(file: File): boolean {
return (
file.type?.toLowerCase() === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
file.name.toLowerCase().endsWith('.xlsx')
);
}
private _isWordFile(file: File): boolean {
#isWordFile(file: File): boolean {
return (
file.type?.toLowerCase() === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
file.name.toLowerCase().endsWith('.docx')

View File

@ -1,13 +1,13 @@
<div class="pagination noselect">
<div (click)="changePage.emit(1)" class="page-button" id="portraitPage">
<mat-icon class="chevron-icon" svgIcon="iqser:nav-prev"></mat-icon>
Portrait
{{ 'watermark-screen.pagination.portrait' | translate }}
</div>
<div class="separator">/</div>
<div (click)="changePage.emit(2)" class="page-button" id="landscapePage">
Landscape
{{ 'watermark-screen.pagination.landscape' | translate }}
<mat-icon class="chevron-icon" svgIcon="iqser:nav-next"></mat-icon>
</div>
</div>

View File

@ -1,12 +1,13 @@
import { Component, EventEmitter, Output } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
@Component({
selector: 'redaction-paginator',
templateUrl: './paginator.component.html',
styleUrls: ['./paginator.component.scss'],
standalone: true,
imports: [MatIcon],
imports: [MatIcon, TranslateModule],
})
export class PaginatorComponent {
@Output() readonly changePage = new EventEmitter<number>();

View File

@ -4,6 +4,7 @@
.container {
padding: 32px;
width: 1000px;
max-width: 100%;
box-sizing: border-box;
}

View File

@ -9,7 +9,7 @@
<mat-icon *ngIf="!fileAttribute.editable" [matTooltip]="'readonly' | translate" svgIcon="red:read-only"></mat-icon>
<span
*ngIf="!isDate; else date"
[style.max-width]="attributeValueWidth"
[style.max-width]="attributeValueWidth()"
[matTooltip]="fileAttributeValue"
[ngClass]="{ hide: isInEditMode, 'clamp-3': mode !== 'workflow' }"
>
@ -56,13 +56,12 @@
class="edit-input"
iqserStopPropagation
>
<form [formGroup]="form">
<form [formGroup]="form" [style.width]="inputFormWidth()">
<iqser-dynamic-input
(closedDatepicker)="closedDatepicker = $event"
(keyup.enter)="form.valid && save()"
(keydown.escape)="close()"
[style.max-width]="editFieldWidth"
[style.min-width]="editFieldWidth"
[style.width]="inputFieldWidth()"
[formControlName]="fileAttribute.id"
[id]="fileAttribute.id"
[ngClass]="{ 'workflow-input': mode === 'workflow' || fileNameColumn, 'file-name-input': fileNameColumn }"

View File

@ -97,7 +97,7 @@
iqser-circle-button {
margin-left: 15px;
@media screen and (max-width: 1395px) {
@media screen and (max-width: 1745px) {
margin-left: 6px;
}
}

View File

@ -1,5 +1,5 @@
import { AsyncPipe, NgClass, NgIf, NgTemplateOutlet } from '@angular/common';
import { Component, computed, effect, HostListener, Input, OnDestroy } from '@angular/core';
import { Component, computed, effect, HostListener, input, Input, OnDestroy } from '@angular/core';
import { AbstractControl, FormBuilder, FormsModule, ReactiveFormsModule, UntypedFormGroup, ValidatorFn } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
@ -14,7 +14,7 @@ import {
StopPropagationDirective,
Toaster,
} from '@iqser/common-ui';
import { Debounce, log } from '@iqser/common-ui/lib/utils';
import { Debounce } from '@iqser/common-ui/lib/utils';
import { TranslateModule } from '@ngx-translate/core';
import { Dossier, File, FileAttributeConfigTypes, IFileAttributeConfig } from '@red/domain';
import { FileAttributesService } from '@services/entity-services/file-attributes.service';
@ -49,7 +49,6 @@ import { ConfigService } from '../../config.service';
})
export class FileAttributeComponent extends BaseFormComponent implements OnDestroy {
readonly #subscriptions = new Subscription();
#widthFactor = window.innerWidth >= 1800 ? 0.85 : 0.7;
isInEditMode = false;
closedDatepicker = true;
@Input({ required: true }) fileAttribute!: IFileAttributeConfig;
@ -66,7 +65,10 @@ export class FileAttributeComponent extends BaseFormComponent implements OnDestr
@Input({ required: true }) dossier!: Dossier;
@Input() fileNameColumn = false;
readonlyAttrs: string[] = [];
@Input() width?: number;
readonly width = input<number>();
readonly inputFormWidth = computed(() => (this.width() ? this.width() + 'px' : 'unset'));
readonly inputFieldWidth = computed(() => (this.width() ? this.width() - 50 + 'px' : 'unset'));
readonly attributeValueWidth = computed(() => (this.width() ? `${this.width() * 0.9}px` : 'unset'));
constructor(
router: Router,
@ -99,14 +101,6 @@ export class FileAttributeComponent extends BaseFormComponent implements OnDestr
);
}
get editFieldWidth(): string {
return this.width ? `${this.width * this.#widthFactor}px` : 'unset';
}
get attributeValueWidth(): string {
return this.width ? `${this.width * 0.9}px` : 'unset';
}
get isDate(): boolean {
return this.fileAttribute.type === FileAttributeConfigTypes.DATE;
}
@ -123,15 +117,6 @@ export class FileAttributeComponent extends BaseFormComponent implements OnDestr
return this.file.fileAttributes.attributeIdToValue[this.fileAttribute.id];
}
@HostListener('window:resize')
onResize() {
if (window.innerWidth >= 1800) {
this.#widthFactor = 0.85;
} else {
this.#widthFactor = 0.7;
}
}
@Debounce(60)
@HostListener('document:click', ['$event'])
clickOutside($event: MouseEvent) {
@ -147,6 +132,7 @@ export class FileAttributeComponent extends BaseFormComponent implements OnDestr
handleClick($event: MouseEvent) {
$event.stopPropagation();
$event.preventDefault();
}
ngOnDestroy() {
@ -154,12 +140,14 @@ export class FileAttributeComponent extends BaseFormComponent implements OnDestr
}
handleFieldClick($event: MouseEvent) {
$event.preventDefault();
if (!this.fileNameColumn) {
this.editFileAttribute($event);
}
}
editFileAttribute($event: MouseEvent) {
$event.preventDefault();
if (
!this.file.isInitialProcessing &&
this.permissionsService.canEditFileAttributes(this.file, this.dossier) &&

View File

@ -2,18 +2,33 @@
<redaction-annotation-icon
*ngIf="file.analysisRequired"
[color]="analysisColor$ | async"
label="A"
[label]="(workloadTranslations['analysis'] | translate)[0]"
type="square"
></redaction-annotation-icon>
<redaction-annotation-icon
*ngIf="updated"
[color]="updatedColor$ | async"
[label]="(workloadTranslations['updated'] | translate)[0]"
type="square"
></redaction-annotation-icon>
<redaction-annotation-icon *ngIf="updated" [color]="updatedColor$ | async" label="U" type="square"></redaction-annotation-icon>
<redaction-annotation-icon
*ngIf="file.hasRedactions"
[color]="redactionColor$ | async"
[label]="'redaction-abbreviation' | translate"
type="square"
></redaction-annotation-icon>
<redaction-annotation-icon *ngIf="file.hasImages" [color]="imageColor$ | async" label="I" type="square"></redaction-annotation-icon>
<redaction-annotation-icon *ngIf="file.hintsOnly" [color]="hintColor$ | async" label="H" type="circle"></redaction-annotation-icon>
<redaction-annotation-icon
*ngIf="file.hasImages"
[color]="imageColor$ | async"
[label]="(workloadTranslations['image'] | translate)[0]"
type="square"
></redaction-annotation-icon>
<redaction-annotation-icon
*ngIf="file.hintsOnly"
[color]="hintColor$ | async"
[label]="(workloadTranslations['hint'] | translate)[0]"
type="circle"
></redaction-annotation-icon>
<mat-icon *ngIf="file.hasAnnotationComments" svgIcon="red:comment"></mat-icon>
<ng-container *ngIf="noWorkloadItems"> -</ng-container>
</div>

View File

@ -10,6 +10,7 @@ import { AnnotationIconComponent } from '@shared/components/annotation-icon/anno
import { AsyncPipe, NgIf } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { MatIcon } from '@angular/material/icon';
import { workloadTranslations } from '@translations/workload-translations';
@Component({
selector: 'redaction-file-workload',
@ -27,6 +28,7 @@ export class FileWorkloadComponent implements OnInit {
analysisColor$: Observable<string>;
hintColor$: Observable<string>;
redactionColor$: Observable<string>;
readonly workloadTranslations = workloadTranslations;
constructor(
private readonly _userService: UserService,

View File

@ -56,7 +56,7 @@
</iqser-workflow>
</div>
<div *ngIf="dossierAttributes$ | async" [class.collapsed]="collapsedDetails" class="right-container" iqserHasScrollbar>
<div *ngIf="dossierAttributes$ | async" [class.collapsed]="collapsedDetails" class="right-container">
<redaction-dossier-details
(toggleCollapse)="collapsedDetails = !collapsedDetails"
[dossierAttributes]="dossierAttributes"

View File

@ -25,10 +25,7 @@
width: 375px;
min-width: 375px;
padding: 16px 24px 16px 24px;
&.has-scrollbar:hover {
padding-right: 13px;
}
overflow-y: auto;
redaction-dossier-details {
width: 100%;

View File

@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { Injectable, signal } from '@angular/core';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { IConfirmationDialogData, IqserDialog, LoadingService } from '@iqser/common-ui';
import { Dossier, File, User, WorkflowFileStatus, WorkflowFileStatuses } from '@red/domain';
import { ApproveResponse, Dossier, File, User, WorkflowFileStatus, WorkflowFileStatuses } from '@red/domain';
import { ActiveDossiersService } from '@services/dossiers/active-dossiers.service';
import { FileManagementService } from '@services/files/file-management.service';
import { FilesService } from '@services/files/files.service';
@ -11,6 +11,7 @@ import { AssignReviewerApproverDialogComponent } from '../../shared-dossiers/dia
import { DossiersDialogService } from '../../shared-dossiers/services/dossiers-dialog.service';
import { FileAssignService } from '../../shared-dossiers/services/file-assign.service';
import { getCurrentUser } from '@common-ui/users';
import { ApproveWarningDetailsComponent } from '@shared/components/approve-warning-details/approve-warning-details.component';
@Injectable()
export class BulkActionsService {
@ -110,34 +111,35 @@ export class BulkActionsService {
}
async approve(files: File[]): Promise<void> {
const foundAnalysisRequiredFile = files.find(file => file.analysisRequired);
const foundUpdatedFile = files.find(file => file.hasUpdates);
if (foundAnalysisRequiredFile || foundUpdatedFile) {
this._dialogService.openDialog(
'confirm',
{
title: foundAnalysisRequiredFile
? _('confirmation-dialog.approve-multiple-files-without-analysis.title')
: _('confirmation-dialog.approve-multiple-files.title'),
question: foundAnalysisRequiredFile
? _('confirmation-dialog.approve-multiple-files-without-analysis.question')
: _('confirmation-dialog.approve-multiple-files.question'),
confirmationText: foundAnalysisRequiredFile
? _('confirmation-dialog.approve-multiple-files-without-analysis.confirmationText')
: null,
denyText: foundAnalysisRequiredFile ? _('confirmation-dialog.approve-multiple-files-without-analysis.denyText') : null,
} as IConfirmationDialogData,
async () => {
this._loadingService.start();
await this._filesService.setApproved(files);
this._loadingService.stop();
},
);
} else {
this._loadingService.start();
await this._filesService.setApproved(files);
this._loadingService.stop();
this._loadingService.start();
const approvalResponse: ApproveResponse[] = await this._filesService.getApproveWarnings(files);
this._loadingService.stop();
const hasWarnings = approvalResponse.some(response => response.hasWarnings);
if (!hasWarnings) {
return;
}
const fileWarnings = approvalResponse
.filter(response => response.hasWarnings)
.map(response => ({ fileId: response.fileId, fileWarnings: response.fileWarnings }));
this._dialogService.openDialog(
'confirm',
{
title: _('confirmation-dialog.approve-file.title'),
question: _('confirmation-dialog.approve-file.question'),
translateParams: { questionLength: files.length },
confirmationText: _('confirmation-dialog.approve-file.confirmationText'),
denyText: _('confirmation-dialog.approve-file.denyText'),
component: ApproveWarningDetailsComponent,
componentInputs: { data: signal(fileWarnings), files: signal(files) },
cancelButtonPrimary: true,
} as IConfirmationDialogData,
async () => {
this._loadingService.start();
await this._filesService.setApproved(files);
this._loadingService.stop();
},
);
}
assign(files: File[]): void {

View File

@ -1,161 +1,165 @@
<div
*ngIf="canPerformAnnotationActions && annotationPermissions"
[class.always-visible]="alwaysVisible || (helpModeService.isHelpModeActive$ | async)"
class="annotation-actions"
>
<!-- Resize Mode for annotation -> only resize accept and deny actions are available-->
<ng-container *ngIf="resizing && annotationPermissions.canResizeAnnotation">
<iqser-circle-button
(action)="acceptResize()"
[buttonId]="annotations.length === 1 ? 'annotation-' + annotations[0].id + '-accept_resize' : 'annotations-accept_resize'"
[class.disabled]="!resized"
[tooltipPosition]="tooltipPosition"
[tooltip]="resized ? ('annotation-actions.resize-accept.label' | translate) : ''"
[type]="buttonType"
icon="iqser:check"
></iqser-circle-button>
@if (canPerformAnnotationActions && annotationPermissions()) {
<div [class.always-visible]="alwaysVisible || (helpModeService.isHelpModeActive$ | async)" class="annotation-actions">
<!-- Resize Mode for annotation -> only resize accept and deny actions are available-->
<ng-container *ngIf="resizing() && annotationPermissions().canResizeAnnotation">
<iqser-circle-button
(action)="acceptResize()"
[buttonId]="
annotations().length === 1 ? 'annotation-' + annotations()[0].id + '-accept_resize' : 'annotations-accept_resize'
"
[class.disabled]="!resized"
[tooltipPosition]="tooltipPosition"
[tooltip]="resized ? ('annotation-actions.resize-accept.label' | translate) : ''"
[type]="buttonType"
icon="iqser:check"
></iqser-circle-button>
<iqser-circle-button
(action)="cancelResize()"
[buttonId]="annotations.length === 1 ? 'annotation-' + annotations[0].id + '-cancel_resize' : 'annotations-cancel_resize'"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.resize-cancel.label' | translate"
[type]="buttonType"
icon="iqser:close"
></iqser-circle-button>
</ng-container>
<iqser-circle-button
(action)="cancelResize()"
[buttonId]="
annotations().length === 1 ? 'annotation-' + annotations()[0].id + '-cancel_resize' : 'annotations-cancel_resize'
"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.resize-cancel.label' | translate"
[type]="buttonType"
icon="iqser:close"
></iqser-circle-button>
</ng-container>
<!-- Not resizing - standard actions -->
<ng-container *ngIf="!resizing">
<iqser-circle-button
(action)="resize()"
*ngIf="canResize"
[attr.help-mode-key]="helpModeKey('resize')"
[buttonId]="annotations.length === 1 ? 'annotation-' + annotations[0].id + '-resize' : 'annotations-resize'"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.resize.label' | translate"
[type]="buttonType"
icon="iqser:resize"
></iqser-circle-button>
<!-- Not resizing - standard actions -->
<ng-container *ngIf="!resizing()">
<iqser-circle-button
(action)="resize()"
*ngIf="canResize()"
[attr.help-mode-key]="helpModeKey('resize')"
[buttonId]="annotations().length === 1 ? 'annotation-' + annotations()[0].id + '-resize' : 'annotations-resize'"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.resize.label' | translate"
[type]="buttonType"
icon="iqser:resize"
></iqser-circle-button>
<iqser-circle-button
(action)="annotationActionsService.editRedaction(annotations)"
*ngIf="canEdit"
[attr.help-mode-key]="helpModeKey('edit')"
[buttonId]="annotations.length === 1 ? 'annotation-' + annotations[0].id + '-edit' : 'annotations-edit'"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.edit-redaction.label' | translate"
[type]="buttonType"
icon="iqser:edit"
></iqser-circle-button>
<iqser-circle-button
(action)="annotationActionsService.editRedaction(annotations())"
*ngIf="canEdit()"
[attr.help-mode-key]="helpModeKey('edit')"
[buttonId]="annotations().length === 1 ? 'annotation-' + annotations()[0].id + '-edit' : 'annotations-edit'"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.edit-redaction.label' | translate"
[type]="buttonType"
icon="iqser:edit"
></iqser-circle-button>
<iqser-circle-button
(action)="acceptRecommendation()"
*ngIf="canAcceptRecommendation"
[attr.help-mode-key]="helpModeKey('accept')"
[buttonId]="
annotations.length === 1
? 'annotation-' + annotations[0].id + '-accept_recommendation'
: 'annotations-accept_recommendation'
"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.accept-recommendation.label' | translate"
[type]="buttonType"
icon="iqser:check"
></iqser-circle-button>
<iqser-circle-button
(action)="acceptRecommendation()"
*ngIf="canAcceptRecommendation()"
[attr.help-mode-key]="helpModeKey('accept')"
[buttonId]="
annotations().length === 1
? 'annotation-' + annotations()[0].id + '-accept_recommendation'
: 'annotations-accept_recommendation'
"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.accept-recommendation.label' | translate"
[type]="buttonType"
icon="iqser:check"
></iqser-circle-button>
<iqser-circle-button
(action)="annotationActionsService.convertHighlights(annotations)"
*ngIf="viewModeService.isEarmarks()"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.convert-highlights.label' | translate"
[type]="buttonType"
icon="red:convert"
></iqser-circle-button>
<iqser-circle-button
(action)="annotationActionsService.convertHighlights(annotations())"
*ngIf="viewModeService.isEarmarks()"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.convert-highlights.label' | translate"
[type]="buttonType"
icon="red:convert"
></iqser-circle-button>
<iqser-circle-button
(action)="annotationActionsService.removeHighlights(annotations)"
*ngIf="viewModeService.isEarmarks()"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.remove-highlights.label' | translate"
[type]="buttonType"
icon="iqser:trash"
></iqser-circle-button>
<iqser-circle-button
(action)="annotationActionsService.removeHighlights(annotations())"
*ngIf="viewModeService.isEarmarks()"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.remove-highlights.label' | translate"
[type]="buttonType"
icon="iqser:trash"
></iqser-circle-button>
<iqser-circle-button
(action)="annotationActionsService.undoDirectAction(annotations)"
*allow="roles.redactions.deleteManual; if: annotationPermissions.canUndo"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.undo' | translate"
[type]="buttonType"
icon="red:undo"
></iqser-circle-button>
<iqser-circle-button
(action)="annotationActionsService.undoDirectAction(annotations())"
*allow="roles.redactions.deleteManual; if: annotationPermissions().canUndo"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.undo' | translate"
[type]="buttonType"
icon="red:undo"
></iqser-circle-button>
<iqser-circle-button
(action)="annotationReferencesService.show(annotations[0])"
*ngIf="multiSelectService.inactive() && annotations[0].reference.length"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.see-references.label' | translate"
[type]="buttonType"
icon="red:reference"
></iqser-circle-button>
<iqser-circle-button
(action)="annotationReferencesService.show(annotations()[0])"
*ngIf="multiSelectService.inactive() && annotations()[0].reference.length"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.see-references.label' | translate"
[type]="buttonType"
icon="red:reference"
></iqser-circle-button>
<iqser-circle-button
(action)="annotationActionsService.forceAnnotation(annotations)"
*ngIf="canForceRedaction"
[attr.help-mode-key]="isImageHint ? helpModeKey('redact') : helpModeKey('force')"
[buttonId]="annotations.length === 1 ? 'annotation-' + annotations[0].id + '-force_redaction' : 'annotations-force_redaction'"
[icon]="isImageHint ? 'red:pdftron-action-add-redaction' : 'iqser:thumb-up'"
[tooltipPosition]="tooltipPosition"
[tooltip]="
isImageHint
? ('annotation-actions.force-redaction.label-image-hint' | translate)
: ('annotation-actions.force-redaction.label' | translate)
"
[type]="buttonType"
></iqser-circle-button>
<iqser-circle-button
(action)="annotationActionsService.forceAnnotation(annotations())"
*ngIf="canForceRedaction()"
[attr.help-mode-key]="isImageHint() ? helpModeKey('redact') : helpModeKey('force')"
[buttonId]="
annotations().length === 1 ? 'annotation-' + annotations()[0].id + '-force_redaction' : 'annotations-force_redaction'
"
[icon]="isImageHint() ? 'red:pdftron-action-add-redaction' : 'iqser:thumb-up'"
[tooltipPosition]="tooltipPosition"
[tooltip]="
isImageHint()
? ('annotation-actions.force-redaction.label-image-hint' | translate)
: ('annotation-actions.force-redaction.label' | translate)
"
[type]="buttonType"
></iqser-circle-button>
<iqser-circle-button
(action)="annotationActionsService.forceAnnotation(annotations, true)"
*ngIf="canForceHint"
[attr.help-mode-key]="actionsHelpModeKey"
[buttonId]="annotations.length === 1 ? 'annotation-' + annotations[0].id + '-force_hint' : 'annotations-force_hint'"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.force-hint.label' | translate"
[type]="buttonType"
icon="iqser:thumb-up"
></iqser-circle-button>
<iqser-circle-button
(action)="annotationActionsService.forceAnnotation(annotations(), true)"
*ngIf="canForceHint()"
[attr.help-mode-key]="actionsHelpModeKey"
[buttonId]="annotations().length === 1 ? 'annotation-' + annotations()[0].id + '-force_hint' : 'annotations-force_hint'"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.force-hint.label' | translate"
[type]="buttonType"
icon="iqser:thumb-up"
></iqser-circle-button>
<iqser-circle-button
(action)="hideAnnotation()"
*ngIf="isImage && isVisible() && !hideSkipped"
[attr.help-mode-key]="helpModeKey('hide')"
[buttonId]="annotations.length === 1 ? 'annotation-' + annotations[0].id + '-hide' : 'annotations-hide'"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.hide' | translate"
[type]="buttonType"
icon="iqser:visibility-off"
></iqser-circle-button>
<iqser-circle-button
(action)="hideAnnotation()"
*ngIf="isImage() && isVisible() && !hideSkipped()"
[attr.help-mode-key]="helpModeKey('hide')"
[buttonId]="annotations().length === 1 ? 'annotation-' + annotations()[0].id + '-hide' : 'annotations-hide'"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.hide' | translate"
[type]="buttonType"
icon="iqser:visibility-off"
></iqser-circle-button>
<iqser-circle-button
(action)="showAnnotation()"
*ngIf="isImage && !isVisible() && !hideSkipped"
[buttonId]="annotations.length === 1 ? 'annotation-' + annotations[0].id + '-show' : 'annotations-show'"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.show' | translate"
[type]="buttonType"
icon="iqser:visibility"
></iqser-circle-button>
<iqser-circle-button
(action)="showAnnotation()"
*ngIf="isImage() && !isVisible() && !hideSkipped()"
[buttonId]="annotations().length === 1 ? 'annotation-' + annotations()[0].id + '-show' : 'annotations-show'"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.show' | translate"
[type]="buttonType"
icon="iqser:visibility"
></iqser-circle-button>
<iqser-circle-button
(action)="removeRedaction()"
*ngIf="canRemoveRedaction"
[attr.help-mode-key]="helpModeKey('remove')"
[buttonId]="annotations.length === 1 ? 'annotation-' + annotations[0].id + '-remove' : 'annotations-remove'"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.remove-annotation.remove-redaction' | translate"
[type]="buttonType"
icon="iqser:trash"
></iqser-circle-button>
</ng-container>
</div>
<iqser-circle-button
(action)="removeRedaction()"
*ngIf="canRemoveRedaction()"
[attr.help-mode-key]="helpModeKey('remove')"
[buttonId]="annotations().length === 1 ? 'annotation-' + annotations()[0].id + '-remove' : 'annotations-remove'"
[tooltipPosition]="tooltipPosition"
[tooltip]="'annotation-actions.remove-annotation.remove-redaction' | translate"
[type]="buttonType"
icon="iqser:trash"
></iqser-circle-button>
</ng-container>
</div>
}

View File

@ -1,6 +1,6 @@
import { AsyncPipe, NgIf } from '@angular/common';
import { Component, computed, Input, OnChanges } from '@angular/core';
import { CircleButtonComponent, getConfig, HelpModeService, IqserAllowDirective, IqserPermissionsService } from '@iqser/common-ui';
import { Component, computed, input, Input, untracked } from '@angular/core';
import { CircleButtonComponent, getConfig, HelpModeService, IqserPermissionsService } from '@iqser/common-ui';
import { AnnotationPermissions } from '@models/file/annotation.permissions';
import { AnnotationWrapper } from '@models/file/annotation.wrapper';
import { TranslateModule } from '@ngx-translate/core';
@ -26,23 +26,79 @@ export type AnnotationButtonType = keyof typeof AnnotationButtonTypes;
templateUrl: './annotation-actions.component.html',
styleUrls: ['./annotation-actions.component.scss'],
standalone: true,
imports: [CircleButtonComponent, NgIf, TranslateModule, AsyncPipe, IqserAllowDirective],
imports: [CircleButtonComponent, TranslateModule, AsyncPipe, NgIf],
})
export class AnnotationActionsComponent implements OnChanges {
#annotations: AnnotationWrapper[] = [];
export class AnnotationActionsComponent {
readonly #isDocumine = getConfig().IS_DOCUMINE;
protected _annotationId = '';
@Input() buttonType: AnnotationButtonType = AnnotationButtonTypes.default;
@Input() tooltipPosition: 'before' | 'above' = 'before';
@Input() canPerformAnnotationActions: boolean;
@Input() alwaysVisible: boolean;
@Input() actionsHelpModeKey: string;
readonly roles = Roles;
annotationPermissions: AnnotationPermissions;
isImage = true;
readonly annotations = input.required<AnnotationWrapper[], (AnnotationWrapper | undefined)[]>({
transform: value => value.filter(a => a !== undefined),
});
readonly isVisible = computed(() => {
const hidden = this._annotationManager.hidden();
return this.#annotations.reduce((acc, annotation) => !hidden.has(annotation.id) && acc, true);
return this.annotations().reduce((acc, annotation) => !hidden.has(annotation.id) && acc, true);
});
readonly somePending = computed(() => {
return this.annotations().some(a => a.pending);
});
readonly sameType = computed(() => {
const annotations = this.annotations();
const type = annotations[0].superType;
return annotations.every(a => a.superType === type);
});
readonly resizing = computed(() => {
return this.annotations().length === 1 && this.annotations()[0].id === this._annotationManager.resizingAnnotationId;
});
readonly viewerAnnotations = computed(() => {
return this._annotationManager.get(this.annotations());
});
readonly annotationPermissions = computed(() =>
AnnotationPermissions.forUser(
this._permissionsService.isApprover(this._state.dossier()),
this.annotations(),
this._state.dictionaries,
this._iqserPermissionsService,
this._state.file().excludedFromAutomaticAnalysis,
),
);
readonly hideSkipped = computed(() => this.skippedService.hideSkipped() && this.annotations().some(a => a.isSkipped));
readonly isImageHint = computed(() => this.annotations().every(a => a.IMAGE_HINT));
readonly isImage = computed(() => this.annotations().reduce((acc, a) => acc && a.isImage, true));
readonly annotationChangesAllowed = computed(
() => (!this.#isDocumine || !this._state.file().excludedFromAutomaticAnalysis) && !this.somePending(),
);
readonly canRemoveRedaction = computed(
() => this.annotationChangesAllowed() && this.annotationPermissions().canRemoveRedaction && this.sameType(),
);
readonly canForceRedaction = computed(() => this.annotationChangesAllowed() && this.annotationPermissions().canForceRedaction);
readonly canForceHint = computed(() => this.annotationChangesAllowed() && this.annotationPermissions().canForceHint);
readonly canAcceptRecommendation = computed(
() => this.annotationChangesAllowed() && this.annotationPermissions().canAcceptRecommendation,
);
readonly canResize = computed(
() => this.annotationChangesAllowed() && this.annotationPermissions().canResizeAnnotation && this.annotations().length === 1,
);
readonly canEdit = computed(() => {
const canEditRedactions =
this.annotationPermissions().canChangeLegalBasis ||
this.annotationPermissions().canRecategorizeAnnotation ||
this.annotationPermissions().canForceHint ||
this.annotationPermissions().canForceRedaction;
return (
this.annotationChangesAllowed() &&
(this.annotations().length > 1
? this.#isDocumine
? this.annotationPermissions().canEditAnnotations
: this.annotationPermissions().canEditHints ||
this.annotationPermissions().canEditImages ||
this.annotationPermissions().canEditAnnotations
: canEditRedactions)
);
});
constructor(
@ -58,137 +114,54 @@ export class AnnotationActionsComponent implements OnChanges {
readonly annotationReferencesService: AnnotationReferencesService,
) {}
get annotations(): AnnotationWrapper[] {
return this.#annotations;
}
@Input()
set annotations(annotations: AnnotationWrapper[]) {
this.#annotations = annotations.filter(a => a !== undefined);
this.isImage = this.#annotations?.reduce((accumulator, annotation) => annotation.isImage && accumulator, true);
this._annotationId = this.#annotations[0]?.id;
}
get isImageHint(): boolean {
return this.annotations.every(annotation => annotation.IMAGE_HINT);
}
get canEdit(): boolean {
const canEditRedactions =
this.annotationPermissions.canChangeLegalBasis ||
this.annotationPermissions.canRecategorizeAnnotation ||
this.annotationPermissions.canForceHint ||
this.annotationPermissions.canForceRedaction;
return (
this.#annotationChangesAllowed &&
(this.annotations.length > 1
? this.#isDocumine
? this.annotationPermissions.canEditAnnotations
: this.annotationPermissions.canEditHints ||
this.annotationPermissions.canEditImages ||
this.annotationPermissions.canEditAnnotations
: canEditRedactions)
);
}
get canResize(): boolean {
return this.#annotationChangesAllowed && this.annotationPermissions.canResizeAnnotation && this.annotations.length === 1;
}
get canRemoveRedaction(): boolean {
return this.#annotationChangesAllowed && this.annotationPermissions.canRemoveRedaction && this.#sameType;
}
get canForceRedaction() {
return this.#annotationChangesAllowed && this.annotationPermissions.canForceRedaction;
}
get canForceHint() {
return this.#annotationChangesAllowed && this.annotationPermissions.canForceHint;
}
get canAcceptRecommendation() {
return this.#annotationChangesAllowed && this.annotationPermissions.canAcceptRecommendation;
}
get viewerAnnotations() {
return this._annotationManager.get(this.#annotations);
}
get resizing() {
return this.#annotations?.length === 1 && this.#annotations?.[0].id === this._annotationManager.resizingAnnotationId;
}
get resized() {
get resized(): boolean {
return this._annotationManager.annotationHasBeenResized;
}
get hideSkipped() {
return this.skippedService.hideSkipped() && this.annotations.some(a => a.isSkipped);
async removeRedaction(): Promise<void> {
const annotations = untracked(this.annotations);
const permissions = untracked(this.annotationPermissions);
await this.annotationActionsService.removeRedaction(annotations, permissions);
}
get #sameType() {
const type = this.annotations[0].superType;
return this.annotations.every(a => a.superType === type);
}
get #annotationChangesAllowed() {
return (!this.#isDocumine || !this._state.file().excludedFromAutomaticAnalysis) && !this.#somePending;
}
get #somePending() {
return this.#annotations.some(a => a.pending);
}
ngOnChanges(): void {
this.#setPermissions();
}
removeRedaction() {
this.annotationActionsService.removeRedaction(this.annotations, this.annotationPermissions);
}
acceptRecommendation() {
return this.annotationActionsService.convertRecommendationToAnnotation(this.annotations);
async acceptRecommendation(): Promise<void> {
const annotations = untracked(this.annotations);
await this.annotationActionsService.convertRecommendationToAnnotation(annotations);
}
hideAnnotation() {
this._annotationManager.hide(this.viewerAnnotations);
const viewerAnnotations = untracked(this.viewerAnnotations);
this._annotationManager.hide(viewerAnnotations);
this._annotationManager.deselect();
this._annotationManager.addToHidden(this.viewerAnnotations[0].Id);
this._annotationManager.addToHidden(viewerAnnotations[0].Id);
}
showAnnotation() {
this._annotationManager.show(this.viewerAnnotations);
const viewerAnnotations = untracked(this.viewerAnnotations);
this._annotationManager.show(viewerAnnotations);
this._annotationManager.deselect();
this._annotationManager.removeFromHidden(this.viewerAnnotations[0].Id);
this._annotationManager.removeFromHidden(viewerAnnotations[0].Id);
}
resize() {
return this.annotationActionsService.resize(this.#annotations[0]);
const annotations = untracked(this.annotations);
return this.annotationActionsService.resize(annotations[0]);
}
acceptResize() {
if (this.resized) {
return this.annotationActionsService.acceptResize(this.#annotations[0], this.annotationPermissions);
const annotations = untracked(this.annotations);
const permissions = untracked(this.annotationPermissions);
return this.annotationActionsService.acceptResize(annotations[0], permissions);
}
}
cancelResize() {
return this.annotationActionsService.cancelResize(this.#annotations[0]);
const annotations = untracked(this.annotations);
return this.annotationActionsService.cancelResize(annotations[0]);
}
helpModeKey(action: string) {
return this.#isDocumine ? `${action}_annotation` : `${this.actionsHelpModeKey}_${action}`;
}
#setPermissions() {
this.annotationPermissions = AnnotationPermissions.forUser(
this._permissionsService.isApprover(this._state.dossier()),
this.#annotations,
this._state.dictionaries,
this._iqserPermissionsService,
this._state.file().excludedFromAutomaticAnalysis,
);
}
}

View File

@ -1,29 +1,31 @@
<div
*ngIf="noSelection && changesTooltip"
[matTooltip]="changesTooltip"
class="chip"
matTooltipClass="multiline"
matTooltipPosition="above"
>
<mat-icon [svgIcon]="'red:redaction-changes'"></mat-icon>
</div>
@if (_noSelection() && _changesTooltip()) {
<div [matTooltip]="_changesTooltip()" class="chip" matTooltipClass="multiline" matTooltipPosition="above">
<mat-icon [svgIcon]="'red:redaction-changes'"></mat-icon>
</div>
}
<ng-container *ngIf="noSelection && engines">
<div #trigger="cdkOverlayOrigin" (mouseout)="isPopoverOpen = false" (mouseover)="isPopoverOpen = true" cdkOverlayOrigin class="chip">
<mat-icon *ngFor="let engine of engines" [svgIcon]="engine.icon"></mat-icon>
@if (_noSelection() && _engines()) {
<div
#trigger="cdkOverlayOrigin"
(mouseout)="_isPopoverOpen.set(false)"
(mouseover)="_isPopoverOpen.set(true)"
cdkOverlayOrigin
class="chip"
>
<mat-icon *ngFor="let engine of _engines()" [svgIcon]="engine.icon"></mat-icon>
</div>
<ng-template
[cdkConnectedOverlayOffsetY]="-8"
[cdkConnectedOverlayOpen]="isPopoverOpen"
[cdkConnectedOverlayOpen]="_isPopoverOpen()"
[cdkConnectedOverlayOrigin]="trigger"
cdkConnectedOverlay
>
<div class="popover">
<div *ngFor="let engine of engines" class="flex-align-items-center">
<div *ngFor="let engine of _engines()" class="flex-align-items-center">
<mat-icon [svgIcon]="engine.icon"></mat-icon>
<span [innerHTML]="engine.description | translate : engine.translateParams"></span>
<span [innerHTML]="engine.description | translate: engine.translateParams"></span>
</div>
</div>
</ng-template>
</ng-container>
}

View File

@ -2,7 +2,7 @@
display: flex;
position: absolute;
top: 6px;
right: 19px;
right: 8px;
}
.popover {

View File

@ -1,4 +1,4 @@
import { Component, inject, Input, OnChanges } from '@angular/core';
import { Component, computed, inject, input, signal } from '@angular/core';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { KeysOf } from '@iqser/common-ui/lib/utils';
import { AnnotationWrapper } from '@models/file/annotation.wrapper';
@ -33,30 +33,27 @@ const changesProperties: KeysOf<AnnotationWrapper>[] = [
];
@Component({
selector: 'redaction-annotation-details [annotation]',
selector: 'redaction-annotation-details',
templateUrl: './annotation-details.component.html',
styleUrls: ['./annotation-details.component.scss'],
standalone: true,
imports: [NgIf, MatTooltip, MatIcon, CdkOverlayOrigin, NgForOf, CdkConnectedOverlay, TranslateModule],
})
export class AnnotationDetailsComponent implements OnChanges {
@Input() annotation: ListItem<AnnotationWrapper>;
isPopoverOpen = false;
engines: Engine[];
changesTooltip: string;
noSelection: boolean;
export class AnnotationDetailsComponent {
readonly annotation = input.required<ListItem<AnnotationWrapper>>();
protected readonly _isPopoverOpen = signal(false);
protected readonly _engines = computed(() => this.#extractEngines(this.annotation().item).filter(engine => engine.show));
private readonly _translateService = inject(TranslateService);
private readonly _multiSelectService = inject(MultiSelectService);
protected readonly _changesTooltip = computed(() => {
const annotation = this.annotation().item;
const changes = changesProperties.filter(key => annotation[key]);
getChangesTooltip(): string | undefined {
const changes = changesProperties.filter(key => this.annotation.item[key]);
if (!changes.length && !this.annotation.item.engines?.includes(LogEntryEngines.MANUAL)) {
if (!changes.length && !annotation.engines?.includes(LogEntryEngines.MANUAL)) {
return;
}
const details = [];
if (this.annotation.item.engines?.includes(LogEntryEngines.MANUAL)) {
if (annotation.engines?.includes(LogEntryEngines.MANUAL)) {
details.push(this._translateService.instant(_('annotation-changes.added-locally')));
}
@ -66,13 +63,9 @@ export class AnnotationDetailsComponent implements OnChanges {
const header = this._translateService.instant(_('annotation-changes.header'));
return [header, ...details.map(change => `${change}`)].join('\n');
}
ngOnChanges() {
this.engines = this.#extractEngines(this.annotation.item).filter(engine => engine.show);
this.changesTooltip = this.getChangesTooltip();
this.noSelection = !this.annotation.isSelected || this._multiSelectService.inactive();
}
});
private readonly _multiSelectService = inject(MultiSelectService);
protected readonly _noSelection = computed(() => !this.annotation().isSelected || this._multiSelectService.inactive());
#extractEngines(annotation: AnnotationWrapper): Engine[] {
return [

View File

@ -1,37 +1,44 @@
<div class="active-bar-marker"></div>
<div [class.removed]="annotation.item.isRemoved" class="annotation">
<div #annotationDiv [class.removed]="annotation().item.isRemoved" class="annotation">
<redaction-annotation-card
[annotation]="annotation.item"
[isSelected]="annotation.isSelected"
[matTooltip]="annotation.item.content"
[annotation]="annotation().item"
[isSelected]="annotation().isSelected"
[matTooltip]="annotation().item.content.translation | translate: annotation().item.content.params | replaceNbsp"
matTooltipPosition="above"
></redaction-annotation-card>
<div *ngIf="!annotation.item.isEarmark" class="actions-wrapper">
<div
*ngIf="!annotation.item.pending"
(click)="showComments = !showComments"
[matTooltip]="'comments.comments' | translate: { count: annotation.item.numberOfComments }"
class="comments-counter"
iqserStopPropagation
matTooltipPosition="above"
>
<mat-icon svgIcon="red:comment"></mat-icon>
{{ annotation.item.numberOfComments }}
</div>
@if (!annotation().item.isEarmark) {
<div class="actions-wrapper">
@if (!annotation().item.pending) {
<div
(click)="showComments = !showComments"
[matTooltip]="'comments.comments' | translate: { count: annotation().item.numberOfComments }"
class="comments-counter"
iqserStopPropagation
matTooltipPosition="above"
>
<mat-icon svgIcon="red:comment"></mat-icon>
{{ annotation().item.numberOfComments }}
</div>
}
<div *ngIf="multiSelectService.inactive()" class="actions">
<redaction-annotation-actions
[annotations]="[annotation.item]"
[actionsHelpModeKey]="actionsHelpModeKey"
[canPerformAnnotationActions]="pdfProxyService.canPerformActions()"
></redaction-annotation-actions>
@if (multiSelectService.inactive()) {
@defer (on hover(annotationDiv)) {
<div class="actions">
<redaction-annotation-actions
[actionsHelpModeKey]="actionsHelpModeKey()"
[annotations]="[annotation().item]"
[canPerformAnnotationActions]="pdfProxyService.canPerformActions()"
></redaction-annotation-actions>
</div>
}
}
</div>
</div>
}
<ng-container *ngIf="showComments">
<redaction-comments [annotation]="annotation.item"></redaction-comments>
@if (showComments) {
<redaction-comments [annotation]="annotation().item"></redaction-comments>
<div
(click)="showComments = false"
@ -39,7 +46,7 @@
iqserStopPropagation
translate="comments.hide-comments"
></div>
</ng-container>
}
</div>
<redaction-annotation-details [annotation]="annotation"></redaction-annotation-details>
<redaction-annotation-details [annotation]="annotation()"></redaction-annotation-details>

View File

@ -1,4 +1,4 @@
import { Component, HostBinding, inject, Input, OnChanges } from '@angular/core';
import { Component, computed, effect, HostBinding, inject, input } from '@angular/core';
import { getConfig, StopPropagationDirective } from '@iqser/common-ui';
import { AnnotationWrapper } from '@models/file/annotation.wrapper';
import { ListItem } from '@models/file/list-item';
@ -6,13 +6,13 @@ import { MultiSelectService } from '../../services/multi-select.service';
import { PdfProxyService } from '../../services/pdf-proxy.service';
import { ActionsHelpModeKeys } from '../../utils/constants';
import { AnnotationCardComponent } from '../annotation-card/annotation-card.component';
import { NgIf } from '@angular/common';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { MatIcon } from '@angular/material/icon';
import { AnnotationActionsComponent } from '../annotation-actions/annotation-actions.component';
import { CommentsComponent } from '../comments/comments.component';
import { AnnotationDetailsComponent } from '../annotation-details/annotation-details.component';
import { ReplaceNbspPipe } from '@common-ui/pipes/replace-nbsp.pipe';
@Component({
selector: 'redaction-annotation-wrapper',
@ -21,7 +21,6 @@ import { AnnotationDetailsComponent } from '../annotation-details/annotation-det
standalone: true,
imports: [
AnnotationCardComponent,
NgIf,
MatTooltip,
TranslateModule,
StopPropagationDirective,
@ -29,28 +28,31 @@ import { AnnotationDetailsComponent } from '../annotation-details/annotation-det
AnnotationActionsComponent,
CommentsComponent,
AnnotationDetailsComponent,
ReplaceNbspPipe,
],
})
export class AnnotationWrapperComponent implements OnChanges {
readonly #isDocumine = getConfig().IS_DOCUMINE;
export class AnnotationWrapperComponent {
readonly annotation = input.required<ListItem<AnnotationWrapper>>();
@HostBinding('attr.annotation-id') annotationId: string;
@HostBinding('class.active') active: boolean;
readonly actionsHelpModeKey = computed(() => this.#getActionsHelpModeKey(this.annotation().item));
showComments = false;
protected readonly pdfProxyService = inject(PdfProxyService);
protected readonly multiSelectService = inject(MultiSelectService);
@Input({ required: true }) annotation!: ListItem<AnnotationWrapper>;
@HostBinding('attr.annotation-id') annotationId: string;
@HostBinding('class.active') active = false;
actionsHelpModeKey: string;
showComments = false;
readonly #isDocumine = getConfig().IS_DOCUMINE;
ngOnChanges() {
this.annotationId = this.annotation.item.id;
this.active = this.annotation.isSelected;
this.actionsHelpModeKey = this.#getActionsHelpModeKey();
constructor() {
effect(() => {
this.active = this.annotation().isSelected;
this.annotationId = this.annotation().item.id;
});
}
#getActionsHelpModeKey(): string {
#getActionsHelpModeKey(item: AnnotationWrapper): string {
if (!this.#isDocumine) {
const superType = this.annotation.item.superTypeLabel?.split('.')[1];
const type = this.annotation.item.type;
const superType = item.superTypeLabel?.split('.')[1];
const type = item.type;
if (superType === 'hint' && (type === 'ocr' || type === 'formula' || type === 'image')) {
return ActionsHelpModeKeys[`${superType}-${type}`];
}

View File

@ -1,16 +1,16 @@
<ng-container *ngFor="let annotation of annotations; let idx = index; trackBy: _trackBy">
<div *ngIf="showHighlightGroup(idx) as highlightGroup" class="workload-separator">
@for (annotation of annotations(); track annotation.item.id) {
@if (displayedHighlightGroups()[$index]; as highlightGroup) {
<redaction-highlights-separator [annotation]="annotation.item" [highlightGroup]="highlightGroup"></redaction-highlights-separator>
</div>
}
<redaction-annotation-wrapper
*ngIf="!annotation.item.hiddenInWorkload"
(click)="annotationClicked(annotation.item, $event)"
*ngIf="!annotation.item.hiddenInWorkload"
[annotation]="annotation"
[id]="'annotation-' + annotation.item.id"
[class.documine-wrapper]="isDocumine"
[id]="'annotation-' + annotation.item.id"
></redaction-annotation-wrapper>
</ng-container>
}
<redaction-annotation-references-list
(referenceClicked)="referenceClicked($event)"

View File

@ -3,21 +3,13 @@
:host {
width: 100%;
position: relative;
overflow: hidden;
&:hover {
overflow-y: auto;
@include common-mixins.scroll-bar;
}
overflow-y: auto;
@include common-mixins.scroll-bar;
&.has-scrollbar:hover redaction-annotation-wrapper::ng-deep,
&::ng-deep.documine-wrapper {
.annotation {
padding-right: 5px;
}
redaction-annotation-details {
right: 8px;
}
}
}

View File

@ -1,5 +1,6 @@
import { NgForOf, NgIf } from '@angular/common';
import { Component, computed, ElementRef, EventEmitter, Input, Output } from '@angular/core';
import { Clipboard } from '@angular/cdk/clipboard';
import { NgIf } from '@angular/common';
import { Component, computed, ElementRef, input, output } from '@angular/core';
import { getConfig, HasScrollbarDirective } from '@iqser/common-ui';
import { FilterService } from '@iqser/common-ui/lib/filtering';
import { IqserEventTarget } from '@iqser/common-ui/lib/utils';
@ -21,18 +22,29 @@ import { HighlightsSeparatorComponent } from '../highlights-separator/highlights
templateUrl: './annotations-list.component.html',
styleUrls: ['./annotations-list.component.scss'],
standalone: true,
imports: [NgForOf, NgIf, HighlightsSeparatorComponent, AnnotationWrapperComponent, AnnotationReferencesListComponent],
imports: [NgIf, HighlightsSeparatorComponent, AnnotationWrapperComponent, AnnotationReferencesListComponent],
})
export class AnnotationsListComponent extends HasScrollbarDirective {
readonly #earmarkGroups = computed(() => {
if (this._viewModeService.isEarmarks()) {
return this.#getEarmarksGroups();
}
return [] as EarmarkGroup[];
});
protected readonly isDocumine = getConfig().IS_DOCUMINE;
@Input({ required: true }) annotations: ListItem<AnnotationWrapper>[];
@Output() readonly pagesPanelActive = new EventEmitter<boolean>();
readonly annotations = input.required<ListItem<AnnotationWrapper>[]>();
readonly pagesPanelActive = output<boolean>();
readonly displayedHighlightGroups = computed(() => {
const isEarMarks = this._viewModeService.isEarmarks();
let result: Record<number, EarmarkGroup> = {};
if (isEarMarks) {
const annotations = this.annotations();
const earmarkGroups = this.#getEarmarksGroups(annotations);
for (let idx = 0; idx < annotations.length; ++idx) {
const group = earmarkGroups.find(h => h.startIdx === idx);
if (group) {
result[idx] = group;
}
}
}
return result;
});
constructor(
protected readonly _elementRef: ElementRef,
@ -43,11 +55,15 @@ export class AnnotationsListComponent extends HasScrollbarDirective {
private readonly _annotationManager: REDAnnotationManager,
private readonly _listingService: AnnotationsListingService,
readonly annotationReferencesService: AnnotationReferencesService,
private readonly clipboard: Clipboard,
) {
super(_elementRef);
}
annotationClicked(annotation: AnnotationWrapper, $event: MouseEvent): void {
if ($event.ctrlKey && $event.altKey) {
this.clipboard.copy(annotation.id);
}
if (this._userPreferenceService.isIqserDevMode) {
console.log('Selected Annotation:', annotation);
}
@ -68,6 +84,7 @@ export class AnnotationsListComponent extends HasScrollbarDirective {
this._multiSelectService.activate();
}
this._listingService.selectAnnotations(annotation);
this._annotationManager.setSelectedFromWorkload();
}
referenceClicked(annotation: AnnotationWrapper): void {
@ -77,26 +94,20 @@ export class AnnotationsListComponent extends HasScrollbarDirective {
}
}
showHighlightGroup(idx: number): EarmarkGroup {
return this._viewModeService.isEarmarks() && this.#earmarkGroups().find(h => h.startIdx === idx);
}
protected readonly _trackBy = (index: number, listItem: ListItem<AnnotationWrapper>) => listItem.item.id;
#getEarmarksGroups() {
#getEarmarksGroups(annotations: ListItem<AnnotationWrapper>[]) {
const earmarksGroups: EarmarkGroup[] = [];
if (!this.annotations?.length) {
if (!annotations.length) {
return earmarksGroups;
}
let lastGroup: EarmarkGroup;
for (let idx = 0; idx < this.annotations.length; ++idx) {
if (idx === 0 || this.annotations[idx].item.color !== this.annotations[idx - 1].item.color) {
for (let idx = 0; idx < annotations.length; ++idx) {
if (idx === 0 || annotations[idx].item.color !== annotations[idx - 1].item.color) {
if (lastGroup) {
earmarksGroups.push(lastGroup);
}
lastGroup = { startIdx: idx, length: 1, color: this.annotations[idx].item.color };
lastGroup = { startIdx: idx, length: 1, color: annotations[idx].item.color };
} else {
lastGroup.length += 1;
}

View File

@ -32,7 +32,7 @@
<div cdkDrag class="editing-value">
<mat-icon
[class.hidden-button]="currentEntry().componentValues.length === 1"
[attr.help-mode-key]="'change_component_order'"
[attr.help-mode-key]="currentEntry().componentValues.length > 1 ? 'change_component_order' : null"
cdkDragHandle
class="draggable"
svgIcon="red:draggable-dots"
@ -44,7 +44,7 @@
(action)="removeValue($index)"
[tooltip]="'component-management.actions.delete' | translate"
[class.hidden-button]="currentEntry().componentValues.length === 1"
[attr.help-mode-key]="'remove_component_value'"
[attr.help-mode-key]="currentEntry().componentValues.length > 1 ? 'remove_component_value' : null"
class="remove-value"
icon="iqser:trash"
></iqser-circle-button>

View File

@ -2,12 +2,13 @@
display: flex;
flex-direction: row;
padding: 10px 0 10px 0;
margin-left: 26px;
margin-right: 26px;
margin-left: 14px;
margin-right: 20px;
position: relative;
.component {
width: 40%;
margin-right: 8px;
}
.value {
@ -72,13 +73,13 @@
}
.value {
margin-right: 26px;
margin-right: 20px;
}
}
&:hover {
.component {
margin-left: 26px;
margin-left: 14px;
}
.value {
@ -94,7 +95,7 @@
border-left: 4px solid var(--iqser-primary);
.component {
margin-left: 22px;
margin-left: 10px;
}
.arrow-right {

View File

@ -32,11 +32,11 @@
</ng-template>
</ng-container>
@if (displayedAnnotations$ | async; as annotations) {
@if (filteredAnnotations$ | async; as annotations) {
<div class="right-content">
<ng-container *ngIf="!isDocumine">
<redaction-readonly-banner
*ngIf="showAnalysisDisabledBanner; else readOnlyBanner"
*ngIf="showAnalysisDisabledBanner(); else readOnlyBanner"
[customTranslation]="translations.analysisDisabled"
></redaction-readonly-banner>
<ng-template #readOnlyBanner>
@ -112,7 +112,7 @@
></iqser-circle-button>
<span
[translateParams]="{ page: pdf.currentPage(), count: activeAnnotations.length }"
[translateParams]="{ page: pdf.currentPage(), count: activeAnnotations().length }"
[translate]="'page'"
class="all-caps-label"
></span>
@ -200,7 +200,7 @@
<redaction-annotations-list
(pagesPanelActive)="pagesPanelActive = $event"
[annotations]="annotations.get(pdf.currentPage())"
[annotations]="annotationsList$ | async"
></redaction-annotations-list>
</div>
}
@ -224,7 +224,7 @@
</ng-template>
<ng-template #documineHeader>
<span [translate]="'annotations'" [attr.help-mode-key]="'annotations_list'"></span>
<span [attr.help-mode-key]="'annotations_list'" [translate]="'annotations'"></span>
<ng-container *ngTemplateOutlet="annotationsFilter"></ng-container>
</ng-template>

View File

@ -43,7 +43,7 @@
flex-direction: column;
&.documine-width {
width: calc(var(--documine-workload-content-width) - 50px);
width: calc(var(--documine-workload-content-width) - 55px);
border-right: 1px solid var(--iqser-separator);
z-index: 1;

View File

@ -9,6 +9,7 @@ import {
OnDestroy,
OnInit,
TemplateRef,
untracked,
viewChild,
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
@ -36,7 +37,7 @@ import { workloadTranslations } from '@translations/workload-translations';
import { UserPreferenceService } from '@users/user-preference.service';
import { getLocalStorageDataByFileId } from '@utils/local-storage';
import { combineLatest, delay, Observable } from 'rxjs';
import { map, tap } from 'rxjs/operators';
import { filter, map, tap } from 'rxjs/operators';
import scrollIntoView from 'scroll-into-view-if-needed';
import { REDAnnotationManager } from '../../../pdf-viewer/services/annotation-manager.service';
import { REDDocumentViewer } from '../../../pdf-viewer/services/document-viewer.service';
@ -57,6 +58,7 @@ import { PageExclusionComponent } from '../page-exclusion/page-exclusion.compone
import { PagesComponent } from '../pages/pages.component';
import { ReadonlyBannerComponent } from '../readonly-banner/readonly-banner.component';
import { DocumentInfoComponent } from '../document-info/document-info.component';
import { getLast } from '@utils/functions';
const COMMAND_KEY_ARRAY = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Escape'];
const ALL_HOTKEY_ARRAY = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'];
@ -89,23 +91,30 @@ const ALL_HOTKEY_ARRAY = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'];
],
})
export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, OnDestroy {
private readonly _annotationsElement = viewChild<ElementRef>('annotationsElement');
private readonly _quickNavigationElement = viewChild<ElementRef>('quickNavigation');
readonly multiSelectTemplate = viewChild<TemplateRef<any>>('multiSelect');
readonly #isIqserDevMode = this._userPreferenceService.isIqserDevMode;
readonly annotationsList$: Observable<ListItem<AnnotationWrapper>[]>;
readonly allPages = computed(() => Array.from({ length: this.state.file()?.numberOfPages }, (_x, i) => i + 1));
protected readonly iconButtonTypes = IconButtonTypes;
protected readonly circleButtonTypes = CircleButtonTypes;
protected readonly displayedAnnotations$: Observable<Map<number, ListItem<AnnotationWrapper>[]>>;
protected readonly filteredAnnotations$: Observable<Map<number, ListItem<AnnotationWrapper>[]>>;
protected readonly title = computed(() =>
this.viewModeService.isEarmarks() ? _('file-preview.tabs.highlights.label') : _('file-preview.tabs.annotations.label'),
);
protected readonly currentPageIsExcluded = computed(() => this.state.file().excludedPages.includes(this.pdf.currentPage()));
protected readonly translations = workloadTranslations;
protected readonly isDocumine = getConfig().IS_DOCUMINE;
readonly showAnalysisDisabledBanner = computed(() => {
const file = this.state.file();
return this.isDocumine && file.excludedFromAutomaticAnalysis && file.workflowStatus !== WorkflowFileStatuses.APPROVED;
});
protected displayedAnnotations = new Map<number, AnnotationWrapper[]>();
readonly activeAnnotations = computed(() => this.displayedAnnotations.get(this.pdf.currentPage()) || []);
protected displayedPages: number[] = [];
protected pagesPanelActive = true;
protected enabledFilters = [];
private readonly _annotationsElement = viewChild<ElementRef>('annotationsElement');
private readonly _quickNavigationElement = viewChild<ElementRef>('quickNavigation');
readonly #isIqserDevMode = this._userPreferenceService.isIqserDevMode;
#displayedPagesChanged = false;
constructor(
@ -129,10 +138,9 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
) {
super();
// TODO: ngOnDetach is not called here, so we need to unsubscribe manually
this.addActiveScreenSubscription = this.pdf.currentPage$.subscribe(pageNumber => {
effect(() => {
this._scrollViews();
this.scrollAnnotationsToPage(pageNumber, 'always');
this.scrollAnnotationsToPage(this.pdf.currentPage(), 'always');
});
this.addActiveScreenSubscription = this.listingService.selected$.subscribe(annotationIds => {
@ -146,7 +154,11 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
this.handleKeyEvent($event);
});
this.displayedAnnotations$ = this._displayedAnnotations$;
this.filteredAnnotations$ = this._filteredAnnotations$;
this.annotationsList$ = combineLatest([this.filteredAnnotations$, this.pdf.currentPage$]).pipe(
map(([annotations, page]) => annotations.get(page)),
);
effect(() => {
if (this.multiSelectService.inactive()) {
@ -169,24 +181,16 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
);
}
get activeAnnotations(): AnnotationWrapper[] {
return this.displayedAnnotations.get(this.pdf.currentPage()) || [];
}
get showAnalysisDisabledBanner() {
const file = this.state.file();
return this.isDocumine && file.excludedFromAutomaticAnalysis && file.workflowStatus !== WorkflowFileStatuses.APPROVED;
}
private get _firstSelectedAnnotation() {
return this.listingService.selected.length ? this.listingService.selected[0] : null;
}
private get _displayedAnnotations$(): Observable<Map<number, ListItem<AnnotationWrapper>[]>> {
private get _filteredAnnotations$(): Observable<Map<number, ListItem<AnnotationWrapper>[]>> {
const primary$ = this.filterService.getFilterModels$('primaryFilters');
const secondary$ = this.filterService.getFilterModels$('secondaryFilters');
return combineLatest([
this._documentViewer.loaded$,
this.fileDataService.all$,
primary$,
secondary$,
@ -195,7 +199,8 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
this._pageRotationService.rotations$,
]).pipe(
delay(0),
map(([annotations, primary, secondary, componentReferenceIds]) =>
filter(([loaded]) => loaded),
map(([, annotations, primary, secondary, componentReferenceIds]) =>
this.#filterAnnotations(annotations, primary, secondary, componentReferenceIds),
),
map(annotations => this.#mapListItemsFromAnnotationWrapperArray(annotations)),
@ -203,10 +208,6 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
);
}
get #allPages() {
return Array.from({ length: this.state.file()?.numberOfPages }, (_x, i) => i + 1);
}
private static _scrollToFirstElement(elements: HTMLElement[], mode: 'always' | 'if-needed' = 'if-needed') {
if (elements.length > 0) {
scrollIntoView(elements[0], {
@ -220,12 +221,13 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
ngOnInit(): void {
setTimeout(() => {
const showExcludePages = getLocalStorageDataByFileId(this.state.file()?.id, 'show-exclude-pages') ?? false;
const file = untracked(this.state.file);
const showExcludePages = getLocalStorageDataByFileId(file?.id, 'show-exclude-pages') ?? false;
if (showExcludePages) {
this.excludedPagesService.show();
}
const showDocumentInfo = getLocalStorageDataByFileId(this.state.file()?.id, 'show-document-info') ?? false;
const showDocumentInfo = getLocalStorageDataByFileId(file?.id, 'show-document-info') ?? false;
if (showDocumentInfo) {
this.documentInfoService.show();
}
@ -233,16 +235,20 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
}
selectAllOnActivePage() {
this.listingService.selectAnnotations(this.activeAnnotations);
const activeAnnotations = untracked(this.activeAnnotations);
this.listingService.selectAnnotations(activeAnnotations);
}
deselectAllOnActivePage(): void {
this.listingService.deselect(this.activeAnnotations);
this.annotationManager.deselect(this.activeAnnotations);
const activeAnnotations = untracked(this.activeAnnotations);
this.listingService.deselect(activeAnnotations);
this.annotationManager.deselect(activeAnnotations);
}
@HostListener('window:keyup', ['$event'])
handleKeyEvent($event: KeyboardEvent): void {
const multiSelectServiceInactive = untracked(this.multiSelectService.inactive);
if (
!ALL_HOTKEY_ARRAY.includes($event.key) ||
this._dialog.openDialogs.length ||
@ -262,7 +268,7 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
// if we activated annotationsPanel -
// select first annotation from this page in case there is no
// selected annotation on this page and not in multi select mode
if (!this.pagesPanelActive && this.multiSelectService.inactive()) {
if (!this.pagesPanelActive && multiSelectServiceInactive) {
this._documentViewer.clearSelection();
this.#selectFirstAnnotationOnCurrentPageIfNecessary();
}
@ -273,7 +279,7 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
if (!this.pagesPanelActive) {
// Disable annotation navigation in multi select mode
// => TODO: maybe implement selection on enter?
if (this.multiSelectService.inactive()) {
if (multiSelectServiceInactive) {
this.navigateAnnotations($event);
}
} else {
@ -284,7 +290,7 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
}
scrollAnnotations(): void {
const currentPage = this.pdf.currentPage();
const currentPage = untracked(this.pdf.currentPage);
if (this._firstSelectedAnnotation?.pageNumber === currentPage) {
return;
}
@ -292,27 +298,27 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
}
scrollAnnotationsToPage(page: number, mode: 'always' | 'if-needed' = 'if-needed'): void {
if (this._annotationsElement()) {
const elements: HTMLElement[] = this._annotationsElement().nativeElement.querySelectorAll(
`div[anotation-page-header="${page}"]`,
);
const annotationsElement = untracked(this._annotationsElement);
if (annotationsElement) {
const elements: HTMLElement[] = annotationsElement.nativeElement.querySelectorAll(`div[anotation-page-header="${page}"]`);
FileWorkloadComponent._scrollToFirstElement(elements, mode);
}
}
@Debounce()
scrollToSelectedAnnotation(): void {
if (this.listingService.selected.length === 0 || !this._annotationsElement()) {
const annotationsElement = untracked(this._annotationsElement);
if (this.listingService.selected.length === 0 || !annotationsElement) {
return;
}
const elements: HTMLElement[] = this._annotationsElement().nativeElement.querySelectorAll(
const elements: HTMLElement[] = annotationsElement.nativeElement.querySelectorAll(
`[annotation-id="${this._firstSelectedAnnotation?.id}"]`,
);
FileWorkloadComponent._scrollToFirstElement(elements);
}
scrollQuickNavigation(): void {
const currentPage = this.pdf.currentPage();
const currentPage = untracked(this.pdf.currentPage);
let quickNavPageIndex = this.displayedPages.findIndex(p => p >= currentPage);
if (quickNavPageIndex === -1 || this.displayedPages[quickNavPageIndex] !== currentPage) {
quickNavPageIndex = Math.max(0, quickNavPageIndex - 1);
@ -325,7 +331,8 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
}
scrollQuickNavLast() {
this.pdf.navigateTo(this.state.file().numberOfPages);
const file = untracked(this.state.file);
this.pdf.navigateTo(file.numberOfPages);
}
preventKeyDefault($event: KeyboardEvent): void {
@ -342,12 +349,14 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
this.pdf.navigateTo(this.#nextPageWithAnnotations());
}
@Debounce(15)
navigateAnnotations($event: KeyboardEvent) {
const currentPage = this.pdf.currentPage();
const currentPage = untracked(this.pdf.currentPage);
const activeAnnotations = untracked(this.activeAnnotations);
if (!this._firstSelectedAnnotation || currentPage !== this._firstSelectedAnnotation.pageNumber) {
if (this.displayedPages.indexOf(currentPage) !== -1) {
// Displayed page has annotations
return this.listingService.selectAnnotations(this.activeAnnotations ? this.activeAnnotations[0] : null);
return this.listingService.selectAnnotations(activeAnnotations ? activeAnnotations[0] : null);
}
// Displayed page doesn't have annotations
if ($event.key === 'ArrowDown') {
@ -359,7 +368,7 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
const prevPage = this.#prevPageWithAnnotations();
const prevPageAnnotations = this.displayedAnnotations.get(prevPage);
return this.listingService.selectAnnotations(prevPageAnnotations[prevPageAnnotations.length - 1]);
return this.listingService.selectAnnotations(getLast(prevPageAnnotations));
}
const page = this._firstSelectedAnnotation.pageNumber;
@ -396,7 +405,7 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
for (let i = previousPageIdx; i >= 0; i--) {
const prevPageAnnotations = this.displayedAnnotations.get(this.displayedPages[i]);
if (prevPageAnnotations) {
this.listingService.selectAnnotations(prevPageAnnotations[prevPageAnnotations.length - 1]);
this.listingService.selectAnnotations(getLast(prevPageAnnotations));
break;
}
}
@ -420,14 +429,16 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
secondary: INestedFilter[] = [],
componentReferenceIds: string[],
): Map<number, AnnotationWrapper[]> {
const onlyPageWithAnnotations = this.viewModeService.onlyPagesWithAnnotations();
const onlyPageWithAnnotations = untracked(this.viewModeService.onlyPagesWithAnnotations);
const isRedacted = untracked(this.viewModeService.isRedacted);
const allPages = untracked(this.allPages);
if (!primary || primary.length === 0) {
const pages = onlyPageWithAnnotations ? [] : this.#allPages;
const pages = onlyPageWithAnnotations ? [] : allPages;
this.#setDisplayedPages(pages);
return;
}
if (this.viewModeService.isRedacted()) {
if (isRedacted) {
annotations = annotations.filter(a => !a.isRemoved);
}
@ -445,7 +456,7 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
this.enabledFilters = this.filterService.enabledFlatFilters;
if (this.enabledFilters.some(f => f.id === 'pages-without-annotations')) {
if (this.enabledFilters.length === 1 && !onlyPageWithAnnotations) {
const pages = this.#allPages.filter(page => !pagesThatDisplayAnnotations.includes(page));
const pages = allPages.filter(page => !pagesThatDisplayAnnotations.includes(page));
this.#setDisplayedPages(pages);
} else {
this.#setDisplayedPages([]);
@ -454,7 +465,7 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
} else if (this.enabledFilters.length || onlyPageWithAnnotations || componentReferenceIds) {
this.#setDisplayedPages(pagesThatDisplayAnnotations);
} else {
this.#setDisplayedPages(this.#allPages);
this.#setDisplayedPages(allPages);
}
this.displayedPages.sort((a, b) => a - b);
@ -462,18 +473,20 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
}
#selectFirstAnnotationOnCurrentPageIfNecessary() {
const currentPage = this.pdf.currentPage();
const currentPage = untracked(this.pdf.currentPage);
const activeAnnotations = untracked(this.activeAnnotations);
if (
(!this._firstSelectedAnnotation || currentPage !== this._firstSelectedAnnotation.pageNumber) &&
this.displayedPages.indexOf(currentPage) >= 0 &&
this.activeAnnotations.length > 0
activeAnnotations.length > 0
) {
this.listingService.selectAnnotations(this.activeAnnotations[0]);
this.listingService.selectAnnotations(activeAnnotations[0]);
}
}
#navigatePages($event: KeyboardEvent) {
const pageIdx = this.displayedPages.indexOf(this.pdf.currentPage());
const currentPage = untracked(this.pdf.currentPage);
const pageIdx = this.displayedPages.indexOf(currentPage);
if ($event.key !== 'ArrowDown') {
if (pageIdx === -1) {
@ -505,9 +518,10 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
}
#nextPageWithAnnotations() {
const currentPage = untracked(this.pdf.currentPage);
let idx = 0;
for (const page of this.displayedPages) {
if (page > this.pdf.currentPage() && this.displayedAnnotations.get(page)) {
if (page > currentPage && this.displayedAnnotations.get(page)) {
break;
}
++idx;
@ -516,10 +530,11 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
}
#prevPageWithAnnotations() {
const currentPage = untracked(this.pdf.currentPage);
let idx = this.displayedPages.length - 1;
const reverseDisplayedPages = [...this.displayedPages].reverse();
for (const page of reverseDisplayedPages) {
if (page < this.pdf.currentPage() && this.displayedAnnotations.get(page)) {
if (page < currentPage && this.displayedAnnotations.get(page)) {
break;
}
--idx;
@ -528,8 +543,9 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
}
#scrollQuickNavigationToPage(page: number) {
if (this._quickNavigationElement()) {
const elements: HTMLElement[] = this._quickNavigationElement().nativeElement.querySelectorAll(`#quick-nav-page-${page}`);
const quickNavigationElement = untracked(this._quickNavigationElement);
if (quickNavigationElement) {
const elements: HTMLElement[] = quickNavigationElement.nativeElement.querySelectorAll(`#quick-nav-page-${page}`);
FileWorkloadComponent._scrollToFirstElement(elements);
}
}
@ -550,7 +566,8 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
}
#scrollToFirstAnnotationPage(annotations: Map<number, ListItem<AnnotationWrapper>[]>) {
if (this.isDocumine && annotations.size && this.#displayedPagesChanged && !this.displayedPages.includes(this.pdf.currentPage())) {
const currentPage = untracked(this.pdf.currentPage);
if (this.isDocumine && annotations.size && this.#displayedPagesChanged && !this.displayedPages.includes(currentPage)) {
const page = annotations.keys().next().value;
this.pdf.navigateTo(page);
}
@ -571,4 +588,16 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnInit, On
this.displayedPages.every((value, index) => value === newDisplayedPages[index])
);
}
@HostListener('click', ['$event'])
clickInsideWorkloadView($event: MouseEvent) {
$event?.stopPropagation();
}
@HostListener('document: click')
clickOutsideWorkloadView() {
if (this.multiSelectService.active() && !this._dialog.openDialogs.length) {
this.multiSelectService.deactivate();
}
}
}

View File

@ -1,16 +1,14 @@
<table>
<thead>
<tr>
<th *ngFor="let column of columns" [ngClass]="{ hide: !column.show, 'w-50': staticColumns }">
<label>{{ column.label }}</label>
</th>
</tr>
</thead>
<tbody [ngStyle]="{ height: redactedTextsAreaHeight + 'px' }">
<tr *ngFor="let row of data">
<td *ngFor="let cell of row" [ngClass]="{ hide: !cell.show, bold: cell.bold, 'w-50': staticColumns }">
<div [ngStyle]="gridConfig()" class="table">
@for (column of _columns(); track column.label) {
<div [ngClass]="{ hide: !!column.hide }" class="col cell">
<label>{{ column.label }}</label>
</div>
}
@for (row of _data(); track $index) {
@for (cell of row; track cell.label) {
<div [ngClass]="{ background: _data().indexOf(row) % 2 === 0, hide: !!cell.hide, bold: cell.bold }" class="cell">
{{ cell.label }}
</td>
</tr>
</tbody>
</table>
</div>
}
}
</div>

View File

@ -1,74 +1,44 @@
@use 'common-mixins';
table {
.table {
padding: 0 13px;
max-width: 100%;
min-width: 100%;
border-spacing: 0;
display: grid;
overflow-x: hidden;
overflow-y: auto;
@include common-mixins.scroll-bar;
tbody {
padding-top: 2px;
overflow-y: auto;
display: block;
@include common-mixins.scroll-bar;
.col {
position: sticky;
top: 0;
z-index: 1;
background: white;
label {
opacity: 0.7;
font-weight: normal;
}
}
tr {
max-width: 100%;
min-width: 100%;
display: table;
.cell {
text-align: start;
white-space: nowrap;
text-overflow: ellipsis;
list-style-position: inside;
overflow: hidden;
th {
label {
opacity: 0.7;
font-weight: normal;
}
}
th,
td {
&:not(.w-50) {
width: 25%;
}
max-width: 0;
text-align: start;
white-space: nowrap;
text-overflow: ellipsis;
list-style-position: inside;
overflow: hidden;
padding-right: 8px;
}
th:last-child,
td:last-child {
max-width: 0;
width: 50%;
padding-right: 0;
}
padding-right: 8px;
line-height: 1.5;
}
}
tbody tr:nth-child(odd) {
td {
background-color: var(--iqser-alt-background);
}
.background {
background-color: var(--iqser-alt-background);
}
.hide {
visibility: hidden;
}
.w-50 {
max-width: 0;
min-width: 50%;
&.hide {
display: none;
}
}
.bold {
font-weight: bold;
}

View File

@ -1,13 +1,14 @@
import { Component, Input } from '@angular/core';
import { Component, computed, input } from '@angular/core';
import { NgClass, NgForOf, NgStyle } from '@angular/common';
export interface ValueColumn {
label: string;
show: boolean;
hide?: boolean;
bold?: boolean;
width?: string;
}
const TABLE_ROW_SIZE = 18;
const TABLE_ROW_SIZE = 20;
const MAX_ITEMS_DISPLAY = 10;
@Component({
@ -18,11 +19,33 @@ const MAX_ITEMS_DISPLAY = 10;
styleUrl: './selected-annotations-table.component.scss',
})
export class SelectedAnnotationsTableComponent {
@Input({ required: true }) columns: ValueColumn[];
@Input({ required: true }) data: ValueColumn[][];
@Input() staticColumns = false;
readonly defaultColumnWidth = input(false);
get redactedTextsAreaHeight() {
return this.data.length <= MAX_ITEMS_DISPLAY ? TABLE_ROW_SIZE * this.data.length : TABLE_ROW_SIZE * MAX_ITEMS_DISPLAY;
readonly columns = input.required<ValueColumn[]>();
readonly _columns = computed(() => this.columns().filter(item => !this.defaultColumnWidth() || !item.hide));
readonly data = input.required<ValueColumn[][]>();
readonly _data = computed(() => this.data().map(item => item.filter(cell => !this.defaultColumnWidth() || !cell.hide)));
readonly redactedTextsAreaHeight = computed(
() =>
(this._data().length <= MAX_ITEMS_DISPLAY ? TABLE_ROW_SIZE * this._data().length : TABLE_ROW_SIZE * MAX_ITEMS_DISPLAY) +
TABLE_ROW_SIZE,
);
readonly gridConfig = computed(() => ({
height: `${this.redactedTextsAreaHeight()}px`,
'grid-template-columns': !this.defaultColumnWidth() ? this.#computeCustomColumnWidths() : this.#getDefaultColumnWidth(),
'grid-template-rows': `repeat(${this._data().length + 1}, ${TABLE_ROW_SIZE}px)`,
}));
#computeCustomColumnWidths() {
return this._columns()
.map(column => column.width ?? `auto`)
.join(' ');
}
#getDefaultColumnWidth() {
return `repeat(${this._columns().length}, 1fr)`;
}
}

View File

@ -1,3 +1,5 @@
@use 'common-mixins';
.components-header {
display: flex;
flex-direction: row;
@ -24,18 +26,19 @@ mat-icon {
display: flex;
flex-direction: column;
font-size: 12px;
overflow: scroll;
overflow-x: hidden;
overflow-y: scroll;
height: calc(100% - 40px);
@include common-mixins.scroll-bar;
.component-row {
display: flex;
flex-direction: column;
margin-left: 13px;
margin-right: 13px;
.header {
display: flex;
padding: 10px 26px;
padding: 10px 14px;
font-weight: 600;
:first-child {
@ -47,7 +50,9 @@ mat-icon {
&:not(:last-child) {
border-bottom: 1px solid var(--iqser-separator);
}
border-bottom: 1px solid var(--iqser-separator);
margin-right: 13px;
}
}
}

View File

@ -1,4 +1,4 @@
import { Component, Input, OnInit, signal, ViewChildren } from '@angular/core';
import { Component, effect, Input, OnInit, signal, ViewChildren } from '@angular/core';
import { List } from '@common-ui/utils';
import { IconButtonTypes, LoadingService } from '@iqser/common-ui';
import { ComponentLogEntry, Dictionary, File, IComponentLogEntry, WorkflowFileStatuses } from '@red/domain';
@ -11,6 +11,7 @@ import { map } from 'rxjs/operators';
import { toObservable } from '@angular/core/rxjs-interop';
import { AsyncPipe, NgForOf, NgIf } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { FilePreviewStateService } from '../../services/file-preview-state.service';
@Component({
selector: 'redaction-structured-component-management',
@ -33,7 +34,13 @@ export class StructuredComponentManagementComponent implements OnInit {
private readonly _loadingService: LoadingService,
private readonly _componentLogFilterService: ComponentLogFilterService,
private readonly _filterService: FilterService,
) {}
private readonly _state: FilePreviewStateService,
) {
effect(async () => {
this._state.file();
await this.#loadData();
});
}
get canEdit() {
return this.file.workflowStatus !== WorkflowFileStatuses.APPROVED;
@ -75,7 +82,6 @@ export class StructuredComponentManagementComponent implements OnInit {
}
async #loadData(): Promise<void> {
this._loadingService.start();
const componentLogData = await firstValueFrom(
this._componentLogService.getComponentLogData(
this.file.dossierTemplateId,

View File

@ -27,7 +27,7 @@ export class ViewSwitchComponent {
});
protected readonly canSwitchToRedactedView = computed(() => {
const file = this._state.file();
return !file.analysisRequired && !file.excluded && !file.isError;
return !file.excluded && !file.isError;
});
protected readonly canSwitchToEarmarksView = computed(() => {
const file = this._state.file();

View File

@ -3,9 +3,56 @@
<div [translate]="'add-hint.dialog.title'" class="dialog-header heading-l"></div>
<div class="dialog-content redaction">
<div class="iqser-input-group w-450">
<label class="selected-text" [translate]="'add-hint.dialog.content.selected-text'"></label>
{{ form.get('selectedText').value }}
<div class="iqser-input-group w-full selected-text-group">
<div
[class.fixed-height-36]="dictionaryRequest"
[ngClass]="isEditingSelectedText ? 'flex relative' : 'flex-align-items-center'"
>
<div class="table">
<label> {{ 'add-hint.dialog.content.value' | translate }} </label>
<div class="row">
<span
*ngIf="!isEditingSelectedText"
[innerHTML]="form.controls.selectedText.value"
[ngStyle]="{
'min-width': textWidth > maximumSelectedTextWidth ? '95%' : 'unset',
'max-width': textWidth > maximumSelectedTextWidth ? 0 : 'unset',
}"
></span>
<textarea
*ngIf="isEditingSelectedText"
[rows]="selectedTextRows"
[ngStyle]="{ width: maximumTextAreaWidth + 'px' }"
formControlName="selectedText"
iqserHasScrollbar
name="value"
type="text"
></textarea>
<iqser-circle-button
(action)="toggleEditingSelectedText()"
*ngIf="dictionaryRequest && !isEditingSelectedText"
[tooltip]="'redact-text.dialog.content.edit-text' | translate"
[size]="20"
[iconSize]="13"
icon="iqser:edit"
tooltipPosition="below"
></iqser-circle-button>
<iqser-circle-button
(action)="undoTextChange(); toggleEditingSelectedText()"
*ngIf="isEditingSelectedText"
[showDot]="initialText !== form.get('selectedText').value"
[tooltip]="'redact-text.dialog.content.revert-text' | translate"
[size]="20"
[iconSize]="13"
class="undo-button"
icon="red:undo"
tooltipPosition="below"
></iqser-circle-button>
</div>
</div>
</div>
</div>
<iqser-details-radio

View File

@ -1,3 +1,67 @@
.dialog-content {
height: 400px;
height: 493px;
overflow-y: auto;
}
.selected-text-group > div {
gap: 0.5rem;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
iqser-circle-button {
padding-left: 10px;
&.undo-button {
margin-left: 8px;
}
::ng-deep mat-icon {
padding: 2px;
}
}
.w-full {
width: 100%;
}
.fixed-height-36 {
min-height: 36px;
}
textarea[name='value'] {
margin-top: 0;
min-height: 0;
line-height: 1;
}
.table {
display: flex;
flex-direction: column;
min-width: calc(100% - 26px);
padding: 0 13px;
label {
opacity: 0.7;
font-weight: normal;
}
.row {
display: inline-flex;
flex-direction: row;
align-items: center;
background-color: var(--iqser-alt-background);
min-width: 100%;
span {
white-space: nowrap;
text-overflow: ellipsis;
list-style-position: inside;
overflow: hidden;
}
}
}

View File

@ -1,4 +1,4 @@
import { NgForOf, NgIf } from '@angular/common';
import { NgClass, NgForOf, NgIf, NgStyle } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
@ -23,12 +23,14 @@ import { ActiveDossiersService } from '@services/dossiers/active-dossiers.servic
import { DictionaryService } from '@services/entity-services/dictionary.service';
import { Roles } from '@users/roles';
import { UserPreferenceService } from '@users/user-preference.service';
import { stringToBoolean } from '@utils/functions';
import { calcTextWidthInPixels, stringToBoolean } from '@utils/functions';
import { tap } from 'rxjs/operators';
import { SystemDefaultOption, SystemDefaults } from '../../../account/utils/dialog-defaults';
import { getRedactOrHintOptions } from '../../utils/dialog-options';
import { AddHintData, AddHintResult, RedactOrHintOption, RedactOrHintOptions } from '../../utils/dialog-types';
const MAXIMUM_TEXT_AREA_WIDTH = 421;
@Component({
templateUrl: './add-hint-dialog.component.html',
styleUrls: ['./add-hint-dialog.component.scss'],
@ -49,6 +51,8 @@ import { AddHintData, AddHintResult, RedactOrHintOption, RedactOrHintOptions } f
CircleButtonComponent,
MatDialogClose,
NgForOf,
NgClass,
NgStyle,
],
})
export class AddHintDialogComponent extends IqserDialogComponent<AddHintDialogComponent, AddHintData, AddHintResult> implements OnInit {
@ -58,9 +62,15 @@ export class AddHintDialogComponent extends IqserDialogComponent<AddHintDialogCo
readonly roles = Roles;
readonly iconButtonTypes = IconButtonTypes;
readonly options: DetailsRadioOption<RedactOrHintOption>[];
readonly initialText = this.data?.manualRedactionEntryWrapper?.manualRedactionEntry?.value;
readonly maximumTextAreaWidth = MAXIMUM_TEXT_AREA_WIDTH;
readonly maximumSelectedTextWidth = 567;
dictionaryRequest = false;
dictionaries: Dictionary[] = [];
form!: UntypedFormGroup;
isEditingSelectedText = false;
selectedTextRows = 1;
textWidth: number;
constructor(
private readonly _activeDossiersService: ActiveDossiersService,
@ -83,6 +93,7 @@ export class AddHintDialogComponent extends IqserDialogComponent<AddHintDialogCo
);
this.form = this.#getForm();
this.textWidth = calcTextWidthInPixels(this.form.controls.selectedText.value);
this.form
.get('option')
@ -97,6 +108,18 @@ export class AddHintDialogComponent extends IqserDialogComponent<AddHintDialogCo
.subscribe();
}
toggleEditingSelectedText() {
this.isEditingSelectedText = !this.isEditingSelectedText;
if (this.isEditingSelectedText) {
const width = calcTextWidthInPixels(this.form.controls.selectedText.value);
this.selectedTextRows = Math.ceil(width / MAXIMUM_TEXT_AREA_WIDTH);
}
}
undoTextChange() {
this.form.patchValue({ selectedText: this.initialText });
}
get displayedDictionaryLabel() {
const dictType = this.form.get('dictionary').value;
if (dictType) {
@ -137,7 +160,7 @@ export class AddHintDialogComponent extends IqserDialogComponent<AddHintDialogCo
}
extraOptionChanged(option: DetailsRadioOption<RedactOrHintOption>): void {
this.#applyToAllDossiers = option.extraOption.checked;
this.#applyToAllDossiers = option.additionalCheck.checked;
this.#setDictionaries();
if (this.#applyToAllDossiers && this.form.get('dictionary').value) {
@ -153,7 +176,7 @@ export class AddHintDialogComponent extends IqserDialogComponent<AddHintDialogCo
if (!this.#applyToAllDossiers) {
const selectedDictionaryType = this.form.get('dictionary').value;
const selectedDictionary = this.dictionaries.find(d => d.type === selectedDictionaryType);
this.options[1].extraOption.disabled = selectedDictionary.dossierDictionaryOnly;
this.options[1].additionalCheck.disabled = selectedDictionary.dossierDictionaryOnly;
}
}
@ -200,7 +223,7 @@ export class AddHintDialogComponent extends IqserDialogComponent<AddHintDialogCo
#resetValues() {
this.#applyToAllDossiers = this.applyToAll;
if (!this.#isRss) {
this.options[1].extraOption.checked = this.#applyToAllDossiers;
this.options[1].additionalCheck.checked = this.#applyToAllDossiers;
}
this.form.get('dictionary').setValue(null);
}

View File

@ -11,12 +11,7 @@ import { MatOption, MatSelect, MatSelectTrigger } from '@angular/material/select
import { NgForOf, NgIf } from '@angular/common';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
export interface LegalBasisOption {
label?: string;
legalBasis?: string;
description?: string;
}
import { LegalBasisOption } from '../../utils/dialog-types';
@Component({
templateUrl: './change-legal-basis-dialog.component.html',

View File

@ -8,7 +8,7 @@ import {
IconButtonTypes,
IqserDialogComponent,
} from '@iqser/common-ui';
import { Dictionary, Dossier, SuperTypes } from '@red/domain';
import { Dictionary, Dossier } from '@red/domain';
import { ActiveDossiersService } from '@services/dossiers/active-dossiers.service';
import { DictionaryService } from '@services/entity-services/dictionary.service';
import { Roles } from '@users/roles';
@ -47,12 +47,12 @@ export class EditAnnotationDialogComponent
extends IqserDialogComponent<EditAnnotationDialogComponent, EditRedactionData, EditRedactResult>
implements OnInit
{
readonly #dossier: Dossier;
readonly roles = Roles;
readonly iconButtonTypes = IconButtonTypes;
readonly redactedTexts: string[];
dictionaries: Dictionary[] = [];
form: UntypedFormGroup;
readonly #dossier: Dossier;
constructor(
private readonly _activeDossiersService: ActiveDossiersService,
@ -60,7 +60,7 @@ export class EditAnnotationDialogComponent
private readonly _formBuilder: FormBuilder,
) {
super();
this.#dossier = _activeDossiersService.find(this.data.dossierId);
this.#dossier = this._activeDossiersService.find(this.data.dossierId);
const annotations = this.data.annotations;
this.redactedTexts = annotations.map(annotation => annotation.value);
this.form = this.#getForm();
@ -83,10 +83,6 @@ export class EditAnnotationDialogComponent
this.#setTypes();
}
reasonChanged() {
this.form.patchValue({ reason: this.dictionaries.find(d => d.type === SuperTypes.ManualRedaction) });
}
save(): void {
const value = this.form.value;
this.dialogRef.close({
@ -106,8 +102,4 @@ export class EditAnnotationDialogComponent
type: [sameType ? this.data.annotations[0].type : null],
});
}
#allRectangles() {
return this.data.annotations.reduce((acc, a) => acc && a.AREA, true);
}
}

View File

@ -6,16 +6,23 @@
class="dialog-header heading-l"
></div>
<div class="dialog-content redaction" [class.fixed-height]="isRedacted && isImage">
<div class="iqser-input-group" *ngIf="!isImage && redactedTexts.length && !allRectangles">
<div [class.image-dialog]="isRedacted && isImage" [class.rectangle-dialog]="allRectangles" class="dialog-content redaction">
<div *ngIf="!isImage && redactedTexts.length && !allRectangles" class="iqser-input-group">
<redaction-selected-annotations-table
[columns]="tableColumns"
[data]="tableData"
[staticColumns]="true"
[defaultColumnWidth]="true"
></redaction-selected-annotations-table>
</div>
<div *ngIf="!isManualRedaction" class="iqser-input-group w-450" [class.required]="!form.controls.type.disabled">
<iqser-details-radio
*ngIf="!isImage && annotations.length === 1"
[options]="options"
(extraOptionChanged)="extraOptionChanged($event)"
formControlName="option"
></iqser-details-radio>
<div *ngIf="!isManualRedaction" [class.required]="!form.controls.type.disabled" class="iqser-input-group w-450">
<label [translate]="'edit-redaction.dialog.content.type'"></label>
<mat-form-field>
@ -83,7 +90,7 @@
</div>
</ng-container>
<div *ngIf="allRectangles" class="iqser-input-group w-400">
<div *ngIf="allRectangles" class="iqser-input-group w-450">
<label [translate]="'change-legal-basis-dialog.content.classification'"></label>
<input
[placeholder]="'edit-redaction.dialog.content.unchanged' | translate"
@ -100,7 +107,7 @@
formControlName="comment"
iqserHasScrollbar
name="comment"
rows="4"
rows="3"
type="text"
></textarea>
</div>

View File

@ -1,8 +1,16 @@
.dialog-content {
padding-top: 8px;
&.fixed-height {
height: 386px;
&.rectangle-dialog {
height: 600px;
}
&.image-dialog {
height: 346px;
}
.rectangle-dialog,
.image-dialog {
overflow-y: auto;
}
}

View File

@ -5,7 +5,6 @@ import { MatDialogClose } from '@angular/material/dialog';
import { MatFormField } from '@angular/material/form-field';
import { MatOption, MatSelect, MatSelectTrigger } from '@angular/material/select';
import { MatTooltip } from '@angular/material/tooltip';
import { DetailsRadioOption } from '@common-ui/inputs/details-radio/details-radio-option';
import {
CircleButtonComponent,
HasScrollbarDirective,
@ -26,10 +25,20 @@ import {
SelectedAnnotationsTableComponent,
ValueColumn,
} from '../../components/selected-annotations-table/selected-annotations-table.component';
import { DialogHelpModeKeys } from '../../utils/constants';
import { getEditRedactionOptions } from '../../utils/dialog-options';
import { EditRedactionData, EditRedactResult, RedactOrHintOption } from '../../utils/dialog-types';
import { LegalBasisOption } from '../manual-redaction-dialog/manual-annotation-dialog.component';
import { getEditRedactionOptions, getRectangleRedactOptions } from '../../utils/dialog-options';
import {
EditRedactionData,
EditRedactionOption,
EditRedactResult,
LegalBasisOption,
RectangleRedactOption,
RectangleRedactOptions,
RedactOrHintOptions,
} from '../../utils/dialog-types';
import { DetailsRadioComponent } from '@common-ui/inputs/details-radio/details-radio.component';
import { DetailsRadioOption } from '@common-ui/inputs/details-radio/details-radio-option';
import { validatePageRange } from '../../utils/form-validators';
import { parseRectanglePosition, parseSelectedPageNumbers, prefillPageRange } from '../../utils/enhance-manual-redaction-request.utils';
interface TypeSelectOptions {
type: string;
@ -58,18 +67,16 @@ interface TypeSelectOptions {
HelpButtonComponent,
MatDialogClose,
HasScrollbarDirective,
DetailsRadioComponent,
],
})
export class EditRedactionDialogComponent
extends IqserDialogComponent<EditRedactionDialogComponent, EditRedactionData, EditRedactResult>
implements OnInit
{
readonly #dossier = inject(ActiveDossiersService).find(this.data.dossierId);
readonly #applyToAllDossiers = this.data.applyToAllDossiers;
protected readonly roles = Roles;
readonly ignoredKeys = ['option', 'comment'];
readonly annotations = this.data.annotations;
readonly iconButtonTypes = IconButtonTypes;
readonly isModifyDictionary = this.annotations.every(annotation => annotation.isModifyDictionary);
readonly isImage = this.annotations.reduce((acc, next) => acc && next.isImage, true);
readonly redactedTexts = !this.isImage ? this.annotations.map(annotation => annotation.value).filter(value => !!value) : null;
readonly isManualRedaction = this.annotations.some(annotation => annotation.type === SuperTypes.ManualRedaction);
@ -77,33 +84,34 @@ export class EditRedactionDialogComponent
readonly isRedacted = this.annotations.every(annotation => annotation.isRedacted);
readonly isImported: boolean = this.annotations.every(annotation => annotation.imported);
readonly allRectangles = this.annotations.reduce((acc, a) => acc && a.AREA, true);
readonly tableColumns = [
{
label: 'Value',
show: true,
},
{
label: 'Type',
show: true,
},
];
readonly tableColumns: ValueColumn[] = [{ label: 'Value' }, { label: 'Type' }];
readonly tableData: ValueColumn[][] = this.data.annotations.map(redaction => [
{ label: redaction.value, show: true, bold: true },
{ label: redaction.typeLabel, show: true },
{ label: redaction.value, bold: true },
{ label: redaction.typeLabel },
]);
options: DetailsRadioOption<RedactOrHintOption>[] | undefined;
options = this.allRectangles ? getRectangleRedactOptions('edit') : getEditRedactionOptions(this.isHint);
legalOptions: LegalBasisOption[] = [];
dictionaries: Dictionary[] = [];
typeSelectOptions: TypeSelectOptions[] = [];
readonly form = this.#getForm();
hasTypeChanged = false;
initialReasonDisabled = this.someSkipped;
protected readonly roles = Roles;
readonly #dossier = inject(ActiveDossiersService).find(this.data.dossierId);
constructor(
private readonly _justificationsService: JustificationsService,
private readonly _dictionaryService: DictionaryService,
) {
super();
if (this.allRectangles) {
prefillPageRange(
this.data.annotations[0],
this.data.allFileAnnotations,
this.options as DetailsRadioOption<RectangleRedactOption>[],
);
}
}
get displayedDictionaryLabel() {
@ -153,20 +161,18 @@ export class EditRedactionDialogComponent
return this.annotations.length > 1;
}
get helpButtonKey() {
if (this.isHint) {
return DialogHelpModeKeys.HINT_EDIT;
}
if (this.someSkipped) {
return DialogHelpModeKeys.SKIPPED_EDIT;
}
return DialogHelpModeKeys.REDACTION_EDIT;
}
get sameType() {
return this.annotations.every(annotation => annotation.type === this.annotations[0].type);
}
extraOptionChanged(option: DetailsRadioOption<EditRedactionOption | RectangleRedactOption>): void {
if (option.value === RectangleRedactOptions.MULTIPLE_PAGES) {
setTimeout(() => {
this.form.get('option')?.updateValueAndValidity();
}, 0);
}
}
async ngOnInit() {
this.#setTypes();
const data = await firstValueFrom(this._justificationsService.loadAll(this.#dossier.dossierTemplateId));
@ -188,7 +194,6 @@ export class EditRedactionDialogComponent
typeChanged() {
const selectedDictionaryType = this.form.controls.type.value;
this.#setOptions(selectedDictionaryType);
const initialReason = this.form.get('type').value === this.initialFormValue.type && !this.initialReasonDisabled;
if (this.redactBasedTypes.includes(selectedDictionaryType) || initialReason) {
@ -202,19 +207,29 @@ export class EditRedactionDialogComponent
} else {
this.form.controls.reason.disable();
}
this.form.patchValue({ reason: null, option: null });
this.form.patchValue({ reason: null });
}
save() {
const value = this.form.value;
const initialReason: LegalBasisOption = this.initialFormValue.reason;
const initialLegalBasis = initialReason?.legalBasis ?? '';
const pageNumbers = parseSelectedPageNumbers(
this.form.get('option').value?.additionalInput?.value,
this.data.file,
this.data.annotations[0],
);
const position = parseRectanglePosition(this.annotations[0]);
this.close({
legalBasis: value.reason?.legalBasis ?? (this.isImage ? initialLegalBasis : ''),
section: value.section,
comment: value.comment,
type: value.type,
value: this.allRectangles ? value.value : null,
option: value.option?.value ?? RedactOrHintOptions.ONLY_HERE,
position,
pageNumbers,
});
}
@ -239,22 +254,6 @@ export class EditRedactionDialogComponent
}
}
#setOptions(type: string, reasonChanged = false) {
const selectedDictionary = this.dictionaries.find(d => d.type === type);
this.options = getEditRedactionOptions(
this.#dossier.dossierName,
this.#applyToAllDossiers,
!!selectedDictionary?.dossierDictionaryOnly,
this.isModifyDictionary,
);
this.form.patchValue(
{
option: !this.isModifyDictionary || reasonChanged ? this.options[0] : this.options[1],
},
{ emitEvent: false },
);
}
#getForm() {
const sameSection = this.annotations.every(annotation => annotation.section === this.annotations[0].section);
return new FormGroup({
@ -265,7 +264,10 @@ export class EditRedactionDialogComponent
disabled: this.isImported,
}),
section: new FormControl<string>({ value: sameSection ? this.annotations[0].section : null, disabled: this.isImported }),
option: new FormControl<LegalBasisOption>(null),
option: new FormControl<DetailsRadioOption<EditRedactionOption | RectangleRedactOption>>(
this.options[0],
validatePageRange(this.data.file.numberOfPages),
),
value: new FormControl<string>(this.allRectangles ? this.annotations[0].value : null),
});
}

View File

@ -2,32 +2,40 @@
<form (submit)="save()" [formGroup]="form">
<div class="dialog-header heading-l" [translate]="dialogTitle"></div>
<div class="dialog-content">
<redaction-selected-annotations-table
[columns]="tableColumns"
[data]="tableData"
[staticColumns]="true"
*ngIf="!isImageHint"
></redaction-selected-annotations-table>
<div *ngIf="!isHintDialog && !isDocumine" class="iqser-input-group required w-400">
<label [translate]="'manual-annotation.dialog.content.reason'"></label>
<mat-form-field>
<mat-select
[placeholder]="'manual-annotation.dialog.content.reason-placeholder' | translate"
class="full-width"
formControlName="reason"
>
<mat-option *ngFor="let option of legalOptions" [matTooltip]="option.description" [value]="option">
{{ option.label }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="dialog-content force-annotation">
@if (!isImageHint) {
<redaction-selected-annotations-table
[columns]="tableColumns"
[data]="tableData"
[defaultColumnWidth]="true"
></redaction-selected-annotations-table>
}
<div *ngIf="!isHintDialog && !isDocumine" class="iqser-input-group w-400">
<label [translate]="'manual-annotation.dialog.content.legalBasis'"></label>
<input [value]="form.get('reason').value?.legalBasis" disabled type="text" />
</div>
@if (!isHintDialog && !isDocumine) {
<iqser-details-radio [options]="options" formControlName="option"></iqser-details-radio>
<div class="iqser-input-group required w-400">
<label [translate]="'manual-annotation.dialog.content.reason'"></label>
<mat-form-field>
<mat-select
[placeholder]="'manual-annotation.dialog.content.reason-placeholder' | translate"
class="full-width"
formControlName="reason"
>
@for (option of legalOptions; track option) {
<mat-option [matTooltip]="option.description" [value]="option">
{{ option.label }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="iqser-input-group w-400">
<label [translate]="'manual-annotation.dialog.content.legalBasis'"></label>
<input [value]="form.get('reason').value?.legalBasis" disabled type="text" />
</div>
}
<div class="iqser-input-group w-300">
<label [translate]="'manual-annotation.dialog.content.comment'"></label>

View File

@ -1,14 +1,13 @@
import { Component, Inject, OnInit } from '@angular/core';
import { ReactiveFormsModule, UntypedFormGroup, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, UntypedFormGroup, Validators } from '@angular/forms';
import {
BaseDialogComponent,
CircleButtonComponent,
getConfig,
HasScrollbarDirective,
HelpButtonComponent,
IconButtonComponent,
IqserDenyDirective,
IqserDialogComponent,
} from '@iqser/common-ui';
import { JustificationsService } from '@services/entity-services/justifications.service';
import { Dossier, ILegalBasisChangeRequest } from '@red/domain';
@ -21,21 +20,19 @@ import {
ValueColumn,
} from '../../components/selected-annotations-table/selected-annotations-table.component';
import { NgForOf, NgIf } from '@angular/common';
import { MatFormField } from '@angular/material/form-field';
import { MatOption, MatSelect, MatSelectTrigger } from '@angular/material/select';
import { DetailsRadioOption } from '@common-ui/inputs/details-radio/details-radio-option';
import { ForceAnnotationData, ForceAnnotationOption, ForceAnnotationResult, LegalBasisOption } from '../../utils/dialog-types';
import { getForceAnnotationOptions } from '../../utils/dialog-options';
import { SystemDefaults } from '../../../account/utils/dialog-defaults';
import { MatFormField } from '@angular/material/form-field';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
export interface LegalBasisOption {
label?: string;
legalBasis?: string;
description?: string;
}
import { DetailsRadioComponent } from '@common-ui/inputs/details-radio/details-radio.component';
const DOCUMINE_LEGAL_BASIS = 'n-a.';
@Component({
selector: 'redaction-force-annotation-dialog',
templateUrl: './force-annotation-dialog.component.html',
styleUrls: ['./force-annotation-dialog.component.scss'],
standalone: true,
@ -55,24 +52,21 @@ const DOCUMINE_LEGAL_BASIS = 'n-a.';
CircleButtonComponent,
NgForOf,
HelpButtonComponent,
DetailsRadioComponent,
],
})
export class ForceAnnotationDialogComponent extends BaseDialogComponent implements OnInit {
export class ForceAnnotationDialogComponent
extends IqserDialogComponent<ForceAnnotationDialogComponent, ForceAnnotationData, ForceAnnotationResult>
implements OnInit
{
readonly isDocumine = getConfig().IS_DOCUMINE;
readonly options: DetailsRadioOption<ForceAnnotationOption>[];
readonly tableColumns = [
{
label: 'Value',
show: true,
},
{
label: 'Type',
show: true,
},
];
readonly tableData: ValueColumn[][] = this._data.annotations.map(redaction => [
{ label: redaction.value, show: true, bold: true },
{ label: redaction.typeLabel, show: true },
readonly form: FormGroup;
readonly tableColumns: ValueColumn[] = [{ label: 'Value' }, { label: 'Type' }];
readonly tableData: ValueColumn[][] = this.data.annotations.map(redaction => [
{ label: redaction.value, bold: true },
{ label: redaction.typeLabel },
]);
legalOptions: LegalBasisOption[] = [];
@ -80,20 +74,23 @@ export class ForceAnnotationDialogComponent extends BaseDialogComponent implemen
constructor(
private readonly _justificationsService: JustificationsService,
protected readonly _dialogRef: MatDialogRef<ForceAnnotationDialogComponent>,
@Inject(MAT_DIALOG_DATA)
private readonly _data: { readonly dossier: Dossier; readonly hint: boolean; annotations: AnnotationWrapper[] },
private readonly _formBuilder: FormBuilder,
) {
super(_dialogRef);
super();
this.options = getForceAnnotationOptions(this.isDocumine, this.isHintDialog, this.isImageDialog);
this.form = this.#getForm();
}
get isImageHint() {
return this._data.annotations.every(annotation => annotation.IMAGE_HINT);
return this.data.annotations.every(annotation => annotation.IMAGE_HINT);
}
get isHintDialog() {
return this._data.hint;
return this.data.hint;
}
get isImageDialog() {
return this.data.image;
}
get disabled(): boolean {
@ -110,7 +107,7 @@ export class ForceAnnotationDialogComponent extends BaseDialogComponent implemen
async ngOnInit() {
if (!this.isDocumine) {
const data = await firstValueFrom(this._justificationsService.getForDossierTemplate(this._data.dossier.dossierTemplateId));
const data = await firstValueFrom(this._justificationsService.getForDossierTemplate(this.data.dossier.dossierTemplateId));
this.legalOptions = data.map(lbm => ({
legalBasis: lbm.reason,
@ -121,8 +118,8 @@ export class ForceAnnotationDialogComponent extends BaseDialogComponent implemen
this.legalOptions.sort((a, b) => a.label.localeCompare(b.label));
// Set pre-existing reason if it exists
const existingReason = this.legalOptions.find(option => option.legalBasis === this._data.annotations[0].legalBasis);
if (!this._data.hint && existingReason) {
const existingReason = this.legalOptions.find(option => option.legalBasis === this.data.annotations[0].legalBasis);
if (!this.data.hint && existingReason) {
this.form.patchValue({ reason: existingReason }, { emitEvent: false });
}
}
@ -130,13 +127,14 @@ export class ForceAnnotationDialogComponent extends BaseDialogComponent implemen
}
save() {
this._dialogRef.close(this.#createForceRedactionRequest());
this.close(this.#createForceRedactionRequest());
}
#getForm(): UntypedFormGroup {
return this._formBuilder.group({
reason: this._data.hint ? ['Forced Hint'] : [null, !this.isDocumine ? Validators.required : null],
reason: this.data.hint ? ['Forced Hint'] : [null, !this.isDocumine ? Validators.required : null],
comment: [null],
option: this.options.find(o => o.value === SystemDefaults.FORCE_REDACTION_DEFAULT),
});
}
@ -145,6 +143,8 @@ export class ForceAnnotationDialogComponent extends BaseDialogComponent implemen
request.legalBasis = !this.isDocumine ? this.form.get('reason').value.legalBasis : DOCUMINE_LEGAL_BASIS;
request.comment = this.form.get('comment').value;
request.reason = this.form.get('reason').value.description;
request.option = this.form.get('option').value?.value;
return request;
}

View File

@ -1,129 +0,0 @@
<section class="dialog">
<form (submit)="save()" [formGroup]="form">
<div [translate]="title" class="dialog-header heading-l"></div>
<div class="dialog-content">
<div *ngIf="!isRectangle" class="iqser-input-group w-450">
<label [translate]="'manual-annotation.dialog.content.text'"></label>
<div *ngIf="!isEditingSelectedText" class="flex-align-items-center">
{{ form.get('selectedText').value }}
<iqser-circle-button
(action)="isEditingSelectedText = true"
*ngIf="isDictionaryRequest"
[tooltip]="'manual-annotation.dialog.content.edit-selected-text' | translate"
icon="iqser:edit"
tooltipPosition="below"
></iqser-circle-button>
</div>
<textarea
*ngIf="isEditingSelectedText"
formControlName="selectedText"
iqserHasScrollbar
name="comment"
rows="4"
type="text"
></textarea>
</div>
<div *ngIf="isRectangle" class="iqser-input-group">
<label [translate]="'manual-annotation.dialog.content.rectangle'"></label>
</div>
<div
*ngIf="!isFalsePositiveRequest && (isDictionaryRequest || !manualRedactionTypeExists)"
class="iqser-input-group required w-450"
>
<label *ngIf="isDictionaryRequest" [translate]="'manual-annotation.dialog.content.dictionary'"></label>
<label *ngIf="!isDictionaryRequest" [translate]="'manual-annotation.dialog.content.type'"></label>
<mat-form-field>
<mat-select formControlName="dictionary">
<mat-select-trigger>{{ displayedDictionaryLabel }}</mat-select-trigger>
<mat-option
*ngFor="let dictionary of possibleDictionaries"
[matTooltip]="dictionary.description"
[value]="dictionary.type"
matTooltipPosition="after"
>
<span> {{ dictionary.label }} </span>
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div *deny="roles.getRss; if: !isDictionaryRequest" class="iqser-input-group required w-450">
<label [translate]="'manual-annotation.dialog.content.reason'"></label>
<mat-form-field>
<mat-select
[placeholder]="'manual-annotation.dialog.content.reason-placeholder' | translate"
class="full-width"
formControlName="reason"
>
<mat-option
*ngFor="let option of legalOptions"
[matTooltip]="option.description"
[value]="option"
matTooltipPosition="after"
>
{{ option.label }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div *deny="roles.getRss; if: !isDictionaryRequest" class="iqser-input-group w-450">
<label [translate]="'manual-annotation.dialog.content.legalBasis'"></label>
<input [value]="form.get('reason').value?.legalBasis" disabled type="text" />
</div>
<div *ngIf="isRectangle" class="iqser-input-group w-450">
<label [translate]="'manual-annotation.dialog.content.section'"></label>
<input formControlName="section" name="section" type="text" />
</div>
<div *ngIf="isRectangle" class="iqser-input-group w-450">
<label [translate]="'manual-annotation.dialog.content.classification'"></label>
<input formControlName="classification" name="classification" type="text" />
</div>
<div class="iqser-input-group w-450">
<label [translate]="'manual-annotation.dialog.content.comment'"></label>
<textarea formControlName="comment" iqserHasScrollbar name="comment" rows="4" type="text"></textarea>
</div>
<div *ngIf="isRectangle" class="apply-on-multiple-pages iqser-input-group w-450">
<mat-checkbox
(change)="applyOnMultiplePages = !applyOnMultiplePages"
[checked]="applyOnMultiplePages"
class="mb-15"
color="primary"
>
{{ 'manual-annotation.dialog.content.apply-on-multiple-pages' | translate }}
</mat-checkbox>
<div *ngIf="applyOnMultiplePages">
<input
[placeholder]="'manual-annotation.dialog.content.apply-on-multiple-pages-placeholder' | translate"
class="full-width"
formControlName="multiplePages"
/>
<span class="hint">{{ 'manual-annotation.dialog.content.apply-on-multiple-pages-hint' | translate }}</span>
</div>
</div>
</div>
<div class="dialog-actions">
<iqser-icon-button
[disabled]="disabled"
[label]="'manual-annotation.dialog.actions.save' | translate"
[submit]="true"
[type]="iconButtonTypes.primary"
>
</iqser-icon-button>
</div>
</form>
<iqser-circle-button (action)="close()" class="dialog-close" icon="iqser:close"></iqser-circle-button>
</section>

View File

@ -1,235 +0,0 @@
import { Component, Inject, OnInit } from '@angular/core';
import { ReactiveFormsModule, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import {
BaseDialogComponent,
CircleButtonComponent,
HasScrollbarDirective,
IconButtonComponent,
IqserDenyDirective,
IqserPermissionsService,
} from '@iqser/common-ui';
import { ManualRedactionEntryWrapper } from '@models/file/manual-redaction-entry.wrapper';
import { Dictionary, Dossier, File, IAddRedactionRequest, SuperTypes } from '@red/domain';
import { ActiveDossiersService } from '@services/dossiers/active-dossiers.service';
import { DictionaryService } from '@services/entity-services/dictionary.service';
import { JustificationsService } from '@services/entity-services/justifications.service';
import { Roles } from '@users/roles';
import { firstValueFrom } from 'rxjs';
import { ManualRedactionService } from '../../services/manual-redaction.service';
import { REDAnnotationManager } from '../../../pdf-viewer/services/annotation-manager.service';
import { NgForOf, NgIf } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { MatFormField } from '@angular/material/form-field';
import { MatOption, MatSelect, MatSelectTrigger } from '@angular/material/select';
import { MatTooltip } from '@angular/material/tooltip';
import { MatCheckbox } from '@angular/material/checkbox';
export interface LegalBasisOption {
label?: string;
legalBasis?: string;
description?: string;
}
@Component({
templateUrl: './manual-annotation-dialog.component.html',
styleUrls: ['./manual-annotation-dialog.component.scss'],
standalone: true,
imports: [
ReactiveFormsModule,
NgIf,
CircleButtonComponent,
TranslateModule,
HasScrollbarDirective,
MatFormField,
MatSelectTrigger,
MatSelect,
MatOption,
NgForOf,
MatTooltip,
IqserDenyDirective,
MatCheckbox,
IconButtonComponent,
],
providers: [ManualRedactionService],
})
export class ManualAnnotationDialogComponent extends BaseDialogComponent implements OnInit {
readonly #dossier: Dossier;
readonly roles = Roles;
isDictionaryRequest: boolean;
isFalsePositiveRequest: boolean;
isEditingSelectedText = false;
applyOnMultiplePages = false;
manualRedactionTypeExists = true;
possibleDictionaries: Dictionary[] = [];
legalOptions: LegalBasisOption[] = [];
constructor(
readonly iqserPermissionsService: IqserPermissionsService,
private readonly _justificationsService: JustificationsService,
private readonly _manualRedactionService: ManualRedactionService,
activeDossiersService: ActiveDossiersService,
private readonly _dictionaryService: DictionaryService,
protected readonly _dialogRef: MatDialogRef<ManualAnnotationDialogComponent>,
private readonly _annotationManager: REDAnnotationManager,
@Inject(MAT_DIALOG_DATA) readonly data: { manualRedactionEntryWrapper: ManualRedactionEntryWrapper; dossierId: string; file: File },
) {
super(_dialogRef);
this.#dossier = activeDossiersService.find(this.data.dossierId);
this.isFalsePositiveRequest = this.data.manualRedactionEntryWrapper.type === 'FALSE_POSITIVE';
this.isDictionaryRequest = this.data.manualRedactionEntryWrapper.type === 'DICTIONARY' || this.isFalsePositiveRequest;
this.manualRedactionTypeExists = this._dictionaryService.hasManualType(this.#dossier.dossierTemplateId);
this.form = this.#getForm();
this.initialFormValue = this.form.getRawValue();
}
get title() {
return this._manualRedactionService.getTitle(this.data.manualRedactionEntryWrapper.type);
}
get isRectangle() {
return !!this.data.manualRedactionEntryWrapper.manualRedactionEntry.rectangle;
}
get displayedDictionaryLabel() {
const dictType = this.form.get('dictionary').value;
if (dictType) {
return this.possibleDictionaries.find(d => d.type === dictType).label;
}
return null;
}
get disabled() {
return this.form.invalid || (this.applyOnMultiplePages && !this.form.get('multiplePages')?.value);
}
async ngOnInit() {
this.possibleDictionaries = this.isDictionaryRequest
? this._dictionaryService.getDictionariesOptions(this.#dossier.dossierTemplateId)
: this._dictionaryService.getRedactionTypes(this.#dossier.dossierTemplateId);
const data = await firstValueFrom(this._justificationsService.getForDossierTemplate(this.#dossier.dossierTemplateId));
this.legalOptions = data.map(lbm => ({
legalBasis: lbm.reason,
description: lbm.description,
label: lbm.name,
}));
this.legalOptions.sort((a, b) => a.label.localeCompare(b.label));
this.#selectReason();
if (!this.isRectangle) {
this.#formatSelectedTextValue();
}
}
save() {
this.#enhanceManualRedaction(this.data.manualRedactionEntryWrapper.manualRedactionEntry);
try {
const annotations =
this.isRectangle && !!this.form.get('multiplePages').value
? this.#getRectangles()
: [this.data.manualRedactionEntryWrapper];
this._dialogRef.close({
annotations,
dictionary: this.possibleDictionaries.find(d => d.type === this.form.get('dictionary').value),
});
} catch (e) {
this._toaster.error(_('manual-annotation.dialog.error'));
}
}
close() {
super.close();
if (this.isRectangle) {
this._annotationManager.delete(this._annotationManager.selected[0].Id);
}
}
#getRectangles() {
const quads = this.data.manualRedactionEntryWrapper.manualRedactionEntry.positions.find(a => !!a);
const value: string = this.form.get('multiplePages').value.replace(/[^0-9-,]/g, '');
const entry = { ...this.data.manualRedactionEntryWrapper.manualRedactionEntry };
const wrapper = { ...this.data.manualRedactionEntryWrapper };
const wrappers: ManualRedactionEntryWrapper[] = [wrapper];
value.split(',').forEach(range => {
const splitted = range.split('-');
const startPage = parseInt(splitted[0], 10);
const endPage = splitted.length > 1 ? parseInt(splitted[1], 10) : startPage;
if (!startPage || !endPage || startPage > this.data.file.numberOfPages || endPage > this.data.file.numberOfPages) {
throw new Error();
}
for (let page = startPage; page <= endPage; page++) {
if (page === wrapper.manualRedactionEntry.positions[0].page) {
continue;
}
const manualRedactionEntry = { ...entry, positions: [{ ...quads, page }] };
wrappers.push({ ...wrapper, manualRedactionEntry });
}
});
return wrappers;
}
#formatSelectedTextValue() {
this.data.manualRedactionEntryWrapper.manualRedactionEntry.value =
this.data.manualRedactionEntryWrapper.manualRedactionEntry.value.replace(
// eslint-disable-next-line no-control-regex,max-len
/([^\s\d-]{2,})[-\u00AD]\u000D\u000A|[\u000A\u000B\u000C\u000D\u0085\u2028\u2029]/gi,
'$1',
);
}
#getForm() {
return this._formBuilder.group({
selectedText: this.data?.manualRedactionEntryWrapper?.manualRedactionEntry?.value,
section: [null],
reason: this.isDictionaryRequest ? [null] : [null, Validators.required],
dictionary: this.isDictionaryRequest
? [this.isFalsePositiveRequest ? 'false_positive' : null, Validators.required]
: [this.manualRedactionTypeExists ? SuperTypes.ManualRedaction : null, Validators.required],
comment: [null],
classification: ['non-readable content'],
multiplePages: '',
});
}
#enhanceManualRedaction(addRedactionRequest: IAddRedactionRequest) {
const legalOption: LegalBasisOption = this.form.get('reason').value;
addRedactionRequest.type = this.form.get('dictionary').value;
if (legalOption) {
addRedactionRequest.reason = legalOption.description;
addRedactionRequest.legalBasis = legalOption.legalBasis;
}
if (this.iqserPermissionsService.has(Roles.getRss)) {
const selectedType = this.possibleDictionaries.find(d => d.type === addRedactionRequest.type);
addRedactionRequest.addToDictionary = selectedType.hasDictionary;
} else {
addRedactionRequest.addToDictionary = this.isDictionaryRequest && addRedactionRequest.type !== 'dossier_redaction';
}
if (!addRedactionRequest.reason) {
addRedactionRequest.reason = 'Dictionary Request';
}
const commentValue = this.form.get('comment').value;
addRedactionRequest.comment = commentValue ? { text: commentValue } : null;
addRedactionRequest.section = this.form.get('section').value;
addRedactionRequest.value = addRedactionRequest.rectangle
? this.form.get('classification').value
: this.form.get('selectedText').value;
}
#selectReason() {
if (this.legalOptions.length === 1) {
this.form.get('reason').setValue(this.legalOptions[0]);
}
}
}

View File

@ -0,0 +1,62 @@
<section class="dialog">
<form (submit)="save()" [formGroup]="form">
<div [translate]="'manual-annotation.dialog.header.redaction'" class="dialog-header heading-l"></div>
<div class="dialog-content redaction">
<iqser-details-radio
[options]="options"
(extraOptionChanged)="extraOptionChanged($event)"
formControlName="option"
></iqser-details-radio>
<div *deny="roles.getRss" class="iqser-input-group required w-450">
<label [translate]="'manual-annotation.dialog.content.reason'"></label>
<mat-form-field>
<mat-select
[placeholder]="'manual-annotation.dialog.content.reason-placeholder' | translate"
class="full-width"
formControlName="reason"
>
@for (option of legalOptions; track option) {
<mat-option [matTooltip]="option.description" [value]="option" matTooltipPosition="after">
{{ option.label }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div *deny="roles.getRss" class="iqser-input-group w-450">
<label [translate]="'manual-annotation.dialog.content.legalBasis'"></label>
<input [value]="form.get('reason').value?.legalBasis" disabled type="text" />
</div>
<div class="iqser-input-group w-450">
<label [translate]="'manual-annotation.dialog.content.section'"></label>
<input formControlName="section" name="section" type="text" />
</div>
<div class="iqser-input-group w-450">
<label [translate]="'manual-annotation.dialog.content.classification'"></label>
<input formControlName="classification" name="classification" type="text" />
</div>
<div class="iqser-input-group w-450">
<label [translate]="'manual-annotation.dialog.content.comment'"></label>
<textarea formControlName="comment" iqserHasScrollbar name="comment" rows="3" type="text"></textarea>
</div>
</div>
<div class="dialog-actions">
<iqser-icon-button
[disabled]="disabled"
[label]="'manual-annotation.dialog.actions.save' | translate"
[submit]="true"
[type]="iconButtonTypes.primary"
>
</iqser-icon-button>
</div>
</form>
<iqser-circle-button (action)="close()" class="dialog-close" icon="iqser:close"></iqser-circle-button>
</section>

View File

@ -2,6 +2,11 @@
width: 100%;
}
.dialog-content {
height: 600px;
padding-top: 8px;
}
.apply-on-multiple-pages {
min-height: 55px;
display: flex;

View File

@ -0,0 +1,185 @@
import { Component, OnInit } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, UntypedFormGroup, Validators } from '@angular/forms';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import {
CircleButtonComponent,
HasScrollbarDirective,
IconButtonComponent,
IqserDenyDirective,
IqserDialogComponent,
Toaster,
} from '@iqser/common-ui';
import { Dossier, IAddRedactionRequest, SuperTypes } from '@red/domain';
import { ActiveDossiersService } from '@services/dossiers/active-dossiers.service';
import { JustificationsService } from '@services/entity-services/justifications.service';
import { Roles } from '@users/roles';
import { firstValueFrom } from 'rxjs';
import { ManualRedactionService } from '../../services/manual-redaction.service';
import { NgForOf, NgIf } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { MatFormField } from '@angular/material/form-field';
import { MatOption, MatSelect, MatSelectTrigger } from '@angular/material/select';
import { MatTooltip } from '@angular/material/tooltip';
import { MatCheckbox } from '@angular/material/checkbox';
import { DetailsRadioOption } from '@common-ui/inputs/details-radio/details-radio-option';
import {
LegalBasisOption,
RectangleDialogData,
RectangleDialogResult,
RectangleRedactOption,
RectangleRedactOptions,
} from '../../utils/dialog-types';
import { getRectangleRedactOptions } from '../../utils/dialog-options';
import { DetailsRadioComponent } from '@common-ui/inputs/details-radio/details-radio.component';
import { SystemDefaults } from '../../../account/utils/dialog-defaults';
import { validatePageRange } from '../../utils/form-validators';
export const NON_READABLE_CONTENT = 'non-readable content';
@Component({
templateUrl: './rectangle-annotation-dialog.component.html',
styleUrls: ['./rectangle-annotation-dialog.component.scss'],
standalone: true,
imports: [
ReactiveFormsModule,
NgIf,
CircleButtonComponent,
TranslateModule,
HasScrollbarDirective,
MatFormField,
MatSelectTrigger,
MatSelect,
MatOption,
NgForOf,
MatTooltip,
IqserDenyDirective,
MatCheckbox,
IconButtonComponent,
DetailsRadioComponent,
],
providers: [ManualRedactionService],
})
export class RectangleAnnotationDialog
extends IqserDialogComponent<RectangleAnnotationDialog, RectangleDialogData, RectangleDialogResult>
implements OnInit
{
readonly #dossier: Dossier;
protected readonly roles = Roles;
protected readonly options: DetailsRadioOption<RectangleRedactOption>[];
protected legalOptions: LegalBasisOption[] = [];
readonly form: UntypedFormGroup;
constructor(
private readonly activeDossiersService: ActiveDossiersService,
private readonly _justificationsService: JustificationsService,
private readonly _formBuilder: FormBuilder,
private readonly _toaster: Toaster,
) {
super();
this.#dossier = this.activeDossiersService.find(this.data.dossierId);
this.options = getRectangleRedactOptions();
this.form = this.#getForm();
this.initialFormValue = this.form.getRawValue();
}
get #isMultiplePages() {
return this.form.get('option').value.value === RectangleRedactOptions.MULTIPLE_PAGES;
}
extraOptionChanged(option: DetailsRadioOption<RectangleRedactOption>): void {
if (option.value === RectangleRedactOptions.MULTIPLE_PAGES) {
setTimeout(() => {
this.form.get('option')?.updateValueAndValidity();
}, 0);
}
}
async ngOnInit() {
const data = await firstValueFrom(this._justificationsService.getForDossierTemplate(this.#dossier.dossierTemplateId));
this.legalOptions = data.map(lbm => ({
legalBasis: lbm.reason,
description: lbm.description,
label: lbm.name,
}));
this.legalOptions.sort((a, b) => a.label.localeCompare(b.label));
this.#selectReason();
}
save() {
this.#enhanceManualRedaction(this.data.manualRedactionEntryWrapper.manualRedactionEntry);
try {
const annotation = (this.#isMultiplePages ? this.#multiplePagesRectangle : this.data.manualRedactionEntryWrapper)
.manualRedactionEntry;
super.close({
annotation,
});
} catch (e) {
this._toaster.error(_('manual-annotation.dialog.error'));
}
}
#getForm() {
return this._formBuilder.group({
selectedText: this.data?.manualRedactionEntryWrapper?.manualRedactionEntry?.value,
section: [null],
reason: [null, Validators.required],
comment: [null],
classification: [NON_READABLE_CONTENT],
option: [this.#getOption(SystemDefaults.RECTANGLE_REDACT_DEFAULT), validatePageRange(this.data.file.numberOfPages)],
});
}
#enhanceManualRedaction(addRedactionRequest: IAddRedactionRequest) {
const legalOption: LegalBasisOption = this.form.get('reason').value;
addRedactionRequest.type = SuperTypes.ManualRedaction;
if (legalOption) {
addRedactionRequest.reason = legalOption.description;
addRedactionRequest.legalBasis = legalOption.legalBasis;
}
addRedactionRequest.addToDictionary = false;
const commentValue = this.form.get('comment').value;
addRedactionRequest.comment = commentValue ? (this.#isMultiplePages ? commentValue : { text: commentValue }) : null;
addRedactionRequest.section = this.form.get('section').value;
addRedactionRequest.value = this.form.get('classification').value;
}
#getOption(option: RectangleRedactOption): DetailsRadioOption<RectangleRedactOption> {
return this.options.find(o => o.value === option);
}
get #multiplePagesRectangle() {
const value: string = this.form.get('option').value.additionalInput.value.replace(/[^0-9-,]/g, '');
const entry = { ...this.data.manualRedactionEntryWrapper.manualRedactionEntry, pageNumbers: [] };
const wrapper = {
...this.data.manualRedactionEntryWrapper,
manualRedactionEntry: entry,
};
value.split(',').forEach(range => {
const splitted = range.split('-');
const startPage = parseInt(splitted[0], 10);
const endPage = splitted.length > 1 ? parseInt(splitted[1], 10) : startPage;
if (!startPage || !endPage || startPage > this.data.file.numberOfPages || endPage > this.data.file.numberOfPages) {
throw new Error();
}
for (let page = startPage; page <= endPage; page++) {
wrapper.manualRedactionEntry.pageNumbers.push(page);
}
});
return wrapper;
}
#selectReason() {
if (this.legalOptions.length === 1) {
this.form.get('reason').setValue(this.legalOptions[0]);
}
}
}

View File

@ -7,7 +7,7 @@
<redaction-selected-annotations-table
[columns]="tableColumns"
[data]="tableData"
[staticColumns]="true"
[defaultColumnWidth]="true"
></redaction-selected-annotations-table>
</div>

View File

@ -22,8 +22,14 @@ import {
ValueColumn,
} from '../../components/selected-annotations-table/selected-annotations-table.component';
import { getRedactOrHintOptions } from '../../utils/dialog-options';
import { RedactOrHintOption, RedactOrHintOptions, RedactRecommendationData, RedactRecommendationResult } from '../../utils/dialog-types';
import { LegalBasisOption } from '../manual-redaction-dialog/manual-annotation-dialog.component';
import {
LegalBasisOption,
RedactOrHintOption,
RedactOrHintOptions,
RedactRecommendationData,
RedactRecommendationResult,
ResizeOptions,
} from '../../utils/dialog-types';
@Component({
templateUrl: './redact-recommendation-dialog.component.html',
@ -69,19 +75,10 @@ export class RedactRecommendationDialogComponent
reason: [null],
});
readonly tableColumns = [
{
label: 'Value',
show: true,
},
{
label: 'Type',
show: true,
},
];
readonly tableColumns: ValueColumn[] = [{ label: 'Value' }, { label: 'Type' }];
readonly tableData: ValueColumn[][] = this.data.annotations.map(redaction => [
{ label: redaction.value, show: true, bold: true },
{ label: redaction.typeLabel, show: true },
{ label: redaction.value, bold: true },
{ label: redaction.typeLabel },
]);
constructor(
@ -105,6 +102,10 @@ export class RedactRecommendationDialogComponent
this.form.controls.option.setValue(this.options[0]);
}
get isBulkLocal(): boolean {
return this.form.controls.option.value.value === ResizeOptions.IN_DOCUMENT;
}
get displayedDictionaryLabel() {
const dictType = this.form.controls.dictionary.value;
if (dictType) {
@ -131,7 +132,7 @@ export class RedactRecommendationDialogComponent
}
extraOptionChanged(option: DetailsRadioOption<RedactOrHintOption>): void {
this.#applyToAllDossiers = option.extraOption.checked;
this.#applyToAllDossiers = option.additionalCheck.checked;
this.#setDictionaries();
if (this.#applyToAllDossiers && this.form.controls.dictionary.value) {
@ -147,7 +148,7 @@ export class RedactRecommendationDialogComponent
if (!this.#applyToAllDossiers) {
const selectedDictionaryType = this.form.controls.dictionary.value;
const selectedDictionary = this.dictionaries.find(d => d.type === selectedDictionaryType);
this.options[0].extraOption.disabled = selectedDictionary.dossierDictionaryOnly;
this.options[0].additionalCheck.disabled = selectedDictionary.dossierDictionaryOnly;
}
}
@ -156,11 +157,16 @@ export class RedactRecommendationDialogComponent
this.close({
redaction,
isMulti: this.isMulti,
bulkLocal: this.isBulkLocal,
});
}
#setDictionaries() {
this.dictionaries = this._dictionaryService.getRedactTextDictionaries(this.#dossier.dossierId, !this.#applyToAllDossiers);
this.dictionaries = this._dictionaryService.getRedactTextDictionaries(
this.#dossier.dossierId,
!this.#applyToAllDossiers,
this.#dossier.dossierTemplateId,
);
}
#selectReason() {
@ -183,7 +189,7 @@ export class RedactRecommendationDialogComponent
}
const commentValue = this.form.controls.comment.value;
addRedactionRequest.comment = commentValue ? { text: commentValue } : null;
addRedactionRequest.comment = commentValue ? (this.isBulkLocal ? commentValue : { text: commentValue }) : null;
addRedactionRequest.addToAllDossiers = this.data.isApprover && this.dictionaryRequest && this.#applyToAllDossiers;
return addRedactionRequest;
}

View File

@ -9,14 +9,14 @@
[ngClass]="isEditingSelectedText ? 'flex relative' : 'flex-align-items-center'"
>
<div class="table">
<label>Value</label>
<label> {{ 'redact-text.dialog.content.value' | translate }} </label>
<div class="row">
<span
*ngIf="!isEditingSelectedText"
[innerHTML]="form.controls.selectedText.value"
[ngStyle]="{
'min-width': textWidth > maximumSelectedTextWidth ? '95%' : 'unset',
'max-width': textWidth > maximumSelectedTextWidth ? 0 : 'unset'
'max-width': textWidth > maximumSelectedTextWidth ? 0 : 'unset',
}"
></span>
<textarea

View File

@ -1,5 +1,5 @@
.dialog-content {
height: 493px;
height: 540px;
overflow-y: auto;
}

View File

@ -10,7 +10,7 @@ import { DetailsRadioOption } from '@common-ui/inputs/details-radio/details-radi
import { DetailsRadioComponent } from '@common-ui/inputs/details-radio/details-radio.component';
import { CircleButtonComponent, HasScrollbarDirective, IconButtonComponent, IconButtonTypes, IqserDialogComponent } from '@iqser/common-ui';
import { TranslateModule } from '@ngx-translate/core';
import { Dictionary, IAddRedactionRequest, SuperTypes } from '@red/domain';
import { Dictionary, SuperTypes } from '@red/domain';
import { ActiveDossiersService } from '@services/dossiers/active-dossiers.service';
import { DictionaryService } from '@services/entity-services/dictionary.service';
import { JustificationsService } from '@services/entity-services/justifications.service';
@ -21,8 +21,15 @@ import { firstValueFrom, Observable } from 'rxjs';
import { map, tap } from 'rxjs/operators';
import { SystemDefaultOption, SystemDefaults } from '../../../account/utils/dialog-defaults';
import { getRedactOrHintOptions } from '../../utils/dialog-options';
import { RedactOrHintOption, RedactOrHintOptions, RedactTextData, RedactTextResult } from '../../utils/dialog-types';
import { LegalBasisOption } from '../manual-redaction-dialog/manual-annotation-dialog.component';
import {
LegalBasisOption,
RedactOrHintOption,
RedactOrHintOptions,
RedactTextData,
RedactTextResult,
ResizeOptions,
} from '../../utils/dialog-types';
import { enhanceManualRedactionRequest, EnhanceRequestData } from '../../utils/enhance-manual-redaction-request.utils';
const MAXIMUM_TEXT_AREA_WIDTH = 421;
@ -109,6 +116,10 @@ export class RedactTextDialogComponent
);
}
get isBulkLocal() {
return this.form.controls.option.value.value === ResizeOptions.IN_DOCUMENT;
}
get isSystemDefault(): boolean {
return this._userPreferences.getAddRedactionDefaultOption() === SystemDefaultOption.SYSTEM_DEFAULT;
}
@ -149,7 +160,7 @@ export class RedactTextDialogComponent
}
extraOptionChanged(option: DetailsRadioOption<RedactOrHintOption>): void {
this.#applyToAllDossiers = option.extraOption.checked;
this.#applyToAllDossiers = option.additionalCheck.checked;
this.#setDictionaries();
if (this.#applyToAllDossiers && this.form.controls.dictionary.value) {
@ -165,16 +176,17 @@ export class RedactTextDialogComponent
if (!this.#applyToAllDossiers) {
const selectedDictionaryType = this.form.controls.dictionary.value;
const selectedDictionary = this.dictionaries.find(d => d.type === selectedDictionaryType);
this.options[1].extraOption.disabled = selectedDictionary.dossierDictionaryOnly;
this.options[2].additionalCheck.disabled = selectedDictionary.dossierDictionaryOnly;
}
}
save(): void {
this.#enhanceManualRedaction(this.data.manualRedactionEntryWrapper.manualRedactionEntry);
const redaction = this.data.manualRedactionEntryWrapper.manualRedactionEntry;
enhanceManualRedactionRequest(redaction, this.#enhanceRequestData);
this.close({
redaction,
dictionary: this.dictionaries.find(d => d.type === this.form.controls.dictionary.value),
bulkLocal: this.isBulkLocal,
});
}
@ -188,14 +200,18 @@ export class RedactTextDialogComponent
#setupValidators(option: RedactOrHintOption) {
switch (option) {
case RedactOrHintOptions.IN_DOSSIER:
this.form.controls.reason.clearValidators();
this.form.controls.dictionary.addValidators(Validators.required);
break;
case RedactOrHintOptions.ONLY_HERE:
this.form.controls.dictionary.clearValidators();
this.form.controls.reason.addValidators(Validators.required);
break;
case RedactOrHintOptions.IN_DOCUMENT:
this.form.controls.dictionary.clearValidators();
this.form.controls.reason.addValidators(Validators.required);
break;
case RedactOrHintOptions.IN_DOSSIER:
this.form.controls.reason.clearValidators();
this.form.controls.dictionary.addValidators(Validators.required);
break;
}
this.form.controls.reason.updateValueAndValidity();
@ -203,7 +219,11 @@ export class RedactTextDialogComponent
}
#setDictionaries() {
this.dictionaries = this._dictionaryService.getRedactTextDictionaries(this.#dossier.dossierId, !this.#applyToAllDossiers);
this.dictionaries = this._dictionaryService.getRedactTextDictionaries(
this.#dossier.dossierId,
!this.#applyToAllDossiers,
this.#dossier.dossierTemplateId,
);
}
#getForm(): FormGroup {
@ -222,36 +242,9 @@ export class RedactTextDialogComponent
}
}
#enhanceManualRedaction(addRedactionRequest: IAddRedactionRequest) {
addRedactionRequest.type = this.form.controls.dictionary.value;
addRedactionRequest.section = null;
addRedactionRequest.value = this.form.controls.selectedText.value;
const legalOption: LegalBasisOption = this.form.controls.reason.value;
if (legalOption) {
addRedactionRequest.reason = legalOption.description;
addRedactionRequest.legalBasis = legalOption.legalBasis;
}
const selectedType = this.dictionaries.find(d => d.type === addRedactionRequest.type);
if (selectedType) {
addRedactionRequest.addToDictionary = selectedType.hasDictionary;
} else {
addRedactionRequest.addToDictionary = this.dictionaryRequest && addRedactionRequest.type !== 'dossier_redaction';
}
if (!addRedactionRequest.reason) {
addRedactionRequest.reason = 'Dictionary Request';
}
const commentValue = this.form.controls.comment.value;
addRedactionRequest.comment = commentValue ? { text: commentValue } : null;
addRedactionRequest.addToAllDossiers = this.data.isApprover && this.dictionaryRequest && this.#applyToAllDossiers;
}
#resetValues() {
this.#applyToAllDossiers = this.applyToAll;
this.options[1].extraOption.checked = this.#applyToAllDossiers;
this.options[2].additionalCheck.checked = this.#applyToAllDossiers;
if (this.dictionaryRequest) {
this.form.controls.reason.setValue(null);
this.form.controls.dictionary.setValue(null);
@ -263,4 +256,18 @@ export class RedactTextDialogComponent
#getOption(option: RedactOrHintOption): DetailsRadioOption<RedactOrHintOption> {
return this.options.find(o => o.value === option);
}
get #enhanceRequestData(): EnhanceRequestData {
return {
type: this.form.controls.dictionary.value,
selectedText: this.form.controls.selectedText.value,
reason: this.form.controls.reason.value,
dictionaries: this.dictionaries,
dictionaryRequest: this.dictionaryRequest,
comment: this.form.controls.comment.value,
isApprover: this.data.isApprover,
applyToAllDossiers: this.#applyToAllDossiers,
bulkLocal: this.isBulkLocal,
};
}
}

View File

@ -10,11 +10,15 @@
<redaction-selected-annotations-table
[columns]="tableColumns()"
[data]="tableData()"
[staticColumns]="!hasFalsePositiveOption"
[defaultColumnWidth]="!hasFalsePositiveOption"
></redaction-selected-annotations-table>
</div>
<iqser-details-radio [options]="options" formControlName="option"></iqser-details-radio>
<iqser-details-radio
(extraOptionChanged)="extraOptionChanged($event)"
[options]="options"
formControlName="option"
></iqser-details-radio>
<div class="iqser-input-group w-450">
<label [translate]="'remove-redaction.dialog.content.comment'"></label>
@ -31,6 +35,7 @@
<div class="dialog-actions">
<iqser-icon-button
[disabled]="disabled"
[label]="'remove-redaction.dialog.actions.save' | translate"
[submit]="true"
[type]="iconButtonTypes.primary"

View File

@ -24,9 +24,19 @@ import {
SelectedAnnotationsTableComponent,
ValueColumn,
} from '../../components/selected-annotations-table/selected-annotations-table.component';
import { DialogHelpModeKeys } from '../../utils/constants';
import { getRemoveRedactionOptions } from '../../utils/dialog-options';
import { RemoveRedactionData, RemoveRedactionOption, RemoveRedactionOptions, RemoveRedactionResult } from '../../utils/dialog-types';
import { getRectangleRedactOptions, getRemoveRedactionOptions } from '../../utils/dialog-options';
import {
RectangleRedactOption,
RectangleRedactOptions,
RemoveRedactionData,
RemoveRedactionOption,
RemoveRedactionOptions,
RemoveRedactionResult,
ResizeOptions,
} from '../../utils/dialog-types';
import { isJustOne } from '@common-ui/utils';
import { validatePageRange } from '../../utils/form-validators';
import { parseRectanglePosition, parseSelectedPageNumbers, prefillPageRange } from '../../utils/enhance-manual-redaction-request.utils';
@Component({
templateUrl: './remove-redaction-dialog.component.html',
@ -84,6 +94,7 @@ export class RemoveRedactionDialogComponent extends IqserDialogComponent<
extra: false,
},
};
readonly #allRectangles = this.data.redactions.reduce((acc, a) => acc && a.AREA, true);
readonly #applyToAllDossiers = this.systemDefaultByType[this.annotationsType].extra;
readonly isSystemDefault = this.optionByType[this.annotationsType].main === SystemDefaultOption.SYSTEM_DEFAULT;
readonly isExtraOptionSystemDefault = this.optionByType[this.annotationsType].extra === 'undefined';
@ -91,39 +102,36 @@ export class RemoveRedactionDialogComponent extends IqserDialogComponent<
? this.systemDefaultByType[this.annotationsType].main
: this.optionByType[this.annotationsType].main;
readonly extraOptionPreference = stringToBoolean(this.optionByType[this.annotationsType].extra);
readonly options: DetailsRadioOption<RemoveRedactionOption>[] = getRemoveRedactionOptions(
this.data,
this.isSystemDefault || this.isExtraOptionSystemDefault ? this.#applyToAllDossiers : this.extraOptionPreference,
);
readonly options: DetailsRadioOption<RectangleRedactOption | RemoveRedactionOption>[] = this.#allRectangles
? getRectangleRedactOptions('remove')
: getRemoveRedactionOptions(
this.data,
this.isSystemDefault || this.isExtraOptionSystemDefault ? this.#applyToAllDossiers : this.extraOptionPreference,
);
readonly skipped = this.data.redactions.some(annotation => annotation.isSkipped);
readonly redactedTexts = this.data.redactions.map(annotation => annotation.value);
form: UntypedFormGroup = this._formBuilder.group({
comment: [null],
option: [this.defaultOption],
option: [this.defaultOption, validatePageRange(this.data.file.numberOfPages, true)],
});
readonly selectedOption = toSignal(this.form.get('option').valueChanges.pipe(map(value => value.value)));
readonly isFalsePositive = computed(() => this.selectedOption() === RemoveRedactionOptions.FALSE_POSITIVE);
readonly tableColumns = computed<ValueColumn[]>(() => [
{
label: 'Value',
show: true,
},
{
label: 'Type',
show: true,
},
{ label: 'Value', width: '25%' },
{ label: 'Type', width: '25%' },
{
label: 'Context',
show: this.isFalsePositive(),
hide: !this.isFalsePositive(),
width: '50%',
},
]);
readonly tableData = computed<ValueColumn[][]>(() =>
this.data.redactions.map((redaction, index) => [
{ label: redaction.value, show: true, bold: true },
{ label: redaction.typeLabel, show: true },
{ label: this.data.falsePositiveContext[index], show: this.isFalsePositive() },
{ label: redaction.value, bold: true },
{ label: redaction.typeLabel },
{ label: this.data.falsePositiveContext[index], hide: !this.isFalsePositive() },
]),
);
@ -132,19 +140,14 @@ export class RemoveRedactionDialogComponent extends IqserDialogComponent<
private readonly _userPreferences: UserPreferenceService,
) {
super();
}
get helpButtonKey() {
if (this.hint) {
return DialogHelpModeKeys.HINT_REMOVE;
if (this.#allRectangles) {
prefillPageRange(
this.data.redactions[0],
this.data.allFileRedactions,
this.options as DetailsRadioOption<RectangleRedactOption>[],
);
}
if (this.recommendation) {
return DialogHelpModeKeys.RECOMMENDATION_REMOVE;
}
if (this.skipped) {
return DialogHelpModeKeys.SKIPPED_REMOVE;
}
return DialogHelpModeKeys.REDACTION_REMOVE;
}
get hasFalsePositiveOption() {
@ -162,7 +165,7 @@ export class RemoveRedactionDialogComponent extends IqserDialogComponent<
}
get isBulk() {
return this.data.redactions.length > 1;
return !isJustOne(this.data.redactions);
}
get redactedTextsAreaHeight() {
@ -173,11 +176,29 @@ export class RemoveRedactionDialogComponent extends IqserDialogComponent<
return this.options.length * 75 + 230;
}
save(): void {
this.close(this.form.getRawValue());
extraOptionChanged(option: DetailsRadioOption<RemoveRedactionOption | RectangleRedactOption>): void {
if (option.value === RectangleRedactOptions.MULTIPLE_PAGES) {
setTimeout(() => {
this.form.get('option')?.updateValueAndValidity();
}, 0);
}
}
#getOption(option: RemoveRedactionOption): DetailsRadioOption<RemoveRedactionOption> {
save(): void {
const optionValue = this.form.controls.option?.value?.value;
const optionInputValue = this.form.controls.option?.value?.additionalInput?.value;
const pageNumbers = parseSelectedPageNumbers(optionInputValue, this.data.file, this.data.redactions[0]);
const position = parseRectanglePosition(this.data.redactions[0]);
this.close({
...this.form.getRawValue(),
bulkLocal: optionValue === ResizeOptions.IN_DOCUMENT || optionValue === RectangleRedactOptions.MULTIPLE_PAGES,
pageNumbers,
position,
});
}
#getOption(option: RemoveRedactionOption): DetailsRadioOption<RectangleRedactOption | RemoveRedactionOption> {
return this.options.find(o => o.value === option);
}
}

View File

@ -77,12 +77,12 @@ export class ResizeRedactionDialogComponent extends IqserDialogComponent<
save() {
const formValue = this.form.getRawValue();
const updateDictionary = formValue.option.value === ResizeOptions.IN_DOSSIER;
const updateDictionary = formValue.option?.value === ResizeOptions.IN_DOSSIER;
super.close({
comment: formValue.comment,
updateDictionary,
addToAllDossiers: !!formValue.option?.extraOption?.checked,
addToAllDossiers: !!formValue.option?.additionalCheck?.checked,
});
}

View File

@ -39,7 +39,7 @@
}
&.documine-container {
width: 60%;
width: calc(100% - var(--structured-component-management-width));
}
}

View File

@ -1,11 +1,9 @@
import { NgIf } from '@angular/common';
import { ChangeDetectorRef, Component, effect, NgZone, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
import { ActivatedRouteSnapshot, NavigationExtras, Router, RouterLink } from '@angular/router';
import { ChangeDetectorRef, Component, effect, NgZone, OnDestroy, OnInit, TemplateRef, untracked, ViewChild } from '@angular/core';
import { ActivatedRouteSnapshot, NavigationExtras, Router } from '@angular/router';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { InitialsAvatarComponent } from '@common-ui/users';
import { ComponentCanDeactivate } from '@guards/can-deactivate.guard';
import {
CircleButtonComponent,
CircleButtonTypes,
ConfirmOption,
ConfirmOptions,
@ -14,7 +12,6 @@ import {
ErrorService,
getConfig,
IConfirmationDialogData,
IqserAllowDirective,
IqserDialog,
LoadingService,
Toaster,
@ -24,7 +21,7 @@ import { AutoUnsubscribe, Bind, bool, List, log, OnAttach, OnDetach } from '@iqs
import { AnnotationWrapper } from '@models/file/annotation.wrapper';
import { ManualRedactionEntryTypes, ManualRedactionEntryWrapper } from '@models/file/manual-redaction-entry.wrapper';
import { TranslateModule } from '@ngx-translate/core';
import { AnalyseStatuses, AnalysisEvent, Dictionary, File, ViewModes, WsTopics } from '@red/domain';
import { AnalyseStatuses, AnalysisEvent, File, ViewModes, WsTopics } from '@red/domain';
import { ConfigService } from '@services/config.service';
import { DossierTemplatesService } from '@services/dossier-templates/dossier-templates.service';
import { DossiersService } from '@services/dossiers/dossiers.service';
@ -33,7 +30,6 @@ import { FilesService } from '@services/files/files.service';
import { PermissionsService } from '@services/permissions.service';
import { ReanalysisService } from '@services/reanalysis.service';
import { WebSocketService } from '@services/web-socket.service';
import { ProcessingIndicatorComponent } from '@shared/components/processing-indicator/processing-indicator.component';
import { TypeFilterComponent } from '@shared/components/type-filter/type-filter.component';
import { Roles } from '@users/roles';
import { PreferencesKeys, UserPreferenceService } from '@users/user-preference.service';
@ -49,14 +45,12 @@ import { PdfViewer } from '../pdf-viewer/services/pdf-viewer.service';
import { ReadableRedactionsService } from '../pdf-viewer/services/readable-redactions.service';
import { ViewerHeaderService } from '../pdf-viewer/services/viewer-header.service';
import { ROTATION_ACTION_BUTTONS, ViewerEvents } from '../pdf-viewer/utils/constants';
import { FileActionsComponent } from '../shared-dossiers/components/file-actions/file-actions.component';
import { FileHeaderComponent } from './components/file-header/file-header.component';
import { FilePreviewRightContainerComponent } from './components/right-container/file-preview-right-container.component';
import { StructuredComponentManagementComponent } from './components/structured-component-management/structured-component-management.component';
import { UserManagementComponent } from './components/user-management/user-management.component';
import { ViewSwitchComponent } from './components/view-switch/view-switch.component';
import { AddHintDialogComponent } from './dialogs/add-hint-dialog/add-hint-dialog.component';
import { AddAnnotationDialogComponent } from './dialogs/docu-mine/add-annotation-dialog/add-annotation-dialog.component';
import { RectangleAnnotationDialog } from './dialogs/rectangle-annotation-dialog/rectangle-annotation-dialog.component';
import { RedactTextDialogComponent } from './dialogs/redact-text-dialog/redact-text-dialog.component';
import { filePreviewScreenProviders } from './file-preview-providers';
import { AnnotationProcessingService } from './services/annotation-processing.service';
@ -80,16 +74,8 @@ import { RedactTextData } from './utils/dialog-types';
standalone: true,
imports: [
NgIf,
ViewSwitchComponent,
ProcessingIndicatorComponent,
UserManagementComponent,
TranslateModule,
InitialsAvatarComponent,
CircleButtonComponent,
IqserAllowDirective,
FileActionsComponent,
DisableStopPropagationDirective,
RouterLink,
FilePreviewRightContainerComponent,
TypeFilterComponent,
FileHeaderComponent,
@ -157,6 +143,13 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
// this._fileDataService.loadAnnotations(file).then();
});
effect(() => {
const file = this.state.file();
if (file.analysisRequired && !file.excludedFromAutomaticAnalysis) {
this._reanalysisService.reanalyzeFilesForDossier([file], file.dossierId, { force: true }).then();
}
});
this.#wsConnection$ = this._webSocketService.listen<AnalysisEvent>(WsTopics.ANALYSIS).pipe(
log('[WS] Analysis events'),
filter(event => event.analyseStatus === AnalyseStatuses.FINISHED),
@ -193,7 +186,7 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
effect(() => {
this._viewModeService.viewMode();
this.updateViewMode().then();
this.#updateViewMode().then();
});
effect(() => {
@ -221,7 +214,7 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
const earmarks$ = isEarmarksViewMode$.pipe(
tap(() => this._loadingService.start()),
switchMap(() => this._fileDataService.loadEarmarks()),
tap(() => this.updateViewMode().then(() => this._loadingService.stop())),
tap(() => this.#updateViewMode().then(() => this._loadingService.stop())),
);
const currentPageIfEarmarksView$ = combineLatest([this.pdf.currentPage$, this._viewModeService.viewMode$]).pipe(
@ -247,7 +240,7 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
return isChangingFromEarmarksViewMode$.pipe(
map(() => this._fileDataService.earmarks().get(this.pdf.currentPage()) ?? []),
map(earmarks => this.deleteAnnotations(earmarks, [])),
map(earmarks => this.#deleteAnnotations(earmarks, [])),
);
}
@ -256,15 +249,188 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
this._viewerHeaderService.disable(ROTATION_ACTION_BUTTONS);
}
async updateViewMode(): Promise<void> {
this._logger.info(`[PDF] Update ${this._viewModeService.viewMode()} view mode`);
ngOnDetach() {
this._viewerHeaderService.resetCompareButtons();
this._viewerHeaderService.enableLoadAllAnnotations(); // Reset the button state (since the viewer is reused between files)
super.ngOnDetach();
this.pdf.instance.UI.hotkeys.off('esc');
this.pdf.instance.UI.iframeWindow.document.removeEventListener('click', this.handleViewerClick);
this._changeRef.markForCheck();
this.#wsConnectionSub.unsubscribe();
}
ngOnDestroy() {
this.pdf.instance.UI.hotkeys.off('esc');
this.pdf.instance.UI.iframeWindow.document.removeEventListener('click', this.handleViewerClick);
super.ngOnDestroy();
this.#wsConnectionSub.unsubscribe();
}
@Bind()
handleEscInsideViewer($event: KeyboardEvent) {
$event.preventDefault();
if (!!this._annotationManager.selected[0]) {
const doesHaveWrapper = this._fileDataService.find(this._annotationManager.selected[0]?.Id);
if (!doesHaveWrapper) {
this._annotationManager.delete(this._annotationManager.selected[0]?.Id);
} else {
this._annotationManager.deselect(this._annotationManager.selected[0]?.Id);
}
}
if (this._annotationManager.selected.length) {
this._annotationManager.deselectAll();
}
if (this._multiSelectService.active()) {
this._multiSelectService.deactivate();
}
}
@Bind()
handleViewerClick(event: MouseEvent) {
this._ngZone.run(() => {
if (event.isTrusted) {
const clickedElement = event.target as HTMLElement;
const editingAnnotation =
(clickedElement as HTMLImageElement).src?.includes('edit.svg') || clickedElement.getAttribute('aria-label') === 'Edit';
if (this._multiSelectService.active() && !editingAnnotation) {
if (
clickedElement.querySelector('#selectionrect') ||
clickedElement.id === `pageWidgetContainer${this.pdf.currentPage()}`
) {
if (!this._annotationManager.selected.length) {
this._multiSelectService.deactivate();
}
} else {
this._multiSelectService.deactivate();
}
}
}
});
}
async ngOnAttach(previousRoute: ActivatedRouteSnapshot) {
if (!this.state.file().canBeOpened) {
return this.#navigateToDossier();
}
this._viewModeService.switchToStandard();
await this.ngOnInit();
this._viewerHeaderService.updateElements();
const page = previousRoute.queryParams.page ?? '1';
await this.#updateQueryParamsPage(Number(page));
await this.viewerReady(page);
}
async ngOnInit(): Promise<void> {
this.#wsConnectionSub = this.#wsConnection$.subscribe();
this.#updateViewerPosition();
const file = this.state.file();
if (!file) {
return this.#handleDeletedFile();
}
this._loadingService.start();
this.userPreferenceService.saveLastOpenedFileForDossier(this.dossierId, this.fileId).then();
this.#subscribeToFileUpdates();
this.pdfProxyService.configureElements();
this.#restoreOldFilters();
this.pdf.instance.UI.hotkeys.on('esc', this.handleEscInsideViewer);
this._viewerHeaderService.resetLayers();
this.pdf.instance.UI.iframeWindow.document.removeEventListener('click', this.handleViewerClick);
this.pdf.instance.UI.iframeWindow.document.addEventListener('click', this.handleViewerClick);
}
async openRectangleAnnotationDialog(manualRedactionEntryWrapper: ManualRedactionEntryWrapper) {
const file = this.state.file();
const data = { manualRedactionEntryWrapper, file, dossierId: this.dossierId };
const result = await this._iqserDialog.openDefault(RectangleAnnotationDialog, { data }).result();
if (!result) {
return;
}
const selectedAnnotations = this._annotationManager.selected;
if (selectedAnnotations.length > 0) {
this._annotationManager.delete([selectedAnnotations[0].Id]);
}
const add$ = this._manualRedactionService.addAnnotation([result.annotation], this.dossierId, this.fileId);
const addAndReload$ = add$.pipe(switchMap(() => this._filesService.reload(this.dossierId, file)));
return firstValueFrom(addAndReload$.pipe(catchError(() => of(undefined))));
}
async viewerReady(pageNumber?: string) {
if (pageNumber) {
const file = this.state.file();
let page = parseInt(pageNumber, 10);
if (page < 1 || Number.isNaN(page)) {
page = 1;
await this.#updateQueryParamsPage(page);
} else if (page > file.numberOfPages) {
page = file.numberOfPages;
}
setTimeout(() => {
this.pdf.navigateTo(page);
}, 300);
}
this._loadingService.stop();
this._changeRef.markForCheck();
}
loadAnnotations$() {
const annotations$ = this._fileDataService.annotations$.pipe(
startWith([] as AnnotationWrapper[]),
pairwise(),
tap(annotations => this.#deleteAnnotations(...annotations)),
tap(() => this.#updateFiltersAfterAnnotationsChanged()),
tap(() => this.#updateViewMode()),
);
const currentPageIfNotHighlightsView$ = combineLatest([this.pdf.currentPage$, this._viewModeService.viewMode$]).pipe(
filter(([, viewMode]) => viewMode !== ViewModes.TEXT_HIGHLIGHTS),
map(([page]) => page),
);
const currentPageAnnotations$ = combineLatest([currentPageIfNotHighlightsView$, annotations$]).pipe(
map(([page, [oldAnnotations, newAnnotations]]) =>
this.#loadAllAnnotationsEnabled
? ([oldAnnotations, newAnnotations] as const)
: ([oldAnnotations.filter(byPage(page)), newAnnotations.filter(byPage(page))] as const),
),
);
return combineLatest([currentPageAnnotations$, this._documentViewer.loaded$]).pipe(
filter(([, loaded]) => loaded),
map(([annotations]) => annotations),
switchMap(async ([oldAnnotations, newAnnotations]) => {
await this.#drawChangedAnnotations(oldAnnotations, newAnnotations);
return newAnnotations;
}),
tap(newAnnotations => this.#highlightSelectedAnnotations(newAnnotations)),
);
}
async #updateViewMode(): Promise<void> {
const viewMode = untracked(this._viewModeService.viewMode);
this._logger.info(`[PDF] Update ${viewMode} view mode`);
const annotations = this._annotationManager.get(a => bool(a.getCustomData('redact-manager')));
const redactions = annotations.filter(a => bool(a.getCustomData('redaction')));
switch (this._viewModeService.viewMode()) {
switch (viewMode) {
case ViewModes.STANDARD: {
const wrappers = this._fileDataService.annotations();
// TODO: const wrappers = untracked(this._fileDataService.annotations);
const ocrAnnotationIds = wrappers.filter(a => a.isOCR).map(a => a.id);
const standardEntries = annotations
.filter(a => !bool(a.getCustomData('changeLogRemoved')) && !this._annotationManager.isHidden(a.Id))
@ -273,7 +439,7 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
a =>
bool(a.getCustomData('changeLogRemoved')) ||
this._annotationManager.isHidden(a.Id) ||
(this._skippedService.hideSkipped() && bool(a.getCustomData('skipped'))),
(untracked(this._skippedService.hideSkipped) && bool(a.getCustomData('skipped'))),
);
this._readableRedactionsService.setAnnotationsColor(standardEntries, 'annotationColor');
this._readableRedactionsService.setAnnotationsOpacity(standardEntries, true);
@ -311,154 +477,7 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
this._logger.info('[PDF] Update done');
}
ngOnDetach() {
this._viewerHeaderService.resetCompareButtons();
this._viewerHeaderService.enableLoadAllAnnotations(); // Reset the button state (since the viewer is reused between files)
super.ngOnDetach();
this.pdf.instance.UI.hotkeys.off('esc');
this._changeRef.markForCheck();
this.#wsConnectionSub.unsubscribe();
}
ngOnDestroy() {
this.pdf.instance.UI.hotkeys.off('esc');
super.ngOnDestroy();
this.#wsConnectionSub.unsubscribe();
}
@Bind()
handleEscInsideViewer($event: KeyboardEvent) {
$event.preventDefault();
if (!!this._annotationManager.selected[0]) {
const doesHaveWrapper = this._fileDataService.find(this._annotationManager.selected[0]?.Id);
if (!doesHaveWrapper) {
this._annotationManager.delete(this._annotationManager.selected[0]?.Id);
} else {
this._annotationManager.deselect(this._annotationManager.selected[0]?.Id);
}
}
if (this._annotationManager.selected.length) {
this._annotationManager.deselectAll();
}
if (this._multiSelectService.active()) {
this._multiSelectService.deactivate();
}
}
async ngOnAttach(previousRoute: ActivatedRouteSnapshot) {
if (!this.state.file().canBeOpened) {
return this.#navigateToDossier();
}
this._viewModeService.switchToStandard();
await this.ngOnInit();
this._viewerHeaderService.updateElements();
const page = previousRoute.queryParams.page ?? '1';
await this.#updateQueryParamsPage(Number(page));
await this.viewerReady(page);
}
async ngOnInit(): Promise<void> {
this.#wsConnectionSub = this.#wsConnection$.subscribe();
this.#updateViewerPosition();
const file = this.state.file();
if (!file) {
return this.#handleDeletedFile();
}
this._loadingService.start();
this.userPreferenceService.saveLastOpenedFileForDossier(this.dossierId, this.fileId).then();
this.#subscribeToFileUpdates();
if (file?.analysisRequired && !file.excludedFromAutomaticAnalysis) {
await this._reanalysisService.reanalyzeFilesForDossier([file], this.dossierId, { force: true });
}
this.pdfProxyService.configureElements();
this.#restoreOldFilters();
this.pdf.instance.UI.hotkeys.on('esc', this.handleEscInsideViewer);
this._viewerHeaderService.resetLayers();
}
openManualAnnotationDialog(manualRedactionEntryWrapper: ManualRedactionEntryWrapper) {
const file = this.state.file();
this._dialogService.openDialog(
'manualAnnotation',
{ manualRedactionEntryWrapper, dossierId: this.dossierId, file },
(result: { annotations: ManualRedactionEntryWrapper[]; dictionary?: Dictionary }) => {
const selectedAnnotations = this._annotationManager.selected;
if (selectedAnnotations.length > 0) {
this._annotationManager.delete([selectedAnnotations[0].Id]);
}
const add$ = this._manualRedactionService.addAnnotation(
result.annotations.map(w => w.manualRedactionEntry).filter(e => e.positions[0].page <= file.numberOfPages),
this.dossierId,
this.fileId,
{ dictionaryLabel: result.dictionary?.label },
);
const addAndReload$ = add$.pipe(switchMap(() => this._filesService.reload(this.dossierId, file)));
return firstValueFrom(addAndReload$.pipe(catchError(() => of(undefined))));
},
);
}
async viewerReady(pageNumber?: string) {
if (pageNumber) {
const file = this.state.file();
let page = parseInt(pageNumber, 10);
if (page < 1 || Number.isNaN(page)) {
page = 1;
await this.#updateQueryParamsPage(page);
} else if (page > file.numberOfPages) {
page = file.numberOfPages;
}
setTimeout(() => {
this.pdf.navigateTo(page);
}, 300);
}
this._loadingService.stop();
this._changeRef.markForCheck();
}
loadAnnotations$() {
const annotations$ = this._fileDataService.annotations$.pipe(
startWith([] as AnnotationWrapper[]),
pairwise(),
tap(annotations => this.deleteAnnotations(...annotations)),
);
const currentPageIfNotHighlightsView$ = combineLatest([this.pdf.currentPage$, this._viewModeService.viewMode$]).pipe(
filter(([, viewMode]) => viewMode !== ViewModes.TEXT_HIGHLIGHTS),
map(([page]) => page),
);
const currentPageAnnotations$ = combineLatest([currentPageIfNotHighlightsView$, annotations$]).pipe(
map(([page, [oldAnnotations, newAnnotations]]) =>
this.#loadAllAnnotationsEnabled
? ([oldAnnotations, newAnnotations] as const)
: ([oldAnnotations.filter(byPage(page)), newAnnotations.filter(byPage(page))] as const),
),
);
return combineLatest([currentPageAnnotations$, this._documentViewer.loaded$]).pipe(
filter(([, loaded]) => loaded),
map(([annotations]) => annotations),
tap(([oldA, newA]) => this.drawChangedAnnotations(oldA, newA)?.then(() => this.updateViewMode())),
tap(([, newAnnotations]) => this.#highlightSelectedAnnotations(newAnnotations)),
);
}
deleteAnnotations(oldAnnotations: AnnotationWrapper[], newAnnotations: AnnotationWrapper[]) {
#deleteAnnotations(oldAnnotations: AnnotationWrapper[], newAnnotations: AnnotationWrapper[]) {
const annotationsToDelete = oldAnnotations.filter(oldAnnotation => {
const newAnnotation = newAnnotations.find(byId(oldAnnotation.id));
return newAnnotation ? hasChanges(oldAnnotation, newAnnotation) : true;
@ -468,11 +487,11 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
this._annotationManager.delete(annotationsToDelete);
}
drawChangedAnnotations(oldAnnotations: AnnotationWrapper[], newAnnotations: AnnotationWrapper[]) {
async #drawChangedAnnotations(oldAnnotations: AnnotationWrapper[], newAnnotations: AnnotationWrapper[]): Promise<void> {
const annotationsToDraw = this.#getAnnotationsToDraw(oldAnnotations, newAnnotations);
this._logger.info('[ANNOTATIONS] To draw: ', annotationsToDraw);
this._annotationManager.delete(annotationsToDraw);
return this.#cleanupAndRedrawAnnotations(annotationsToDraw);
await this.#cleanupAndRedrawAnnotations(annotationsToDraw);
}
async #openRedactTextDialog(manualRedactionEntryWrapper: ManualRedactionEntryWrapper) {
@ -489,6 +508,7 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
const add$ = this._manualRedactionService.addAnnotation([result.redaction], this.dossierId, this.fileId, {
hint,
dictionaryLabel: result.dictionary?.label,
bulkLocal: result.bulkLocal,
});
const addAndReload$ = add$.pipe(
@ -642,7 +662,7 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
.subscribe();
this.addActiveScreenSubscription = this.pdfProxyService.manualAnnotationRequested$.subscribe($event => {
this.openManualAnnotationDialog($event);
this.openRectangleAnnotationDialog($event).then();
});
this.addActiveScreenSubscription = this.pdfProxyService.redactTextRequested$.subscribe($event => {
@ -662,7 +682,7 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
filter(event => event.type === ViewerEvents.LOAD_ALL_ANNOTATIONS),
switchMap(() => {
// TODO: this switchMap is ugly, to be refactored
const annotations = this._fileDataService.annotations();
const annotations = untracked(this._fileDataService.annotations);
const showWarning = !this.userPreferenceService.getBool(PreferencesKeys.loadAllAnnotationsWarning);
const annotationsExceedThreshold = annotations.length >= this.configService.values.ANNOTATIONS_THRESHOLD;
@ -675,7 +695,7 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
filter(([confirmed]) => confirmed),
map(([, annotations]) => {
this.#loadAllAnnotationsEnabled = true;
this.drawChangedAnnotations([], annotations).then(() => {
this.#drawChangedAnnotations([], annotations).then(() => {
this._toaster.success(_('load-all-annotations-success'));
this._viewerHeaderService.disableLoadAllAnnotations();
});
@ -683,7 +703,9 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
)
.subscribe();
this.addActiveScreenSubscription = this._readableRedactionsService.active$.pipe(map(() => this.updateViewMode())).subscribe();
this.addActiveScreenSubscription = this._readableRedactionsService.active$
.pipe(switchMap(() => this.#updateViewMode()))
.subscribe();
this.addActiveScreenSubscription = combineLatest([this._viewModeService.viewMode$, this.state.file$, this._documentViewer.loaded$])
.pipe(
@ -722,11 +744,15 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
this._errorService.set(error);
}
#cleanupAndRedrawAnnotations(newAnnotations: List<AnnotationWrapper>) {
async #cleanupAndRedrawAnnotations(newAnnotations: List<AnnotationWrapper>): Promise<void> {
if (!newAnnotations.length) {
return undefined;
}
await this._annotationDrawService.draw(newAnnotations, this._skippedService.hideSkipped(), this.state.dossierTemplateId);
}
#updateFiltersAfterAnnotationsChanged(): void {
const currentFilters = this._filterService.getGroup('primaryFilters')?.filters || [];
this.#rebuildFilters();
@ -735,8 +761,6 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
this.#handleDeltaAnnotationFilters(currentFilters);
}, 100);
}
return this._annotationDrawService.draw(newAnnotations, this._skippedService.hideSkipped(), this.state.dossierTemplateId);
}
#handleDeltaAnnotationFilters(currentFilters: NestedFilter[]) {

View File

@ -1,19 +1,22 @@
import { inject, Injectable } from '@angular/core';
import { Injectable } from '@angular/core';
import { IqserDialog } from '@common-ui/dialog/iqser-dialog.service';
import { getConfig, Toaster } from '@iqser/common-ui';
import { getConfig } from '@iqser/common-ui';
import { List, log } from '@iqser/common-ui/lib/utils';
import { AnnotationPermissions } from '@models/file/annotation.permissions';
import { AnnotationWrapper } from '@models/file/annotation.wrapper';
import { Core } from '@pdftron/webviewer';
import {
DictionaryEntryTypes,
DownloadFileTypes,
EarmarkOperation,
type IBulkLocalRemoveRequest,
IBulkRecategorizationRequest,
ILegalBasisChangeRequest,
IRecategorizationRequest,
IRectangle,
type IRemoveRedactionRequest,
IResizeRequest,
} from '@red/domain';
import { CommentsApiService } from '@services/comments-api.service';
import { DossierTemplatesService } from '@services/dossier-templates/dossier-templates.service';
import { PermissionsService } from '@services/permissions.service';
import { firstValueFrom, Observable } from 'rxjs';
@ -32,6 +35,10 @@ import { ResizeRedactionDialogComponent } from '../dialogs/resize-redaction-dial
import {
EditRedactionData,
EditRedactResult,
ForceAnnotationOptions,
RectangleRedactOptions,
RedactOrHintOptions,
RedactRecommendationData,
RemoveRedactionData,
RemoveRedactionOptions,
RemoveRedactionPermissions,
@ -44,11 +51,12 @@ import { FilePreviewDialogService } from './file-preview-dialog.service';
import { FilePreviewStateService } from './file-preview-state.service';
import { ManualRedactionService } from './manual-redaction.service';
import { SkippedService } from './skipped.service';
import { NON_READABLE_CONTENT } from '../dialogs/rectangle-annotation-dialog/rectangle-annotation-dialog.component';
import { ForceAnnotationDialogComponent } from '../dialogs/force-redaction-dialog/force-annotation-dialog.component';
@Injectable()
export class AnnotationActionsService {
readonly #isDocumine = getConfig().IS_DOCUMINE;
readonly #commentsApiService = inject(CommentsApiService);
constructor(
private readonly _manualRedactionService: ManualRedactionService,
@ -63,7 +71,6 @@ export class AnnotationActionsService {
private readonly _skippedService: SkippedService,
private readonly _dossierTemplatesService: DossierTemplatesService,
private readonly _permissionsService: PermissionsService,
private readonly _toaster: Toaster,
) {}
removeHighlights(highlights: AnnotationWrapper[]): void {
@ -76,29 +83,51 @@ export class AnnotationActionsService {
this._dialogService.openDialog('highlightAction', data);
}
forceAnnotation(annotations: AnnotationWrapper[], hint: boolean = false) {
async forceAnnotation(annotations: AnnotationWrapper[], hint: boolean = false) {
const { dossierId, fileId } = this._state;
const data = { dossier: this._state.dossier(), annotations, hint };
this._dialogService.openDialog('forceAnnotation', data, (request: ILegalBasisChangeRequest) => {
this.#processObsAndEmit(
this._manualRedactionService.bulkForce(
annotations.map(a => ({ ...request, annotationId: a.id })),
dossierId,
fileId,
annotations[0].isIgnoredHint,
),
).then();
});
const image = annotations.every(a => a.isImage);
const data = { dossier: this._state.dossier(), annotations, hint, image };
const dialogRef = this._iqserDialog.openDefault(ForceAnnotationDialogComponent, { data });
const result = await dialogRef.result();
if (!result) {
return;
}
let obs$: Observable<unknown>;
if (result.option === ForceAnnotationOptions.ONLY_HERE || hint || image) {
obs$ = this._manualRedactionService.bulkForce(
annotations.map(a => ({ ...result, annotationId: a.id })),
dossierId,
fileId,
annotations[0].isIgnoredHint,
);
} else {
const addAnnotationRequest = annotations.map(a => ({
comment: result.comment,
legalBasis: result.legalBasis,
reason: result.reason,
positions: a.positions,
type: a.type,
value: a.value,
}));
obs$ = this._manualRedactionService.addAnnotation(addAnnotationRequest, dossierId, fileId, {
hint,
bulkLocal: true,
});
}
this.#processObsAndEmit(obs$).then();
}
async editRedaction(annotations: AnnotationWrapper[]) {
const { dossierId, dossierTemplateId, fileId, file } = this._state;
const includeUnprocessed = annotations.every(annotation => this.#includeUnprocessed(annotation, true));
const dossierTemplate = this._dossierTemplatesService.find(dossierTemplateId);
const { dossierId, file } = this._state;
const allFileAnnotations = this._fileDataService.annotations();
const data = {
annotations,
allFileAnnotations,
dossierId,
applyToAllDossiers: dossierTemplate.applyDictionaryUpdatesToAllDossiersByDefault,
file: file(),
};
const result = await this.#getEditRedactionDialog(data).result();
@ -106,40 +135,49 @@ export class AnnotationActionsService {
return;
}
const recategorizeBody: List<IRecategorizationRequest> = annotations.map(annotation => {
const body = { annotationId: annotation.id, type: result.type ?? annotation.type };
if (!this.#isDocumine) {
return {
...body,
legalBasis: result.legalBasis,
section: result.section ?? annotation.section,
value: result.value ?? annotation.value,
let recategorizeBody: List<IRecategorizationRequest> | IBulkRecategorizationRequest;
if (result.option === RedactOrHintOptions.ONLY_HERE || result.option === RectangleRedactOptions.ONLY_THIS_PAGE) {
recategorizeBody = annotations.map(annotation => {
const body: IRecategorizationRequest = {
annotationId: annotation.id,
type: result.type ?? annotation.type,
comment: result.comment,
};
}
return body;
});
if (!this.#isDocumine) {
return {
...body,
legalBasis: result.legalBasis,
section: result.section ?? annotation.section,
value: result.value ?? annotation.value,
};
}
return body;
});
} else {
recategorizeBody = {
value: annotations[0].value,
type: result.type,
legalBasis: result.legalBasis,
section: result.section,
rectangle: annotations[0].AREA,
pageNumbers: result.pageNumbers,
position: result.position,
comment: result.comment,
};
}
await this.#processObsAndEmit(
this._manualRedactionService
.recategorizeRedactions(
recategorizeBody,
dossierId,
fileId,
file().id,
this.#getChangedFields(annotations, result),
includeUnprocessed,
result.option === RedactOrHintOptions.IN_DOCUMENT || !!result.pageNumbers.length,
)
.pipe(log()),
);
if (result.comment) {
try {
for (const a of annotations) {
await this.#commentsApiService.add(result.comment, a.id, dossierId, fileId);
}
} catch (error) {
this._toaster.rawError(error.error.message);
}
}
}
async removeRedaction(redactions: AnnotationWrapper[], permissions: AnnotationPermissions) {
@ -150,9 +188,13 @@ export class AnnotationActionsService {
};
const dossierTemplate = this._dossierTemplatesService.find(this._state.dossierTemplateId);
const isApprover = this._permissionsService.isApprover(this._state.dossier());
const { file } = this._state;
const allFileRedactions = this._fileDataService.annotations();
const data = {
redactions,
allFileRedactions,
file: file(),
dossier: this._state.dossier(),
falsePositiveContext: redactions.map(r => this.#getFalsePositiveText(r)),
permissions: removePermissions,
@ -167,8 +209,8 @@ export class AnnotationActionsService {
}
if (
result.option.value === RemoveRedactionOptions.FALSE_POSITIVE ||
result.option.value === RemoveRedactionOptions.DO_NOT_RECOMMEND
result.option?.value === RemoveRedactionOptions.FALSE_POSITIVE ||
result.option?.value === RemoveRedactionOptions.DO_NOT_RECOMMEND
) {
this.#setAsFalsePositive(redactions, result);
} else {
@ -190,7 +232,7 @@ export class AnnotationActionsService {
async convertRecommendationToAnnotation(recommendations: AnnotationWrapper[]) {
const { dossierId, fileId } = this._state;
const data = this.#getRedactRecommendationDialogData(recommendations);
const data = this.#getRedactRecommendationDialogData(recommendations) as RedactRecommendationData;
const dialog = this._iqserDialog.openDefault(RedactRecommendationDialogComponent, { data });
const result = await dialog.result();
if (!result) {
@ -201,7 +243,13 @@ export class AnnotationActionsService {
this.cancelResize(recommendations[0]).then();
}
const request$ = this._manualRedactionService.addRecommendation(recommendations, result.redaction, dossierId, fileId);
const request$ = this._manualRedactionService.addRecommendation(
recommendations,
result.redaction,
dossierId,
fileId,
result.bulkLocal,
);
return this.#processObsAndEmit(request$);
}
@ -224,7 +272,6 @@ export class AnnotationActionsService {
async acceptResize(annotation: AnnotationWrapper, permissions: AnnotationPermissions): Promise<void> {
const textAndPositions = await this.#extractTextAndPositions(annotation.id);
const includeUnprocessed = this.#includeUnprocessed(annotation);
if (annotation.isRecommendation) {
const recommendation = {
...annotation,
@ -275,16 +322,16 @@ export class AnnotationActionsService {
await this.cancelResize(annotation);
const { fileId, dossierId } = this._state;
const request = this._manualRedactionService.resize([resizeRequest], dossierId, fileId, includeUnprocessed);
const request = this._manualRedactionService.resize([resizeRequest], dossierId, fileId);
return this.#processObsAndEmit(request);
}
async cancelResize(annotationWrapper: AnnotationWrapper) {
this._annotationManager.resizingAnnotationId = undefined;
this._annotationManager.annotationHasBeenResized = false;
this._annotationManager.deselect();
this._annotationManager.delete(annotationWrapper);
await this._annotationDrawService.draw([annotationWrapper], this._skippedService.hideSkipped(), this._state.dossierTemplateId);
this._annotationManager.deselect();
}
#generateRectangle(annotationWrapper: AnnotationWrapper) {
@ -418,7 +465,7 @@ export class AnnotationActionsService {
type: redaction.type,
positions: redaction.positions,
addToDictionary: true,
addToAllDossiers: !!dialogResult.option.extraOption?.checked || !!dialogResult.applyToAllDossiers,
addToAllDossiers: !!dialogResult.option.additionalCheck?.checked || !!dialogResult.applyToAllDossiers,
reason: 'False Positive',
dictionaryEntryType: redaction.isRecommendation
? DictionaryEntryTypes.FALSE_RECOMMENDATION
@ -431,26 +478,52 @@ export class AnnotationActionsService {
}
#removeRedaction(redactions: AnnotationWrapper[], dialogResult: RemoveRedactionResult) {
const removeFromDictionary = dialogResult.option.value === RemoveRedactionOptions.IN_DOSSIER;
const includeUnprocessed = redactions.every(redaction => this.#includeUnprocessed(redaction, true));
const body = redactions.map(redaction => ({
annotationId: redaction.id,
comment: dialogResult.comment,
removeFromDictionary,
removeFromAllDossiers: !!dialogResult.option.extraOption?.checked || !!dialogResult.applyToAllDossiers,
}));
const removeFromDictionary = dialogResult.option?.value === RemoveRedactionOptions.IN_DOSSIER;
const body = this.#getRemoveRedactionBody(redactions, dialogResult);
// todo: might not be correct, probably shouldn't get to this point if they are not all the same
const isHint = redactions.every(r => r.isHint);
const { dossierId, fileId } = this._state;
const req$ = this._manualRedactionService.removeRedaction(
body,
dossierId,
fileId,
removeFromDictionary,
isHint,
includeUnprocessed,
);
this.#processObsAndEmit(req$).then();
const maximumNumberEntries = 100;
if (removeFromDictionary && (body as List<IRemoveRedactionRequest>).length > maximumNumberEntries) {
const requests = body as List<IRemoveRedactionRequest>;
const splitNumber = Math.floor(requests.length / maximumNumberEntries);
const remainder = requests.length % maximumNumberEntries;
const splitRequests = [];
for (let i = 0; i < splitNumber; i++) {
splitRequests.push(requests.slice(i * maximumNumberEntries, (i + 1) * maximumNumberEntries));
}
if (remainder) {
splitRequests.push(requests.slice(splitNumber * maximumNumberEntries, splitNumber * maximumNumberEntries + remainder));
}
const promises = [];
for (const split of splitRequests) {
promises.push(
firstValueFrom(
this._manualRedactionService.removeRedaction(
split,
dossierId,
fileId,
removeFromDictionary,
isHint,
dialogResult.bulkLocal,
),
),
);
}
Promise.all(promises).finally(() => this._fileDataService.annotationsChanged());
return;
}
this.#processObsAndEmit(
this._manualRedactionService.removeRedaction(
body,
dossierId,
fileId,
removeFromDictionary,
isHint,
dialogResult.bulkLocal || !!dialogResult.pageNumbers.length,
),
).then();
}
#getRemoveRedactionDialog(data: RemoveRedactionData) {
@ -506,17 +579,27 @@ export class AnnotationActionsService {
return { changes: changedFields.join(', ') };
}
//TODO this is temporary, based on RED-8950. Should be removed when a better solution will be found
#includeUnprocessed(annotation: AnnotationWrapper, isRemoveOrRecategorize = false) {
const processed = annotation.entry.manualChanges.at(-1)?.processed;
if (!processed) {
const autoAnalysisDisabled = this._state.file().excludedFromAutomaticAnalysis;
const addedLocallyWhileDisabled = annotation.manual;
if (autoAnalysisDisabled) {
return addedLocallyWhileDisabled;
}
return isRemoveOrRecategorize && addedLocallyWhileDisabled;
#getRemoveRedactionBody(
redactions: AnnotationWrapper[],
dialogResult: RemoveRedactionResult,
): List<IRemoveRedactionRequest> | IBulkLocalRemoveRequest {
if (dialogResult.bulkLocal || !!dialogResult.pageNumbers.length) {
const redaction = redactions[0];
return {
value: redaction.value,
rectangle: redaction.value === NON_READABLE_CONTENT,
pageNumbers: dialogResult.pageNumbers,
position: dialogResult.position,
comment: dialogResult.comment,
};
}
return false;
return redactions.map(redaction => ({
annotationId: redaction.id,
value: redaction.value,
comment: dialogResult.comment,
removeFromDictionary: dialogResult.option?.value === RemoveRedactionOptions.IN_DOSSIER,
removeFromAllDossiers: !!dialogResult.option?.additionalCheck?.checked || !!dialogResult.applyToAllDossiers,
}));
}
}

View File

@ -1,5 +1,4 @@
import { computed, Injectable, Signal, signal } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { AnnotationWrapper } from '@models/file/annotation.wrapper';
import { FileDataService } from './file-data.service';
@ -7,7 +6,6 @@ import { FileDataService } from './file-data.service';
export class AnnotationReferencesService {
readonly references: Signal<AnnotationWrapper[]>;
readonly annotation: Signal<AnnotationWrapper | undefined>;
private readonly _annotation$ = new BehaviorSubject<AnnotationWrapper | undefined>(undefined);
readonly #annotation = signal<AnnotationWrapper | undefined>(undefined);
constructor(private readonly _fileDataService: FileDataService) {

View File

@ -1,17 +1,16 @@
import { AnnotationWrapper } from '@models/file/annotation.wrapper';
import { Injectable, OnDestroy } from '@angular/core';
import { effect, Injectable, untracked } from '@angular/core';
import { EntitiesService, ListingService, SearchService } from '@iqser/common-ui';
import { filter, tap } from 'rxjs/operators';
import { MultiSelectService } from './multi-select.service';
import { PdfViewer } from '../../pdf-viewer/services/pdf-viewer.service';
import { REDAnnotationManager } from '../../pdf-viewer/services/annotation-manager.service';
import { Subscription } from 'rxjs';
import { FilterService } from '@iqser/common-ui/lib/filtering';
import { SortingService } from '@iqser/common-ui/lib/sorting';
import { toSignal } from '@angular/core/rxjs-interop';
@Injectable()
export class AnnotationsListingService extends ListingService<AnnotationWrapper> implements OnDestroy {
readonly #subscriptions: Subscription;
export class AnnotationsListingService extends ListingService<AnnotationWrapper> {
readonly selectedLength = toSignal(this.selectedLength$);
constructor(
protected readonly _filterService: FilterService,
@ -24,23 +23,22 @@ export class AnnotationsListingService extends ListingService<AnnotationWrapper>
) {
super(_filterService, _searchService, _entitiesService, _sortingService);
this.#subscriptions = this.selectedLength$
.pipe(
filter(length => length > 1),
tap(() => this._multiSelectService.activate()),
)
.subscribe();
}
ngOnDestroy() {
this.#subscriptions.unsubscribe();
effect(
() => {
if (this.selectedLength() > 1) {
this._multiSelectService.activate();
}
},
{ allowSignalWrites: true },
);
}
selectAnnotations(annotations: AnnotationWrapper[] | AnnotationWrapper) {
annotations = Array.isArray(annotations) ? annotations : [annotations];
const pageNumber = annotations[annotations.length - 1].pageNumber;
const annotationsToSelect = this._multiSelectService.active() ? [...this.selected, ...annotations] : annotations;
const multiSelectActive = untracked(this._multiSelectService.active);
const annotationsToSelect = multiSelectActive ? [...this.selected, ...annotations] : annotations;
this.#selectAnnotations(annotationsToSelect, pageNumber);
}
@ -49,16 +47,18 @@ export class AnnotationsListingService extends ListingService<AnnotationWrapper>
return;
}
if (this._multiSelectService.inactive()) {
const multiSelectInactive = untracked(this._multiSelectService.inactive);
if (multiSelectInactive) {
this._annotationManager.deselect();
}
if (pageNumber === this._pdf.currentPage()) {
const currentPage = untracked(this._pdf.currentPage);
if (pageNumber === currentPage) {
return this._annotationManager.jumpAndSelect(annotations);
}
this._pdf.navigateTo(pageNumber);
// wait for page to be loaded and to draw annotations
setTimeout(() => this._annotationManager.jumpAndSelect(annotations), 300);
setTimeout(() => this._annotationManager.jumpAndSelect(annotations), 10);
}
}

View File

@ -3,11 +3,9 @@ import { MatDialog } from '@angular/material/dialog';
import { ConfirmationDialogComponent, DialogConfig, DialogService } from '@iqser/common-ui';
import { ChangeLegalBasisDialogComponent } from '../dialogs/change-legal-basis-dialog/change-legal-basis-dialog.component';
import { DocumentInfoDialogComponent } from '../dialogs/document-info-dialog/document-info-dialog.component';
import { ForceAnnotationDialogComponent } from '../dialogs/force-redaction-dialog/force-annotation-dialog.component';
import { HighlightActionDialogComponent } from '../dialogs/highlight-action-dialog/highlight-action-dialog.component';
import { ManualAnnotationDialogComponent } from '../dialogs/manual-redaction-dialog/manual-annotation-dialog.component';
type DialogType = 'confirm' | 'documentInfo' | 'changeLegalBasis' | 'forceAnnotation' | 'manualAnnotation' | 'highlightAction';
type DialogType = 'confirm' | 'documentInfo' | 'changeLegalBasis' | 'highlightAction';
@Injectable()
export class FilePreviewDialogService extends DialogService<DialogType> {
@ -23,13 +21,6 @@ export class FilePreviewDialogService extends DialogService<DialogType> {
changeLegalBasis: {
component: ChangeLegalBasisDialogComponent,
},
forceAnnotation: {
component: ForceAnnotationDialogComponent,
},
manualAnnotation: {
component: ManualAnnotationDialogComponent,
dialogConfig: { autoFocus: true },
},
highlightAction: {
component: HighlightActionDialogComponent,
},

View File

@ -8,6 +8,8 @@ import { type ManualRedactionEntryType } from '@models/file/manual-redaction-ent
import type {
DictionaryActions,
IAddRedactionRequest,
IBulkLocalRemoveRequest,
IBulkRecategorizationRequest,
ILegalBasisChangeRequest,
IManualAddResponse,
IRecategorizationRequest,
@ -39,6 +41,7 @@ function getMessage(action: ManualRedactionActions, isDictionary = false, error
export class ManualRedactionService extends GenericService<IManualAddResponse> {
protected readonly _defaultModelPath = 'manualRedaction';
readonly #bulkRedaction = `${this._defaultModelPath}/bulk/redaction`;
readonly #bulkLocal = `${this._defaultModelPath}/bulk-local`;
constructor(
private readonly _toaster: Toaster,
@ -48,7 +51,13 @@ export class ManualRedactionService extends GenericService<IManualAddResponse> {
super();
}
addRecommendation(annotations: AnnotationWrapper[], redaction: IAddRedactionRequest, dossierId: string, fileId: string) {
addRecommendation(
annotations: AnnotationWrapper[],
redaction: IAddRedactionRequest,
dossierId: string,
fileId: string,
bulkLocal: boolean = false,
) {
const recommendations: List<IAddRedactionRequest> = annotations.map(annotation => ({
addToDictionary: redaction.addToDictionary,
addToAllDossiers: redaction.addToAllDossiers,
@ -59,19 +68,17 @@ export class ManualRedactionService extends GenericService<IManualAddResponse> {
type: redaction.type ?? annotation.type,
comment: redaction.comment,
}));
return this.addAnnotation(recommendations, dossierId, fileId);
return this.addAnnotation(recommendations, dossierId, fileId, { bulkLocal });
}
recategorizeRedactions(
body: List<IRecategorizationRequest>,
body: List<IRecategorizationRequest> | IBulkRecategorizationRequest,
dossierId: string,
fileId: string,
successMessageParameters?: {
[key: string]: string;
},
includeUnprocessed = false,
successMessageParameters?: { [p: string]: string },
bulkLocal = false,
) {
return this.recategorize(body, dossierId, fileId, includeUnprocessed).pipe(
return this.#recategorize(body, dossierId, fileId, bulkLocal).pipe(
this.#showToast('recategorize-annotation', false, successMessageParameters),
);
}
@ -80,14 +87,14 @@ export class ManualRedactionService extends GenericService<IManualAddResponse> {
requests: List<IAddRedactionRequest>,
dossierId: string,
fileId: string,
options?: { hint?: boolean; dictionaryLabel?: string },
options?: { hint?: boolean; dictionaryLabel?: string; bulkLocal?: boolean },
) {
const toast = requests[0].addToDictionary
? this.#showAddToDictionaryToast(requests, options?.dictionaryLabel)
: this.#showToast(options?.hint ? 'force-hint' : 'add');
const canAddRedaction = this._iqserPermissionsService.has(Roles.redactions.write);
if (canAddRedaction) {
return this.add(requests, dossierId, fileId).pipe(toast);
return this.add(requests, dossierId, fileId, options?.bulkLocal).pipe(toast);
}
return of(undefined);
@ -102,14 +109,14 @@ export class ManualRedactionService extends GenericService<IManualAddResponse> {
}
removeRedaction(
body: List<IRemoveRedactionRequest>,
body: List<IRemoveRedactionRequest> | IBulkLocalRemoveRequest,
dossierId: string,
fileId: string,
removeFromDictionary = false,
isHint = false,
includeUnprocessed = false,
bulkLocal = false,
) {
return this.remove(body, dossierId, fileId, includeUnprocessed).pipe(
return this.#remove(body, dossierId, fileId, bulkLocal).pipe(
this.#showToast(!isHint ? 'remove' : 'remove-hint', removeFromDictionary),
);
}
@ -127,14 +134,11 @@ export class ManualRedactionService extends GenericService<IManualAddResponse> {
}
}
add(body: List<IAddRedactionRequest>, dossierId: string, fileId: string) {
return this._post(body, `${this.#bulkRedaction}/add/${dossierId}/${fileId}`).pipe(this.#log('Add', body));
}
add(body: List<IAddRedactionRequest>, dossierId: string, fileId: string, bulkLocal = false) {
bulkLocal = bulkLocal || !!body[0].pageNumbers?.length;
const bulkPath = bulkLocal ? this.#bulkLocal : this.#bulkRedaction;
recategorize(body: List<IRecategorizationRequest>, dossierId: string, fileId: string, includeUnprocessed = false) {
return this._post(body, `${this.#bulkRedaction}/recategorize/${dossierId}/${fileId}?includeUnprocessed=${includeUnprocessed}`).pipe(
this.#log('Recategorize', body),
);
return this._post(bulkLocal ? body[0] : body, `${bulkPath}/add/${dossierId}/${fileId}`).pipe(this.#log('Add', body));
}
undo(annotationIds: List, dossierId: string, fileId: string) {
@ -142,20 +146,27 @@ export class ManualRedactionService extends GenericService<IManualAddResponse> {
return super.delete(annotationIds, url).pipe(this.#log('Undo', annotationIds));
}
remove(body: List<IRemoveRedactionRequest>, dossierId: string, fileId: string, includeUnprocessed = false) {
return this._post(body, `${this.#bulkRedaction}/remove/${dossierId}/${fileId}?includeUnprocessed=${includeUnprocessed}`).pipe(
this.#log('Remove', body),
);
}
forceRedaction(body: List<ILegalBasisChangeRequest>, dossierId: string, fileId: string) {
return this._post(body, `${this.#bulkRedaction}/force/${dossierId}/${fileId}`).pipe(this.#log('Force redaction', body));
}
resize(body: List<IResizeRequest>, dossierId: string, fileId: string, includeUnprocessed = false) {
return this._post(body, `${this.#bulkRedaction}/resize/${dossierId}/${fileId}?includeUnprocessed=${includeUnprocessed}`).pipe(
this.#log('Resize', body),
);
resize(body: List<IResizeRequest>, dossierId: string, fileId: string) {
return this._post(body, `${this.#bulkRedaction}/resize/${dossierId}/${fileId}`).pipe(this.#log('Resize', body));
}
#recategorize(
body: List<IRecategorizationRequest> | IBulkRecategorizationRequest,
dossierId: string,
fileId: string,
bulkLocal = false,
) {
const bulkPath = bulkLocal ? this.#bulkLocal : this.#bulkRedaction;
return this._post(body, `${bulkPath}/recategorize/${dossierId}/${fileId}`).pipe(this.#log('Recategorize', body));
}
#remove(body: List<IRemoveRedactionRequest> | IBulkLocalRemoveRequest, dossierId: string, fileId: string, bulkLocal = false) {
const bulkPath = bulkLocal ? this.#bulkLocal : this.#bulkRedaction;
return this._post(body, `${bulkPath}/remove/${dossierId}/${fileId}`).pipe(this.#log('Remove', body));
}
#log(action: string, body: unknown) {

View File

@ -1,4 +1,4 @@
import { computed, Injectable, Signal, signal } from '@angular/core';
import { computed, Injectable, Signal, signal, untracked } from '@angular/core';
import { ViewModeService } from './view-mode.service';
import { FilePreviewStateService } from './file-preview-state.service';
import { ViewMode, ViewModes } from '@red/domain';
@ -13,13 +13,17 @@ export class MultiSelectService {
readonly #active = signal(false);
constructor(protected readonly _viewModeService: ViewModeService, protected readonly _state: FilePreviewStateService) {
constructor(
protected readonly _viewModeService: ViewModeService,
protected readonly _state: FilePreviewStateService,
) {
this.active = this.#active.asReadonly();
this.inactive = computed(() => !this.#active());
}
activate() {
if (this.enabled()) {
const enabled = untracked(this.enabled);
if (enabled) {
this.#active.set(true);
}
}

View File

@ -26,7 +26,7 @@ export class PdfAnnotationActionsService {
get(annotations: AnnotationWrapper[], annotationChangesAllowed: boolean): IHeaderElement[] {
const availableActions: IHeaderElement[] = [];
const permissions = this.#getAnnotationsPermissions(annotations);
const sameType = annotations.every(a => a.type === annotations[0].type);
const sameType = annotations.every(a => a.superType === annotations[0].superType);
// you can only resize one annotation at a time
if (permissions.canResizeAnnotation && annotationChangesAllowed) {

View File

@ -1,4 +1,4 @@
import { computed, effect, inject, Injectable, NgZone } from '@angular/core';
import { computed, effect, inject, Injectable, NgZone, untracked } from '@angular/core';
import { getConfig, IqserPermissionsService } from '@iqser/common-ui';
import { getCurrentUser } from '@iqser/common-ui/lib/users';
import { isJustOne, shareDistinctLast, UI_ROOT_PATH_FN } from '@iqser/common-ui/lib/utils';
@ -49,7 +49,7 @@ export class PdfProxyService {
readonly redactTextRequested$ = new Subject<ManualRedactionEntryWrapper>();
readonly currentUser = getCurrentUser<User>();
readonly pageChanged$ = this._pdf.pageChanged$.pipe(
tap(() => this.#handleExcludedPageActions()),
tap(pageNumber => this.#handleExcludedPageActions(pageNumber)),
tap(() => {
if (this._multiSelectService.inactive()) {
this._annotationManager.deselect();
@ -98,7 +98,7 @@ export class PdfProxyService {
effect(
() => {
const canPerformActions = this.canPerformActions();
this._pdf.isCompareMode();
const isCompareMode = this._pdf.isCompareMode();
this.#configureTextPopup();
@ -109,7 +109,8 @@ export class PdfProxyService {
this.#deactivateMultiSelect();
}
this.#handleExcludedPageActions();
const currentPage = untracked(this._pdf.currentPage);
this.#handleExcludedPageActions(currentPage);
},
{ allowSignalWrites: true },
);
@ -227,8 +228,9 @@ export class PdfProxyService {
this.redactTextRequested$.next({ manualRedactionEntry, type });
}
#handleExcludedPageActions() {
const isCurrentPageExcluded = this._state.file().isPageExcluded(this._pdf.currentPage());
#handleExcludedPageActions(currentPage: number) {
const isCurrentPageExcluded = this._state.file().isPageExcluded(currentPage);
if (!isCurrentPageExcluded) {
return;
}
@ -402,11 +404,14 @@ export class PdfProxyService {
const annotationChangesAllowed = !this.#isDocumine || !this._state.file().excludedFromAutomaticAnalysis;
const somePending = annotationWrappers.some(a => a.pending);
const selectedFromWorkload = untracked(this._annotationManager.selectedFromWorkload);
actions =
this._multiSelectService.inactive() && !this._documentViewer.selectedText.length && !somePending
(this._multiSelectService.inactive() || !selectedFromWorkload) && !this._documentViewer.selectedText.length && !somePending
? [...actions, ...this._pdfAnnotationActionsService.get(annotationWrappers, annotationChangesAllowed)]
: [];
this._pdf.instance.UI.annotationPopup.update(actions);
this._annotationManager.resetSelectedFromWorkload();
}
#getTitle(type: ManualRedactionEntryType) {

View File

@ -19,16 +19,6 @@ export const ActionsHelpModeKeys = {
'hint-image': 'hint',
} as const;
export const DialogHelpModeKeys = {
REDACTION_EDIT: 'redaction_edit',
REDACTION_REMOVE: 'redaction_remove',
SKIPPED_EDIT: 'skipped_edit',
SKIPPED_REMOVE: 'skipped_remove',
RECOMMENDATION_REMOVE: 'recommendation_remove',
HINT_EDIT: 'hint_edit',
HINT_REMOVE: 'hint_remove',
} as const;
export const ALL_HOTKEYS: List = ['Escape', 'F', 'f', 'ArrowUp', 'ArrowDown', 'H', 'h'] as const;
export const HeaderElements = {

Some files were not shown because too many files have changed in this diff Show More