import { Component, Input, HostListener, ViewChild, ElementRef, OnInit, OnChanges, SimpleChanges, OnDestroy, NgZone, Output, EventEmitter } from '@angular/core'; import { Router, ActivatedRoute } from '@angular/router'; import { forkJoin, Observable, Subject } from 'rxjs'; import { first, take, filter, takeUntil } from 'rxjs/operators'; import * as momentImported from 'moment'; const moment = momentImported; import { SocketsService, AnalyticsService, PermissionsService, UserService, MessageBusService, AnalyticsEvent, ApiSocketListenAction, PaginationParamsModel } from '@arrow/bom/core'; import { ConfigService } from '@arrow/bom/config'; import { WptDialog } from '@arrow/warpaint/dialog'; import { WptIconComponent } from '@arrow/warpaint/icon'; import { FeatureFlagService } from '@arrow/bom/feature-flag'; import { ShareBomComponent } from './share-bom/share-bom.component'; import { RevisionHistoryComponent } from './revision-history/revision-history.component'; import { SettingsService } from '@arrow/bom/shared'; @Component({ selector: 'bom-controls', templateUrl: 'controls.component.html', styleUrls: ['controls.component.scss'], }) export class ControlsComponent implements OnInit, OnChanges, OnDestroy { private _firstLoad: boolean = true; private _killSig$$ = new Subject(); // Kill signal for observables public isAnonymous: boolean; public totalParts: number = 0; public permissions: any; public shareStatus: string; public isSelfOwned: boolean = true; public bomStub: any; public bomLoading: boolean = true; public changeCount: number = 0; public showSettings: boolean = false; public showEditName: boolean = false; public showEditMultiplier: boolean = false; public originalBomName: string; public isSearching: boolean; public quantityConflictCount: number; public searchCriteria: { state: boolean; value: string } = { state: false, value: '' }; public selectedFilters: any = { matched: [], search: '' }; @Input() bom: any; @Input() cartLimit: boolean; @Input() partInfo$: Observable; @Input() paginationParams: PaginationParamsModel; @Output() filterChanged = new EventEmitter(); @Output() multiplierChanged = new EventEmitter(); @Output() paginator = new EventEmitter(); @ViewChild('editName') editNameEl: ElementRef; @ViewChild('editMultiplier') editMultiplierEl: ElementRef; @ViewChild('editIcon') editIconEl: WptIconComponent; @ViewChild('inventorySettingsBtn') inventorySettingsEl: ElementRef; @HostListener('document:click', ['$event']) clickedOutside($event: any) { if ( (this.editNameEl && $event.target === this.editNameEl.nativeElement) || (this.editIconEl && $event.target === this.editIconEl._elementRef.nativeElement) ) return; // Just making sure the 'multiInput' property is there // (otherwise we get 'Null Reference Exceptions' occasionally) if ( this.editMultiplierEl && this.editMultiplierEl['multInput'] && $event.target === this.editMultiplierEl['multInput'].nativeElement ) return; this.toggleEditName(false, this.isAnonymous); } constructor( private _messageBus: MessageBusService, private wptDialog: WptDialog, private sockets: SocketsService, private settingsService: SettingsService, private zone: NgZone, private analyticsService: AnalyticsService, private ps: PermissionsService, private router: Router, public config: ConfigService, public userService: UserService, public route: ActivatedRoute, public ffService: FeatureFlagService ) {} ngOnInit() { this.originalBomName = this.bom.name; this.ps .getPermissions() .pipe(takeUntil(this._killSig$$)) .subscribe((permissions: any) => (this.permissions = permissions)); this.userService .isReady() .pipe(first()) .subscribe(() => { this.isAnonymous = this.userService.isAnonymous; }); this.userService.requestFreshAuth(); this.sockets .observeAction(ApiSocketListenAction.BOM_METADATA) .pipe(takeUntil(this._killSig$$)) .subscribe((data: any) => { this.zone.run(() => { if (!data) return; this.updateBomName(data.bomName, this.bom); this.updateMultiplier(data.multiplier, this.bom); this.bom.edited = moment(data.lastEdited).format('D MMM YYYY'); }); }); // On bomStub this.sockets .observeAction(ApiSocketListenAction.BOM_STUB) .pipe(takeUntil(this._killSig$$)) .subscribe((bomStub: any) => { this.zone.run(() => { this.bomStub = bomStub; this.totalParts = bomStub.bom.rowCount; }); }); // On bomTotals this.sockets .observeAction(ApiSocketListenAction.BOM_TOTALS) .pipe(takeUntil(this._killSig$$)) .subscribe((data: any) => { this.zone.run(() => { this.quantityConflictCount = data.quantityConflictCount this.totalParts = data.rowCount; this.updateAllBomInfo(data, this.bom); }); }); this.sockets .observeAction(ApiSocketListenAction.BOM_CHANGE) .pipe(takeUntil(this._killSig$$)) .subscribe((changeData: any) => { if (changeData && changeData.length > 0) { for (let ii = 0; ii < changeData.length; ii++) { if (changeData[ii].u !== this.userService.email) this.changeCount++; } } }); this.userService .getBomChangesSinceHistoryLastViewed(this.bom.id) .pipe(first()) .subscribe(numChanges => { this.changeCount = numChanges; }); this.sockets .observeAction(ApiSocketListenAction.SBO_SETTINGS) .pipe(takeUntil(this._killSig$$)) .subscribe((sboSettings: any) => { // this is only to update cheapest/fastest and filterTypes (not regions anymore) this.settingsService.setMetadata(sboSettings); // bomStub.SBOsettings.regions is now used to update region information for both MyArrow and Arrow.com this.settingsService.setRegions(sboSettings.regions); this._messageBus.to('ch:loader', { payload: true }); if (this.bom.rows && this.bom.rows.length > 0) { this.bom.rows.forEach(r => r.setItemModelLoading(true)); } }); // On team list this.sockets .observeAction(ApiSocketListenAction.TEAM_LIST) .pipe(takeUntil(this._killSig$$)) .subscribe((teamListArr: any) => { this.zone.run(() => { this.onBomTeamListReceived({ payload: teamListArr }, this.bom); }); }); // On Share Permissions this.sockets .observeAction(ApiSocketListenAction.CURRENT_PERMISSIONS) .pipe(takeUntil(this._killSig$$)) .subscribe((sharePermissions: any) => { this.zone.run(() => { sharePermissions.level = sharePermissions.level.toLowerCase(); this.onBomPermissionsReceived( { payload: sharePermissions }, this.bom ); }); }); // On progress received this.sockets .observeAction(ApiSocketListenAction.PROGRESS) .pipe(takeUntil(this._killSig$$)) .subscribe((progressData: any) => { this.zone.run(() => { if (progressData.length) { this.bom.progress = progressData[0].completePercentage; } }); }); this.sockets .observeAction(ApiSocketListenAction.FILTER_COUNTS) .pipe(takeUntil(this._killSig$$)) .subscribe((data: any) => { this.zone.run(() => { if (data && data.length) { const filters = data[0]; // response data is a 1 element array this.bom.InStockCount = filters.matched.find( (f: any) => f.name === 'InStock' ).total; this.bom.OutOfStock = filters.matched.find( (f: any) => f.name === 'OutOfStock' ).total; this.bom.Conflict = filters.matched.find( (f: any) => f.name === 'Conflict' ).total; this.bom.HasCrosses = filters.matched.find( (f: any) => f.name === 'HasCrosses' ).total; this.bom.NotMatched = filters.notMatched.find( (f: any) => f.name === 'NotMatched' ).total; this.bom.HasAlt = filters.notMatched.find( (f: any) => f.name === 'HasAlts' ).total; } }) }); // On bomStub this.sockets .observeAction(ApiSocketListenAction.BOM_STUB) .pipe(takeUntil(this._killSig$$)) .subscribe((bomStub: any) => { this.zone.run(() => { // TODO removed payload, for now kept consistent with the original response data format this.onNewBomData({ payload: bomStub }, this._firstLoad, this.bom); }); }); this._messageBus .from('ch:loader') .pipe(takeUntil(this._killSig$$)) .subscribe(message => { this.bomLoading = message.payload; }); this.sockets .observeAction(ApiSocketListenAction.BOM_INFO_METRICS) .pipe(take(1)) .subscribe(result => { if (!result) return; const analyticData = result; const bomMissedStockValue = analyticData.BomSalesMetrics.PotentialPartPrice - analyticData.BomSalesMetrics.AvailablePartPrice; console.log(`FINISHED LOADING BOM AT: ${performance.now()}`) this.analyticsService.googleTrack(AnalyticsEvent.BOM_INFO, { bomId: this.bom.id, bomInStockCount: analyticData.StockCount, bomOutOfStockCount: analyticData.OutOfStockCount, bomStockErrorCount: analyticData.StockErrorCount, bomPartCount: analyticData.PartCount, bomAvailableValue: analyticData.BomSalesMetrics.AvailablePartPrice, bomPotentialValue: analyticData.BomSalesMetrics.PotentialPartPrice, bomMissedStockValue: bomMissedStockValue }); }); if (!this.analyticsService.isBomNew) { this.analyticsService.googleTrackEvent(AnalyticsEvent.EVENT, { category: 'BOM', action: 'Existing', label: this.bom.id }); } else { this.analyticsService.isBomNew = false; } } ngOnChanges(changes: SimpleChanges) { if (changes.bom) this.settingsService.setBomId(this.bom.id); } ngOnDestroy() { this._killSig$$.next(); this._killSig$$.complete(); } /** * Calls setName api endpoint in order to update bom name. * If user has entered an empty name or name that is only spaces bom * name is reverted to original name and api call is not made. If bom name is valid * it is saved and set as new originalBomName. * @param bom * @param _originalBomName * @param MAX_LENGTH_BOM_NAME */ updateName( bom: any, _originalBomName: string, MAX_LENGTH_BOM_NAME: number ): void { if (bom.name) { bom.name = bom.name.trim().substring(0, MAX_LENGTH_BOM_NAME); } if (!bom.name) { bom.name = _originalBomName; return; } this.sockets.emitAction('alterMetadata', { bomId: bom.id, name: bom.name }); } /** * Check if bomName has changed as the result of metadata request and update the model * @param newBomName - bomName returned from the metadata request * @param bom */ updateBomName(newBomName: string, bom: any) { if (newBomName && bom.name !== newBomName) { bom.name = newBomName; } if (bom.name !== this.originalBomName) { this.originalBomName = bom.name; } } /** * display settings * @param saved * @param bom */ toggleSettings(saved: boolean, bom: any): void { this.showSettings = !this.showSettings; // Return focus back to the inventory settings button (accessibility purpose) this.inventorySettingsEl?.nativeElement?.focus(); if (this.showSettings) this.analyticsService.googleTrackEvent(AnalyticsEvent.CLICK, { category: 'BOM', action: 'settings', label: 'show' }); if (saved) { bom.rows.forEach(row => row.setItemModelLoading(true)); } } /** * Enables/disables bom name edit. Anonymous users can't edit name. * @param status * @param isAnonymous */ toggleEditName(status: boolean, isAnonymous: boolean): void { if (!isAnonymous) { this.showEditName = status; this.showEditMultiplier = status; } else { this.showEditMultiplier = status; } } /** * On socket bom stub received: scroll to the top of the page, load the bom model with the bom stub data, * set the main loader to false (now only the incomplete rows are loading), bom first load track * @param response * @param _firstLoad * @param bom */ onNewBomData(response: any, _firstLoad: boolean, bom: any) { window.scrollTo(0, 0); // this is only to update cheapest/fastest and filterTypes (not regions anymore) this.settingsService.setMetadata(response.payload.SBOsettings); // bomStub.SBOsettings.regions is now used to update region information for both MyArrow and Arrow.com this.settingsService.setRegions(response.payload.SBOsettings.regions); this.settingsService.setBomId(response.payload._id); // check if first load then send tracking information if (_firstLoad) { this.firstLoadTracking(bom); } } /** * On socket BOM user share status received * @param response * @param bom */ onBomPermissionsReceived(response: any, bom: any) { bom.permissions = response.payload.level; if (response.payload.level === 'own') { this.sockets.emitAction('getTeam', { bomId: bom.id }); } else { this.updateShareStatus(response.payload.level, false); } } /** * On socket BOM team list received * @param response * @param bom */ onBomTeamListReceived(response: any, bom: any): void { const isShared = response.payload.length > 1 ? true : false; bom.isShared = isShared; this.updateShareStatus('own', isShared); } /** * On bom totals received from websockets update total price & row count * @param data - bomTotals object * @param bom */ updateAllBomInfo(data: any, bom: any) { const { totalCost, totalCostMultiplied, rowCount } = data; if (bom.multiplier > 1) { this.setTotalPrice(totalCostMultiplied, bom); } else { this.setTotalPrice(totalCost, bom); } this.setBomTotalParts(rowCount, bom); } /** * Update bom total price * @param price - new total price * @param bom */ setTotalPrice(price: number, bom: any) { bom.totalPrice = price; } /** * Update bom row count * @param rowCount - new row count * @param bom */ setBomTotalParts(rowCount: number, bom: any) { bom.parts = rowCount; } /** * Send analytic data, but only on first load * @param bom */ firstLoadTracking(bom: any) { this._firstLoad = false; // tracking event this.analyticsService .track( // tracking action 'firstLoad', // tracking payload { bomid: bom.id, bomName: bom.name } ) .subscribe(); } /** * Check if multiplier has changed as the result of metadata request and update the model & inform the app about the update * @param newMultiplier - multiplier returned from the metadata request * @param bom */ updateMultiplier(newMultiplier: number, bom: any) { if (newMultiplier && bom.multiplier !== newMultiplier) { bom.multiplier = newMultiplier; this.multiplierChanged.emit(newMultiplier); } } parsePermissionLevelToShareStatus( permissionLevel: string, isShared: boolean ): string { let result: string = ''; switch (permissionLevel) { case 'own': result = isShared ? 'shared' : ''; break; case 'r': result = 'view'; break; case 'rw': result = 'edit'; break; default: break; } return result; } updateShareStatus(permissionLevel: string, isShared: boolean) { this.shareStatus = this.parsePermissionLevelToShareStatus( permissionLevel, isShared ); // We know that a user owns the BOM if they don't have a "view" or "edit" share status switch (this.shareStatus) { case 'view': this.isSelfOwned = false; this.permissions = { canRead: true, canWrite: false, isOwner: false }; break; case 'edit': this.isSelfOwned = false; this.permissions = { canRead: true, canWrite: true, isOwner: false }; break; default: this.isSelfOwned = true; this.permissions = { canRead: true, canWrite: true, isOwner: true }; break; } } /** * open share bom modal * @param bom */ openShareBomModal(bom: any): void { this.wptDialog.open(ShareBomComponent, { minHeight: 275, data: { bom: bom } }); } /** * Navigates to the bom tool home page. * @param route */ goBomHome(route: ActivatedRoute): void { this.router.navigate(['../../../'], { relativeTo: route }); event.stopPropagation(); } /** * redirect to login page */ gotoLogin(): void { const url = encodeURIComponent(location.href); location.replace(`${this.config.getRoute('login')}?url=${url}`); } /** * redirect to register page */ gotoRegister(): void { const url = encodeURIComponent(location.href); location.replace(`${this.config.getRoute('register')}?gotoURL=${url}`); } onFilterChanged(selectedFilters: Object) { this.selectedFilters = selectedFilters; this.selectedFilters.search = this.searchCriteria.value; this.filterChanged.emit(this.selectedFilters); } onSearchChanged(searchCriteria: { state: boolean; value: string }) { this.isSearching = searchCriteria.state; this.searchCriteria = searchCriteria; this.selectedFilters.search = this.searchCriteria.value; this.filterChanged.emit(this.selectedFilters); } /** * open bom history modal * @param bom */ openHistoryModal(bom: any): void { this.wptDialog.open(RevisionHistoryComponent, { data: bom }); this.changeCount = 0; } /** * Updates the page when clicking the paginator * @param page */ updatePage(page: any) { this.paginator.emit(page); } goToBomhealthCheck(event) { if (event.keyCode === 13 || 32) { event.preventDefault(); window.open('https://widget.siliconexpert.com/bomhealthCheck', '_blank'); } } }