2024-08-02 10:54:51 +03:00

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' });
}
}