From 50ce7994080e28987e02129b02f7c01967673b97 Mon Sep 17 00:00:00 2001 From: Davo00 <57496007+Davo00@users.noreply.github.com> Date: Thu, 30 Nov 2023 10:45:25 +0100 Subject: [PATCH] Refactoring/filter technician UI query api (#76) * UI changes to display filtering by internal and crowd as well as by skill * query service now adds skills in return -> filtering * listening to selection and filtering resources based on multi-selection in skills dropdown * ts lint trigger list at removal * swapped expansion panel, filter is now collapsed * shared skill service to bind mandatory skills selection * resources listen to mandatory skills selection; AND logic for filtering * UI changes * added change trigger to resource-query.component.ts * ui adjustment * filtering in SQL * caps in SQL * adjust request payload accordingly * adjust request payload accordingly no animation * skills sync * moved the search component * cleaned up --- workbench/frontend/src/app/app.module.ts | 5 +- .../resource-query.component.html | 58 +++-- .../resource-query.component.scss | 17 ++ .../resource-query.component.ts | 229 +++++++++++++----- .../src/app/common/services/query.service.ts | 70 +++--- .../common/services/shared-skill.service.ts | 14 ++ .../job-builder/job-builder.component.ts | 59 +++-- .../services/slot-booking.service.ts | 11 +- .../slot-booking/slot-booking.component.html | 83 ++++--- .../slot-booking/slot-booking.component.scss | 18 +- .../slot-booking/slot-booking.component.ts | 142 +++++++---- 11 files changed, 472 insertions(+), 234 deletions(-) mode change 100644 => 100755 workbench/frontend/src/app/common/components/resource-query/resource-query.component.html create mode 100644 workbench/frontend/src/app/common/services/shared-skill.service.ts diff --git a/workbench/frontend/src/app/app.module.ts b/workbench/frontend/src/app/app.module.ts index a6bf683..d2b02b9 100644 --- a/workbench/frontend/src/app/app.module.ts +++ b/workbench/frontend/src/app/app.module.ts @@ -2,7 +2,7 @@ import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { MonacoEditorModule } from 'ngx-monaco-editor'; -import { ReactiveFormsModule } from '@angular/forms'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { HttpClientModule } from '@angular/common/http'; import { FlexLayoutModule } from '@angular/flex-layout'; import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field'; @@ -65,7 +65,8 @@ import { PluginEditorComponent } from './common/components/plugin-editor/plugin- ReactiveFormsModule, MonacoEditorModule.forRoot(ngxMonacoEditorConfig), FlexLayoutModule, - ...MatModules + ...MatModules, + FormsModule ], providers: [ ConfigService, diff --git a/workbench/frontend/src/app/common/components/resource-query/resource-query.component.html b/workbench/frontend/src/app/common/components/resource-query/resource-query.component.html old mode 100644 new mode 100755 index 988fb82..43c8de7 --- a/workbench/frontend/src/app/common/components/resource-query/resource-query.component.html +++ b/workbench/frontend/src/app/common/components/resource-query/resource-query.component.html @@ -1,32 +1,60 @@ -

resources: {{ (resources$ | async)?.length }}

+
+
+

Resources: {{ (resources$ | async)?.length }}

+
+

Selected Skills:

+ + + {{ skill }} + close + + +
-
-
- - delete + [{{ resource.id | slice:0:6 }}] {{ resource.firstName }} {{ resource.lastName }} + close
- - - query-templates: - - - - - +

Query Templates:

+ +
+ + + +
+ +
+
+ Crowd + Internal +
+
+
+ formControlName="query" style="min-height: 400px;">
+ +
+ +
+ +
-
\ No newline at end of file +
diff --git a/workbench/frontend/src/app/common/components/resource-query/resource-query.component.scss b/workbench/frontend/src/app/common/components/resource-query/resource-query.component.scss index e69de29..dd8227e 100644 --- a/workbench/frontend/src/app/common/components/resource-query/resource-query.component.scss +++ b/workbench/frontend/src/app/common/components/resource-query/resource-query.component.scss @@ -0,0 +1,17 @@ +.selected { + background-color: #e0e0e0; + color: #333; + border-bottom: 3px solid #ccc; +} + +.mat-button { + transition: background-color 0.3s; +} + +.mat-button:not(.selected):hover { + background-color: #e0e0e0; +} + +.selected:hover { + background-color: #e0e0e0; +} diff --git a/workbench/frontend/src/app/common/components/resource-query/resource-query.component.ts b/workbench/frontend/src/app/common/components/resource-query/resource-query.component.ts index 5a6ddde..64bb9ba 100644 --- a/workbench/frontend/src/app/common/components/resource-query/resource-query.component.ts +++ b/workbench/frontend/src/app/common/components/resource-query/resource-query.component.ts @@ -1,54 +1,46 @@ import { HttpErrorResponse } from '@angular/common/http'; -import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { FormBuilder, FormGroup } from '@angular/forms'; import { MatSnackBar } from '@angular/material/snack-bar'; import { BehaviorSubject, merge, of, Subject } from 'rxjs'; -import { catchError, mergeMap, take, takeUntil, tap, withLatestFrom } from 'rxjs/operators'; +import { catchError, map, mergeMap, take, takeUntil, tap, withLatestFrom } from 'rxjs/operators'; import { QueryService } from '../../services/query.service'; +import { SharedSkillsService } from '../../services/shared-skill.service'; const TEMPLATES = { default: ` -SELECT - resource.id as id, - resource.firstName as firstName, - resource.lastName as lastName -FROM - UnifiedPerson resource -WHERE - resource.plannableResource = true - AND resource.inactive != true -LIMIT 25 -`, + SELECT resource.id as id, + resource.firstName as firstName, + resource.lastName as lastName + FROM UnifiedPerson resource + WHERE resource.plannableResource = true + AND resource.inactive != true + LIMIT 25 + `, skill: ` -SELECT - resource.id as id, - resource.firstName as firstName, - resource.lastName as lastName -FROM - Tag tag - LEFT JOIN Skill skill ON skill.tag = tag.id - LEFT JOIN UnifiedPerson resource ON resource.id = skill.person -WHERE - resource.plannableResource = true - AND resource.inactive != true - AND tag.name = '' -LIMIT 25 -`, + SELECT resource.id as id, + resource.firstName as firstName, + resource.lastName as lastName + FROM Tag tag + LEFT JOIN Skill skill ON skill.tag = tag.id + LEFT JOIN UnifiedPerson resource ON resource.id = skill.person + WHERE resource.plannableResource = true + AND resource.inactive != true + AND tag.name = '' + LIMIT 25 + `, region: ` -SELECT - resource.id as id, - resource.firstName as firstName, - resource.lastName as lastName -FROM - UnifiedPerson resource - LEFT JOIN Region region ON region.name = '' -WHERE - region.id IN resource.regions - AND resource.plannableResource = true - AND resource.inactive != true -LIMIT 25 -` -} + SELECT resource.id as id, + resource.firstName as firstName, + resource.lastName as lastName + FROM UnifiedPerson resource + LEFT JOIN Region region ON region.name = '' + WHERE region.id IN resource.regions + AND resource.plannableResource = true + AND resource.inactive != true + LIMIT 25 + ` +}; @Component({ selector: 'resource-query', @@ -61,10 +53,21 @@ export class ResourceQueryComponent implements OnInit, OnDestroy { public resources$ = new BehaviorSubject[]>([]); public isLoading$ = new BehaviorSubject(false); + public allSkills = []; + public skillResourcesMap: Map> = new Map(); + public allResources: any[] = []; + public selectedSkills$ = new BehaviorSubject([]); + + public selectedTemplate = 'default'; + public crowdChecked = false; + public internalChecked = false; + + + @Input() selectedMandatorySkills: string[]; @Output() change = new EventEmitter(); public form: FormGroup; - private onDistroy$ = new Subject(); + private onDestroy$ = new Subject(); public editorOptions = { theme: 'vs-dark', language: 'pgsql', @@ -75,10 +78,12 @@ export class ResourceQueryComponent implements OnInit, OnDestroy { private fb: FormBuilder, private svc: QueryService, private snackBar: MatSnackBar, - ) { } + private sharedSkillsService: SharedSkillsService + ) { + } - public ngOnDestroy() { - this.onDistroy$.next(); + public ngOnDestroy(): void { + this.onDestroy$.next(); } public ngOnInit(): void { @@ -86,20 +91,35 @@ export class ResourceQueryComponent implements OnInit, OnDestroy { query: [TEMPLATES.default], }); - this.resources$.pipe( - tap(list => { - if (list.length) { - this.change.next(list.map(it => it.id)); - } - }), - takeUntil(this.onDistroy$) - ).subscribe(); + this.svc.queryResourceSkills(TEMPLATES.default).subscribe(resources => { + this.allResources = resources; + this.skillResourcesMap.clear(); + + resources.forEach(resource => { + resource.skills.forEach(skill => { + const key = skill.name.toLowerCase(); + if (!this.skillResourcesMap.has(key)) { + this.skillResourcesMap.set(key, new Set()); + } + this.skillResourcesMap.get(key).add(resource); + }); + }); + + this.resources$.next(this.allResources); + this.allSkills = Array.from(this.skillResourcesMap.keys()); + }); + + this.selectedSkills$ = this.sharedSkillsService.selectedSkills$; + + this.sharedSkillsService.selectedSkills$.subscribe((selectedSkills) => { + this.updateResources(selectedSkills); + }); this.onQuery.pipe( withLatestFrom(merge(of(this.form.value), this.form.valueChanges)), mergeMap(([_, form]) => { this.isLoading$.next(true); - return this.svc.queryResource(form.query).pipe( + return this.svc.queryResourceSkills(form.query).pipe( catchError(error => { console.error(error); @@ -107,40 +127,123 @@ export class ResourceQueryComponent implements OnInit, OnDestroy { if (error instanceof HttpErrorResponse) { errorMessage = `Error [❌ ${error.status} ❌ ]\n\n${error.message}`; } - - const snackBarRef = this.snackBar.open(errorMessage, 'ok', { duration: 3000 }); - + this.snackBar.open(errorMessage, 'ok', { duration: 3000 }); return of([]); }) - ) + ); }), tap(list => { this.isLoading$.next(false); this.resources$.next(list); }), - takeUntil(this.onDistroy$) + takeUntil(this.onDestroy$) ).subscribe(); setTimeout(() => this.onQuery.next(), 100); + + this.resources$.pipe( + tap(list => { + if (list.length) { + this.change.next(list.map(it => it.id)); + } + }), + takeUntil(this.onDestroy$) + ).subscribe(); } - public onEditor(editor) { + public onEditor(editor): void { // let line = editor.getPosition(); } - public remove(item: { id: string }) { + public remove(item: Partial<{ id: string; firstName: string; lastName: string }>): void { this.resources$.pipe( take(1), tap(current => this.resources$.next(current.filter(it => it.id !== item.id))) ).subscribe(); } - public doQuery() { + + public removeSkill(skill: string): void { + this.sharedSkillsService.selectedSkills$.pipe( + take(1), + map((selectedSkills) => selectedSkills.filter((s) => s !== skill)) + ).subscribe((updatedSkills) => { + this.selectedSkills$.next(updatedSkills); + }); + this.selectedSkills$ = this.sharedSkillsService.selectedSkills$; + } + + public doQuery(): void { this.onQuery.next(); } - public applyTmpl(t: keyof typeof TEMPLATES) { + public applyTmpl(t: keyof typeof TEMPLATES): void { + this.selectedTemplate = t; this.form.patchValue({ query: TEMPLATES[t] }); + this.updateSqlCode(); + } + + private updateResources(skills): void { + if (skills.length > 0) { + const firstSkill = skills[0]; + const firstResourceSet = this.skillResourcesMap.get(firstSkill.toLowerCase()) || new Set(); + + const intersection = skills.slice(1).reduce((commonResources, skill) => { + const resourceSet = this.skillResourcesMap.get(skill.toLowerCase()) || new Set(); + return new Set([...commonResources].filter(resource => resourceSet.has(resource))); + }, firstResourceSet); + + this.resources$.next(Array.from(intersection)); + } else { + this.resources$.next(this.allResources); + } } + + public switchCrowdInternal(changed: string): void { + if (changed === 'CROWD' && this.crowdChecked) { + this.internalChecked = false; + } + else if (changed === 'INTERNAL' && this.internalChecked) { + this.crowdChecked = false; + } + + this.updateSqlCode(); + } + + public updateSqlCode(): void { + const updateCondition = (conditionToAdd: string, include: boolean): void => { + const query = this.form.value.query; + + if (query.trim() !== '') { + if (include) { + const updatedQuery = this.insertTextAfterWhere(query, conditionToAdd); + this.form.patchValue({ query: updatedQuery }); + } else { + const modifiedQuery = this.removeCondition(query, conditionToAdd); + this.form.patchValue({ query: modifiedQuery }); + } + } + }; + + updateCondition('resource.crowdType LIKE \'CROWD\'\n\tAND ', this.crowdChecked); + updateCondition('resource.crowdType LIKE \'NON_CROWD\'\n\tAND ', this.internalChecked); + } + + private insertTextAfterWhere(input: string, newText: string): string { + const whereRegex = /\bWHERE\b/i; + const whereMatch = whereRegex.exec(input); + + if (whereMatch) { + const insertionIndex = whereMatch.index + whereMatch[0].length; + + return `${input.slice(0, insertionIndex)} ${newText}${input.slice(insertionIndex)}`; + } else { + return `${input}\n${newText}`; + } + } + + private removeCondition(query: string, conditionToRemove: string): string { + return query.replace(conditionToRemove, ''); + } } diff --git a/workbench/frontend/src/app/common/services/query.service.ts b/workbench/frontend/src/app/common/services/query.service.ts index 506d38d..fbb1d29 100644 --- a/workbench/frontend/src/app/common/services/query.service.ts +++ b/workbench/frontend/src/app/common/services/query.service.ts @@ -111,48 +111,48 @@ export class QueryService { } - public queryResource, item extends { + public queryResourceSkills, item extends { id: string, firstName: string, - lastName: string + lastName: string, + skills: { name: string, start: string, end: string }[] }>(query: string): Observable { return combineLatest([ this._query(query), this._query, TagObj>(`SELECT tag.id as id, tag.name as name - FROM Tag tag - WHERE tag.inactive = false`), + FROM Tag tag + WHERE tag.inactive = false`), this._query, SkillObj>(`SELECT skill.id as id, - skill.tag as tag, - skill.person as person, - skill.startDate as startDate, - skill.endDate as endDate - FROM Skill skill - WHERE skill.inactive = false`) - ]) - .pipe( - map(([resources, tags, skills]) => { - const tagMap = tags.data.reduce((m, { id, name }) => m.set(id, name), new Map()); - const skillMap = skills.data.reduce((m, skill) => { - const currentItem = { - name: (tagMap.get(skill.tag) || skill.tag), - start: skill.startDate === 'null' ? null : skill.startDate, - end: skill.endDate === 'null' ? null : skill.endDate - }; - if (m.has(skill.person)) { - m.get(skill.person)?.push(currentItem); - } else { - m.set(skill.person, [currentItem]); - } - return m; - }, new Map()); - - return resources.data.map(it => ({ - ...it, - skills: skillMap.has(it.id) ? skillMap.get(it.id).sort((a, b) => (a.name > b.name) ? 1 : ((b.name > a.name) ? -1 : 0)) : [] - })); - }), - tap((currentList) => this.addToCache('resource', currentList)) - ); + skill.tag as tag, + skill.person as person, + skill.startDate as startDate, + skill.endDate as endDate + FROM Skill skill + WHERE skill.inactive = false`) + ]).pipe( + map(([resources, tags, skills]) => { + const tagMap = tags.data.reduce((m, { id, name }) => m.set(id, name), new Map()); + const skillMap = skills.data.reduce((m, skill) => { + const currentItem = { + name: (tagMap.get(skill.tag) || skill.tag), + start: skill.startDate === 'null' ? null : skill.startDate, + end: skill.endDate === 'null' ? null : skill.endDate + }; + if (m.has(skill.person)) { + m.get(skill.person)?.push(currentItem); + } else { + m.set(skill.person, [currentItem]); + } + return m; + }, new Map()); + + return resources.data.map(it => ({ + ...it, + skills: skillMap.has(it.id) ? skillMap.get(it.id).sort((a, b) => (a.name > b.name) ? 1 : ((b.name > a.name) ? -1 : 0)) : [] + })); + }), + tap((currentList) => this.addToCache('resource', currentList)) + ); } public getResourceFromCache(id: string): PersonObj { diff --git a/workbench/frontend/src/app/common/services/shared-skill.service.ts b/workbench/frontend/src/app/common/services/shared-skill.service.ts new file mode 100644 index 0000000..ddcf4ff --- /dev/null +++ b/workbench/frontend/src/app/common/services/shared-skill.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class SharedSkillsService { + private selectedSkillsSubject = new BehaviorSubject([]); + selectedSkills$ = this.selectedSkillsSubject; + + updateSelectedSkills(selectedSkills: string[]): void { + this.selectedSkillsSubject.next(selectedSkills); + } +} diff --git a/workbench/frontend/src/app/slot-booking/components/job-builder/job-builder.component.ts b/workbench/frontend/src/app/slot-booking/components/job-builder/job-builder.component.ts index 3f0c80f..21f7ae6 100644 --- a/workbench/frontend/src/app/slot-booking/components/job-builder/job-builder.component.ts +++ b/workbench/frontend/src/app/slot-booking/components/job-builder/job-builder.component.ts @@ -7,6 +7,7 @@ import { COMMA, ENTER } from '@angular/cdk/keycodes'; import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { MatChipInputEvent } from '@angular/material/chips'; import { filter, map, take, takeUntil, tap } from 'rxjs/operators'; +import { SharedSkillsService } from '../../../common/services/shared-skill.service'; @Component({ selector: 'job-builder', @@ -32,17 +33,19 @@ export class JobBuilderComponent implements OnInit, OnDestroy, AfterContentInit @ViewChild('mandatorySkillsInput') mandatorySkillsInput: ElementRef; @ViewChild('optionalSkillsInput') optionalSkillsInput: ElementRef; @Output() change = new EventEmitter(); + form: FormGroup; selectedAddress: FormControl; - onDistroy$ = new Subject(); + onDestroy$ = new Subject(); constructor( private fb: FormBuilder, private service: JobService, + private sharedSkillsService: SharedSkillsService ) { } - public ngAfterContentInit() { + public ngAfterContentInit(): void { } @@ -55,10 +58,10 @@ export class JobBuilderComponent implements OnInit, OnDestroy, AfterContentInit this.form.patchValue({ location_latitude: item.location.latitude, location_longitude: item.location.longitude - }) + }); this.selectedAddress.patchValue(null); }), - takeUntil(this.onDistroy$) + takeUntil(this.onDestroy$) ).subscribe(); this.allAddress$ = this.service.fetchAllAddress().pipe( @@ -71,7 +74,7 @@ export class JobBuilderComponent implements OnInit, OnDestroy, AfterContentInit ) ); - const alltags = this.service.fetchAllTags() + const alltags = this.service.fetchAllTags(); alltags.pipe( @@ -83,15 +86,15 @@ export class JobBuilderComponent implements OnInit, OnDestroy, AfterContentInit // select mandatory skill // this.selectedMandatorySkills$.next([first.name]); } - }, 100) + }, 100); }) - ).subscribe() + ).subscribe(); this.matchingResources$ = combineLatest([this.selectedMandatorySkills$, alltags]).pipe( map(([selected, all]) => selected.map(tagName => all.find(x => x.name === tagName)) .filter(it => !!it) .reduce((theSet, it) => { - it.persons.forEach(p => theSet.add(p)) + it.persons.forEach(p => theSet.add(p)); return theSet; }, new Set())), map((theSet) => Array.from(theSet)), @@ -115,12 +118,16 @@ export class JobBuilderComponent implements OnInit, OnDestroy, AfterContentInit this.selectedMandatorySkills$.pipe( tap((list) => this.form.patchValue({ mandatorySkills: list })), - takeUntil(this.onDistroy$) + takeUntil(this.onDestroy$) ).subscribe(); + this.sharedSkillsService.selectedSkills$.subscribe(() => + this.selectedMandatorySkills$ = this.sharedSkillsService.selectedSkills$ + ); + this.selectedOptionalSkills$.pipe( tap((list) => this.form.patchValue({ optionalSkills: list })), - takeUntil(this.onDistroy$) + takeUntil(this.onDestroy$) ).subscribe(); const form$ = merge(of(this.form.value), this.form.valueChanges); @@ -141,24 +148,25 @@ export class JobBuilderComponent implements OnInit, OnDestroy, AfterContentInit }; this.change.next(job); }), - takeUntil(this.onDistroy$) + takeUntil(this.onDestroy$) ).subscribe(); } - public ngOnDestroy() { - this.onDistroy$.next(); + public ngOnDestroy(): void { + this.onDestroy$.next(); } - public mandatorySkillsSelectionChanged(event: MatAutocompleteSelectedEvent) { + public mandatorySkillsSelectionChanged(event: MatAutocompleteSelectedEvent): void { this.selectedMandatorySkills$.next([...this.selectedMandatorySkills$.value, event.option.value]); this.mandatorySkillsInput.nativeElement.value = ''; this.mandatorySkillsCtrl.setValue(null); + this.sharedSkillsService.updateSelectedSkills(this.selectedMandatorySkills$.value); } - public addMandatorySkill(event: MatChipInputEvent) { + public addMandatorySkill(event: MatChipInputEvent): void { const input = event.input; const value = event.value; - if (!value) return; + if (!value) { return; } this.selectedMandatorySkills$.next([...this.selectedMandatorySkills$.value, value.trim()]); if (input) { @@ -166,20 +174,21 @@ export class JobBuilderComponent implements OnInit, OnDestroy, AfterContentInit } } - public removeMandatorySkill(tagName: string) { + public removeMandatorySkill(tagName: string): void { this.selectedMandatorySkills$.next(this.selectedMandatorySkills$.value.filter(it => (it !== tagName))); + this.sharedSkillsService.updateSelectedSkills(this.selectedMandatorySkills$.value); } - public optionalSkillsSelectionChanged(event: MatAutocompleteSelectedEvent) { + public optionalSkillsSelectionChanged(event: MatAutocompleteSelectedEvent): void { this.selectedOptionalSkills$.next([...this.selectedOptionalSkills$.value, event.option.value]); this.optionalSkillsInput.nativeElement.value = ''; this.optionalSkillsCtrl.setValue(null); } - public addOptionalSkill(event: MatChipInputEvent) { + public addOptionalSkill(event: MatChipInputEvent): void { const input = event.input; const value = event.value; - if (!value) return; + if (!value) { return; } this.selectedOptionalSkills$.next([...this.selectedOptionalSkills$.value, value.trim()]); if (input) { @@ -187,23 +196,23 @@ export class JobBuilderComponent implements OnInit, OnDestroy, AfterContentInit } } - public removeOptionalSkill(tagName: string) { + public removeOptionalSkill(tagName: string): void { this.selectedOptionalSkills$.next(this.selectedOptionalSkills$.value.filter(it => (it !== tagName))); } - public locationClear() { - this.selectedAddress.patchValue(null) + public locationClear(): void { + this.selectedAddress.patchValue(null); this.form.patchValue({ location_latitude: null, location_longitude: null }); } - public pickFromMap() { + public pickFromMap(): void { this.isPicking$.next(true); } - public mapSelect({ latitude, longitude }: { latitude: number; longitude: number; }) { + public mapSelect({ latitude, longitude }: { latitude: number; longitude: number; }): void { if (this.isPicking$.value) { this.form.patchValue({ location_latitude: latitude, diff --git a/workbench/frontend/src/app/slot-booking/services/slot-booking.service.ts b/workbench/frontend/src/app/slot-booking/services/slot-booking.service.ts index a27b4f6..67fda11 100644 --- a/workbench/frontend/src/app/slot-booking/services/slot-booking.service.ts +++ b/workbench/frontend/src/app/slot-booking/services/slot-booking.service.ts @@ -59,9 +59,7 @@ export type SearchRequest = Readonly<{ } }>; slots: ISearchRequestSlot[]; - resources: { - personIds: string[] - }, + resources: {personIds: string[]} | ResourceFilters, options: Readonly<{ maxResultsPerSlot: number; }>; @@ -72,6 +70,13 @@ type ILocation = { longitude: number; }; +export type ResourceFilters = { + filters: { + includeInternalPersons: boolean, + includeCrowdPersons: boolean, + includeMandatorySkills: boolean + } +}; @Injectable({ providedIn: 'root' diff --git a/workbench/frontend/src/app/slot-booking/slot-booking.component.html b/workbench/frontend/src/app/slot-booking/slot-booking.component.html index f3543e8..bc334ab 100644 --- a/workbench/frontend/src/app/slot-booking/slot-booking.component.html +++ b/workbench/frontend/src/app/slot-booking/slot-booking.component.html @@ -1,4 +1,4 @@ - + Slot Search Request / Response @@ -12,18 +12,18 @@
{{ (response$ | async).errorMessage }}
+ class="error-box">{{ (response$ | async).errorMessage }}
+ [expanded]="i === 0 && false"> + [job]="jobBuilder$ | async"> @@ -35,7 +35,7 @@
+ [value]="(100 / (group.maxScore * 1000) ) * (item.score * 1000)">
@@ -52,7 +52,7 @@
directions_car - {{ item.trip.durationInMinutes }} min ({{ (item.trip.distanceInMeters / 1000 ) }} km) + {{ item.trip.durationInMinutes }} min ({{ (item.trip.distanceInMeters / 1000) }} km)
@@ -65,7 +65,7 @@
+ [title]="skill.start + ' '+ skill.end"> {{ skill.name }}
@@ -80,8 +80,43 @@

Request

- - Full JSON Request + +
+ Filter Based +
+ +
+ Internal + Crowd + Use mandatory skills +
+ + + + using + /api/v3/job-slots/actions/search + Search Job Slot API see + + + documentation + + +
+ auto refresh +
+ +
+ Timing: {{ (response$ | async).time | number }} ms for {{ (response$ | async).grouped.length }} slots with + {{ (response$ | async).results.length }} matches +
+ + + Full JSON Request
{{ requestPayload$ | async | json }}
@@ -91,7 +126,7 @@

Request

Response

- Full JSON Response + Full JSON Response
{{ (response$ | async)?.results | json }}
@@ -104,30 +139,6 @@

Response

- - - - - using /api/v3/job-slots/actions/search Search Job Slot API see - - - documentation - - -
- auto refresh -
- -
- Timing: {{ (response$ | async).time | number }} ms for {{ (response$ | async).grouped.length }} solts with - {{ (response$ | async).results.length }} matches -
- -

Slot Search Options:

@@ -135,7 +146,7 @@

Slot Search Options:

🕑  When / Timing - {{ (requestPayload$ | async)?.slots.length }} slots + {{ (requestPayload$ | async)?.slots.length }} slots @@ -160,7 +171,7 @@

Slot Search Options:

👷  Who / People - {{ (requestPayload$ | async)?.resources.personIds.length }} technicians + {{ (requestPayload$ | async)?.resources?.personIds?.length }} technicians diff --git a/workbench/frontend/src/app/slot-booking/slot-booking.component.scss b/workbench/frontend/src/app/slot-booking/slot-booking.component.scss index a50a154..82c08b4 100644 --- a/workbench/frontend/src/app/slot-booking/slot-booking.component.scss +++ b/workbench/frontend/src/app/slot-booking/slot-booking.component.scss @@ -3,24 +3,24 @@ } .error-box { - width: 100%; - border-radius: 10px; - background-color: #ab4545; - color:#fff; + width: 100%; + border-radius: 10px; + background-color: #ab4545; + color:#fff; padding: 10px; } .panel-title { - padding-top:5px; + padding-top:5px; margin-right: 10px; } .matching-tech { display: inline-grid; - text-align: center; - width: 300px; + text-align: center; + width: 300px; border: 2px solid #c1c1c1; - border-radius: 10px; + border-radius: 10px; padding: 10px; margin-left: 10px; margin-bottom: 10px; @@ -30,7 +30,7 @@ .tech-info { margin-bottom: 12px; display: flex; - + mat-icon { margin-right: 10px; } diff --git a/workbench/frontend/src/app/slot-booking/slot-booking.component.ts b/workbench/frontend/src/app/slot-booking/slot-booking.component.ts index 61defe2..deafdcd 100644 --- a/workbench/frontend/src/app/slot-booking/slot-booking.component.ts +++ b/workbench/frontend/src/app/slot-booking/slot-booking.component.ts @@ -1,18 +1,19 @@ import { HttpErrorResponse } from '@angular/common/http'; -import { Component, OnDestroy, OnInit, } from '@angular/core'; -import { FormBuilder, FormGroup, } from '@angular/forms'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; import { MatSnackBar } from '@angular/material/snack-bar'; import { BehaviorSubject, combineLatest, Observable, of, Subject } from 'rxjs'; import { catchError, filter, map, mergeMap, pairwise, take, takeUntil, tap } from 'rxjs/operators'; import { AuthService } from '../common/services/auth.service'; import { Slot } from './components/slot-builder/slot-builder.component'; -import { SlotSearchService, SearchRequest, SearchResponseWrapper } from './services/slot-booking.service'; +import { ResourceFilters, SearchRequest, SearchResponseWrapper, SlotSearchService } from './services/slot-booking.service'; import { Job } from './services/job.service'; +import { Event } from '@angular/router'; @Component({ selector: 'slot-booking', templateUrl: './slot-booking.component.html', - styleUrls: ['./slot-booking.component.scss'] + styleUrls: ['./slot-booking.component.scss'], }) export class SlotBookingComponent implements OnInit, OnDestroy { @@ -27,6 +28,16 @@ export class SlotBookingComponent implements OnInit, OnDestroy { public requestOptions: FormGroup; private onDistroy$ = new Subject(); + public filterBased = false; + public internalChecked = true; + public crowdChecked = true; + public skillsChecked = false; + public filters$ = new BehaviorSubject(null); + public requestPayloadResources = null; + + animationState = 'end'; + + constructor( private fb: FormBuilder, private service: SlotSearchService, @@ -38,7 +49,7 @@ export class SlotBookingComponent implements OnInit, OnDestroy { const autoRefresh$ = new Observable((op) => { const timer = setInterval(() => { - const { refresh } = this.requestOptions.value + const { refresh } = this.requestOptions.value; if (refresh && !this.isLoading$.value && !!this.response$.value && !this.response$.value.isError) { this.doRequest(); op.next(); @@ -47,7 +58,7 @@ export class SlotBookingComponent implements OnInit, OnDestroy { return () => { clearInterval(timer); - } + }; }); autoRefresh$.pipe( @@ -56,72 +67,111 @@ export class SlotBookingComponent implements OnInit, OnDestroy { this.isLoggedIn$ = this.auth.isLoggedIn$; - this.requestPayload$ = combineLatest([this.pluginEditor$, this.slotBuilder$, this.jobBuilder$, this.personIds$]) - .pipe( - filter(([pluginEditor, slots, job, personIds]) => !!(pluginEditor && slots && job && personIds.length)), - map(([pluginEditor, slots, job, personIds]): SearchRequest => { - return { - - job: { - durationMinutes: job.durationMinutes, - location: job.location, - mandatorySkills: job.mandatorySkills, - optionalSkills: job.optionalSkills, - udfValues: {} - }, - - slots, - - resources: { - personIds - }, - - options: { - maxResultsPerSlot: 8 - }, - - policy: pluginEditor, - - } - }) - ); + this.updateRequestPayload(); this.requestOptions = this.fb.group({ - refresh: [false] + refresh: [false], + filterBasedToggle: [false] }); + this.response$.pipe( filter(it => !!it), map(it => it.time), pairwise(), + // tslint:disable-next-line:no-console tap(r => console.debug(r)), takeUntil(this.onDistroy$) ).subscribe(); } - public ngOnDestroy() { + public ngOnDestroy(): void { this.onDistroy$.next(); } - public onChangeSlots(windows: Slot[]) { + public onChangeSlots(windows: Slot[]): void { this.slotBuilder$.next(windows); } - public onChangePluginEditor(name: string) { + public onChangePluginEditor(name: string): void { this.pluginEditor$.next(name); } - public onChangeJobBuilder(job: Job) { + public onChangeJobBuilder(job: Job): void { this.jobBuilder$.next(job); } - public onChangePersonIds(ids: string[]) { + public onChangePersonIds(ids: string[]): void { this.personIds$.next(ids); } - public doRequest() { + public onCheckboxChange(): void { + this.filters$.next(this.buildFilters()); + this.updateRequestPayload(); + } + + public onFilterBasedToggleChange(event: Event): void { + this.filterBased = !this.filterBased; + this.filters$.next(this.buildFilters()); + const prevRequest = this.requestPayload$?.pipe(); + + this.requestPayload$.pipe( + tap(_ => this.animationState = 'start'), + ).subscribe(requestPayload => { + this.updateRequestPayload(); + setTimeout(() => { + this.animationState = 'end'; + }, 1000); + }); + } + + + private buildFilters(): ResourceFilters { + return {filters: { + includeInternalPersons: this.internalChecked, + includeCrowdPersons: this.crowdChecked, + includeMandatorySkills: this.skillsChecked + }}; + } + + + private updateRequestPayload(): void { + this.requestPayload$ = combineLatest([ + this.pluginEditor$, + this.slotBuilder$, + this.jobBuilder$, + this.personIds$, + this.filters$ + ]).pipe( + filter(([pluginEditor, slots, job, personIds, filters]) => !!( + pluginEditor && slots && job && (personIds.length || filters) + )), + map(([pluginEditor, slots, job, personIds, filters]): SearchRequest => { + + const newResources = this.filterBased ? filters : { personIds }; + + return { + job: { + durationMinutes: job.durationMinutes, + location: job.location, + mandatorySkills: job.mandatorySkills, + optionalSkills: job.optionalSkills, + udfValues: {} + }, + slots, + resources: newResources, + options: { + maxResultsPerSlot: 8 + }, + policy: pluginEditor, + }; + }) + ); + } + + public doRequest(): void { this.isLoading$.next(true); @@ -166,13 +216,13 @@ export class SlotBookingComponent implements OnInit, OnDestroy { time: -1, grouped: [], results: [] - }) + }); }) - ) + ); }), tap(value => { - this.isLoading$.next(false) + this.isLoading$.next(false); if (value) { this.response$.next(value); } @@ -180,7 +230,7 @@ export class SlotBookingComponent implements OnInit, OnDestroy { ).subscribe(); } - public bookingLoading(isLoading: boolean) { + public bookingLoading(isLoading: boolean): void { this.isLoading$.next(isLoading); }