480 lines
20 KiB
TypeScript
480 lines
20 KiB
TypeScript
import { Injectable, TemplateRef } from '@angular/core';
|
|
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
|
|
import {
|
|
ActionConfig,
|
|
getConfig,
|
|
IqserPermissionsService,
|
|
largeDialogConfig,
|
|
ListingMode,
|
|
ListingModes,
|
|
TableColumnConfig,
|
|
WorkflowColumn,
|
|
WorkflowConfig,
|
|
} from '@iqser/common-ui';
|
|
import { IFilterGroup, INestedFilter, keyChecker, NestedFilter } from '@iqser/common-ui/lib/filtering';
|
|
import { getCurrentUser } from '@iqser/common-ui/lib/users';
|
|
import { getParam, List } from '@iqser/common-ui/lib/utils';
|
|
import { TranslateService } from '@ngx-translate/core';
|
|
import {
|
|
annotationDefaultColorConfig,
|
|
AnnotationShapeMap,
|
|
AppConfig,
|
|
DOSSIER_ID,
|
|
File,
|
|
FileAttributeConfigType,
|
|
FileAttributeConfigTypes,
|
|
IFileAttributeConfig,
|
|
ProcessingType,
|
|
StatusSorter,
|
|
User,
|
|
WorkflowFileStatus,
|
|
WorkflowFileStatuses,
|
|
} from '@red/domain';
|
|
import { DossiersService } from '@services/dossiers/dossiers.service';
|
|
import { DefaultColorsService } from '@services/entity-services/default-colors.service';
|
|
import { DictionariesMapService } from '@services/entity-services/dictionaries-map.service';
|
|
import { PermissionsService } from '@services/permissions.service';
|
|
import { workflowFileStatusTranslations } from '@translations/file-status-translations';
|
|
import { workloadTranslations } from '@translations/workload-translations';
|
|
import { Roles } from '@users/roles';
|
|
import { UserPreferenceService } from '@users/user-preference.service';
|
|
import { UserService } from '@users/user.service';
|
|
import dayjs from 'dayjs';
|
|
import { BehaviorSubject, Observable } from 'rxjs';
|
|
import { map } from 'rxjs/operators';
|
|
import { annotationFilterChecker, RedactionFilterSorter, sortArray, sortByName } from '../../utils';
|
|
import { EditDossierDialogComponent } from '../shared-dossiers/dialogs/edit-dossier-dialog/edit-dossier-dialog.component';
|
|
import { DossiersDialogService } from '../shared-dossiers/services/dossiers-dialog.service';
|
|
import { BulkActionsService } from './services/bulk-actions.service';
|
|
|
|
@Injectable()
|
|
export class ConfigService {
|
|
readonly #dossierId = getParam(DOSSIER_ID);
|
|
readonly #currentUser = getCurrentUser<User>();
|
|
readonly #config = getConfig<AppConfig>();
|
|
readonly #listingMode$: BehaviorSubject<ListingMode>;
|
|
readonly #isDocumine = getConfig().IS_DOCUMINE;
|
|
readonly listingMode$: Observable<ListingMode>;
|
|
|
|
constructor(
|
|
private readonly _permissionsService: PermissionsService,
|
|
private readonly _translateService: TranslateService,
|
|
private readonly _userService: UserService,
|
|
private readonly _dialogService: DossiersDialogService,
|
|
private readonly _bulkActionsService: BulkActionsService,
|
|
private readonly _defaultColorsService: DefaultColorsService,
|
|
private readonly _dictionariesMapService: DictionariesMapService,
|
|
private readonly _userPreferenceService: UserPreferenceService,
|
|
private readonly _dossiersService: DossiersService,
|
|
private readonly _iqserPermissionsService: IqserPermissionsService,
|
|
) {
|
|
const previousListingMode = this._userPreferenceService.getFilesListingMode();
|
|
const listingMode = previousListingMode ? previousListingMode : ListingModes.table;
|
|
this.#listingMode$ = new BehaviorSubject<ListingMode>(listingMode);
|
|
this.listingMode$ = this.#listingMode$.asObservable();
|
|
}
|
|
|
|
get listingMode(): ListingMode {
|
|
return this.#listingMode$.value;
|
|
}
|
|
|
|
set listingMode(listingMode: ListingMode) {
|
|
this.#listingMode$.next(listingMode);
|
|
this._userPreferenceService.saveFilesListingMode(listingMode).then();
|
|
}
|
|
|
|
get #dossier() {
|
|
return this._dossiersService.find(this.#dossierId);
|
|
}
|
|
|
|
workflowConfig(): WorkflowConfig<File, WorkflowFileStatus> {
|
|
const newColumn = this.#getNewColumn();
|
|
const underReviewColumn = this.#getUnderReviewColumn();
|
|
const underApprovalColumn = this.#getUnderApprovalColumn();
|
|
const approvedColumn = this.#getApprovedColumn();
|
|
|
|
return {
|
|
columnIdentifierFn: entity => entity.workflowStatus,
|
|
itemVersionFn: (entity: File) => `${entity.lastUpdated}-${entity.numberOfAnalyses}`,
|
|
columns: [newColumn, underReviewColumn, underApprovalColumn, approvedColumn],
|
|
};
|
|
}
|
|
|
|
workflowConfigRss(): WorkflowConfig<File, WorkflowFileStatus> {
|
|
const newColumn = this.#getNewColumn();
|
|
const underReviewColumn = this.#getUnderReviewColumn();
|
|
const approvedColumn = this.#getApprovedColumn();
|
|
|
|
return {
|
|
columnIdentifierFn: entity => entity.workflowStatus,
|
|
itemVersionFn: (entity: File) => `${entity.lastUpdated}-${entity.numberOfAnalyses}`,
|
|
columns: [newColumn, underReviewColumn, approvedColumn],
|
|
};
|
|
}
|
|
|
|
actionConfig(dossierId: string, disabled$: Observable<boolean>): List<ActionConfig> {
|
|
return [
|
|
{
|
|
id: 'editDossier',
|
|
label: _('dossier-overview.header-actions.edit'),
|
|
action: () => this.#openEditDossierDialog(dossierId),
|
|
icon: 'iqser:edit',
|
|
hide: !this.#currentUser.isManager && !this._iqserPermissionsService.has(Roles.dossiers.edit),
|
|
helpModeKey: 'edit_dossier',
|
|
disabled$,
|
|
},
|
|
];
|
|
}
|
|
|
|
tableConfig(displayedAttributes: IFileAttributeConfig[]): TableColumnConfig<File>[] {
|
|
const dynamicColumns: TableColumnConfig<File>[] = displayedAttributes.map(config => ({
|
|
label: config.label,
|
|
notTranslatable: true,
|
|
}));
|
|
const columns: TableColumnConfig<File>[] = [
|
|
{
|
|
label: _('dossier-overview.table-col-names.name'),
|
|
sortByKey: 'searchKey',
|
|
width: '3fr',
|
|
},
|
|
{
|
|
label: _('dossier-overview.table-col-names.added-on'),
|
|
sortByKey: 'added',
|
|
width: '2fr',
|
|
},
|
|
{
|
|
label: _('dossier-overview.table-col-names.last-modified'),
|
|
sortByKey: 'redactionModificationDate',
|
|
width: '2fr',
|
|
},
|
|
...dynamicColumns,
|
|
{
|
|
label: _('dossier-overview.table-col-names.assigned-to'),
|
|
class: 'user-column',
|
|
sortByKey: 'reviewerName',
|
|
width: '2fr',
|
|
},
|
|
{
|
|
label: _('dossier-overview.table-col-names.pages'),
|
|
sortByKey: 'numberOfPages',
|
|
},
|
|
{
|
|
label: _('dossier-overview.table-col-names.status'),
|
|
class: 'flex-end',
|
|
sortByKey: 'statusSort',
|
|
},
|
|
];
|
|
if (!this.#isDocumine) {
|
|
columns.splice(3 + displayedAttributes.length, 0, {
|
|
label: _('dossier-overview.table-col-names.needs-work'),
|
|
});
|
|
}
|
|
return columns;
|
|
}
|
|
|
|
filterGroups(
|
|
entities: File[],
|
|
fileAttributeConfigs: IFileAttributeConfig[],
|
|
dossierTemplateId: string,
|
|
needsWorkFilterTemplate: TemplateRef<unknown>,
|
|
checkedRequiredFilters: () => NestedFilter[],
|
|
checkedNotRequiredFilters: () => NestedFilter[],
|
|
) {
|
|
const allDistinctWorkflowFileStatuses = new Set<WorkflowFileStatus>();
|
|
const allDistinctPeople = new Set<string>();
|
|
const allDistinctNeedsWork = new Set<string>();
|
|
const allDistinctProcessingTypes = new Set<ProcessingType>();
|
|
|
|
const dynamicFilters = new Map<string, { type: FileAttributeConfigType; filterValue: Set<string> }>();
|
|
|
|
const filterGroups: IFilterGroup[] = [];
|
|
|
|
entities.forEach(file => {
|
|
allDistinctPeople.add(file.assignee);
|
|
allDistinctWorkflowFileStatuses.add(file.workflowStatus);
|
|
|
|
if (file.analysisRequired) {
|
|
allDistinctNeedsWork.add('analysis');
|
|
}
|
|
if (file.hintsOnly) {
|
|
allDistinctNeedsWork.add('hint');
|
|
}
|
|
if (file.hasRedactions) {
|
|
allDistinctNeedsWork.add('redaction');
|
|
}
|
|
if (file.hasUpdates && file.assignee === this._userService.currentUser.id && !file.isApproved) {
|
|
allDistinctNeedsWork.add('updated');
|
|
}
|
|
if (file.hasImages) {
|
|
allDistinctNeedsWork.add('image');
|
|
}
|
|
if (file.hasNone) {
|
|
allDistinctNeedsWork.add('none');
|
|
}
|
|
if (file.hasAnnotationComments) {
|
|
allDistinctNeedsWork.add('comment');
|
|
}
|
|
|
|
allDistinctProcessingTypes.add(file.processingType);
|
|
|
|
// extract values for dynamic filters
|
|
fileAttributeConfigs.forEach(config => {
|
|
if (config.filterable) {
|
|
const filterKey = `${config.id}:${config.label}`;
|
|
let filters = dynamicFilters.get(filterKey);
|
|
if (!filters) {
|
|
dynamicFilters.set(filterKey, { filterValue: new Set<string>(), type: config.type });
|
|
filters = dynamicFilters.get(filterKey);
|
|
}
|
|
const filterValue = file.fileAttributes?.attributeIdToValue[config.id];
|
|
if (filterValue === undefined || filterValue === null) {
|
|
filters.filterValue.add(undefined);
|
|
} else {
|
|
filters.filterValue.add(filterValue);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
if (this.listingMode === ListingModes.table) {
|
|
const statusFilters = [...allDistinctWorkflowFileStatuses].map(
|
|
status =>
|
|
new NestedFilter({
|
|
id: status,
|
|
label: this._translateService.instant(workflowFileStatusTranslations[status]),
|
|
}),
|
|
);
|
|
statusFilters.sort((a, b) => StatusSorter[a.id] - StatusSorter[b.id]);
|
|
filterGroups.push({
|
|
slug: 'statusFilters',
|
|
label: this._translateService.instant('filters.documents-status'),
|
|
icon: 'red:status',
|
|
filters: statusFilters,
|
|
checker: keyChecker('workflowStatus'),
|
|
});
|
|
}
|
|
|
|
const peopleFilters: NestedFilter[] = [];
|
|
if (allDistinctPeople.has(undefined) || allDistinctPeople.has(null)) {
|
|
allDistinctPeople.delete(undefined);
|
|
allDistinctPeople.delete(null);
|
|
peopleFilters.push(
|
|
new NestedFilter({
|
|
id: null,
|
|
label: this._translateService.instant('initials-avatar.unassigned'),
|
|
}),
|
|
);
|
|
}
|
|
|
|
sortByName(this._userService, [...allDistinctPeople]).forEach(userId => {
|
|
peopleFilters.push(
|
|
new NestedFilter({
|
|
id: userId,
|
|
label: this._userService.getName(userId),
|
|
}),
|
|
);
|
|
});
|
|
filterGroups.push({
|
|
slug: 'peopleFilters',
|
|
label: this._translateService.instant('filters.assigned-people'),
|
|
icon: 'red:user',
|
|
filters: peopleFilters,
|
|
checker: keyChecker('assignee'),
|
|
});
|
|
|
|
if (!this.#isDocumine) {
|
|
const needsWorkFilters = [...allDistinctNeedsWork].map(
|
|
item =>
|
|
new NestedFilter({
|
|
id: item,
|
|
label: workloadTranslations[item],
|
|
metadata: {
|
|
shape: AnnotationShapeMap[item],
|
|
color$:
|
|
item === 'image'
|
|
? this._dictionariesMapService.watch$(dossierTemplateId, item).pipe(map(e => e.hexColor))
|
|
: this._defaultColorsService.getColor$(dossierTemplateId, annotationDefaultColorConfig[item]),
|
|
},
|
|
}),
|
|
);
|
|
needsWorkFilters.sort(RedactionFilterSorter.byKey);
|
|
filterGroups.push({
|
|
slug: 'needsWorkFilters',
|
|
label: this._translateService.instant('filters.needs-work'),
|
|
icon: 'red:needs-work',
|
|
filterTemplate: needsWorkFilterTemplate,
|
|
filters: needsWorkFilters,
|
|
checker: (file: File, filter: INestedFilter) => annotationFilterChecker(file, filter, this._userService.currentUser.id),
|
|
matchAll: true,
|
|
});
|
|
}
|
|
|
|
const processingTypesFilters = [...allDistinctProcessingTypes].map(item => new NestedFilter({ id: item, label: item }));
|
|
filterGroups.push({
|
|
slug: 'processingTypeFilters',
|
|
filters: processingTypesFilters,
|
|
checker: (file: File, filter: INestedFilter) => file.processingType === filter.id,
|
|
hide: true,
|
|
});
|
|
|
|
dynamicFilters.forEach((value: { filterValue: Set<string>; type: FileAttributeConfigType }, filterKey: string) => {
|
|
const id = filterKey.split(':')[0];
|
|
const key = filterKey.split(':')[1];
|
|
filterGroups.push({
|
|
slug: key,
|
|
label: key,
|
|
icon: 'red:template',
|
|
filters: sortArray([...value.filterValue], value.type === FileAttributeConfigTypes.NUMBER).map(
|
|
(value?: string) =>
|
|
new NestedFilter({
|
|
// id shouldn't be undefined to work correctly
|
|
id: value ?? 'empty',
|
|
label: value ?? this._translateService.instant('filters.empty'),
|
|
}),
|
|
),
|
|
checker: (input: File, filter: INestedFilter) => filter.id === (input.fileAttributes.attributeIdToValue[id] ?? 'empty'),
|
|
});
|
|
});
|
|
|
|
filterGroups.push({
|
|
slug: 'quickFilters',
|
|
filters: this.#quickFilters(entities),
|
|
checker: (file: File) =>
|
|
checkedRequiredFilters().reduce((acc, f) => acc && f.checker(file), true) &&
|
|
(checkedNotRequiredFilters().length === 0 || checkedNotRequiredFilters().reduce((acc, f) => acc || f.checker(file), false)),
|
|
});
|
|
|
|
const filesNamesFilters = sortArray(entities.map(file => file.filename)).map(
|
|
filename =>
|
|
new NestedFilter({
|
|
id: filename,
|
|
label: filename,
|
|
}),
|
|
);
|
|
|
|
filterGroups.push({
|
|
slug: 'filesNamesFilter',
|
|
label: this._translateService.instant('dossier-overview.filters.label'),
|
|
icon: 'iqser:document',
|
|
filters: filesNamesFilters,
|
|
checker: keyChecker('filename'),
|
|
filterceptionPlaceholder: this._translateService.instant('dossier-overview.filters.search'),
|
|
});
|
|
|
|
return filterGroups;
|
|
}
|
|
|
|
_recentlyModifiedChecker = (file: File) => dayjs(file.lastUpdated).add(this.#config.RECENT_PERIOD_IN_HOURS, 'hours').isAfter(dayjs());
|
|
|
|
_assignedToMeChecker = (file: File) => file.assignee === this._userService.currentUser.id;
|
|
|
|
_unassignedChecker = (file: File) => !file.assignee;
|
|
|
|
_assignedToOthersChecker = (file: File) => file.assignee && file.assignee !== this._userService.currentUser.id;
|
|
|
|
#getNewColumn(): WorkflowColumn<File, typeof WorkflowFileStatuses.NEW> {
|
|
return {
|
|
label: workflowFileStatusTranslations[WorkflowFileStatuses.NEW],
|
|
key: WorkflowFileStatuses.NEW,
|
|
enterFn: files => this._bulkActionsService.setToNew(files),
|
|
enterPredicate: files => this._permissionsService.canSetToNew(files, this.#dossier),
|
|
color: '#D3D5DA',
|
|
entities: new BehaviorSubject([]),
|
|
};
|
|
}
|
|
|
|
#getUnderReviewColumn(): WorkflowColumn<File, typeof WorkflowFileStatuses.UNDER_REVIEW> {
|
|
return {
|
|
label: workflowFileStatusTranslations[WorkflowFileStatuses.UNDER_REVIEW],
|
|
enterFn: async files => {
|
|
const statuses: WorkflowFileStatus[] = [WorkflowFileStatuses.UNDER_APPROVAL, WorkflowFileStatuses.APPROVED];
|
|
if (statuses.includes(files[0].workflowStatus)) {
|
|
await this._bulkActionsService.backToUnderReview(files);
|
|
} else {
|
|
await this._bulkActionsService.assignToMe(files);
|
|
}
|
|
},
|
|
enterPredicate: files => {
|
|
const dossier = this.#dossier;
|
|
return (
|
|
this._permissionsService.canSetUnderReview(files, dossier) ||
|
|
this._permissionsService.canAssignToSelf(files, dossier) ||
|
|
this._permissionsService.canAssignUser(files, dossier) ||
|
|
this._permissionsService.canUndoApproval(files, dossier)
|
|
);
|
|
},
|
|
key: WorkflowFileStatuses.UNDER_REVIEW,
|
|
color: '#FDBD00',
|
|
entities: new BehaviorSubject([]),
|
|
};
|
|
}
|
|
|
|
#getUnderApprovalColumn(): WorkflowColumn<File, typeof WorkflowFileStatuses.UNDER_APPROVAL> {
|
|
return {
|
|
label: workflowFileStatusTranslations[WorkflowFileStatuses.UNDER_APPROVAL],
|
|
enterFn: files => this._bulkActionsService.setToUnderApproval(files),
|
|
enterPredicate: files => {
|
|
const dossier = this.#dossier;
|
|
return (
|
|
this._permissionsService.canSetUnderApproval(files, dossier) || this._permissionsService.canUndoApproval(files, dossier)
|
|
);
|
|
},
|
|
key: WorkflowFileStatuses.UNDER_APPROVAL,
|
|
color: '#374C81',
|
|
entities: new BehaviorSubject([]),
|
|
};
|
|
}
|
|
|
|
#getApprovedColumn(): WorkflowColumn<File, typeof WorkflowFileStatuses.APPROVED> {
|
|
return {
|
|
label: workflowFileStatusTranslations[WorkflowFileStatuses.APPROVED],
|
|
enterFn: files => this._bulkActionsService.approve(files),
|
|
enterPredicate: files => this._permissionsService.canBeApproved(files, this.#dossier),
|
|
key: WorkflowFileStatuses.APPROVED,
|
|
color: '#48C9F7',
|
|
entities: new BehaviorSubject([]),
|
|
};
|
|
}
|
|
|
|
#quickFilters(entities: File[]): NestedFilter[] {
|
|
const recentPeriod = this.#config.RECENT_PERIOD_IN_HOURS;
|
|
return [
|
|
{
|
|
id: 'recent',
|
|
label: this._translateService.instant('dossier-overview.quick-filters.recent', {
|
|
hours: recentPeriod,
|
|
}),
|
|
required: true,
|
|
checker: this._recentlyModifiedChecker,
|
|
disabled: entities.filter(this._recentlyModifiedChecker).length === 0,
|
|
helpModeKey: 'quick_filters_documents',
|
|
},
|
|
{
|
|
id: 'assigned-to-me',
|
|
label: this._translateService.instant('dossier-overview.quick-filters.assigned-to-me'),
|
|
checker: this._assignedToMeChecker,
|
|
disabled: entities.filter(this._assignedToMeChecker).length === 0,
|
|
helpModeKey: 'quick_filters_documents',
|
|
},
|
|
{
|
|
id: 'unassigned',
|
|
label: this._translateService.instant('dossier-overview.quick-filters.unassigned'),
|
|
checker: this._unassignedChecker,
|
|
disabled: entities.filter(this._unassignedChecker).length === 0,
|
|
helpModeKey: 'quick_filters_documents',
|
|
},
|
|
{
|
|
id: 'assigned-to-others',
|
|
label: this._translateService.instant('dossier-overview.quick-filters.assigned-to-others'),
|
|
checker: this._assignedToOthersChecker,
|
|
disabled: entities.filter(this._assignedToOthersChecker).length === 0,
|
|
helpModeKey: 'quick_filters_documents',
|
|
},
|
|
].map(filter => new NestedFilter(filter));
|
|
}
|
|
|
|
#openEditDossierDialog(dossierId: string) {
|
|
this._dialogService.open(EditDossierDialogComponent, { dossierId }, { ...largeDialogConfig, width: '98vw', maxWidth: '98vw' });
|
|
}
|
|
}
|