diff --git a/.cspell.json b/.cspell.json index 0e7abbb0716..19e7be763f2 100644 --- a/.cspell.json +++ b/.cspell.json @@ -517,7 +517,8 @@ "nbbutton", "xaxis", "wdth", - "concate" + "concate", + "typeahead" ], "useGitignore": true, "ignorePaths": [ diff --git a/packages/desktop-ui-lib/src/lib/services/client-cache.service.ts b/packages/desktop-ui-lib/src/lib/services/client-cache.service.ts index ecdb1e8c46f..546e1235f9b 100644 --- a/packages/desktop-ui-lib/src/lib/services/client-cache.service.ts +++ b/packages/desktop-ui-lib/src/lib/services/client-cache.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { AbstractCacheService } from './abstract-cache.service'; -import { IOrganizationContact } from '@gauzy/contracts'; +import { IOrganizationContact, IPagination } from '@gauzy/contracts'; import { StorageService } from './storage.service'; import { Store } from '../services'; @@ -8,13 +8,14 @@ import { Store } from '../services'; providedIn: 'root', }) export class ClientCacheService extends AbstractCacheService< - IOrganizationContact[] + IOrganizationContact[] | IPagination > { constructor( - protected _storageService: StorageService, + protected _storageService: StorageService>, protected _store: Store ) { super(_storageService, _store); this.prefix = ClientCacheService.name.toString(); + this.duration = 1 * 3600 * 1000; // 1 hour } } diff --git a/packages/desktop-ui-lib/src/lib/services/index.ts b/packages/desktop-ui-lib/src/lib/services/index.ts index 64d94285696..342dec0657c 100644 --- a/packages/desktop-ui-lib/src/lib/services/index.ts +++ b/packages/desktop-ui-lib/src/lib/services/index.ts @@ -1,29 +1,31 @@ export * from './abstract-cache.service'; export * from './client-cache.service'; export * from './employee-cache.service'; +export * from './error-client.service'; +export * from './error-handler.service'; +export * from './error-server.service'; +export * from './image-cache.service'; +export * from './language-cache.service'; +export * from './native-notification.service'; +export * from './notification.service'; export * from './organizations-cache.service'; export * from './project-cache.service'; +export * from './server-connection.service'; +export * from './status-icon-service/status-icon-cache.service'; +export * from './status-icon-service/status-icon.service'; export * from './storage.service'; +export * from './store.service'; export * from './tag-cache.service'; +export * from './tag.service'; export * from './task-cache.service'; +export * from './task-priority-cache.service'; +export * from './task-size-cache.service'; +export * from './task-statistics-cache.service'; +export * from './task-status-cache.service'; +export * from './teams-cache.service'; export * from './time-log-cache.service'; export * from './time-slot-cache.service'; -export * from './user-organization-cache.service'; -export * from './error-client.service'; -export * from './error-handler.service'; -export * from './error-server.service'; -export * from './native-notification.service'; -export * from './notification.service'; -export * from './toastr-notification.service'; -export * from './store.service'; -export * from './server-connection.service'; -export * from './image-cache.service'; -export * from './language-cache.service'; export * from './time-tracker-date.manager'; export * from './time-zone-manager'; -export * from './task-status-cache.service'; -export * from './teams-cache.service'; -export * from './status-icon-service'; -export * from './task-priority-cache.service'; -export * from './task-size-cache.service'; -export * from './tag.service'; +export * from './toastr-notification.service'; +export * from './user-organization-cache.service'; diff --git a/packages/desktop-ui-lib/src/lib/services/project-cache.service.ts b/packages/desktop-ui-lib/src/lib/services/project-cache.service.ts index 797c538c0a1..ee4e06d0805 100644 --- a/packages/desktop-ui-lib/src/lib/services/project-cache.service.ts +++ b/packages/desktop-ui-lib/src/lib/services/project-cache.service.ts @@ -1,20 +1,21 @@ import { Injectable } from '@angular/core'; +import { IOrganizationProject, IPagination } from '@gauzy/contracts'; +import { Store } from '../services'; import { AbstractCacheService } from './abstract-cache.service'; -import { IOrganizationProject } from '@gauzy/contracts'; import { StorageService } from './storage.service'; -import { Store } from '../services'; @Injectable({ - providedIn: 'root', + providedIn: 'root' }) export class ProjectCacheService extends AbstractCacheService< - IOrganizationProject[] + IOrganizationProject[] | IPagination > { constructor( - protected _storageService: StorageService, + protected _storageService: StorageService>, protected _store: Store ) { super(_storageService, _store); this.prefix = ProjectCacheService.name.toString(); + this.duration = 1 * 3600 * 1000; // 1 hour } } diff --git a/packages/desktop-ui-lib/src/lib/services/task-cache.service.ts b/packages/desktop-ui-lib/src/lib/services/task-cache.service.ts index 557c5a405e9..9b9a6232dbb 100644 --- a/packages/desktop-ui-lib/src/lib/services/task-cache.service.ts +++ b/packages/desktop-ui-lib/src/lib/services/task-cache.service.ts @@ -1,15 +1,15 @@ import { Injectable } from '@angular/core'; import { AbstractCacheService } from './abstract-cache.service'; -import { ITask } from '@gauzy/contracts'; +import { IPagination, ITask } from '@gauzy/contracts'; import { StorageService } from './storage.service'; import { Store } from '../services'; @Injectable({ providedIn: 'root', }) -export class TaskCacheService extends AbstractCacheService { +export class TaskCacheService extends AbstractCacheService> { constructor( - protected _storageService: StorageService, + protected _storageService: StorageService>, protected _store: Store ) { super(_storageService, _store); diff --git a/packages/desktop-ui-lib/src/lib/services/task-statistics-cache.service.ts b/packages/desktop-ui-lib/src/lib/services/task-statistics-cache.service.ts new file mode 100644 index 00000000000..be1c20a332f --- /dev/null +++ b/packages/desktop-ui-lib/src/lib/services/task-statistics-cache.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@angular/core'; +import { ITasksStatistics } from '@gauzy/contracts'; +import { Store } from '.'; +import { AbstractCacheService } from './abstract-cache.service'; +import { StorageService } from './storage.service'; + +@Injectable({ + providedIn: 'root' +}) +export class TaskStatisticsCacheService extends AbstractCacheService { + constructor(protected _storageService: StorageService, protected _store: Store) { + super(_storageService, _store); + this.prefix = TaskStatisticsCacheService.name.toString(); + this.duration = 600 * 1000; // 1O minutes + } +} diff --git a/packages/desktop-ui-lib/src/lib/services/teams-cache.service.ts b/packages/desktop-ui-lib/src/lib/services/teams-cache.service.ts index b5af417bdab..0fdad0dd3b3 100644 --- a/packages/desktop-ui-lib/src/lib/services/teams-cache.service.ts +++ b/packages/desktop-ui-lib/src/lib/services/teams-cache.service.ts @@ -1,19 +1,18 @@ import { Injectable } from '@angular/core'; +import { IOrganizationTeam, IPagination } from '@gauzy/contracts'; import { StorageService, Store } from '../services'; -import { IOrganizationTeam } from '@gauzy/contracts'; import { AbstractCacheService } from './abstract-cache.service'; @Injectable({ - providedIn: 'root', + providedIn: 'root' }) -export class TeamsCacheService extends AbstractCacheService< - IOrganizationTeam[] -> { +export class TeamsCacheService extends AbstractCacheService> { constructor( - protected _storageService: StorageService, + protected _storageService: StorageService>, protected _store: Store ) { super(_storageService, _store); this.prefix = TeamsCacheService.name.toString(); + this.duration = 1 * 3600 * 1000; // 1 hour } } diff --git a/packages/desktop-ui-lib/src/lib/shared/+state/selector.query.ts b/packages/desktop-ui-lib/src/lib/shared/+state/selector.query.ts index 3a8a668f258..5fe84ad5f72 100644 --- a/packages/desktop-ui-lib/src/lib/shared/+state/selector.query.ts +++ b/packages/desktop-ui-lib/src/lib/shared/+state/selector.query.ts @@ -28,6 +28,26 @@ export abstract class SelectorQuery extends Query> { return this.getValue().selected; } + public get page(): number { + return this.getValue().page; + } + + public get page$(): Observable { + return this.select((state) => state.page); + } + + public get limit(): number { + return this.getValue().limit; + } + + public get total(): number { + return this.getValue().total; + } + + public get hasNext(): boolean { + return this.page * this.limit < this.total; + } + public get hasPermission$(): Observable { return this.select((state) => state.hasPermission); } diff --git a/packages/desktop-ui-lib/src/lib/shared/+state/selector.service.ts b/packages/desktop-ui-lib/src/lib/shared/+state/selector.service.ts index 622c9e0b5d7..d8a1daf8b28 100644 --- a/packages/desktop-ui-lib/src/lib/shared/+state/selector.service.ts +++ b/packages/desktop-ui-lib/src/lib/shared/+state/selector.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; +import { concatMap, Observable } from 'rxjs'; import { SelectorQuery } from './selector.query'; import { SelectorStore } from './selector.store'; @@ -12,7 +12,7 @@ export abstract class SelectorService { public readonly selectorQuery: SelectorQuery ) {} - public abstract load(): Promise; + public abstract load(options?: { searchTerm?: string }): Promise; public getAll$(): Observable { return this.selectorQuery.data$; @@ -45,4 +45,18 @@ export abstract class SelectorService { public get selected$(): Observable { return this.selectorQuery.selected$; } + + public onScrollToEnd(): void { + if (this.selectorQuery.hasNext) { + this.selectorStore.next(); + } + } + + public get onScroll$(): Observable { + return this.selectorQuery.page$.pipe(concatMap(() => this.load())); + } + + public resetPage() { + this.selectorStore.update({ page: 1, data: [] }); + } } diff --git a/packages/desktop-ui-lib/src/lib/shared/+state/selector.store.ts b/packages/desktop-ui-lib/src/lib/shared/+state/selector.store.ts index 3cae1c8edd3..0b804c91a36 100644 --- a/packages/desktop-ui-lib/src/lib/shared/+state/selector.store.ts +++ b/packages/desktop-ui-lib/src/lib/shared/+state/selector.store.ts @@ -10,6 +10,15 @@ export abstract class SelectorStore extends Store> { this.update({ data }); } + public updateInfiniteList(list: { data: T[]; total: number }): void { + const { data, total } = list; + const items = this.getValue().data; + this.update({ + data: [...new Map([...items, ...data].map((item) => [item['id'], item])).values()], + total + }); + } + public updateSelected(selected: T | string): void { if (!selected) { this.update({ selected: null }); @@ -28,11 +37,16 @@ export abstract class SelectorStore extends Store> { return; } const data = this.getValue().data; + this.updateData([...new Map([...data, selected].map((item) => [item['id'], item])).values()]); this.updateSelected(selected); - this.updateData(data.concat([selected])); } public resetToInitialState(): void { this.update(this.initialState); } + + public next(): void { + const current = this.getValue().page; + this.update({ page: current + 1 }); + } } diff --git a/packages/desktop-ui-lib/src/lib/shared/components/abstract/selector.abstract.ts b/packages/desktop-ui-lib/src/lib/shared/components/abstract/selector.abstract.ts new file mode 100644 index 00000000000..ef1be0edd60 --- /dev/null +++ b/packages/desktop-ui-lib/src/lib/shared/components/abstract/selector.abstract.ts @@ -0,0 +1,72 @@ +import { Component } from '@angular/core'; +import { ControlValueAccessor } from '@angular/forms'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { BehaviorSubject, Observable, Subject } from 'rxjs'; +import { debounceTime, distinctUntilChanged, switchMap, tap } from 'rxjs/operators'; +import { SelectorService } from '../../+state/selector.service'; + +@UntilDestroy({ checkProperties: true }) +@Component({ + template: '' +}) +export abstract class AbstractSelectorComponent implements ControlValueAccessor { + public search$ = new Subject(); + private onChange: (value: any) => void; + private onTouched: () => void; + protected isDisabled$ = new BehaviorSubject(false); + + // Flag to control whether to update the store + protected useStore: boolean = true; + + // Abstract members to be implemented in derived classes + public abstract data$: Observable; + public abstract selected$: Observable; + public abstract isLoading$: Observable; + public abstract disabled$: Observable; + public abstract hasPermission$: Observable; + + constructor() {} + + // Handle value change + public change(value: string): void { + this.onChange?.(value); // Notify the form control + this.onTouched?.(); + this.updateSelected(value); // Update the store only if useStore is true + } + + // Implement ControlValueAccessor methods + public writeValue(value: string): void { + this.useStore = false; // Disable store updates when used in a form + if (value) { + this.updateSelected(value); + } + } + + public registerOnChange(fn: any): void { + this.onChange = fn; + } + + public registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + public setDisabledState(isDisabled: boolean): void { + this.isDisabled$.next(isDisabled); + } + + // Abstract method to update selected item + protected abstract updateSelected(value: string): void; + + // Common search handling logic + protected handleSearch(service: SelectorService) { + this.search$ + .pipe( + debounceTime(300), + distinctUntilChanged(), + tap(() => service.resetPage()), + switchMap((searchTerm) => service.load({ searchTerm })), + untilDestroyed(this) + ) + .subscribe(); + } +} diff --git a/packages/desktop-ui-lib/src/lib/shared/components/ui/select/select.component.html b/packages/desktop-ui-lib/src/lib/shared/components/ui/select/select.component.html index 61b53c178a5..79db78dccb4 100644 --- a/packages/desktop-ui-lib/src/lib/shared/components/ui/select/select.component.html +++ b/packages/desktop-ui-lib/src/lib/shared/components/ui/select/select.component.html @@ -17,6 +17,9 @@ [bindLabel]="bindLabel" [bindValue]="bindValue" nbTooltipStatus="warning" + [typeahead]="typeahead" + [typeToSearchText]="typeToSearchText | translate" + (scrollToEnd)="onScrollToEnd()" > diff --git a/packages/desktop-ui-lib/src/lib/shared/components/ui/select/select.component.ts b/packages/desktop-ui-lib/src/lib/shared/components/ui/select/select.component.ts index b454e4ae732..22491ea7264 100644 --- a/packages/desktop-ui-lib/src/lib/shared/components/ui/select/select.component.ts +++ b/packages/desktop-ui-lib/src/lib/shared/components/ui/select/select.component.ts @@ -1,12 +1,21 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, forwardRef, Input, Output } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Subject } from 'rxjs'; @Component({ selector: 'gauzy-select', templateUrl: './select.component.html', styleUrls: ['./select.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SelectComponent), + multi: true + } + ] }) -export class SelectComponent { +export class SelectComponent implements ControlValueAccessor { private _selectedItem: string = null; private _items: any[] = []; private _bindLabel: string = 'id'; @@ -20,10 +29,25 @@ export class SelectComponent { private _isLoading: boolean = false; private _addTagText: string = null; private _clearable: boolean = true; + private _typeToSearchText: string = 'FORM.PLACEHOLDERS.TYPE_SEARCH_REQUEST'; + private _typeahead!: Subject; private _addTag!: Function; @Output() clear = new EventEmitter(); @Output() modelChange = new EventEmitter(); + @Output() scrollToEnd = new EventEmitter(); + + onChange: (value: any) => void = () => {}; + onTouched: () => void = () => {}; + + // Getter and Setter for searchTextPlaceholder + @Input() + public get typeToSearchText(): string { + return this._typeToSearchText; + } + public set typeToSearchText(value: string) { + this._typeToSearchText = value; + } // Getter and Setter for selectedItem @Input() @@ -32,6 +56,7 @@ export class SelectComponent { } public set selectedItem(value: any) { this._selectedItem = value; + this.onTouched(); } // Getter and Setter for items @@ -151,13 +176,45 @@ export class SelectComponent { this._addTag = value; } + // Getter and Setter for selectedItem + @Input() + public get typeahead(): Subject { + return this._typeahead; + } + public set typeahead(value: Subject) { + this._typeahead = value; + } // Handle clear action public onClear() { this.clear.emit(); + this.onChange(null); } // Emit model change event public onModelChange(event: any) { this.modelChange.emit(event); + this.onChange(event); + } + + public onScrollToEnd() { + this.scrollToEnd.emit(); + } + + // ControlValueAccessor methods + writeValue(value: any): void { + this.selectedItem = value; + this.onChange(value); + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this._disabled = isDisabled; } } diff --git a/packages/desktop-ui-lib/src/lib/shared/features/client-selector/+state/client-selector.service.ts b/packages/desktop-ui-lib/src/lib/shared/features/client-selector/+state/client-selector.service.ts index 4360629f583..c75455cb178 100644 --- a/packages/desktop-ui-lib/src/lib/shared/features/client-selector/+state/client-selector.service.ts +++ b/packages/desktop-ui-lib/src/lib/shared/features/client-selector/+state/client-selector.service.ts @@ -33,6 +33,7 @@ export class ClientSelectorService extends SelectorService /* Creating a new contact for the organization. */ public async addContact(name: IOrganizationContact['name']): Promise { try { + this.selectorStore.setLoading(true); const { tenantId, organizationId, user } = this.store; const member: any = { ...user.employee }; const payload = { @@ -45,14 +46,19 @@ export class ClientSelectorService extends SelectorService const contact = await this.timeTrackerService.createNewContact(payload, user); this.selectorStore.appendData(contact); this.toastrNotifier.success(this.translateService.instant('TIMER_TRACKER.TOASTR.CLIENT_ADDED')); + this.selectorStore.setError(null); } catch (error) { console.error('ERROR', error); + this.selectorStore.setError(error); + } finally { + this.selectorStore.setLoading(false); } } - public async load(): Promise { + public async load(options?: { searchTerm?: string }): Promise { try { this.selectorStore.setLoading(true); + const { searchTerm: name } = options || {}; const { organizationId, tenantId, @@ -61,12 +67,25 @@ export class ClientSelectorService extends SelectorService } } = this.store; const request = { - organizationId, - employeeId, - tenantId + relations: ['projects.members', 'members.user', 'contact'], + join: { + alias: 'organization_contact', + leftJoin: { + members: 'organization_contact.members' + } + }, + where: { + organizationId, + tenantId, + contactType: ContactType.CLIENT, + members: [employeeId], + ...(name && { name }) + }, + take: this.selectorQuery.limit, + skip: this.selectorQuery.page }; - const data = await this.timeTrackerService.getClient(request); - this.selectorStore.updateData(data); + const { items: data, total } = await this.timeTrackerService.getPaginatedClients(request); + this.selectorStore.updateInfiniteList({ data, total }); this.selectorStore.setError(null); } catch (error) { this.toastrNotifier.error(error.message || 'An error occurred while fetching clients.'); diff --git a/packages/desktop-ui-lib/src/lib/shared/features/client-selector/+state/client-selector.store.ts b/packages/desktop-ui-lib/src/lib/shared/features/client-selector/+state/client-selector.store.ts index 9933ee2ca16..5f5b045e7f6 100644 --- a/packages/desktop-ui-lib/src/lib/shared/features/client-selector/+state/client-selector.store.ts +++ b/packages/desktop-ui-lib/src/lib/shared/features/client-selector/+state/client-selector.store.ts @@ -10,7 +10,10 @@ export function createInitialState(): IClientSelectorState { return { hasPermission: false, selected: null, - data: [] + data: [], + total: 0, + page: 1, + limit: 10 }; } diff --git a/packages/desktop-ui-lib/src/lib/shared/features/client-selector/client-selector.component.html b/packages/desktop-ui-lib/src/lib/shared/features/client-selector/client-selector.component.html index 97d0660f668..4ef926d1913 100644 --- a/packages/desktop-ui-lib/src/lib/shared/features/client-selector/client-selector.component.html +++ b/packages/desktop-ui-lib/src/lib/shared/features/client-selector/client-selector.component.html @@ -15,4 +15,6 @@ [isLoading]="isLoading$ | async" [disabled]="disabled$ | async" [hasError]="error$ | async" + [typeahead]="search$" + (scrollToEnd)="onShowMore()" > diff --git a/packages/desktop-ui-lib/src/lib/shared/features/client-selector/client-selector.component.ts b/packages/desktop-ui-lib/src/lib/shared/features/client-selector/client-selector.component.ts index 0f6c4b05b01..f3325773d06 100644 --- a/packages/desktop-ui-lib/src/lib/shared/features/client-selector/client-selector.component.ts +++ b/packages/desktop-ui-lib/src/lib/shared/features/client-selector/client-selector.component.ts @@ -1,12 +1,12 @@ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, forwardRef, OnInit } from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import { IOrganizationContact } from '@gauzy/contracts'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { IOrganizationContact } from 'packages/contracts/dist'; -import { concatMap, filter, Observable, tap } from 'rxjs'; +import { combineLatest, concatMap, filter, map, Observable, tap } from 'rxjs'; import { ElectronService } from '../../../electron/services'; import { TimeTrackerQuery } from '../../../time-tracker/+state/time-tracker.query'; +import { AbstractSelectorComponent } from '../../components/abstract/selector.abstract'; import { ProjectSelectorService } from '../project-selector/+state/project-selector.service'; -import { TaskSelectorService } from '../task-selector/+state/task-selector.service'; -import { TeamSelectorService } from '../team-selector/+state/team-selector.service'; import { ClientSelectorQuery } from './+state/client-selector.query'; import { ClientSelectorService } from './+state/client-selector.service'; import { ClientSelectorStore } from './+state/client-selector.store'; @@ -16,38 +16,39 @@ import { ClientSelectorStore } from './+state/client-selector.store'; selector: 'gauzy-client-selector', templateUrl: './client-selector.component.html', styleUrls: ['./client-selector.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ClientSelectorComponent), + multi: true + } + ] }) -export class ClientSelectorComponent implements OnInit { +export class ClientSelectorComponent extends AbstractSelectorComponent implements OnInit { constructor( private readonly electronService: ElectronService, public readonly clientSelectorStore: ClientSelectorStore, public readonly clientSelectorQuery: ClientSelectorQuery, private readonly clientSelectorService: ClientSelectorService, private readonly projectSelectorService: ProjectSelectorService, - private readonly taskSelectorService: TaskSelectorService, - private readonly teamSelectorService: TeamSelectorService, private readonly timeTrackerQuery: TimeTrackerQuery - ) {} + ) { + super(); + } public ngOnInit(): void { - this.clientSelectorService - .getAll$() - .pipe( - filter((data) => !data.some((value) => value.id === this.clientSelectorService.selectedId)), - tap(() => (this.clientSelectorService.selected = null)), - untilDestroyed(this) - ) - .subscribe(); + this.clientSelectorService.onScroll$.pipe(untilDestroyed(this)).subscribe(); this.clientSelectorQuery.selected$ .pipe( filter(Boolean), + tap(() => this.projectSelectorService.resetPage()), concatMap(() => this.projectSelectorService.load()), - concatMap(() => this.taskSelectorService.load()), - concatMap(() => this.teamSelectorService.load()), untilDestroyed(this) ) .subscribe(); + // Handle search logic + this.handleSearch(this.clientSelectorService); } public refresh(): void { @@ -70,8 +71,11 @@ export class ClientSelectorComponent implements OnInit { return this.clientSelectorQuery.data$; } - public change(clientId: IOrganizationContact['id']) { - this.clientSelectorStore.updateSelected(clientId); + protected updateSelected(value: IOrganizationContact['id']): void { + // Update store only if useStore is true + if (this.useStore) { + this.clientSelectorStore.updateSelected(value); + } } public get isLoading$(): Observable { @@ -79,10 +83,16 @@ export class ClientSelectorComponent implements OnInit { } public get disabled$(): Observable { - return this.timeTrackerQuery.disabled$; + return combineLatest([this.timeTrackerQuery.disabled$, this.isDisabled$.asObservable()]).pipe( + map(([disabled, selectorDisabled]) => disabled || selectorDisabled) + ); } public get hasPermission$(): Observable { return this.clientSelectorService.hasPermission$; } + + public onShowMore(): void { + this.clientSelectorService.onScrollToEnd(); + } } diff --git a/packages/desktop-ui-lib/src/lib/shared/features/note/+state/note-selector.store.ts b/packages/desktop-ui-lib/src/lib/shared/features/note/+state/note-selector.store.ts index c72dd91b00d..a81defa0991 100644 --- a/packages/desktop-ui-lib/src/lib/shared/features/note/+state/note-selector.store.ts +++ b/packages/desktop-ui-lib/src/lib/shared/features/note/+state/note-selector.store.ts @@ -3,11 +3,13 @@ import { Store, StoreConfig } from '@datorama/akita'; export interface INoteSelectorState { note: string; + disabled: boolean; } export function createInitialState(): INoteSelectorState { return { - note: '' + note: '', + disabled: false }; } diff --git a/packages/desktop-ui-lib/src/lib/shared/features/note/+state/note.service.ts b/packages/desktop-ui-lib/src/lib/shared/features/note/+state/note.service.ts index 2925528aca1..b72a1b678cc 100644 --- a/packages/desktop-ui-lib/src/lib/shared/features/note/+state/note.service.ts +++ b/packages/desktop-ui-lib/src/lib/shared/features/note/+state/note.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; +import { Observable, combineLatest, map } from 'rxjs'; import { TimeTrackerQuery } from '../../../../time-tracker/+state/time-tracker.query'; import { NoteSelectorQuery } from './note-selector.query'; import { NoteSelectorStore } from './note-selector.store'; @@ -23,6 +23,8 @@ export class NoteService { } public get disabled$(): Observable { - return this.timeTrackerQuery.disabled$; + return combineLatest([this.timeTrackerQuery.disabled$, this.query.select((s) => s.disabled)]).pipe( + map(([disabled, noteDisabled]) => disabled || noteDisabled) + ); } } diff --git a/packages/desktop-ui-lib/src/lib/shared/features/note/note.component.ts b/packages/desktop-ui-lib/src/lib/shared/features/note/note.component.ts index 67521fa8108..015f4befb9f 100644 --- a/packages/desktop-ui-lib/src/lib/shared/features/note/note.component.ts +++ b/packages/desktop-ui-lib/src/lib/shared/features/note/note.component.ts @@ -1,4 +1,5 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { Observable } from 'rxjs'; import { ElectronService } from '../../../electron/services'; import { NoteSelectorQuery } from './+state/note-selector.query'; @@ -9,15 +10,43 @@ import { NoteService } from './+state/note.service'; selector: 'gauzy-note', templateUrl: './note.component.html', styleUrls: ['./note.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => NoteComponent), + multi: true + } + ] }) -export class NoteComponent { +export class NoteComponent implements ControlValueAccessor { + private onChange: (value: string) => void; + private onTouched: () => void; + // Flag to control whether to update the store + protected useStore: boolean = true; constructor( private readonly electronService: ElectronService, public readonly noteSelectorStore: NoteSelectorStore, public readonly noteSelectorQuery: NoteSelectorQuery, public readonly noteSelectorService: NoteService ) {} + writeValue(note: string): void { + this.useStore = false; + if (this.useStore) { + this.noteSelectorStore.update({ note }); + } + } + + registerOnChange(fn: (note: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + setDisabledState(disabled: boolean): void { + this.noteSelectorStore.update({ disabled }); + } public refresh(): void { this.electronService.ipcRenderer.send('refresh-timer'); @@ -28,7 +57,11 @@ export class NoteComponent { } public change(note: string) { - this.noteSelectorStore.update({ note }); + if (this.useStore) { + this.noteSelectorStore.update({ note }); + } + this.onChange(note); + this.onTouched(); } public get disabled$(): Observable { diff --git a/packages/desktop-ui-lib/src/lib/shared/features/project-selector/+state/project-selector.service.ts b/packages/desktop-ui-lib/src/lib/shared/features/project-selector/+state/project-selector.service.ts index 1c3ee38a0a8..b738c0d6650 100644 --- a/packages/desktop-ui-lib/src/lib/shared/features/project-selector/+state/project-selector.service.ts +++ b/packages/desktop-ui-lib/src/lib/shared/features/project-selector/+state/project-selector.service.ts @@ -32,6 +32,7 @@ export class ProjectSelectorService extends SelectorService { try { + this.projectSelectorStore.setLoading(true); const { tenantId, user } = this.store; const organizationId = this.store.organizationId; const request = { @@ -49,14 +50,23 @@ export class ProjectSelectorService extends SelectorService { + public async load(options?: { + searchTerm?: string; + organizationContactId?: string; + organizationTeamId?: string; + }): Promise { try { this.projectSelectorStore.setLoading(true); + const { searchTerm: name } = options || {}; const { organizationId, tenantId, @@ -68,11 +78,15 @@ export class ProjectSelectorService extends SelectorService diff --git a/packages/desktop-ui-lib/src/lib/shared/features/project-selector/project-selector.component.ts b/packages/desktop-ui-lib/src/lib/shared/features/project-selector/project-selector.component.ts index cf149d8ae3f..2abb73e04cd 100644 --- a/packages/desktop-ui-lib/src/lib/shared/features/project-selector/project-selector.component.ts +++ b/packages/desktop-ui-lib/src/lib/shared/features/project-selector/project-selector.component.ts @@ -1,9 +1,11 @@ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, forwardRef, OnInit } from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import { IOrganizationProject } from '@gauzy/contracts'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { IOrganizationProject } from 'packages/contracts/dist'; -import { concatMap, filter, Observable, tap } from 'rxjs'; +import { combineLatest, concatMap, filter, map, Observable, tap } from 'rxjs'; import { ElectronService } from '../../../electron/services'; import { TimeTrackerQuery } from '../../../time-tracker/+state/time-tracker.query'; +import { AbstractSelectorComponent } from '../../components/abstract/selector.abstract'; import { TaskSelectorService } from '../task-selector/+state/task-selector.service'; import { TeamSelectorService } from '../team-selector/+state/team-selector.service'; import { ProjectSelectorQuery } from './+state/project-selector.query'; @@ -15,9 +17,16 @@ import { ProjectSelectorStore } from './+state/project-selector.store'; selector: 'gauzy-project-selector', templateUrl: './project-selector.component.html', styleUrls: ['./project-selector.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ProjectSelectorComponent), + multi: true + } + ] }) -export class ProjectSelectorComponent implements OnInit { +export class ProjectSelectorComponent extends AbstractSelectorComponent implements OnInit { constructor( private readonly electronService: ElectronService, private readonly projectSelectorStore: ProjectSelectorStore, @@ -26,24 +35,23 @@ export class ProjectSelectorComponent implements OnInit { private readonly taskSelectorService: TaskSelectorService, private readonly teamSelectorService: TeamSelectorService, private readonly timeTrackerQuery: TimeTrackerQuery - ) {} + ) { + super(); + } public ngOnInit(): void { + this.projectSelectorService.onScroll$.pipe(untilDestroyed(this)).subscribe(); this.projectSelectorQuery.selected$ .pipe( filter(Boolean), + tap(() => this.teamSelectorService.resetPage()), + tap(() => this.taskSelectorService.resetPage()), concatMap(() => Promise.allSettled([this.teamSelectorService.load(), this.taskSelectorService.load()])), untilDestroyed(this) ) .subscribe(); - this.projectSelectorService - .getAll$() - .pipe( - filter((data) => !data.some((value) => value.id === this.projectSelectorService.selectedId)), - tap(() => (this.projectSelectorService.selected = null)), - untilDestroyed(this) - ) - .subscribe(); + // Handle search logic + this.handleSearch(this.projectSelectorService); } public refresh(): void { @@ -66,8 +74,11 @@ export class ProjectSelectorComponent implements OnInit { return this.projectSelectorQuery.data$; } - public change(projectId: IOrganizationProject['id']) { - this.projectSelectorStore.updateSelected(projectId); + protected updateSelected(value: IOrganizationProject['id']): void { + // Update store only if useStore is true + if (this.useStore) { + this.projectSelectorStore.updateSelected(value); + } } public get isLoading$(): Observable { @@ -75,10 +86,16 @@ export class ProjectSelectorComponent implements OnInit { } public get disabled$(): Observable { - return this.timeTrackerQuery.disabled$; + return combineLatest([this.timeTrackerQuery.disabled$, this.isDisabled$.asObservable()]).pipe( + map(([disabled, selectorDisabled]) => disabled || selectorDisabled) + ); } public get hasPermission$(): Observable { return this.projectSelectorService.hasPermission$; } + + public onShowMore(): void { + this.projectSelectorService.onScrollToEnd(); + } } diff --git a/packages/desktop-ui-lib/src/lib/shared/features/task-selector/+state/task-selector.service.ts b/packages/desktop-ui-lib/src/lib/shared/features/task-selector/+state/task-selector.service.ts index 35a53ab31eb..95904822e3a 100644 --- a/packages/desktop-ui-lib/src/lib/shared/features/task-selector/+state/task-selector.service.ts +++ b/packages/desktop-ui-lib/src/lib/shared/features/task-selector/+state/task-selector.service.ts @@ -35,6 +35,7 @@ export class TaskSelectorService extends SelectorService { if (!title) { return; } + this.taskSelectorStore.setLoading(true); const { tenantId, organizationId, user, statuses } = this.store; const taskStatus = statuses.find((status) => status.isInProgress); const data = { @@ -59,14 +60,19 @@ export class TaskSelectorService extends SelectorService { }); this.taskSelectorStore.appendData(task); this.toastrNotifier.success(this.translateService.instant('TIMER_TRACKER.TOASTR.TASK_ADDED')); + this.taskSelectorStore.setError(null); } catch (error) { console.error('ERROR', error); + this.taskSelectorStore.setError(error); + } finally { + this.taskSelectorStore.setLoading(false); } } - public async load(): Promise { + public async load(options?: { searchTerm?: string; projectId?: string }): Promise { try { this.taskSelectorStore.setLoading(true); + const { searchTerm } = options || {}; const { organizationId, tenantId, @@ -77,20 +83,24 @@ export class TaskSelectorService extends SelectorService { const request = { organizationId, tenantId, + searchTerm, projectId: this.projectSelectorQuery.selectedId, organizationTeamId: this.teamSelectorQuery.selectedId, - employeeId + take: this.taskSelectorQuery.limit, + skip: this.taskSelectorQuery.page, + employeeId, + ...options }; - const tasks = await this.timeTrackerService.getTasks(request); + const { total, items: tasks } = await this.timeTrackerService.getPaginatedTasks(request); if (tasks.length) { const statistics = await this.timeTrackerService.getTasksStatistics({ ...request, taskIds: tasks.map((task) => task.id) }); - const merged = this.merge(tasks, statistics); - this.taskSelectorStore.updateData(merged); + const data = this.merge(tasks, statistics); + this.taskSelectorStore.updateInfiniteList({ data, total }); } else { - this.taskSelectorStore.updateData([]); + this.taskSelectorStore.update({ data: [], total: 0 }); } this.taskSelectorStore.setError(null); } catch (error) { diff --git a/packages/desktop-ui-lib/src/lib/shared/features/task-selector/+state/task-selector.store.ts b/packages/desktop-ui-lib/src/lib/shared/features/task-selector/+state/task-selector.store.ts index de16de21ae9..8548308e962 100644 --- a/packages/desktop-ui-lib/src/lib/shared/features/task-selector/+state/task-selector.store.ts +++ b/packages/desktop-ui-lib/src/lib/shared/features/task-selector/+state/task-selector.store.ts @@ -10,7 +10,10 @@ export function createInitialState(): ITaskSelectorState { return { hasPermission: false, selected: null, - data: [] + data: [], + total: 0, + page: 1, + limit: 10 }; } diff --git a/packages/desktop-ui-lib/src/lib/shared/features/task-selector/task-selector.component.html b/packages/desktop-ui-lib/src/lib/shared/features/task-selector/task-selector.component.html index f0847cacbd1..5bdaacd41a6 100644 --- a/packages/desktop-ui-lib/src/lib/shared/features/task-selector/task-selector.component.html +++ b/packages/desktop-ui-lib/src/lib/shared/features/task-selector/task-selector.component.html @@ -15,4 +15,6 @@ [isLoading]="isLoading$ | async" [disabled]="disabled$ | async" [hasError]="error$ | async" + [typeahead]="search$" + (scrollToEnd)="onScrollToEnd()" > diff --git a/packages/desktop-ui-lib/src/lib/shared/features/task-selector/task-selector.component.ts b/packages/desktop-ui-lib/src/lib/shared/features/task-selector/task-selector.component.ts index 086a8ccbf5b..d21e1dcba57 100644 --- a/packages/desktop-ui-lib/src/lib/shared/features/task-selector/task-selector.component.ts +++ b/packages/desktop-ui-lib/src/lib/shared/features/task-selector/task-selector.component.ts @@ -1,10 +1,11 @@ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, forwardRef, OnInit } from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ITask } from '@gauzy/contracts'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { ITask } from 'packages/contracts/dist'; -import { filter, Observable, tap } from 'rxjs'; +import { combineLatest, map, Observable } from 'rxjs'; import { ElectronService } from '../../../electron/services'; import { TimeTrackerQuery } from '../../../time-tracker/+state/time-tracker.query'; -import { NoteService } from '../note/+state/note.service'; +import { AbstractSelectorComponent } from '../../components/abstract/selector.abstract'; import { TaskSelectorQuery } from './+state/task-selector.query'; import { TaskSelectorService } from './+state/task-selector.service'; import { TaskSelectorStore } from './+state/task-selector.store'; @@ -14,27 +15,31 @@ import { TaskSelectorStore } from './+state/task-selector.store'; selector: 'gauzy-task-selector', templateUrl: './task-selector.component.html', styleUrls: ['./task-selector.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TaskSelectorComponent), + multi: true + } + ] }) -export class TaskSelectorComponent implements OnInit { +export class TaskSelectorComponent extends AbstractSelectorComponent implements OnInit { constructor( private readonly electronService: ElectronService, public readonly taskSelectorStore: TaskSelectorStore, public readonly taskSelectorQuery: TaskSelectorQuery, private readonly timeTrackerQuery: TimeTrackerQuery, - private readonly taskSelectorService: TaskSelectorService, - private readonly noteService: NoteService - ) {} + private readonly taskSelectorService: TaskSelectorService + ) { + super(); + } public ngOnInit() { - this.taskSelectorService - .getAll$() - .pipe( - filter((data) => !data.some((value) => value.id === this.taskSelectorService.selectedId)), - tap(() => (this.taskSelectorService.selected = null)), - untilDestroyed(this) - ) - .subscribe(); + // Subscribe to onScroll$ + this.taskSelectorService.onScroll$.pipe(untilDestroyed(this)).subscribe(); + // Handle search logic + this.handleSearch(this.taskSelectorService); } public refresh(): void { @@ -57,8 +62,11 @@ export class TaskSelectorComponent implements OnInit { return this.taskSelectorQuery.data$; } - public change(taskId: ITask['id']) { - this.taskSelectorStore.updateSelected(taskId); + protected updateSelected(value: ITask['id']): void { + // Update store only if useStore is true + if (this.useStore) { + this.taskSelectorStore.updateSelected(value); + } } public get isLoading$(): Observable { @@ -66,10 +74,16 @@ export class TaskSelectorComponent implements OnInit { } public get disabled$(): Observable { - return this.timeTrackerQuery.disabled$; + return combineLatest([this.timeTrackerQuery.disabled$, this.isDisabled$.asObservable()]).pipe( + map(([disabled, selectorDisabled]) => disabled || selectorDisabled) + ); } public get hasPermission$(): Observable { return this.taskSelectorService.hasPermission$; } + + public onScrollToEnd(): void { + this.taskSelectorService.onScrollToEnd(); + } } diff --git a/packages/desktop-ui-lib/src/lib/shared/features/team-selector/+state/team-selector.service.ts b/packages/desktop-ui-lib/src/lib/shared/features/team-selector/+state/team-selector.service.ts index cfcee977c11..847cb922c50 100644 --- a/packages/desktop-ui-lib/src/lib/shared/features/team-selector/+state/team-selector.service.ts +++ b/packages/desktop-ui-lib/src/lib/shared/features/team-selector/+state/team-selector.service.ts @@ -26,9 +26,10 @@ export class TeamSelectorService extends SelectorService { return this.teamSelectorQuery.selectedId; } - public async load(): Promise { + public async load(options?: { searchTerm?: string; projectId?: string }): Promise { try { this.teamSelectorStore.setLoading(true); + const { searchTerm: name } = options || {}; const { organizationId, tenantId, @@ -40,10 +41,14 @@ export class TeamSelectorService extends SelectorService { organizationId, tenantId, employeeId, - projectId: this.projectSelectorQuery.selectedId + name, + projectId: this.projectSelectorQuery.selectedId, + take: this.teamSelectorQuery.limit, + skip: this.teamSelectorQuery.page, + ...options }; - const data = await this.timeTrackerService.getTeams(request); - this.teamSelectorStore.updateData(data); + const { items: data, total } = await this.timeTrackerService.getPaginatedTeams(request); + this.teamSelectorStore.updateInfiniteList({ data, total }); this.teamSelectorStore.setError(null); } catch (error) { this.toastrNotifier.error(error.message || 'An error occurred while fetching teams.'); diff --git a/packages/desktop-ui-lib/src/lib/shared/features/team-selector/+state/team-selector.store.ts b/packages/desktop-ui-lib/src/lib/shared/features/team-selector/+state/team-selector.store.ts index dd1010e4102..bf55c35f633 100644 --- a/packages/desktop-ui-lib/src/lib/shared/features/team-selector/+state/team-selector.store.ts +++ b/packages/desktop-ui-lib/src/lib/shared/features/team-selector/+state/team-selector.store.ts @@ -10,7 +10,10 @@ export function createInitialState(): ITeamSelectorState { return { hasPermission: false, selected: null, - data: [] + data: [], + total: 0, + page: 1, + limit: 10 }; } diff --git a/packages/desktop-ui-lib/src/lib/shared/features/team-selector/team-selector.component.html b/packages/desktop-ui-lib/src/lib/shared/features/team-selector/team-selector.component.html index a7ddf01fb89..c8150f84687 100644 --- a/packages/desktop-ui-lib/src/lib/shared/features/team-selector/team-selector.component.html +++ b/packages/desktop-ui-lib/src/lib/shared/features/team-selector/team-selector.component.html @@ -12,4 +12,6 @@ [isLoading]="isLoading$ | async" [disabled]="disabled$ | async" [hasError]="error$ | async" + [typeahead]="search$" + (scrollToEnd)="onShowMore()" > diff --git a/packages/desktop-ui-lib/src/lib/shared/features/team-selector/team-selector.component.ts b/packages/desktop-ui-lib/src/lib/shared/features/team-selector/team-selector.component.ts index 02535aae13b..e93a4c91b20 100644 --- a/packages/desktop-ui-lib/src/lib/shared/features/team-selector/team-selector.component.ts +++ b/packages/desktop-ui-lib/src/lib/shared/features/team-selector/team-selector.component.ts @@ -1,9 +1,11 @@ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, forwardRef, OnInit } from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { IOrganizationTeam } from 'packages/contracts/dist'; -import { concatMap, filter, Observable, tap } from 'rxjs'; +import { combineLatest, concatMap, filter, map, Observable, of, tap } from 'rxjs'; import { ElectronService } from '../../../electron/services'; import { TimeTrackerQuery } from '../../../time-tracker/+state/time-tracker.query'; +import { AbstractSelectorComponent } from '../../components/abstract/selector.abstract'; import { ProjectSelectorService } from '../project-selector/+state/project-selector.service'; import { TaskSelectorService } from '../task-selector/+state/task-selector.service'; import { TeamSelectorQuery } from './+state/team-selector.query'; @@ -15,9 +17,16 @@ import { TeamSelectorStore } from './+state/team-selector.store'; selector: 'gauzy-team-selector', templateUrl: './team-selector.component.html', styleUrls: ['./team-selector.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TeamSelectorComponent), + multi: true + } + ] }) -export class TeamSelectorComponent implements OnInit { +export class TeamSelectorComponent extends AbstractSelectorComponent implements OnInit { constructor( private readonly electronService: ElectronService, private readonly teamSelectorStore: TeamSelectorStore, @@ -26,24 +35,21 @@ export class TeamSelectorComponent implements OnInit { private readonly taskSelectorService: TaskSelectorService, private readonly timeTrackerQuery: TimeTrackerQuery, private readonly teamSelectorService: TeamSelectorService - ) {} + ) { + super(); + } public ngOnInit(): void { - this.teamSelectorService - .getAll$() - .pipe( - filter((data) => !data.some((value) => value.id === this.teamSelectorService.selectedId)), - tap(() => (this.teamSelectorService.selected = null)), - untilDestroyed(this) - ) - .subscribe(); + this.taskSelectorService.onScroll$.pipe(untilDestroyed(this)).subscribe(); this.teamSelectorQuery.selected$ .pipe( filter(Boolean), + tap(() => this.projectSelectorService.resetPage()), concatMap(() => this.projectSelectorService.load()), - concatMap(() => this.taskSelectorService.load()), untilDestroyed(this) ) .subscribe(); + // Handle search logic + this.handleSearch(this.projectSelectorService); } public refresh(): void { @@ -62,8 +68,11 @@ export class TeamSelectorComponent implements OnInit { return this.teamSelectorQuery.data$; } - public change(teamId: IOrganizationTeam['id']) { - this.teamSelectorStore.updateSelected(teamId); + protected updateSelected(value: IOrganizationTeam['id']): void { + // Update store only if useStore is true + if (this.useStore) { + this.teamSelectorStore.updateSelected(value); + } } public get isLoading$(): Observable { @@ -71,6 +80,16 @@ export class TeamSelectorComponent implements OnInit { } public get disabled$(): Observable { - return this.timeTrackerQuery.disabled$; + return combineLatest([this.timeTrackerQuery.disabled$, this.isDisabled$.asObservable()]).pipe( + map(([disabled, selectorDisabled]) => disabled || selectorDisabled) + ); + } + + public get hasPermission$(): Observable { + return of(false); + } + + public onShowMore(): void { + this.teamSelectorService.onScrollToEnd(); } } diff --git a/packages/desktop-ui-lib/src/lib/shared/interfaces/selector.interface.ts b/packages/desktop-ui-lib/src/lib/shared/interfaces/selector.interface.ts index fa25870d141..034ad9cd1f7 100644 --- a/packages/desktop-ui-lib/src/lib/shared/interfaces/selector.interface.ts +++ b/packages/desktop-ui-lib/src/lib/shared/interfaces/selector.interface.ts @@ -1,5 +1,8 @@ -export interface ISelector { +import { IPaginationInput } from '@gauzy/contracts'; + +export interface ISelector extends IPaginationInput { hasPermission: boolean; + total: number; selected: T; data: T[]; } diff --git a/packages/desktop-ui-lib/src/lib/tasks/tasks.component.html b/packages/desktop-ui-lib/src/lib/tasks/tasks.component.html index 01467686c54..9d2df1434e8 100644 --- a/packages/desktop-ui-lib/src/lib/tasks/tasks.component.html +++ b/packages/desktop-ui-lib/src/lib/tasks/tasks.component.html @@ -1,10 +1,8 @@ - +
- {{ "TIMER_TRACKER.ADD_TASK" | translate }} + {{ 'TIMER_TRACKER.ADD_TASK' | translate }}
@@ -13,182 +11,37 @@
- - - - - - {{ item?.name }} - - - -
- - - {{ item?.name }} - -
-
-
+
- - - - - - {{ item?.name }} - - - -
- - - {{ item?.name }} - -
-
-
+
- - - - - - {{ item?.name }} - - - -
- - - {{ item?.name }} - -
-
-
+
- + - - + + - +
@@ -197,9 +50,7 @@
- +
bindLabel="name" formControlName="tags" > - +
- + {{ tag.name }}
- + @@ -270,41 +106,27 @@
- + - - + + - +
- + bindLabel="name" formControlName="taskSize" > - - + + - +
@@ -334,9 +148,7 @@
- +
- +
- + - diff --git a/packages/desktop-ui-lib/src/lib/tasks/tasks.component.ts b/packages/desktop-ui-lib/src/lib/tasks/tasks.component.ts index 8b55930d6ff..11743fca7cf 100644 --- a/packages/desktop-ui-lib/src/lib/tasks/tasks.component.ts +++ b/packages/desktop-ui-lib/src/lib/tasks/tasks.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Inject, Input, OnInit, Output } from '@angular/core'; +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { FormControl, UntypedFormGroup, Validators } from '@angular/forms'; import { IEmployee, @@ -14,12 +14,11 @@ import { PermissionsEnum, TaskStatusEnum } from '@gauzy/contracts'; -import { NbDialogRef, NbToastrService } from '@nebular/theme'; +import { NbDialogRef } from '@nebular/theme'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateService } from '@ngx-translate/core'; import * as moment from 'moment'; -import { concatMap, from, map, Observable, tap } from 'rxjs'; -import { GAUZY_ENV } from '../constants'; +import { combineLatest, concatMap, from, map, Observable, startWith, tap } from 'rxjs'; import { Store, TagService } from '../services'; import { ClientSelectorService } from '../shared/features/client-selector/+state/client-selector.service'; import { ProjectSelectorService } from '../shared/features/project-selector/+state/project-selector.service'; @@ -36,16 +35,6 @@ import { CkEditorConfig, ColorAdapter } from '../utils'; export class TasksComponent implements OnInit { @Input() userData: IUserOrganization = this.store.user as any; @Input() employee: IEmployee = this.store.user.employee; - @Input() hasProjectPermission: boolean = this.projectSelectorService.hasPermission; - @Input() selected: { - projectId: IOrganizationProject['id']; - teamId: IOrganizationTeam['id']; - contactId: IOrganizationContact['id']; - } = { - projectId: this.projectSelectorService.selectedId, - teamId: this.teamSelectorService.selectedId, - contactId: this.clientSelectorService.selectedId - }; @Output() isAddTask: EventEmitter = new EventEmitter(); @Output() newTaskCallback: EventEmitter<{ isSuccess: boolean; @@ -94,10 +83,7 @@ export class TasksComponent implements OnInit { constructor( private timeTrackerService: TimeTrackerService, - private toastrService: NbToastrService, private translate: TranslateService, - @Inject(GAUZY_ENV) - private readonly _environment: any, private store: Store, private _dialogRef: NbDialogRef, private _tagService: TagService, @@ -108,38 +94,6 @@ export class TasksComponent implements OnInit { this.isSaving = false; } - private async _projects(value?: { - organizationContactId?: string; - organizationTeamId?: string; - projectId?: string; - }): Promise { - try { - const { organizationId, user, tenantId } = this.store; - const employeeId = user?.employee?.id; - - if (!employeeId) { - throw new Error('Employee ID is missing.'); - } - - const filterParams = { - organizationId, - tenantId, - employeeId, - ...(value?.organizationContactId && { organizationContactId: value.organizationContactId }), - ...(value?.organizationTeamId && { organizationTeamId: value.organizationTeamId }) - }; - - this.projects = await this.timeTrackerService.getProjects(filterParams); - - // Clear the form's projectId if the selected project does not exist in the fetched list - if (value?.projectId && !this.projects.some(({ id }) => id === value.projectId)) { - this.form.patchValue({ projectId: null }); - } - } catch (error) { - console.error('[Projects Fetch Error]', `Unable to fetch employee projects: ${error.message}`); - } - } - private async _tags(): Promise { try { this.tags = await this._tagService.getTags(); @@ -159,24 +113,6 @@ export class TasksComponent implements OnInit { } } - private async _clients(): Promise { - try { - const { organizationId, user, tenantId } = this.store; - const employeeId = user.employee.id; - this.contacts = await this.timeTrackerService.getClient({ organizationId, employeeId, tenantId }); - } catch (error) { - console.error('[error]', 'while get contacts::' + error.message); - } - } - - private async _teams(): Promise { - try { - this.teams = await this.timeTrackerService.getTeams(); - } catch (error) { - console.error('[error]', 'while get teams::' + error.message); - } - } - private async _sizes(): Promise { try { this.taskSizes = await this.timeTrackerService.taskSizes(); @@ -204,17 +140,7 @@ export class TasksComponent implements OnInit { const { organizationId, tenantId } = this.store; this.editorConfig.editorplaceholder = this.translate.instant('FORM.PLACEHOLDERS.DESCRIPTION'); this.taskStatuses = this.store.statuses; - from( - Promise.allSettled([ - this._projects(), - this._tags(), - this._employees(), - this._clients(), - this._teams(), - this._sizes(), - this._priorities() - ]) - ) + from(Promise.allSettled([this._tags(), this._employees(), this._sizes(), this._priorities()])) .pipe( tap(() => this.form.patchValue({ taskStatus: this.taskStatuses[0] })), untilDestroyed(this) @@ -231,7 +157,7 @@ export class TasksComponent implements OnInit { members: new FormControl([]), organizationId: new FormControl(organizationId), project: new FormControl(null), - projectId: new FormControl(this.selected.projectId), + projectId: new FormControl(this.projectSelectorService.selectedId), status: new FormControl(TaskStatusEnum.OPEN), priority: new FormControl(null), size: new FormControl(null), @@ -242,18 +168,32 @@ export class TasksComponent implements OnInit { taskStatus: new FormControl(null), taskPriority: new FormControl(null), taskSize: new FormControl(null), - organizationContactId: new FormControl(this.selected.contactId), - organizationTeamId: new FormControl(this.selected.teamId) + organizationContactId: new FormControl(this.clientSelectorService.selectedId), + organizationTeamId: new FormControl(this.teamSelectorService.selectedId) }); - this.form.valueChanges + this.hasAddTagPermission$ = this.store.userRolePermissions$.pipe( + map(() => this.store.hasPermissions(PermissionsEnum.ALL_ORG_EDIT, PermissionsEnum.ORG_TAGS_ADD)) + ); + combineLatest([ + this.form.get('organizationContactId').valueChanges.pipe(startWith(this.clientSelectorService.selectedId)), + this.form.get('organizationTeamId').valueChanges.pipe(startWith(this.teamSelectorService.selectedId)) + ]) .pipe( - concatMap((values) => this._projects(values)), + tap(() => this.projectSelectorService.resetPage()), + concatMap(([organizationContactId, organizationTeamId]) => + this.projectSelectorService.load({ organizationContactId, organizationTeamId }) + ), + untilDestroyed(this) + ) + .subscribe(); + this.form + .get('projectId') + .valueChanges.pipe( + tap(() => this.teamSelectorService.resetPage()), + concatMap((projectId) => this.teamSelectorService.load({ projectId })), untilDestroyed(this) ) .subscribe(); - this.hasAddTagPermission$ = this.store.userRolePermissions$.pipe( - map(() => this.store.hasPermissions(PermissionsEnum.ALL_ORG_EDIT, PermissionsEnum.ORG_TAGS_ADD)) - ); } public close(res?: any): void { @@ -310,10 +250,6 @@ export class TasksComponent implements OnInit { this.isSaving = false; } - public addProject = async (name: string) => { - await this.projectSelectorService.addProject(name); - }; - public background(bgColor: string) { return ColorAdapter.background(bgColor); } diff --git a/packages/desktop-ui-lib/src/lib/tasks/tasks.module.ts b/packages/desktop-ui-lib/src/lib/tasks/tasks.module.ts index 8a0f72ae662..d3622c710ad 100644 --- a/packages/desktop-ui-lib/src/lib/tasks/tasks.module.ts +++ b/packages/desktop-ui-lib/src/lib/tasks/tasks.module.ts @@ -1,33 +1,37 @@ -import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { TasksComponent } from './tasks.component'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { - NbLayoutModule, - NbSidebarModule, - NbMenuModule, + NbAccordionModule, + NbAlertModule, + NbBadgeModule, + NbButtonModule, NbCardModule, + NbDatepickerModule, NbIconModule, - NbListModule, - NbSelectModule, - NbToggleModule, NbInputModule, - NbButtonModule, - NbAlertModule, + NbLayoutModule, + NbListModule, + NbMenuModule, NbProgressBarModule, + NbSelectModule, + NbSidebarModule, NbTabsetModule, NbToastrService, - NbAccordionModule, - NbDatepickerModule, - NbBadgeModule + NbToggleModule } from '@nebular/theme'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { NgSelectModule } from '@ng-select/ng-select'; -import { TimeTrackerService } from '../time-tracker/time-tracker.service'; -import { DesktopDirectiveModule } from '../directives/desktop-directive.module'; import { TranslateModule } from '@ngx-translate/core'; import { CKEditorModule } from 'ckeditor4-angular'; -import { TaskRenderModule } from '../time-tracker/task-render'; +import { DesktopDirectiveModule } from '../directives/desktop-directive.module'; import { TagService } from '../services'; +import { ClientSelectorModule } from '../shared/features/client-selector/client-selector.module'; +import { ProjectSelectorModule } from '../shared/features/project-selector/project-selector.module'; +import { TaskSelectorModule } from '../shared/features/task-selector/task-selector.module'; +import { TeamSelectorModule } from '../shared/features/team-selector/team-selector.module'; +import { TaskRenderModule } from '../time-tracker/task-render'; +import { TimeTrackerService } from '../time-tracker/time-tracker.service'; +import { TasksComponent } from './tasks.component'; @NgModule({ declarations: [TasksComponent], @@ -55,7 +59,11 @@ import { TagService } from '../services'; DesktopDirectiveModule, TranslateModule, CKEditorModule, - TaskRenderModule + TaskRenderModule, + ClientSelectorModule, + TaskSelectorModule, + TeamSelectorModule, + ProjectSelectorModule ], providers: [NbToastrService, TimeTrackerService, TagService], exports: [TasksComponent] diff --git a/packages/desktop-ui-lib/src/lib/time-tracker/task-table/search/search.component.html b/packages/desktop-ui-lib/src/lib/time-tracker/task-table/search/search.component.html index 3e34fccc3b4..24a018e978e 100644 --- a/packages/desktop-ui-lib/src/lib/time-tracker/task-table/search/search.component.html +++ b/packages/desktop-ui-lib/src/lib/time-tracker/task-table/search/search.component.html @@ -3,7 +3,6 @@ { + public get disabled$(): Observable { return this.timeTrackerQuery.disabled$; } + + public get loading$(): Observable { + return this.searchTermQuery.selectLoading(); + } + + ngAfterViewInit() { + fromEvent(this.search.nativeElement, 'input') + .pipe( + map((event: any) => event.target.value), + distinctUntilChange(), + debounceTime(300), + filter((term) => term !== this.searchTermQuery.value), + untilDestroyed(this) + ) + .subscribe((searchTerm: string) => { + this.onSearch(searchTerm); + }); + } } diff --git a/packages/desktop-ui-lib/src/lib/time-tracker/task-table/table/task-table.component.html b/packages/desktop-ui-lib/src/lib/time-tracker/task-table/table/task-table.component.html index 053de80b41b..63fa50fe8a7 100644 --- a/packages/desktop-ui-lib/src/lib/time-tracker/task-table/table/task-table.component.html +++ b/packages/desktop-ui-lib/src/lib/time-tracker/task-table/table/task-table.component.html @@ -11,6 +11,9 @@
+ +
+
; @ViewChild('smartTable') public set smartTable(content: Angular2SmartTableComponent) { @@ -55,6 +57,7 @@ export class TaskTableComponent implements OnInit, AfterViewInit { private readonly projectSelectorService: ProjectSelectorService, private readonly actionButtonStore: ActionButtonStore, private readonly searchTermQuery: SearchTermQuery, + private readonly searchTermStore: SearchTermStore, private readonly taskCacheService: TaskCacheService, private readonly store: Store ) {} @@ -78,6 +81,11 @@ export class TaskTableComponent implements OnInit, AfterViewInit { this.loadSmartTableSettings(); }); this.onChangedSource(); + this.monitorLoadingState(); + } + + private monitorLoadingState(): void { + this.loading$ = this.smartTableSource.loading$; } public refreshTimer(): void { @@ -213,16 +221,19 @@ export class TaskTableComponent implements OnInit, AfterViewInit { } // Prepare request parameters for filtering + const { selectedId: projectId } = this.projectSelectorService; + const { selectedId: teamId } = this.teamSelectorService; + const { value: searchTerm } = this.searchTermQuery; + const requestFilters = { tenantId, organizationId, - ...(this.projectSelectorService.selectedId && { projectId: this.projectSelectorService.selectedId }), - ...(this.teamSelectorService.selectedId && { teams: [this.teamSelectorService.selectedId] }), - members: { id: employeeId }, - ...(this.searchTermQuery.value && { - title: this.searchTermQuery.value, - prefix: this.searchTermQuery.value - }) + ...(projectId && { projectId }), + ...(teamId && { teams: [teamId] }), + ...(searchTerm && { + title: searchTerm + }), + members: { id: employeeId } }; // Initialize the smart table data source diff --git a/packages/desktop-ui-lib/src/lib/time-tracker/task-table/task-table.module.ts b/packages/desktop-ui-lib/src/lib/time-tracker/task-table/task-table.module.ts index 0a7bbc0e7fa..5ef6c6ec30f 100644 --- a/packages/desktop-ui-lib/src/lib/time-tracker/task-table/task-table.module.ts +++ b/packages/desktop-ui-lib/src/lib/time-tracker/task-table/task-table.module.ts @@ -6,6 +6,7 @@ import { NbFormFieldModule, NbIconModule, NbInputModule, + NbSpinnerModule, NbTooltipModule } from '@nebular/theme'; import { Angular2SmartTableModule } from 'angular2-smart-table'; @@ -43,7 +44,8 @@ import { TaskTableComponent } from './table/task-table.component'; ProjectSelectorModule, TeamSelectorModule, LanguageModule.forChild(), - Angular2SmartTableModule + Angular2SmartTableModule, + NbSpinnerModule ], providers: [ ActionButtonStore, diff --git a/packages/desktop-ui-lib/src/lib/time-tracker/time-tracker.component.ts b/packages/desktop-ui-lib/src/lib/time-tracker/time-tracker.component.ts index 7b1a3754c27..be14d9958ec 100644 --- a/packages/desktop-ui-lib/src/lib/time-tracker/time-tracker.component.ts +++ b/packages/desktop-ui-lib/src/lib/time-tracker/time-tracker.component.ts @@ -71,6 +71,7 @@ import { NoteService } from '../shared/features/note/+state/note.service'; import { ProjectSelectorService } from '../shared/features/project-selector/+state/project-selector.service'; import { TaskSelectorService } from '../shared/features/task-selector/+state/task-selector.service'; import { TeamSelectorService } from '../shared/features/team-selector/+state/team-selector.service'; +import { TimeTrackerFormService } from '../shared/features/time-tracker-form/time-tracker-form.service'; import { hasAllPermissions } from '../shared/utils/permission.util'; import { TimeTrackerQuery } from './+state/time-tracker.query'; import { IgnitionState, TimeTrackerStore } from './+state/time-tracker.store'; @@ -189,7 +190,8 @@ export class TimeTrackerComponent implements OnInit, AfterViewInit { private readonly taskSelectorService: TaskSelectorService, private readonly noteService: NoteService, private readonly timeTrackerQuery: TimeTrackerQuery, - private readonly timeTrackerStore: TimeTrackerStore + private readonly timeTrackerStore: TimeTrackerStore, + private readonly timeTrackerFormService: TimeTrackerFormService ) { this.iconLibraries.registerFontPack('font-awesome', { packClass: 'fas', @@ -733,12 +735,15 @@ export class TimeTrackerComponent implements OnInit, AfterViewInit { this.timeTrackerQuery.isEditing$ .pipe( filter((editing) => editing && !this._isOffline), - tap(() => - this.dialogService.open(TimerTrackerChangeDialogComponent, { - backdropClass: 'backdrop-blur', - closeOnBackdropClick: false - }) + concatMap( + () => + this.dialogService.open(TimerTrackerChangeDialogComponent, { + backdropClass: 'backdrop-blur', + closeOnBackdropClick: false + }).onClose ), + filter(Boolean), + tap((value) => this.timeTrackerFormService.setState(value)), untilDestroyed(this) ) .subscribe(); diff --git a/packages/desktop-ui-lib/src/lib/time-tracker/time-tracker.module.ts b/packages/desktop-ui-lib/src/lib/time-tracker/time-tracker.module.ts index bcd270d7ae4..9ef76eef803 100644 --- a/packages/desktop-ui-lib/src/lib/time-tracker/time-tracker.module.ts +++ b/packages/desktop-ui-lib/src/lib/time-tracker/time-tracker.module.ts @@ -34,8 +34,13 @@ import { LanguageModule } from '../language/language.module'; import { TimeSlotQueueService } from '../offline-sync'; import { ErrorHandlerService, NativeNotificationService, Store, ToastrNotificationService } from '../services'; import { SelectModule } from '../shared/components/ui/select/select.module'; +import { ClientSelectorModule } from '../shared/features/client-selector/client-selector.module'; +import { NoteModule } from '../shared/features/note/note.module'; +import { TaskSelectorModule } from '../shared/features/task-selector/task-selector.module'; +import { TeamSelectorModule } from '../shared/features/team-selector/team-selector.module'; import { TimeTrackerFormModule } from '../shared/features/time-tracker-form/time-tracker-form.module'; import { TasksModule } from '../tasks/tasks.module'; +import { ProjectSelectorModule } from './../shared/features/project-selector/project-selector.module'; import { CustomRenderComponent } from './custom-render-cell.component'; import { NoDataMessageModule } from './no-data-message/no-data-message.module'; import { OrganizationSelectorComponent } from './organization-selector/organization-selector.component'; @@ -91,7 +96,12 @@ import { TimerTrackerChangeDialogComponent } from './timer-tracker-change-dialog TimeTrackerFormModule, SelectModule, DesktopDirectiveModule, - NbRouteTabsetModule + NbRouteTabsetModule, + ClientSelectorModule, + TaskSelectorModule, + TeamSelectorModule, + ProjectSelectorModule, + NoteModule ], providers: [ NbSidebarService, diff --git a/packages/desktop-ui-lib/src/lib/time-tracker/time-tracker.service.ts b/packages/desktop-ui-lib/src/lib/time-tracker/time-tracker.service.ts index cd0275953c9..f62d41610e5 100644 --- a/packages/desktop-ui-lib/src/lib/time-tracker/time-tracker.service.ts +++ b/packages/desktop-ui-lib/src/lib/time-tracker/time-tracker.service.ts @@ -9,6 +9,7 @@ import { IOrganizationTeam, IOrganizationTeamEmployee, IPagination, + ITask, ITaskPriority, ITaskSize, ITaskSizeFindInput, @@ -34,6 +35,7 @@ import { TaskCacheService, TaskPriorityCacheService, TaskSizeCacheService, + TaskStatisticsCacheService, TaskStatusCacheService, TeamsCacheService, TimeLogCacheService, @@ -54,6 +56,7 @@ export class TimeTrackerService { private readonly http: HttpClient, private readonly _clientCacheService: ClientCacheService, private readonly _taskCacheService: TaskCacheService, + private readonly _taskStatisticsCacheService: TaskStatisticsCacheService, private readonly _projectCacheService: ProjectCacheService, private readonly _timeSlotCacheService: TimeSlotCacheService, private readonly _employeeCacheService: EmployeeCacheService, @@ -120,7 +123,82 @@ export class TimeTrackerService { ); this._taskCacheService.setValue(tasks$, request); } - return firstValueFrom(tasks$); + return firstValueFrom(tasks$) as Promise; + } + + async getPaginatedTasks(values: { + organizationId: string; + tenantId: string; + projectId?: string; + organizationTeamId?: string; + employeeId: string; + take: number; + skip: number; + searchTerm: string; + }): Promise> { + const { + organizationId, + tenantId, + projectId, + organizationTeamId, + employeeId, + take, + skip, + searchTerm: title + } = values; + + const request = { + where: { + organizationId, + tenantId, + ...(projectId && { projectId }), + ...(organizationTeamId && { teams: [organizationTeamId] }), + ...(title && { title }), + members: { id: employeeId } + }, + relations: [ + 'members', + 'members.user', + 'project', + 'tags', + 'teams', + 'teams.members', + 'teams.members.employee', + 'teams.members.employee.user', + 'creator', + 'organizationSprint', + 'taskStatus', + 'taskSize', + 'taskPriority' + ], + join: { + alias: 'task', + leftJoin: { + members: 'task.members', + user: 'members.user' + } + }, + order: { updatedAt: 'DESC' }, + take, + skip + }; + + let tasks$ = this._taskCacheService.getValue(request); + + if (!tasks$) { + tasks$ = this.http + .get>(`${API_PREFIX}/tasks/pagination`, { + params: toParams(request) + }) + .pipe( + map((response: any) => response), + shareReplay(1) + ); + + this._taskCacheService.setValue(tasks$, request); + } + + return firstValueFrom(tasks$) as Promise>; } /** @@ -145,7 +223,7 @@ export class TimeTrackerService { taskIds: values.taskIds, projectId: values.projectId }; - let tasksStatistics$ = this._taskCacheService.getValue(cacheReference); + let tasksStatistics$ = this._taskStatisticsCacheService.getValue(cacheReference); if (!tasksStatistics$) { // Fetch tasks statistics @@ -157,11 +235,11 @@ export class TimeTrackerService { ); // Set the tasks statistics in the cache - this._taskCacheService.setValue(tasksStatistics$, cacheReference); + this._taskStatisticsCacheService.setValue(tasksStatistics$, cacheReference); } // Return the tasks statistics - return await firstValueFrom(tasksStatistics$); + return firstValueFrom(tasksStatistics$) as Promise; } async getEmployees(values) { @@ -216,7 +294,42 @@ export class TimeTrackerService { ); this._projectCacheService.setValue(projects$, params); } - return firstValueFrom(projects$); + return firstValueFrom(projects$) as Promise; + } + + async getPaginatedProjects(values) { + const { organizationId, tenantId, employeeId, organizationTeamId, organizationContactId, skip, take, name } = + values; + + // Prepare the parameters + const params = { + where: { + organizationId, + tenantId, + ...(employeeId && { members: { employeeId } }), + ...(organizationContactId && { organizationContactId }), + ...(organizationTeamId && { teams: { id: organizationTeamId } }), + ...(name && { name }) + }, + skip, + take + }; + + // Check for cached projects + let projects$ = this._projectCacheService.getValue(params); + if (!projects$) { + // If not cached, make HTTP request and cache result + projects$ = this.http + .get>(`${API_PREFIX}/organization-projects/pagination`, { + params: toParams(params) + }) + .pipe(shareReplay(1)); + + this._projectCacheService.setValue(projects$, params); + } + + // Return the first emitted value from the observable + return firstValueFrom(projects$) as Promise>; } async getClient(values) { @@ -235,7 +348,21 @@ export class TimeTrackerService { ); this._clientCacheService.setValue(clients$, params); } - return firstValueFrom(clients$); + return firstValueFrom(clients$) as Promise; + } + + async getPaginatedClients(values) { + const params = toParams(values); + let clients$ = this._clientCacheService.getValue(params); + if (!clients$) { + clients$ = this.http + .get>(`${API_PREFIX}/organization-contact/pagination`, { + params + }) + .pipe(shareReplay(1)); + this._clientCacheService.setValue(clients$, params); + } + return firstValueFrom(clients$) as Promise>; } getUserDetail() { @@ -661,7 +788,41 @@ export class TimeTrackerService { ); this._teamsCacheService.setValue(teams$, params); } - return firstValueFrom(teams$); + return firstValueFrom(teams$) as Promise; + } + + public async getPaginatedTeams(values?: any): Promise> { + const { employeeId, projectId, skip, take, name } = values ?? {}; + + // Prepare the query parameters + const params = { + where: { + organizationId: this._store.organizationId, + tenantId: this._store.tenantId, + ...(employeeId && { members: { employeeId } }), + ...(projectId && { projects: { id: projectId } }), + ...(name && { name }) + }, + relations: ['projects', 'members.role', 'members.employee.user'], + skip, + take + }; + + // Retrieve cached teams if available + let teams$ = this._teamsCacheService.getValue(params); + if (!teams$) { + // If not cached, make HTTP request and cache the result + teams$ = this.http + .get>(`${API_PREFIX}/organization-team/pagination`, { + params: toParams(params) + }) + .pipe(shareReplay(1)); + + this._teamsCacheService.setValue(teams$, params); + } + + // Return the first emitted value from the observable + return firstValueFrom(teams$) as Promise>; } public async taskSizes(): Promise { diff --git a/packages/desktop-ui-lib/src/lib/time-tracker/timer-tracker-change-dialog/timer-tracker-change-dialog.component.html b/packages/desktop-ui-lib/src/lib/time-tracker/timer-tracker-change-dialog/timer-tracker-change-dialog.component.html index 8fd7a1ca3df..93e458b48ba 100644 --- a/packages/desktop-ui-lib/src/lib/time-tracker/timer-tracker-change-dialog/timer-tracker-change-dialog.component.html +++ b/packages/desktop-ui-lib/src/lib/time-tracker/timer-tracker-change-dialog/timer-tracker-change-dialog.component.html @@ -1,18 +1,33 @@ - - - -
{{ 'Update' | translate }}
-
- - - - - - - -
+
+ + + +
{{ 'Update' | translate }}
+
+ + + + + + + + + + + + + + + + + + + + +
+
diff --git a/packages/desktop-ui-lib/src/lib/time-tracker/timer-tracker-change-dialog/timer-tracker-change-dialog.component.scss b/packages/desktop-ui-lib/src/lib/time-tracker/timer-tracker-change-dialog/timer-tracker-change-dialog.component.scss index 89aeea80c72..15afea12c57 100644 --- a/packages/desktop-ui-lib/src/lib/time-tracker/timer-tracker-change-dialog/timer-tracker-change-dialog.component.scss +++ b/packages/desktop-ui-lib/src/lib/time-tracker/timer-tracker-change-dialog/timer-tracker-change-dialog.component.scss @@ -18,6 +18,21 @@ nb-card { nb-card-body { padding: 1rem; + + &.selector-container { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + gap: .5rem; + flex-direction: column; + position: relative; + + .selector { + width: 100%; + } + } + } nb-card-footer { diff --git a/packages/desktop-ui-lib/src/lib/time-tracker/timer-tracker-change-dialog/timer-tracker-change-dialog.component.ts b/packages/desktop-ui-lib/src/lib/time-tracker/timer-tracker-change-dialog/timer-tracker-change-dialog.component.ts index e3743a56963..90281ecd468 100644 --- a/packages/desktop-ui-lib/src/lib/time-tracker/timer-tracker-change-dialog/timer-tracker-change-dialog.component.ts +++ b/packages/desktop-ui-lib/src/lib/time-tracker/timer-tracker-change-dialog/timer-tracker-change-dialog.component.ts @@ -1,9 +1,15 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { FormControl, FormGroup } from '@angular/forms'; +import { distinctUntilChange } from '@gauzy/ui-core/common'; import { NbDialogRef } from '@nebular/theme'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { filter, map, Observable, startWith, tap } from 'rxjs'; +import { combineLatest, concatMap, filter, map, Observable, startWith, tap } from 'rxjs'; import { TimeTrackerQuery } from '../+state/time-tracker.query'; import { IgnitionState, TimeTrackerStore } from '../+state/time-tracker.store'; +import { ClientSelectorService } from '../../shared/features/client-selector/+state/client-selector.service'; +import { ProjectSelectorService } from '../../shared/features/project-selector/+state/project-selector.service'; +import { TaskSelectorService } from '../../shared/features/task-selector/+state/task-selector.service'; +import { TeamSelectorService } from '../../shared/features/team-selector/+state/team-selector.service'; import { TimeTrackerFormService } from '../../shared/features/time-tracker-form/time-tracker-form.service'; @UntilDestroy({ checkProperties: true }) @@ -14,13 +20,24 @@ import { TimeTrackerFormService } from '../../shared/features/time-tracker-form/ changeDetection: ChangeDetectionStrategy.OnPush }) export class TimerTrackerChangeDialogComponent implements OnInit { - private currentState!: any; + public form: FormGroup = new FormGroup({ + clientId: new FormControl(null), + projectId: new FormControl(null), + teamId: new FormControl(null), + taskId: new FormControl(null), + note: new FormControl(null) + }); constructor( private dialogRef: NbDialogRef, private readonly timeTrackerStore: TimeTrackerStore, private readonly timeTrackerQuery: TimeTrackerQuery, - private readonly timeTrackerFormService: TimeTrackerFormService + private readonly timeTrackerFormService: TimeTrackerFormService, + private readonly projectSelectorService: ProjectSelectorService, + private readonly teamSelectorService: TeamSelectorService, + private readonly taskSelectorService: TaskSelectorService, + private readonly clientSelectorService: ClientSelectorService ) {} + public ngOnInit(): void { this.timeTrackerQuery.ignition$ .pipe( @@ -33,16 +50,43 @@ export class TimerTrackerChangeDialogComponent implements OnInit { .pipe( filter(({ state }) => state === IgnitionState.RESTARTED), tap(() => this.timeTrackerStore.update({ ignition: { state: IgnitionState.STARTED } })), - tap(() => this.setCurrentState()), - tap(() => this.dismiss()), + tap(() => this.dismiss(this.form.value)), untilDestroyed(this) ) .subscribe(); this.setCurrentState(); + combineLatest([ + this.form.get('clientId').valueChanges.pipe(startWith(this.clientSelectorService.selectedId)), + this.form.get('teamId').valueChanges.pipe(startWith(this.teamSelectorService.selectedId)) + ]) + .pipe( + distinctUntilChange(), + tap(() => this.projectSelectorService.resetPage()), + concatMap(([organizationContactId, organizationTeamId]) => + this.projectSelectorService.load({ organizationContactId, organizationTeamId }) + ), + untilDestroyed(this) + ) + .subscribe(); + this.form + .get('projectId') + .valueChanges.pipe( + distinctUntilChange(), + tap(() => this.teamSelectorService.resetPage()), + tap(() => this.taskSelectorService.resetPage()), + concatMap((projectId) => + Promise.allSettled([ + this.teamSelectorService.load({ projectId }), + this.taskSelectorService.load({ projectId }) + ]) + ), + untilDestroyed(this) + ) + .subscribe(); } private setCurrentState() { - this.currentState = this.timeTrackerFormService.getState(); + this.form.patchValue(this.timeTrackerFormService.getState()); } public applyChanges() { @@ -60,9 +104,8 @@ export class TimerTrackerChangeDialogComponent implements OnInit { return this.timeTrackerQuery.isExpanded$; } - public dismiss() { + public dismiss(data?) { this.timeTrackerStore.update({ isEditing: false }); - this.timeTrackerFormService.setState(this.currentState); - this.dialogRef.close(); + this.dialogRef.close(data); } } diff --git a/packages/desktop-ui-lib/src/lib/utils/smart-table/cached-server.data-source.ts b/packages/desktop-ui-lib/src/lib/utils/smart-table/cached-server.data-source.ts index 64895f85f54..4fed4eae3d0 100644 --- a/packages/desktop-ui-lib/src/lib/utils/smart-table/cached-server.data-source.ts +++ b/packages/desktop-ui-lib/src/lib/utils/smart-table/cached-server.data-source.ts @@ -1,7 +1,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { isNotEmpty, toParams } from '@gauzy/ui-core/common'; import { IFilterConfig, LocalDataSource } from 'angular2-smart-table'; -import { firstValueFrom, Observable, shareReplay } from 'rxjs'; +import { BehaviorSubject, firstValueFrom, Observable, shareReplay } from 'rxjs'; import { catchError, map, tap } from 'rxjs/operators'; import { AbstractCacheService } from '../../services/abstract-cache.service'; import { ServerSourceConf } from './server-source.conf'; @@ -10,6 +10,7 @@ export class CachedServerDataSource extends LocalDataSource { protected conf: ServerSourceConf; protected lastRequestCount: number = 0; protected operatorFunctions: any[] = []; + protected _loading$ = new BehaviorSubject(false); constructor( protected http: HttpClient, @@ -46,8 +47,10 @@ export class CachedServerDataSource extends LocalDataSource { ) .pipe( tap(() => this.conf.finalize?.()), + tap(() => this._loading$.next(false)), catchError((error) => { this.conf.finalize?.(); + this._loading$.next(false); throw new Error(error); }) ) @@ -91,6 +94,7 @@ export class CachedServerDataSource extends LocalDataSource { } protected requestElements(): Observable { + this._loading$.next(true); const httpParams = this.createRequestParams(); return this.cacheService ? this.cachedRequestElements(httpParams) @@ -102,10 +106,7 @@ export class CachedServerDataSource extends LocalDataSource { if (!elements$) { // Fetch elements - elements$ = this.http.get(this.conf.endPoint, { params, observe: 'response' }).pipe( - map((httpResponse) => httpResponse), - shareReplay(1) - ); + elements$ = this.http.get(this.conf.endPoint, { params, observe: 'response' }).pipe(shareReplay(1)); // Set elements in the cache this.cacheService.setValue(elements$, params); } @@ -192,4 +193,8 @@ export class CachedServerDataSource extends LocalDataSource { public registerOperatorFunction(operatorFunction: any) { this.operatorFunctions.push(operatorFunction); } + + public get loading$(): Observable { + return this._loading$.asObservable(); + } }