diff --git a/apps/gauzy/src/app/pages/pipelines/pipeline-deals/pipeline-deal-form/pipeline-deal-form.component.html b/apps/gauzy/src/app/pages/pipelines/pipeline-deals/pipeline-deal-form/pipeline-deal-form.component.html index 842845c4ec2..c7246e2ec95 100644 --- a/apps/gauzy/src/app/pages/pipelines/pipeline-deals/pipeline-deal-form/pipeline-deal-form.component.html +++ b/apps/gauzy/src/app/pages/pipelines/pipeline-deals/pipeline-deal-form/pipeline-deal-form.component.html @@ -1,13 +1,11 @@
- +

{{ - (dealId - ? 'PIPELINE_DEAL_EDIT_PAGE.HEADER' - : 'PIPELINE_DEAL_CREATE_PAGE.HEADER' - ) | translate: pipeline + ((deal$ | async) ? 'PIPELINE_DEAL_EDIT_PAGE.HEADER' : 'PIPELINE_DEAL_CREATE_PAGE.HEADER') + | translate : pipeline }}

@@ -17,101 +15,76 @@

- +
- + {{ stage.name }}
-
- - - {{ cl.name }} + + + {{ client.name }}
-
- - {{ pr }} + + {{ probability }}
- - - + + + + + + diff --git a/apps/gauzy/src/app/pages/pipelines/pipeline-deals/pipeline-deal-form/pipeline-deal-form.component.ts b/apps/gauzy/src/app/pages/pipelines/pipeline-deals/pipeline-deal-form/pipeline-deal-form.component.ts index 7d2a3a6a533..b3daa54972b 100644 --- a/apps/gauzy/src/app/pages/pipelines/pipeline-deals/pipeline-deal-form/pipeline-deal-form.component.ts +++ b/apps/gauzy/src/app/pages/pipelines/pipeline-deals/pipeline-deal-form/pipeline-deal-form.component.ts @@ -1,14 +1,14 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, Data, Router } from '@angular/router'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { catchError, map, Observable, of, switchMap, tap } from 'rxjs'; import { filter } from 'rxjs/operators'; -import { Subject } from 'rxjs'; -import { IPipeline, IContact } from '@gauzy/contracts'; -import { AppStore, Store } from '@gauzy/ui-core/common'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateService } from '@ngx-translate/core'; +import { IPipeline, IContact, IOrganization, IDeal, IPagination } from '@gauzy/contracts'; +import { distinctUntilChange, Store } from '@gauzy/ui-core/common'; import { TranslationBaseComponent } from '@gauzy/ui-core/i18n'; -import { DealsService, OrganizationContactService, PipelinesService, ToastrService } from '@gauzy/ui-core/core'; +import { DealsService, ErrorHandlingService, OrganizationContactService, ToastrService } from '@gauzy/ui-core/core'; @UntilDestroy({ checkProperties: true }) @Component({ @@ -17,184 +17,183 @@ import { DealsService, OrganizationContactService, PipelinesService, ToastrServi styleUrls: ['./pipeline-deal-form.component.scss'] }) export class PipelineDealFormComponent extends TranslationBaseComponent implements OnInit, OnDestroy { - form: UntypedFormGroup; - pipeline: IPipeline; - clients: IContact[]; - selectedClient: IContact; - probabilities = [0, 1, 2, 3, 4, 5]; - selectedProbability: number; - mode: 'CREATE' | 'EDIT' = 'CREATE'; - dealId: string; - pipelineId: string; - - private readonly $akitaPreUpdate: AppStore['akitaPreUpdate']; - private _ngDestroy$ = new Subject(); - private organizationId: string; - private tenantId: string; + public selectedClient: IContact; + public probabilities = [0, 1, 2, 3, 4, 5]; + public selectedProbability: number; + public organization: IOrganization; + public deal: IDeal; + public deal$: Observable; + public pipeline: IPipeline; + public pipeline$: Observable; + public clients: IContact[] = []; + public clients$: Observable; + + // Form Builder + public form: UntypedFormGroup = PipelineDealFormComponent.buildForm(this._fb); + static buildForm(fb: UntypedFormBuilder): UntypedFormGroup { + return fb.group({ + stageId: [null, Validators.required], + title: [null, Validators.required], + clientId: [null], + probability: [null, Validators.required] + }); + } constructor( public readonly translateService: TranslateService, - private readonly router: Router, - private readonly fb: UntypedFormBuilder, - private readonly appStore: AppStore, - private readonly store: Store, - private readonly dealsService: DealsService, - private readonly activatedRoute: ActivatedRoute, - private readonly pipelinesService: PipelinesService, - private readonly clientsService: OrganizationContactService, - private readonly toastrService: ToastrService + private readonly _router: Router, + private readonly _fb: UntypedFormBuilder, + private readonly _store: Store, + private readonly _dealsService: DealsService, + private readonly _activatedRoute: ActivatedRoute, + private readonly _clientsService: OrganizationContactService, + private readonly _toastrService: ToastrService, + private readonly _errorHandlingService: ErrorHandlingService ) { super(translateService); - - this.$akitaPreUpdate = appStore.akitaPreUpdate; - - appStore.akitaPreUpdate = (previous, next) => { - if (previous.user !== next.user) { - setTimeout(() => this.form.patchValue({ createdByUserId: next.user.id })); - } - - return this.$akitaPreUpdate(previous, next); - }; } ngOnInit() { - this._initializeForm(); - this.activatedRoute.params - .pipe( - filter((params) => !!params), - untilDestroyed(this) - ) - .subscribe(async ({ pipelineId, dealId }) => { - this.form.disable(); - if (pipelineId) { - this.pipelineId = pipelineId; - this.mode = 'EDIT'; - } - if (dealId) { - this.dealId = dealId; - } - this.form.enable(); - }); - this.store.selectedOrganization$ - .pipe( - filter((organization) => !!organization), - untilDestroyed(this) - ) - .subscribe(async (org) => { - this.organizationId = org.id; - this.tenantId = this.store.user.tenantId; - - await this.getOrganizationContact(); - - if (this.pipelineId) { - await this.getPipelines(); - } - if (this.dealId) { - await this.getDeal(); - } - }); + // Setting up the organization$ observable pipeline + this.clients$ = this._store.selectedOrganization$.pipe( + // Ensure only distinct values are emitted + distinctUntilChange(), + // Exclude falsy values from the emitted values + filter((organization: IOrganization) => !!organization), + // Tap operator for side effects - setting the organization property + tap((organization: IOrganization) => (this.organization = organization)), + // Switch to route data stream once organization is confirmed + switchMap(() => { + // Extract organization properties + const { id: organizationId, tenantId } = this.organization; + // Fetch contacts + return this._clientsService.getAll([], { + organizationId, + tenantId + }); + }), + // Map the contacts to the clients property + map(({ items }: IPagination) => items), + // Handle errors + catchError((error) => { + console.error('Error fetching organization contacts:', error); + // Handle and log errors + this._errorHandlingService.handleError(error); + return of([]); + }), + // Handle component lifecycle to avoid memory leaks + untilDestroyed(this) + ); + this.pipeline$ = this._activatedRoute.params.pipe( + // Filter for the presence of pipelineId in route params + filter(({ pipelineId }) => !!pipelineId), + // Switch to route data stream once pipelineId is confirmed + switchMap(() => this._activatedRoute.data), + // Exclude falsy values from the emitted values + filter(({ pipeline }: Data) => !!pipeline), + // Map the pipeline to the pipeline property + map(({ pipeline }: Data) => pipeline), + // Tap operator for side effects - setting the form property + tap((pipeline: IPipeline) => { + this.pipeline = pipeline; + this.form.patchValue({ stageId: this.pipeline.stages[0]?.id }); + }), + // Handle component lifecycle to avoid memory leaks + untilDestroyed(this) + ); + this.deal$ = this._activatedRoute.params.pipe( + // Filter for the presence of dealId in route params + filter(({ dealId }) => !!dealId), + // Switch to route data stream once dealId is confirmed + switchMap(() => this._activatedRoute.data), + // Exclude falsy values from the emitted values + filter(({ deal }: Data) => !!deal), + // Map the deal to the deal property + map(({ deal }: Data) => deal), + // Tap operator for side effects - setting the form property + tap((deal: IDeal) => { + this.deal = deal; + this.patchFormValue(deal); + }), + // Handle component lifecycle to avoid memory leaks + untilDestroyed(this) + ); } - private _initializeForm() { - this.form = this.fb.group({ - createdByUserId: [null, Validators.required], - stageId: [null, Validators.required], - title: [null, Validators.required], - clientId: [null], - probability: [null, Validators.required] - }); + /** + * Patch form values with the deal data + * + * @param deal The deal object containing data to patch into the form + */ + patchFormValue(deal: IDeal) { + const { title, stageId, createdBy, probability, clientId } = deal; this.form.patchValue({ - createdByUserId: this.appStore.getValue().user?.id + title, + stageId, + createdBy, + probability, + clientId }); + this.selectedProbability = probability; } - async getPipelines() { - const { tenantId } = this; - await this.pipelinesService - .getAll(['stages'], { - id: this.pipelineId, - tenantId - }) - .then(({ items: [value] }) => (this.pipeline = value)); + /** + * Submits the form data for creating or updating a deal. + * + * This method handles the submission of form data for either creating a new deal + * or updating an existing one. It also manages form state (disable/enable) and + * displays success notifications. + * + * @returns {Promise} A promise that resolves when the form submission is complete. + */ + public async onSubmit(): Promise { + const { organization, form } = this; - this.form.patchValue({ stageId: this.pipeline.stages[0]?.id }); - } + // If no organization is selected, do not proceed + if (!organization) { + return; + } - async getDeal() { - const { tenantId } = this; - await this.dealsService - .getOne(this.dealId, { tenantId }, ['client']) - .then(({ title, stageId, createdBy, probability, clientId, client }) => { - this.form.patchValue({ - title, - stageId, - createdBy, - probability, - clientId - }); - this.selectedProbability = probability; - }); - } + // Extract organizationId and tenantId from the selected organization + const { id: organizationId, tenantId } = organization; - async getOrganizationContact() { - await this.clientsService - .getAll([], { - organizationId: this.organizationId, - tenantId: this.tenantId - }) - .then((res) => (this.clients = res.items)); - } + // Merge the form values with organizationId and tenantId + const value = { ...form.value, organizationId, tenantId }; - public async onSubmit(): Promise { - const { - dealId, - activatedRoute: relativeTo, - form: { value } - } = this; - - this.form.disable(); - await (this.dealId - ? this.dealsService.update( - this.dealId, - Object.assign( - { - organizationId: this.organizationId, - tenantId: this.tenantId - }, - value - ) - ) - : this.dealsService.create( - Object.assign( - { - organizationId: this.organizationId, - tenantId: this.tenantId - }, - value - ) - ) - ) - .then(() => { - if (this.dealId) { - this.toastrService.success('PIPELINE_DEALS_PAGE.DEAL_EDITED', { - name: value.title - }); - } else { - this.toastrService.success('PIPELINE_DEALS_PAGE.DEAL_ADDED', { - name: value.title - }); - } - this.router.navigate([dealId ? '../..' : '..'], { relativeTo }); - }) - .catch(() => this.form.enable()); + // Disable the form to prevent further input during submission + form.disable(); + + try { + // Determine whether to create a new deal or update an existing one + if (this.deal) { + await this._dealsService.update(this.deal?.id, value); + } else { + await this._dealsService.create(value); + } + + // Determine the success message based on whether it's a create or update operation + const successMessage = this.deal?.id ? 'PIPELINE_DEALS_PAGE.DEAL_EDITED' : 'PIPELINE_DEALS_PAGE.DEAL_ADDED'; + + // Display a success notification with the deal title + this._toastrService.success(successMessage, { name: value.title }); + + // Navigate to the appropriate route after successful submission + this._router.navigate([this.deal?.id ? '../..' : '..'], { relativeTo: this._activatedRoute }); + } catch (error) { + // Handle and log errors + this._errorHandlingService.handleError(error); + } finally { + // If an error occurs, re-enable the form for further input + form.enable(); + } } + /** + * Cancels the form submission. + */ cancel() { window.history.back(); } - ngOnDestroy() { - this._ngDestroy$.next(); - this._ngDestroy$.complete(); - } + ngOnDestroy() {} } diff --git a/apps/gauzy/src/app/pages/pipelines/pipeline-deals/pipeline-deals.component.html b/apps/gauzy/src/app/pages/pipelines/pipeline-deals/pipeline-deals.component.html index 04c46ec2722..0903a683cad 100644 --- a/apps/gauzy/src/app/pages/pipelines/pipeline-deals/pipeline-deals.component.html +++ b/apps/gauzy/src/app/pages/pipelines/pipeline-deals/pipeline-deals.component.html @@ -1,15 +1,9 @@ - +

- - {{ 'PIPELINE_DEALS_PAGE.HEADER' | translate }} - - | - - {{ pipeline?.name }} - + {{ 'PIPELINE_DEALS_PAGE.HEADER' | translate }} | {{ pipeline?.name }}

@@ -25,8 +19,8 @@

@@ -38,37 +32,38 @@

- + + +
- +
+
@@ -78,8 +73,8 @@

- - + + + + + + diff --git a/apps/gauzy/src/app/pages/pipelines/pipeline-form/pipeline-form.component.ts b/apps/gauzy/src/app/pages/pipelines/pipeline-form/pipeline-form.component.ts index d86e1aeb439..2d6210349d6 100644 --- a/apps/gauzy/src/app/pages/pipelines/pipeline-form/pipeline-form.component.ts +++ b/apps/gauzy/src/app/pages/pipelines/pipeline-form/pipeline-form.component.ts @@ -1,72 +1,119 @@ import { Component, Input, OnInit } from '@angular/core'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { filter, tap } from 'rxjs/operators'; import { NbDialogRef } from '@nebular/theme'; -import { IOrganization, IPipeline, IPipelineCreateInput } from '@gauzy/contracts'; -import { PipelinesService } from '@gauzy/ui-core/core'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { IOrganization, IPipeline } from '@gauzy/contracts'; +import { ErrorHandlingService, PipelinesService } from '@gauzy/ui-core/core'; +import { distinctUntilChange, Store } from '@gauzy/ui-core/common'; +@UntilDestroy({ checkProperties: true }) @Component({ templateUrl: './pipeline-form.component.html', styleUrls: ['./pipeline-form.component.scss'], - selector: 'ga-pipeline-form' + selector: 'ga-pipeline-mutation-form' }) export class PipelineFormComponent implements OnInit { - @Input() pipeline: IPipelineCreateInput & { id?: string }; + public isActive: boolean = true; + public organization: IOrganization; - form: UntypedFormGroup; - icon: string; - isActive: boolean; - organization: IOrganization; + /** + * Form property setter and getter. + */ + public form: UntypedFormGroup = this._fb.group({ + name: ['', Validators.required], + description: [''], + stages: this._fb.array([]), + isActive: [this.isActive] + }); + + /** + * Pipeline property setter and getter. + * @param value + */ + private _pipeline: IPipeline; + @Input() set pipeline(value: IPipeline) { + this._pipeline = value; + this.onPipelineChange(value); + } + get pipeline(): IPipeline { + return this._pipeline; + } constructor( - public readonly dialogRef: NbDialogRef, - private readonly pipelinesService: PipelinesService, - private readonly fb: UntypedFormBuilder + private readonly _dialogRef: NbDialogRef, + private readonly _pipelinesService: PipelinesService, + private readonly _fb: UntypedFormBuilder, + private readonly _store: Store, + private readonly _errorHandlingService: ErrorHandlingService ) {} ngOnInit(): void { - const { id, isActive } = this.pipeline; - isActive === undefined ? (this.isActive = true) : (this.isActive = isActive); + this._store.selectedOrganization$ + .pipe( + distinctUntilChange(), + filter((organization: IOrganization) => !!organization), + tap((organization: IOrganization) => (this.organization = organization)), + untilDestroyed(this) + ) + .subscribe(); + } - this.form = this.fb.group({ - organizationId: [this.pipeline.organizationId || '', Validators.required], - tenantId: [this.pipeline.tenantId || ''], - name: [this.pipeline.name || '', Validators.required], - ...(id ? { id: [id, Validators.required] } : {}), - description: [this.pipeline.description], - stages: this.fb.array([]), - isActive: [this.isActive] + /** + * Handles changes to the pipeline input. + * @param value The new pipeline value + */ + private onPipelineChange(pipeline: IPipeline): void { + this.isActive = pipeline.isActive ?? true; + + // Patch form values with the new pipeline data + this.form.patchValue({ + name: pipeline.name, + description: pipeline.description, + isActive: this.isActive, + stages: pipeline.stages }); } /** - * + * Closes the dialog. + */ + closeDialog() { + this._dialogRef.close(); + } + + /** + * Toggles the isActive property between true and false. */ setIsActive() { this.isActive = !this.isActive; } /** - * + * Persists the form data by either creating a new entity or updating an existing one. + * This method handles the dialog closure and error logging as well. */ async persist(): Promise { - try { - const { - value, - value: { id } - } = this.form; - let entity: IPipeline; + if (!this.organization) { + return; + } + // Destructure the organization details and form value + const { id: organizationId, tenantId } = this.organization; + const value = { ...this.form.value, organizationId, tenantId, isArchived: !this.isActive }; + + try { // Determine whether to create or update based on the presence of an ID - if (id) { - entity = await this.pipelinesService.update(id, value); - } else { - entity = await this.pipelinesService.create(value); - } + const entity = this.pipeline?.id + ? await this._pipelinesService.update(this.pipeline.id, value) + : await this._pipelinesService.create(value); // Close the dialog with the returned entity - this.dialogRef.close(entity); + this._dialogRef.close(entity); } catch (error) { + // Handle and log any error that occurs during the persistence process console.error(`Error occurred while persisting data: ${error.message}`); + this._errorHandlingService.handleError(error); } } } diff --git a/apps/gauzy/src/app/pages/pipelines/pipelines.component.html b/apps/gauzy/src/app/pages/pipelines/pipelines.component.html index a9e3b26cdeb..302491ca4be 100644 --- a/apps/gauzy/src/app/pages/pipelines/pipelines.component.html +++ b/apps/gauzy/src/app/pages/pipelines/pipelines.component.html @@ -18,18 +18,11 @@

>

- - + + @@ -45,24 +38,15 @@

{{ 'PIPELINES_PAGE.SEARCH_PIPELINE' | translate }} -
+
outline [disabled]="searchForm.invalid" > - {{ - 'PIPELINES_PAGE.SEARCH' - | translate - }} + {{ 'PIPELINES_PAGE.SEARCH' | translate }}
@@ -151,14 +120,10 @@

- + @@ -167,11 +132,7 @@

- +
@@ -221,12 +178,7 @@

- @@ -245,9 +197,7 @@

- +
diff --git a/apps/gauzy/src/app/pages/pipelines/pipelines.component.ts b/apps/gauzy/src/app/pages/pipelines/pipelines.component.ts index 67d30e83ec2..598752ab418 100644 --- a/apps/gauzy/src/app/pages/pipelines/pipelines.component.ts +++ b/apps/gauzy/src/app/pages/pipelines/pipelines.component.ts @@ -49,7 +49,6 @@ export class PipelinesComponent extends PaginationFilterBaseComponent implements public viewComponentName: ComponentEnum; public pipeline: IPipeline; public organization: IOrganization; - public name: string; public disableButton: boolean = true; public loading: boolean = false; public pipelineTabsEnum = PipelineTabsEnum; @@ -75,11 +74,11 @@ export class PipelinesComponent extends PaginationFilterBaseComponent implements } constructor( + public readonly translateService: TranslateService, private readonly fb: UntypedFormBuilder, private readonly pipelinesService: PipelinesService, private readonly toastrService: ToastrService, private readonly dialogService: NbDialogService, - readonly translateService: TranslateService, private readonly store: Store, private readonly httpClient: HttpClient, private readonly errorHandlingService: ErrorHandlingService, @@ -221,16 +220,18 @@ export class PipelinesComponent extends PaginationFilterBaseComponent implements // Configure Smart Table settings this.smartTableSettings = { + actions: false, + selectedRowIndex: -1, + noDataMessage: this.getTranslation('SM_TABLE.NO_DATA.PIPELINE'), pager: { display: false, perPage: pagination ? pagination.itemsPerPage : this.minItemPerPage }, - actions: false, - noDataMessage: this.getTranslation('SM_TABLE.NO_DATA.PIPELINE'), columns: { name: { type: 'string', title: this.getTranslation('SM_TABLE.NAME'), + width: '30%', filter: { type: 'custom', component: InputFilterComponent @@ -242,29 +243,30 @@ export class PipelinesComponent extends PaginationFilterBaseComponent implements description: { type: 'string', title: this.getTranslation('SM_TABLE.DESCRIPTION'), + width: '30%', filter: { type: 'custom', component: InputFilterComponent }, - filterFunction: (value) => { + filterFunction: (value: string) => { this.setFilter({ field: 'description', search: value }); } }, stages: { title: this.getTranslation('SM_TABLE.STAGE'), type: 'custom', - filter: false, + width: '30%', + isFilterable: false, renderComponent: StageComponent, componentInitFunction: (instance: StatusBadgeComponent, cell: Cell) => { instance.value = cell.getRawValue(); } }, status: { - filter: false, - editor: false, title: this.getTranslation('SM_TABLE.STATUS'), type: 'custom', - width: '5%', + isFilterable: false, + width: '10%', renderComponent: StatusBadgeComponent, componentInitFunction: (instance: StatusBadgeComponent, cell: Cell) => { instance.value = cell.getRawValue(); @@ -306,8 +308,7 @@ export class PipelinesComponent extends PaginationFilterBaseComponent implements this.loading = true; // Extract organization and tenant information - const { tenantId } = this.store.user; - const { id: organizationId } = this.organization; + const { id: organizationId, tenantId } = this.organization; // Create a new ServerDataSource for pipelines this.smartTableSource = new ServerDataSource(this.httpClient, { @@ -315,9 +316,7 @@ export class PipelinesComponent extends PaginationFilterBaseComponent implements relations: ['stages'], join: { alias: 'pipeline', - leftJoin: { - stages: 'pipeline.stages' - }, + leftJoin: { stages: 'pipeline.stages' }, ...(this.filters.join ? this.filters.join : {}) }, where: { @@ -413,15 +412,17 @@ export class PipelinesComponent extends PaginationFilterBaseComponent implements }); } + // Open a dialog to handle manual job application + const dialog = this.dialogService.open(DeleteConfirmationComponent, { + context: { + recordType: this.getTranslation('PIPELINES_PAGE.RECORD_TYPE', this.pipeline) + }, + hasScroll: false + }); + try { - // Open a confirmation dialog and wait for the result - const confirmationResult: 'ok' = await firstValueFrom( - this.dialogService.open(DeleteConfirmationComponent, { - context: { - recordType: this.getTranslation('PIPELINES_PAGE.RECORD_TYPE', this.pipeline) - } - }).onClose - ); + // Wait for dialog result + const confirmationResult = await firstValueFrom(dialog.onClose); // If the user confirms, proceed with deletion if ('ok' === confirmationResult) { @@ -429,17 +430,15 @@ export class PipelinesComponent extends PaginationFilterBaseComponent implements await this.pipelinesService.delete(this.pipeline.id); // Display a success message - this.toastrService.success('TOASTR.MESSAGE.PIPELINE_DELETED', { - name: this.pipeline.name - }); - - // Trigger a refresh for the component and pipelines - this._refresh$.next(true); - this.pipelines$.next(true); + this.toastrService.success('TOASTR.MESSAGE.PIPELINE_DELETED', { name: this.pipeline.name }); } } catch (error) { // Handle errors using the error handling service this.errorHandlingService.handleError(error); + } finally { + // Trigger a refresh for the component and pipelines + this._refresh$.next(true); + this.pipelines$.next(true); } } @@ -453,19 +452,22 @@ export class PipelinesComponent extends PaginationFilterBaseComponent implements } try { - // Destructure properties needed for creating a pipeline - const { name } = this; - const { tenantId } = this.store.user; - const { id: organizationId } = this.organization; + // Open the PipelineFormComponent with the provided context + const dialogRef = this.dialogService.open(PipelineFormComponent); - // Perform the pipeline creation and navigate to the new pipeline - await this.goto({ pipeline: { name, organizationId, tenantId } }); + // Wait for the dialog to close and get the result + const pipeline = await firstValueFrom(dialogRef.onClose); - // Clear the input field after successful pipeline creation - delete this.name; + // If data is received, display a success message and trigger refresh + if (pipeline) { + this.toastrService.success('TOASTR.MESSAGE.PIPELINE_CREATED', { name: pipeline.name }); + } } catch (error) { // Handle errors using the error handling service this.errorHandlingService.handleError(error); + } finally { + this._refresh$.next(true); + this.pipelines$.next(true); } } @@ -488,51 +490,20 @@ export class PipelinesComponent extends PaginationFilterBaseComponent implements return; } - // Destructure properties needed for editing a pipeline - const { name } = this; - const { tenantId } = this.store.user; - const { id: organizationId } = this.organization; - // If there is a selected pipeline, update its details if (this.pipeline) { - const { id: pipelineId } = this.pipeline; - - // Perform the pipeline update and navigate to the updated pipeline - await this.goto({ pipeline: { id: pipelineId, name, organizationId, tenantId } }); - - // Clear the input field after successful pipeline update - delete this.name; - } - } catch (error) { - // Handle errors using the error handling service - this.errorHandlingService.handleError(error); - } - } - - /** - * Navigates to the PipelineFormComponent to create or update a pipeline based on the provided context. - * @param context - The context containing pipeline details. - */ - private async goto(context: Record): Promise { - try { - // Open the PipelineFormComponent with the provided context - const dialogRef = this.dialogService.open(PipelineFormComponent, { context }); - - // Wait for the dialog to close and get the result - const data = await firstValueFrom(dialogRef.onClose); - - // Extract pipeline details from the context - const { - pipeline: { id, name } - } = context; + // Open the PipelineFormComponent with the provided context + const dialogRef = this.dialogService.open(PipelineFormComponent, { + context: { pipeline: this.pipeline } + }); - // If data is received, display a success message and trigger refresh - if (data) { - const successMessage = id ? `TOASTR.MESSAGE.PIPELINE_UPDATED` : `TOASTR.MESSAGE.PIPELINE_CREATED`; + // Wait for the dialog to close and get the result + const pipeline = await firstValueFrom(dialogRef.onClose); - this.toastrService.success(successMessage, { - name: id ? name : data.name - }); + // If data is received, display a success message and trigger refresh + if (pipeline) { + this.toastrService.success('TOASTR.MESSAGE.PIPELINE_UPDATED', { name: this.pipeline.name }); + } } } catch (error) { // Handle errors using the error handling service diff --git a/apps/gauzy/src/app/pages/pipelines/pipelines.module.ts b/apps/gauzy/src/app/pages/pipelines/pipelines.module.ts index 4e66e28f9ca..3b4f08be2de 100644 --- a/apps/gauzy/src/app/pages/pipelines/pipelines.module.ts +++ b/apps/gauzy/src/app/pages/pipelines/pipelines.module.ts @@ -29,6 +29,8 @@ import { PipelineDealProbabilityComponent } from './table-components/pipeline-de import { StageComponent } from './stage/stage.component'; import { PipelinesRouting } from './pipelines.routing'; import { PipelinesComponent } from './pipelines.component'; +import { PipelineResolver } from './routes/pipeline.resolver'; +import { DealResolver } from './routes/deal.resolver'; @NgModule({ declarations: [ @@ -49,7 +51,6 @@ import { PipelinesComponent } from './pipelines.component'; PipelinesComponent, StageFormComponent ], - providers: [PipelinesService, DealsService], imports: [ CommonModule, ReactiveFormsModule, @@ -74,6 +75,7 @@ import { PipelinesComponent } from './pipelines.component'; PaginationV2Module, GauzyButtonActionModule, NbTabsetModule - ] + ], + providers: [PipelinesService, DealsService, PipelineResolver, DealResolver] }) export class PipelinesModule {} diff --git a/apps/gauzy/src/app/pages/pipelines/pipelines.routing.ts b/apps/gauzy/src/app/pages/pipelines/pipelines.routing.ts index 0e8d306405e..49a28e5d13c 100644 --- a/apps/gauzy/src/app/pages/pipelines/pipelines.routing.ts +++ b/apps/gauzy/src/app/pages/pipelines/pipelines.routing.ts @@ -1,46 +1,60 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; +import { PermissionsEnum } from '@gauzy/contracts'; +import { PermissionsGuard } from '@gauzy/ui-core/core'; import { PipelinesComponent } from './pipelines.component'; import { PipelineDealsComponent } from './pipeline-deals/pipeline-deals.component'; import { PipelineDealFormComponent } from './pipeline-deals/pipeline-deal-form/pipeline-deal-form.component'; -import { PermissionsGuard } from '@gauzy/ui-core/core'; -import { PermissionsEnum } from '@gauzy/contracts'; - -export function redirectTo() { - return '/pages/dashboard'; -} - -const PIPELINES_VIEW_PERMISSION = { - permissions: { - only: [PermissionsEnum.VIEW_SALES_PIPELINES], - redirectTo - } -}; +import { PipelineResolver } from './routes/pipeline.resolver'; +import { DealResolver } from './routes/deal.resolver'; const routes: Routes = [ { path: '', component: PipelinesComponent, canActivate: [PermissionsGuard], - data: PIPELINES_VIEW_PERMISSION + data: { + permissions: { + only: [PermissionsEnum.VIEW_SALES_PIPELINES], + redirectTo: '/pages/dashboard' + } + } }, { path: ':pipelineId/deals', component: PipelineDealsComponent, canActivate: [PermissionsGuard], - data: PIPELINES_VIEW_PERMISSION + data: { + permissions: { + only: [PermissionsEnum.VIEW_SALES_PIPELINES], + redirectTo: '/pages/dashboard' + } + }, + resolve: { pipeline: PipelineResolver } }, { path: ':pipelineId/deals/create', component: PipelineDealFormComponent, canActivate: [PermissionsGuard], - data: PIPELINES_VIEW_PERMISSION + data: { + permissions: { + only: [PermissionsEnum.VIEW_SALES_PIPELINES], + redirectTo: '/pages/dashboard' + } + }, + resolve: { pipeline: PipelineResolver } }, { path: ':pipelineId/deals/:dealId/edit', component: PipelineDealFormComponent, canActivate: [PermissionsGuard], - data: PIPELINES_VIEW_PERMISSION + data: { + permissions: { + only: [PermissionsEnum.VIEW_SALES_PIPELINES], + redirectTo: '/pages/dashboard' + } + }, + resolve: { pipeline: PipelineResolver, deal: DealResolver } } ]; diff --git a/apps/gauzy/src/app/pages/pipelines/routes/deal.resolver.ts b/apps/gauzy/src/app/pages/pipelines/routes/deal.resolver.ts new file mode 100644 index 00000000000..1c715630108 --- /dev/null +++ b/apps/gauzy/src/app/pages/pipelines/routes/deal.resolver.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve } from '@angular/router'; +import { catchError, from, of } from 'rxjs'; +import { Observable } from 'rxjs/internal/Observable'; +import { IDeal } from '@gauzy/contracts'; +import { Store } from '@gauzy/ui-core/common'; +import { DealsService, ErrorHandlingService } from '@gauzy/ui-core/core'; + +@Injectable() +export class DealResolver implements Resolve>> { + constructor( + private readonly _store: Store, + private readonly _dealsService: DealsService, + private readonly _errorHandlingService: ErrorHandlingService + ) {} + + /** + * Resolve method to fetch a deal by its ID. + * + * @param route The activated route snapshot containing the route parameters. + * @returns An observable of IDeal or null if no dealId is present. + */ + resolve(route: ActivatedRouteSnapshot): Observable { + const dealId = route.params['dealId']; + if (!dealId) { + return of(null); + } + + const { id: organizationId, tenantId } = this._store.selectedOrganization; + return from(this._dealsService.getById(dealId, { organizationId, tenantId }, ['client'])).pipe( + catchError((error) => { + // Handle and log errors + this._errorHandlingService.handleError(error); + return of(error); + }) + ); + } +} diff --git a/apps/gauzy/src/app/pages/pipelines/routes/pipeline.resolver.ts b/apps/gauzy/src/app/pages/pipelines/routes/pipeline.resolver.ts new file mode 100644 index 00000000000..c0def4d0450 --- /dev/null +++ b/apps/gauzy/src/app/pages/pipelines/routes/pipeline.resolver.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'; +import { of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { Observable } from 'rxjs/internal/Observable'; +import { IPipeline } from '@gauzy/contracts'; +import { ErrorHandlingService, PipelinesService } from '@gauzy/ui-core/core'; +import { Store } from '@gauzy/ui-core/common'; + +@Injectable() +export class PipelineResolver implements Resolve>> { + constructor( + private readonly _store: Store, + private readonly _router: Router, + private readonly _pipelinesService: PipelinesService, + private readonly _errorHandlingService: ErrorHandlingService + ) {} + + /** + * Resolves a pipeline entity by its ID from the route parameters. + * + * @param route - The activated route snapshot containing route information. + * @returns An observable of the pipeline entity. + */ + resolve(route: ActivatedRouteSnapshot): Observable { + const pipelineId = route.params['pipelineId']; + if (!pipelineId) { + return of(null); + } + + const { id: organizationId, tenantId } = this._store.selectedOrganization; + return this._pipelinesService.getById(pipelineId, { organizationId, tenantId }, ['stages']).pipe( + map((pipeline: IPipeline) => { + if (pipeline.organizationId !== organizationId) { + this._router.navigate(['pages/sales/pipelines']); + } + return pipeline; + }), + catchError((error) => { + // Handle and log errors + this._errorHandlingService.handleError(error); + return of(error); + }) + ); + } +} diff --git a/apps/gauzy/src/app/pages/pipelines/table-components/pipeline-deal-created-by/pipeline-deal-created-by.ts b/apps/gauzy/src/app/pages/pipelines/table-components/pipeline-deal-created-by/pipeline-deal-created-by.ts index e7c33f34dbc..abf530c86ee 100644 --- a/apps/gauzy/src/app/pages/pipelines/table-components/pipeline-deal-created-by/pipeline-deal-created-by.ts +++ b/apps/gauzy/src/app/pages/pipelines/table-components/pipeline-deal-created-by/pipeline-deal-created-by.ts @@ -3,8 +3,7 @@ import { IDeal } from '@gauzy/contracts'; @Component({ selector: 'ga-pipeline-deal-created-by', - template: `{{ rowData?.createdBy?.firstName }} - {{ rowData?.createdBy?.lastName }}` + template: `{{ rowData?.createdBy?.name }}` }) export class PipelineDealCreatedByComponent { @Input() diff --git a/apps/gauzy/src/app/pages/pipelines/table-components/pipeline-deal-excerpt/pipeline-deal-excerpt.component.ts b/apps/gauzy/src/app/pages/pipelines/table-components/pipeline-deal-excerpt/pipeline-deal-excerpt.component.ts index 68bacab6f60..9deb74d7aa9 100644 --- a/apps/gauzy/src/app/pages/pipelines/table-components/pipeline-deal-excerpt/pipeline-deal-excerpt.component.ts +++ b/apps/gauzy/src/app/pages/pipelines/table-components/pipeline-deal-excerpt/pipeline-deal-excerpt.component.ts @@ -3,7 +3,7 @@ import { IDeal } from '@gauzy/contracts'; @Component({ selector: 'ga-pipeline-excerpt', - template: `{{ rowData?.stage.name }}` + template: `{{ rowData?.stage?.name }}` }) export class PipelineDealExcerptComponent { @Input() diff --git a/apps/gauzy/src/app/pages/pipelines/table-components/pipeline-deal-probability/pipeline-deal-probability.component.html b/apps/gauzy/src/app/pages/pipelines/table-components/pipeline-deal-probability/pipeline-deal-probability.component.html index c7a519b13b1..123ae85d8c9 100644 --- a/apps/gauzy/src/app/pages/pipelines/table-components/pipeline-deal-probability/pipeline-deal-probability.component.html +++ b/apps/gauzy/src/app/pages/pipelines/table-components/pipeline-deal-probability/pipeline-deal-probability.component.html @@ -1,21 +1,14 @@ -
- {{ probability }} - {{ 'PIPELINE_DEALS_PAGE.LOW' | translate }} -
-
- {{ probability }} - {{ 'PIPELINE_DEALS_PAGE.MEDIUM' | translate }} -
-
- {{ probability }} - {{ 'PIPELINE_DEALS_PAGE.HIGH' | translate }} +
+
{{ probability }} - {{ 'PIPELINE_DEALS_PAGE.LOW' | translate }}
+
{{ probability }} - {{ 'PIPELINE_DEALS_PAGE.LOW' | translate }}
+
+ {{ probability }} - {{ 'PIPELINE_DEALS_PAGE.MEDIUM' | translate }} +
+
+ {{ probability }} - {{ 'PIPELINE_DEALS_PAGE.MEDIUM' | translate }} +
+
{{ probability }} - {{ 'PIPELINE_DEALS_PAGE.HIGH' | translate }}
+
+ {{ probability }} - {{ 'PIPELINE_DEALS_PAGE.UNKNOWN' | translate }} +
diff --git a/packages/contracts/src/deal.model.ts b/packages/contracts/src/deal.model.ts index 06269c42ef6..4a0d3c1f3c8 100644 --- a/packages/contracts/src/deal.model.ts +++ b/packages/contracts/src/deal.model.ts @@ -1,26 +1,24 @@ -import { IBasePerTenantAndOrganizationEntityModel } from './base-entity.model'; +import { IBasePerTenantAndOrganizationEntityModel, ID } from './base-entity.model'; import { IUser } from './user.model'; import { IPipelineStage } from './pipeline-stage.model'; import { IContact } from './contact.model'; export interface IDeal extends IBasePerTenantAndOrganizationEntityModel { - createdByUserId: string; - stageId: string; - clientId?: string; title: string; probability?: number; createdBy: IUser; + createdByUserId: ID; stage: IPipelineStage; + stageId: ID; client?: IContact; + clientId?: ID; } export type IDealFindInput = Partial; -export interface IDealCreateInput - extends IBasePerTenantAndOrganizationEntityModel { - createdByUserId: string; - stageId: string; - clientId?: string; +export interface IDealCreateInput extends IBasePerTenantAndOrganizationEntityModel { + stageId: ID; + clientId?: ID; title: string; probability?: number; } diff --git a/packages/contracts/src/pipeline.model.ts b/packages/contracts/src/pipeline.model.ts index 5c902263cb8..f754aaa6bc4 100644 --- a/packages/contracts/src/pipeline.model.ts +++ b/packages/contracts/src/pipeline.model.ts @@ -1,8 +1,5 @@ import { IBasePerTenantAndOrganizationEntityModel } from './base-entity.model'; -import { - IPipelineStageCreateInput, - IPipelineStage -} from './pipeline-stage.model'; +import { IPipelineStageCreateInput, IPipelineStage } from './pipeline-stage.model'; export interface IPipeline extends IBasePerTenantAndOrganizationEntityModel { stages: IPipelineStage[]; @@ -10,19 +7,15 @@ export interface IPipeline extends IBasePerTenantAndOrganizationEntityModel { name: string; } -export type IPipelineFindInput = Partial< - Pick ->; +export type IPipelineFindInput = Partial>; -export interface IPipelineCreateInput - extends IBasePerTenantAndOrganizationEntityModel { +export interface IPipelineCreateInput extends IBasePerTenantAndOrganizationEntityModel { stages?: IPipelineStageCreateInput[]; description?: string; name: string; - isActive: boolean; } export enum PipelineTabsEnum { - ACTIONS = "ACTIONS", - SEARCH = "SEARCH" -} \ No newline at end of file + ACTIONS = 'ACTIONS', + SEARCH = 'SEARCH' +} diff --git a/packages/core/src/core/crud/pagination-params.ts b/packages/core/src/core/crud/pagination-params.ts index 8a782de09da..a6ff0903f58 100644 --- a/packages/core/src/core/crud/pagination-params.ts +++ b/packages/core/src/core/crud/pagination-params.ts @@ -14,7 +14,6 @@ import { SimpleObjectLiteral, convertNativeParameters, parseObject } from './pag * Specifies what columns should be retrieved. */ export class OptionsSelect { - @ApiPropertyOptional({ type: 'object' }) @IsOptional() @Transform(({ value }: TransformFnParams) => parseObject(value, parseToBoolean)) @@ -23,9 +22,8 @@ export class OptionsSelect { /** * Indicates what relations of entity should be loaded (simplified left join form). -*/ + */ export class OptionsRelations extends OptionsSelect { - @ApiPropertyOptional({ type: 'object' }) @IsOptional() readonly relations?: FindOptionsRelations; @@ -46,15 +44,15 @@ export class OptionParams extends OptionsRelations { @IsNotEmpty() @ValidateNested({ each: true }) @Type(() => TenantOrganizationBaseDTO) - @Transform(({ value }: TransformFnParams) => value ? escapeQueryWithParameters(value) : {}) + @Transform(({ value }: TransformFnParams) => (value ? escapeQueryWithParameters(value) : {})) readonly where: FindOptionsWhere; /** - * Indicates if soft-deleted rows should be included in entity result. - */ + * Indicates if soft-deleted rows should be included in entity result. + */ @ApiPropertyOptional({ type: 'boolean' }) @IsOptional() - @Transform(({ value }: TransformFnParams) => value ? parseToBoolean(value) : false) + @Transform(({ value }: TransformFnParams) => (value ? parseToBoolean(value) : false)) readonly withDeleted: boolean; } @@ -88,7 +86,6 @@ export class PaginationParams extends OptionParams { * @returns {TenantOrganizationBaseDTO} - The escaped and converted query parameters as a DTO instance. */ export function escapeQueryWithParameters(nativeParameters: SimpleObjectLiteral): TenantOrganizationBaseDTO { - // Convert native parameters based on the database connection type const builtParameters: SimpleObjectLiteral = convertNativeParameters(nativeParameters); diff --git a/packages/core/src/deal/deal.controller.ts b/packages/core/src/deal/deal.controller.ts index a622bf2c914..9036efa7371 100644 --- a/packages/core/src/deal/deal.controller.ts +++ b/packages/core/src/deal/deal.controller.ts @@ -1,57 +1,82 @@ +import { Body, Controller, Get, HttpStatus, Param, Post, Query, UseGuards } from '@nestjs/common'; import { - Controller, - Get, - HttpStatus, - Param, - Query, - UseGuards -} from '@nestjs/common'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { IPagination } from '@gauzy/contracts'; -import { CrudController } from './../core/crud'; + ApiBadRequestResponse, + ApiCreatedResponse, + ApiInternalServerErrorResponse, + ApiOperation, + ApiResponse, + ApiTags +} from '@nestjs/swagger'; +import { ID, IPagination, PermissionsEnum } from '@gauzy/contracts'; import { Deal } from './deal.entity'; import { DealService } from './deal.service'; -import { TenantPermissionGuard } from './../shared/guards'; -import { ParseJsonPipe, UUIDValidationPipe } from './../shared/pipes'; +import { CrudController, OptionParams, PaginationParams } from '../core/crud'; +import { Permissions } from '../shared/decorators'; +import { PermissionGuard, TenantPermissionGuard } from '../shared/guards'; +import { UseValidationPipe, UUIDValidationPipe } from '../shared/pipes'; +import { CreateDealDTO } from './dto'; @ApiTags('Deal') -@UseGuards(TenantPermissionGuard) +@UseGuards(TenantPermissionGuard, PermissionGuard) +@Permissions(PermissionsEnum.VIEW_SALES_PIPELINES) @Controller() export class DealController extends CrudController { - public constructor(private readonly dealService: DealService) { - super(dealService); + constructor(private readonly _dealService: DealService) { + super(_dealService); } - @ApiOperation({ summary: 'Find all deals' }) + /** + * Find all sales pipelines with permissions, API documentation, and query parameter parsing. + * + * @param data - The query parameter data. + * @returns A paginated result of sales pipelines. + */ + @ApiOperation({ summary: 'find all' }) @ApiResponse({ status: HttpStatus.OK, description: 'Found records' }) - @Get() - public async findAll( - @Query('data', ParseJsonPipe) data: any - ): Promise> { - const { relations = [], findInput: where = null } = data; - return this.dealService.findAll({ - relations, - where - }); + @Permissions(PermissionsEnum.VIEW_SALES_PIPELINES) + @Get('/') + async findAll(@Query() filter: PaginationParams): Promise> { + return await this._dealService.findAll(filter); } - @ApiOperation({ summary: 'Find one deal' }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Found record' - }) - @Get(':id') - public async getOne( - @Param('id', UUIDValidationPipe) id: string, - @Query('data', ParseJsonPipe) data: any - ): Promise { - const { relations = [], findInput: where = null } = data; - return await this.dealService.findOneByIdString(id, { - relations, - where - }); + /** + * Find a deal by ID. + * + * Retrieves a deal by its unique identifier. + * + * @param id - The ID of the deal to retrieve. + * @param query - Query parameters for relations. + * @returns A promise resolving to the found deal entity. + */ + @Get('/:id') + @ApiOperation({ summary: 'Find a deal by ID' }) + @ApiResponse({ status: 200, description: 'The found deal' }) + @ApiResponse({ status: 404, description: 'Deal not found' }) + async findById(@Param('id', UUIDValidationPipe) id: ID, @Query() options: OptionParams): Promise { + return await this._dealService.findById(id, options); + } + + /** + * Creates a new deal entity. + * + * This method handles the creation of a new deal entity by calling the create method + * on the dealService with the provided entity data. + * + * @param entity - The partial deal entity data to create. + * @returns A promise that resolves to the created deal entity. + */ + @ApiOperation({ summary: 'Create a new deal' }) + @ApiCreatedResponse({ type: Deal, description: 'The deal has been successfully created.' }) + @ApiBadRequestResponse({ description: 'Invalid request data.' }) + @ApiInternalServerErrorResponse({ description: 'Internal server error.' }) + @Permissions(PermissionsEnum.EDIT_SALES_PIPELINES) + @Post('/') + @UseValidationPipe() + async create(@Body() entity: CreateDealDTO): Promise { + // Call the create method on the dealService with the provided entity data + return await this._dealService.create(entity); } } diff --git a/packages/core/src/deal/deal.entity.ts b/packages/core/src/deal/deal.entity.ts index 5904e7df897..0bb17c436c4 100644 --- a/packages/core/src/deal/deal.entity.ts +++ b/packages/core/src/deal/deal.entity.ts @@ -1,34 +1,19 @@ +import { IDeal, IUser, IPipelineStage, IOrganizationContact, ID } from '@gauzy/contracts'; +import { JoinColumn, RelationId } from 'typeorm'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, Min, Max, IsInt, IsOptional, IsUUID } from 'class-validator'; +import { OrganizationContact, PipelineStage, TenantOrganizationBaseEntity, User } from '../core/entities/internal'; import { - IDeal, - IUser, - IPipelineStage, - IOrganizationContact -} from '@gauzy/contracts'; -import { - JoinColumn, - RelationId -} from 'typeorm'; -import { ApiProperty } from '@nestjs/swagger'; -import { - IsNotEmpty, - IsString, - Min, - Max, - IsInt, - IsOptional -} from 'class-validator'; -import { - OrganizationContact, - PipelineStage, - TenantOrganizationBaseEntity, - User -} from '../core/entities/internal'; -import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne, MultiORMOneToOne } from './../core/decorators/entity'; + ColumnIndex, + MultiORMColumn, + MultiORMEntity, + MultiORMManyToOne, + MultiORMOneToOne +} from './../core/decorators/entity'; import { MikroOrmDealRepository } from './repository/mikro-orm-deal.repository'; @MultiORMEntity('deal', { mikroOrmRepository: () => MikroOrmDealRepository }) export class Deal extends TenantOrganizationBaseEntity implements IDeal { - @ApiProperty({ type: () => String }) @IsNotEmpty() @IsString() @@ -36,7 +21,6 @@ export class Deal extends TenantOrganizationBaseEntity implements IDeal { title: string; @ApiProperty({ type: () => Number }) - @IsOptional() @IsInt() @Min(0) @Max(5) @@ -52,36 +36,36 @@ export class Deal extends TenantOrganizationBaseEntity implements IDeal { /** * User */ - @ApiProperty({ type: () => User }) @MultiORMManyToOne(() => User, { - joinColumn: 'createdByUserId', + joinColumn: 'createdByUserId' }) @JoinColumn({ name: 'createdByUserId' }) createdBy: IUser; - @ApiProperty({ type: () => String }) - @RelationId((it: Deal) => it.createdBy) - @IsString() - @IsNotEmpty() + @ApiPropertyOptional({ type: () => String }) + @IsOptional() + @IsUUID() @ColumnIndex() + @RelationId((it: Deal) => it.createdBy) @MultiORMColumn({ relationId: true }) - createdByUserId: string; + createdByUserId: ID; /** * PipelineStage */ - @ApiProperty({ type: () => PipelineStage }) - @MultiORMManyToOne(() => PipelineStage, { onDelete: 'CASCADE' }) + @MultiORMManyToOne(() => PipelineStage, { + /** Database cascade action on delete. */ + onDelete: 'CASCADE' + }) @JoinColumn() stage: IPipelineStage; @ApiProperty({ type: () => String }) - @RelationId((it: Deal) => it.stage) - @IsNotEmpty() - @IsString() + @IsUUID() @ColumnIndex() + @RelationId((it: Deal) => it.stage) @MultiORMColumn({ relationId: true }) - stageId: string; + stageId: ID; /* |-------------------------------------------------------------------------- @@ -93,6 +77,9 @@ export class Deal extends TenantOrganizationBaseEntity implements IDeal { * OrganizationContact */ @MultiORMOneToOne(() => OrganizationContact, { + /** Indicates if relation column value can be nullable or not. */ + nullable: true, + /** Database cascade action on delete. */ onDelete: 'CASCADE', @@ -100,12 +87,12 @@ export class Deal extends TenantOrganizationBaseEntity implements IDeal { owner: true }) @JoinColumn() - client: IOrganizationContact; + client?: IOrganizationContact; - @ApiProperty({ type: () => String }) - @RelationId((it: Deal) => it.client) + @ApiPropertyOptional({ type: () => String }) @IsOptional() - @IsString() + @IsUUID() + @RelationId((it: Deal) => it.client) @MultiORMColumn({ nullable: true, relationId: true }) - clientId: string; + clientId?: ID; } diff --git a/packages/core/src/deal/deal.module.ts b/packages/core/src/deal/deal.module.ts index 51bafa79049..e6dccfa32c7 100644 --- a/packages/core/src/deal/deal.module.ts +++ b/packages/core/src/deal/deal.module.ts @@ -10,15 +10,13 @@ import { TypeOrmDealRepository } from './repository/type-orm-deal.repository'; @Module({ imports: [ - RouterModule.register([ - { path: '/deals', module: DealModule } - ]), + RouterModule.register([{ path: '/deals', module: DealModule }]), TypeOrmModule.forFeature([Deal]), MikroOrmModule.forFeature([Deal]), RolePermissionModule ], controllers: [DealController], providers: [DealService, TypeOrmDealRepository], - exports: [DealService, TypeOrmDealRepository] + exports: [TypeOrmModule, MikroOrmModule, DealService, TypeOrmDealRepository] }) -export class DealModule { } +export class DealModule {} diff --git a/packages/core/src/deal/deal.service.ts b/packages/core/src/deal/deal.service.ts index 15cd6813417..098e4cccf05 100644 --- a/packages/core/src/deal/deal.service.ts +++ b/packages/core/src/deal/deal.service.ts @@ -1,4 +1,7 @@ -import { Injectable } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { DeepPartial, FindOneOptions } from 'typeorm'; +import { ID } from '@gauzy/contracts'; +import { RequestContext } from '../core/context'; import { TenantAwareCrudService } from './../core/crud'; import { Deal } from './deal.entity'; import { TypeOrmDealRepository } from './repository/type-orm-deal.repository'; @@ -12,4 +15,38 @@ export class DealService extends TenantAwareCrudService { ) { super(typeOrmDealRepository, mikroOrmDealRepository); } + + /** + * Find a Pipeline by ID + * + * @param id - The ID of the Pipeline to find + * @param relations - Optional relations to include in the query + * @returns The found Pipeline + */ + async findById(id: ID, options?: FindOneOptions): Promise { + return await super.findOneByIdString(id, options); + } + + /** + * Creates a new deal entity. + * + * This method sets the `createdByUserId` using the current user's ID from the request context, + * then calls the create method on the superclass (likely a service or repository) with the modified entity data. + * + * @param entity - The partial deal entity data to create. + * @returns A promise that resolves to the created deal entity. + */ + async create(entity: DeepPartial): Promise { + try { + // Set the createdByUserId using the current user's ID from the request context + entity.createdByUserId = RequestContext.currentUserId(); + + // Call the create method on the superclass with the modified entity data + return await super.create(entity); + } catch (error) { + // Handle any errors that occur during deal creation + console.error(`Error occurred while creating deal: ${error.message}`); + throw new HttpException(`Error occurred while creating deal: ${error.message}`, HttpStatus.BAD_REQUEST); + } + } } diff --git a/packages/core/src/deal/dto/create-deal.dto.ts b/packages/core/src/deal/dto/create-deal.dto.ts new file mode 100644 index 00000000000..1fca676b964 --- /dev/null +++ b/packages/core/src/deal/dto/create-deal.dto.ts @@ -0,0 +1,17 @@ +import { IntersectionType, PickType } from '@nestjs/mapped-types'; +import { IDealCreateInput } from '@gauzy/contracts'; +import { Deal } from '../../core/entities/internal'; +import { TenantOrganizationBaseDTO } from '../../core/dto'; + +/** + * Deal DTO + */ +export class DealDTO extends IntersectionType( + TenantOrganizationBaseDTO, + PickType(Deal, ['title', 'probability', 'clientId', 'stageId', 'isActive', 'isArchived']) +) {} + +/** + * Create deal DTO + */ +export class CreateDealDTO extends DealDTO implements IDealCreateInput {} diff --git a/packages/core/src/deal/dto/index.ts b/packages/core/src/deal/dto/index.ts new file mode 100644 index 00000000000..95caa2c3312 --- /dev/null +++ b/packages/core/src/deal/dto/index.ts @@ -0,0 +1,2 @@ +export * from './create-deal.dto'; +export * from './update-deal.dto'; diff --git a/packages/core/src/deal/dto/update-deal.dto.ts b/packages/core/src/deal/dto/update-deal.dto.ts new file mode 100644 index 00000000000..1387883d2ba --- /dev/null +++ b/packages/core/src/deal/dto/update-deal.dto.ts @@ -0,0 +1,6 @@ +import { CreateDealDTO } from './create-deal.dto'; + +/** + * Update dea DTO + */ +export class UpdateDealDTO extends CreateDealDTO {} diff --git a/packages/core/src/pipeline-stage/pipeline-stage.module.ts b/packages/core/src/pipeline-stage/pipeline-stage.module.ts index a3182a56668..f1a121f2305 100644 --- a/packages/core/src/pipeline-stage/pipeline-stage.module.ts +++ b/packages/core/src/pipeline-stage/pipeline-stage.module.ts @@ -3,13 +3,11 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { PipelineStage } from './pipeline-stage.entity'; import { StageService } from './pipeline-stage.service'; +import { TypeOrmPipelineStageRepository } from './repository/type-orm-pipeline-stage.repository'; @Module({ - imports: [ - TypeOrmModule.forFeature([PipelineStage]), - MikroOrmModule.forFeature([PipelineStage]) - ], - providers: [StageService], - exports: [StageService] + imports: [TypeOrmModule.forFeature([PipelineStage]), MikroOrmModule.forFeature([PipelineStage])], + providers: [StageService, TypeOrmPipelineStageRepository], + exports: [TypeOrmModule, MikroOrmModule, StageService, TypeOrmPipelineStageRepository] }) -export class StageModule { } +export class StageModule {} diff --git a/packages/core/src/pipeline-stage/pipeline-stage.service.ts b/packages/core/src/pipeline-stage/pipeline-stage.service.ts index b7c912d5538..11f2babf94f 100644 --- a/packages/core/src/pipeline-stage/pipeline-stage.service.ts +++ b/packages/core/src/pipeline-stage/pipeline-stage.service.ts @@ -1,4 +1,3 @@ -import { InjectRepository } from '@nestjs/typeorm'; import { Injectable } from '@nestjs/common'; import { TenantAwareCrudService } from './../core/crud'; import { PipelineStage } from './pipeline-stage.entity'; @@ -7,11 +6,8 @@ import { MikroOrmPipelineStageRepository } from './repository/mikro-orm-pipeline @Injectable() export class StageService extends TenantAwareCrudService { - constructor( - @InjectRepository(PipelineStage) typeOrmPipelineStageRepository: TypeOrmPipelineStageRepository, - mikroOrmPipelineStageRepository: MikroOrmPipelineStageRepository ) { super(typeOrmPipelineStageRepository, mikroOrmPipelineStageRepository); diff --git a/packages/core/src/pipeline/dto/create-pipeline.dto.ts b/packages/core/src/pipeline/dto/create-pipeline.dto.ts new file mode 100644 index 00000000000..d8830100bfb --- /dev/null +++ b/packages/core/src/pipeline/dto/create-pipeline.dto.ts @@ -0,0 +1,17 @@ +import { IPipelineCreateInput } from '@gauzy/contracts'; +import { IntersectionType, PickType } from '@nestjs/mapped-types'; +import { Pipeline } from '../../core/entities/internal'; +import { TenantOrganizationBaseDTO } from '../../core/dto'; + +/** + * Pipeline DTO + */ +export class PipelineDTO extends IntersectionType( + TenantOrganizationBaseDTO, + PickType(Pipeline, ['name', 'description', 'stages', 'isActive', 'isArchived']) +) {} + +/** + * Create pipeline DTO + */ +export class CreatePipelineDTO extends PipelineDTO implements IPipelineCreateInput {} diff --git a/packages/core/src/pipeline/dto/index.ts b/packages/core/src/pipeline/dto/index.ts new file mode 100644 index 00000000000..e2dbcf1fbaf --- /dev/null +++ b/packages/core/src/pipeline/dto/index.ts @@ -0,0 +1,2 @@ +export * from './create-pipeline.dto'; +export * from './update-pipeline.dto'; diff --git a/packages/core/src/pipeline/dto/update-pipeline.dto.ts b/packages/core/src/pipeline/dto/update-pipeline.dto.ts new file mode 100644 index 00000000000..b6bd4fbbd1f --- /dev/null +++ b/packages/core/src/pipeline/dto/update-pipeline.dto.ts @@ -0,0 +1,6 @@ +import { CreatePipelineDTO } from './create-pipeline.dto'; + +/** + * Update pipeline DTO + */ +export class UpdatePipelineDTO extends CreatePipelineDTO {} diff --git a/packages/core/src/pipeline/pipeline.controller.ts b/packages/core/src/pipeline/pipeline.controller.ts index 7f89645537a..7330746ab94 100644 --- a/packages/core/src/pipeline/pipeline.controller.ts +++ b/packages/core/src/pipeline/pipeline.controller.ts @@ -12,15 +12,15 @@ import { UseGuards } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { DeepPartial } from 'typeorm'; -import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; -import { IDeal, IPagination, IPipeline, PermissionsEnum } from '@gauzy/contracts'; -import { CrudController, PaginationParams } from './../core/crud'; +import { DeleteResult, UpdateResult } from 'typeorm'; +import { ID, IDeal, IPagination, IPipeline, PermissionsEnum } from '@gauzy/contracts'; +import { CrudController, OptionParams, PaginationParams } from './../core/crud'; import { Pipeline } from './pipeline.entity'; import { PipelineService } from './pipeline.service'; import { UUIDValidationPipe, UseValidationPipe } from './../shared/pipes'; import { Permissions } from './../shared/decorators'; import { PermissionGuard, TenantPermissionGuard } from './../shared/guards'; +import { CreatePipelineDTO, UpdatePipelineDTO } from './dto'; @ApiTags('Pipeline') @UseGuards(TenantPermissionGuard, PermissionGuard) @@ -38,7 +38,7 @@ export class PipelineController extends CrudController { * @returns The paginated result of sales pipelines. */ @Permissions(PermissionsEnum.VIEW_SALES_PIPELINES) - @Get('pagination') + @Get('/pagination') @UseValidationPipe({ transform: true }) async pagination(@Query() filter: PaginationParams): Promise> { return await this.pipelineService.pagination(filter); @@ -56,26 +56,47 @@ export class PipelineController extends CrudController { description: 'Found records' }) @Permissions(PermissionsEnum.VIEW_SALES_PIPELINES) - @Get() + @Get('/') public async findAll(@Query() filter: PaginationParams): Promise> { return await this.pipelineService.findAll(filter); } /** - * Find deals for a specific sales pipeline with permissions, API documentation, and parameter validation. + * Get deals associated with a specific pipeline * - * @param id - The identifier of the sales pipeline. - * @returns A paginated result of deals for the specified sales pipeline. + * @param pipelineId The ID of the pipeline + * @param options Filter conditions for fetching the deals + * @returns A promise of paginated deals */ - @ApiOperation({ summary: 'find deals' }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Found records' - }) + @ApiOperation({ summary: 'Get deals for a specific pipeline' }) + @ApiResponse({ status: 200, description: 'Success' }) + @ApiResponse({ status: 400, description: 'Bad Request' }) + @ApiResponse({ status: 404, description: 'Not Found' }) + @Permissions(PermissionsEnum.VIEW_SALES_PIPELINES) + @Get('/:pipelineId/deals') + async getPipelineDeals( + @Param('pipelineId', UUIDValidationPipe) pipelineId: ID, + @Query() options: OptionParams + ): Promise> { + return await this.pipelineService.getPipelineDeals(pipelineId, options.where); + } + + /** + * Find a Pipeline by ID + * + * @param id - The ID of the Pipeline to find + * @returns The found Pipeline + */ + @ApiOperation({ summary: 'Find a Pipeline by ID' }) + @ApiResponse({ status: 200, description: 'The found Pipeline' }) + @ApiResponse({ status: 404, description: 'Pipeline not found' }) @Permissions(PermissionsEnum.VIEW_SALES_PIPELINES) - @Get(':id/deals') - public async findDeals(@Param('id', UUIDValidationPipe) id: string): Promise> { - return await this.pipelineService.findDeals(id); + @Get('/:id') + async findById( + @Param('id', UUIDValidationPipe) id: ID, + @Query() options: OptionParams + ): Promise { + return await this.pipelineService.findById(id, options); } /** @@ -95,8 +116,9 @@ export class PipelineController extends CrudController { }) @HttpCode(HttpStatus.CREATED) @Permissions(PermissionsEnum.EDIT_SALES_PIPELINES) - @Post() - async create(@Body() entity: DeepPartial): Promise { + @Post('/') + @UseValidationPipe() + async create(@Body() entity: CreatePipelineDTO): Promise { return await this.pipelineService.create(entity); } @@ -123,11 +145,12 @@ export class PipelineController extends CrudController { }) @HttpCode(HttpStatus.ACCEPTED) @Permissions(PermissionsEnum.EDIT_SALES_PIPELINES) - @Put(':id') + @Put('/:id') + @UseValidationPipe() async update( - @Param('id', UUIDValidationPipe) id: string, - @Body() entity: QueryDeepPartialEntity - ): Promise { + @Param('id', UUIDValidationPipe) id: ID, + @Body() entity: UpdatePipelineDTO + ): Promise { return await this.pipelineService.update(id, entity); } @@ -149,8 +172,8 @@ export class PipelineController extends CrudController { }) @HttpCode(HttpStatus.ACCEPTED) @Permissions(PermissionsEnum.EDIT_SALES_PIPELINES) - @Delete(':id') - async delete(@Param('id', UUIDValidationPipe) id: string): Promise { + @Delete('/:id') + async delete(@Param('id', UUIDValidationPipe) id: ID): Promise { return await this.pipelineService.delete(id); } } diff --git a/packages/core/src/pipeline/pipeline.entity.ts b/packages/core/src/pipeline/pipeline.entity.ts index 0422e1dec17..252acfd114e 100644 --- a/packages/core/src/pipeline/pipeline.entity.ts +++ b/packages/core/src/pipeline/pipeline.entity.ts @@ -1,16 +1,12 @@ import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IPipeline, IPipelineStage } from '@gauzy/contracts'; -import { - PipelineStage, - TenantOrganizationBaseEntity -} from '../core/entities/internal'; +import { PipelineStage, TenantOrganizationBaseEntity } from '../core/entities/internal'; import { MultiORMColumn, MultiORMEntity, MultiORMOneToMany } from './../core/decorators/entity'; import { MikroOrmPipelineRepository } from './repository/mikro-orm-pipeline.repository'; @MultiORMEntity('pipeline', { mikroOrmRepository: () => MikroOrmPipelineRepository }) export class Pipeline extends TenantOrganizationBaseEntity implements IPipeline { - @ApiPropertyOptional({ type: () => String }) @IsOptional() @IsString() @@ -28,7 +24,6 @@ export class Pipeline extends TenantOrganizationBaseEntity implements IPipeline | @OneToMany |-------------------------------------------------------------------------- */ - @ApiProperty({ type: () => PipelineStage }) @MultiORMOneToMany(() => PipelineStage, (it) => it.pipeline, { cascade: ['insert'] }) @@ -39,14 +34,17 @@ export class Pipeline extends TenantOrganizationBaseEntity implements IPipeline | EventSubscriber |-------------------------------------------------------------------------- */ - + /** + * @BeforeInsert + */ public __before_persist?(): void { const pipelineId = this.id ? { pipelineId: this.id } : {}; let index = 0; - this.stages?.forEach((stage) => { - Object.assign(stage, pipelineId, { index: ++index }); - }); - console.log(this.stages); + if (this.stages) { + this.stages.forEach((stage) => { + Object.assign(stage, pipelineId, { index: ++index }); + }); + } } } diff --git a/packages/core/src/pipeline/pipeline.service.ts b/packages/core/src/pipeline/pipeline.service.ts index 26de7e67f62..551842d513b 100644 --- a/packages/core/src/pipeline/pipeline.service.ts +++ b/packages/core/src/pipeline/pipeline.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { DeepPartial, FindManyOptions, FindOptionsWhere, Raw, UpdateResult } from 'typeorm'; +import { FindManyOptions, FindOneOptions, FindOptionsWhere, Raw, UpdateResult } from 'typeorm'; import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; -import { IDeal, IPagination, IPipeline, IPipelineStage } from '@gauzy/contracts'; +import { ID, IDeal, IPagination, IPipeline, IPipelineStage } from '@gauzy/contracts'; import { isPostgres } from '@gauzy/config'; import { ConnectionEntityManager } from '../database/connection-entity-manager'; import { prepareSQLQuery as p } from './../database/database.helper'; @@ -16,22 +16,44 @@ import { MikroOrmPipelineRepository, TypeOrmPipelineRepository } from './reposit @Injectable() export class PipelineService extends TenantAwareCrudService { public constructor( - private readonly typeOrmPipelineRepository: TypeOrmPipelineRepository, - private readonly mikroOrmPipelineRepository: MikroOrmPipelineRepository, - private readonly typeOrmDealRepository: TypeOrmDealRepository, - private readonly typeOrmUserRepository: TypeOrmUserRepository, - private readonly _connectionEntityManager: ConnectionEntityManager + readonly typeOrmPipelineRepository: TypeOrmPipelineRepository, + readonly mikroOrmPipelineRepository: MikroOrmPipelineRepository, + readonly typeOrmDealRepository: TypeOrmDealRepository, + readonly typeOrmUserRepository: TypeOrmUserRepository, + readonly connectionEntityManager: ConnectionEntityManager ) { super(typeOrmPipelineRepository, mikroOrmPipelineRepository); } - public async findDeals(pipelineId: string) { + /** + * Find a Pipeline by ID + * + * @param id - The ID of the Pipeline to find + * @param relations - Optional relations to include in the query + * @returns The found Pipeline + */ + async findById(id: ID, options?: FindOneOptions): Promise { + return await super.findOneByIdString(id, options); + } + + /** + * Finds deals for a given pipeline. + * + * @param pipelineId - The ID of the pipeline to find deals for. + * @returns An object containing an array of deals and the total number of deals. + */ + public async getPipelineDeals(pipelineId: ID, where?: FindOptionsWhere) { + // Retrieve the current tenant ID from the request context const tenantId = RequestContext.currentTenantId(); + const { organizationId } = where || {}; + + // Fetch deals related to the pipeline, grouping by stage and deal IDs const items: IDeal[] = await this.typeOrmDealRepository .createQueryBuilder('deal') .leftJoin('deal.stage', 'pipeline_stage') .where(p('pipeline_stage.pipelineId = :pipelineId'), { pipelineId }) .andWhere(p('pipeline_stage.tenantId = :tenantId'), { tenantId }) + .andWhere(p('pipeline_stage.organizationId = :organizationId'), { organizationId }) .groupBy(p('pipeline_stage.id')) // FIX: error: column "deal.id" must appear in the GROUP BY clause or be used in an aggregate function .addGroupBy(p('deal.id')) @@ -39,69 +61,85 @@ export class PipelineService extends TenantAwareCrudService { .orderBy(p('pipeline_stage.index'), 'ASC') .getMany(); + // Get the total number of deals const { length: total } = items; + // For each deal, fetch the user who created it for (const deal of items) { - deal.createdBy = await this.typeOrmUserRepository.findOneBy({ - id: deal.createdByUserId - }); + deal.createdBy = await this.typeOrmUserRepository.findOneBy({ id: deal.createdByUserId }); } + // Return the deals and their total count return { items, total }; } /** + * Updates a Pipeline entity and its stages within a transaction. * - * @param id - * @param entity - * @returns + * @param id - The ID of the Pipeline to update. + * @param entity - The partial entity data to update. + * @returns The result of the update operation. */ - public async update( - id: string | number | FindOptionsWhere, - entity: QueryDeepPartialEntity - ): Promise { - const queryRunner = this._connectionEntityManager.rawConnection.createQueryRunner(); + public async update(id: ID, partialEntity: QueryDeepPartialEntity): Promise { + const queryRunner = this.connectionEntityManager.rawConnection.createQueryRunner(); try { - /** - * Query runner connect & start transaction - */ + // Retrieve the current tenant ID from the request context + const tenantId = RequestContext.currentTenantId(); + + // Connect and start transaction await queryRunner.connect(); await queryRunner.startTransaction(); - await queryRunner.manager.findOneByOrFail(Pipeline, { - id: id as any + // Fetch the existing pipeline + await queryRunner.manager.findOneByOrFail(Pipeline, { id, tenantId }); + + // Create a new pipeline instance with the updated data + const pipeline: Pipeline = queryRunner.manager.create( + Pipeline, + new Pipeline({ + ...partialEntity, + id, + tenantId + }) + ); + + // Fetch existing pipeline stages + const existingStages: IPipelineStage[] = await queryRunner.manager.findBy(PipelineStage, { + pipelineId: id, + tenantId }); - const pipeline: Pipeline = await queryRunner.manager.create(Pipeline, { id: id as any, ...entity } as any); + // Get the updated and existing stages const updatedStages: IPipelineStage[] = pipeline.stages?.filter((stage: IPipelineStage) => stage.id) || []; - const stages: IPipelineStage[] = await queryRunner.manager.findBy(PipelineStage, { - pipelineId: id as any - }); + // Create a list of stage IDs that are being updated + const requestStageIds = updatedStages.map((stage) => stage.id); + + // Identify stages to be deleted + const deletedStages = existingStages.filter((stage) => !requestStageIds.includes(stage.id)); - const requestStageIds = updatedStages.map((updatedStage: IPipelineStage) => updatedStage.id); - const deletedStages = stages.filter((stage: IPipelineStage) => !requestStageIds.includes(stage.id)); - const createdStages = pipeline.stages?.filter((stage: IPipelineStage) => !updatedStages.includes(stage)) || []; + //Identify stages to be created + const createdStages = (pipeline.stages ?? []).filter( + (stage) => !updatedStages.some((updatedStage) => updatedStage.id === stage.id) + ); + // Prepare the pipeline for saving pipeline.__before_persist(); delete pipeline.stages; - await queryRunner.manager.remove(deletedStages); + // Perform stage deletions, creations, and updates concurrently + await Promise.all([ + ...deletedStages.map((stage) => queryRunner.manager.remove(PipelineStage, stage)), + ...createdStages.map((stage) => queryRunner.manager.save(PipelineStage, stage)), + ...updatedStages.map((stage) => queryRunner.manager.save(PipelineStage, stage)) + ]); - for await (const stage of createdStages) { - await queryRunner.manager.save( - queryRunner.manager.create(PipelineStage, stage as DeepPartial) - ); - } - for await (const stage of updatedStages) { - await queryRunner.manager.update(PipelineStage, stage.id, stage); - } - - const saved = await queryRunner.manager.update(Pipeline, id, pipeline); + // Save the updated pipeline + const updatePipeline = await queryRunner.manager.save(Pipeline, pipeline); await queryRunner.commitTransaction(); - return saved; + return updatePipeline; } catch (error) { console.log('Rollback Pipeline Transaction', error); await queryRunner.rollbackTransaction(); @@ -117,20 +155,18 @@ export class PipelineService extends TenantAwareCrudService { * @returns The paginated result. */ public async pagination(filter: FindManyOptions): Promise> { - if ('where' in filter) { - const { where } = filter; + if (filter.where) { const likeOperator = isPostgres() ? 'ILIKE' : 'LIKE'; - if ('name' in where) { - const { name } = where; - filter['where']['name'] = Raw((alias) => `${alias} ${likeOperator} '%${name}%'`); + const { name, description, stages } = filter.where as any; // Type assertion for easier destructuring + + if (name) { + filter.where['name'] = Raw((alias) => `${alias} ${likeOperator} '%${name}%'`); } - if ('description' in where) { - const { description } = where; - filter['where']['description'] = Raw((alias) => `${alias} ${likeOperator} '%${description}%'`); + if (description) { + filter.where['description'] = Raw((alias) => `${alias} ${likeOperator} '%${description}%'`); } - if ('stages' in where) { - const { stages } = where; - filter['where']['stages'] = { + if (stages) { + filter.where['stages'] = { name: Raw((alias) => `${alias} ${likeOperator} '%${stages}%'`) }; } diff --git a/packages/core/src/pipeline/pipeline.subscriber.ts b/packages/core/src/pipeline/pipeline.subscriber.ts index a974b92a78d..62f717b6d35 100644 --- a/packages/core/src/pipeline/pipeline.subscriber.ts +++ b/packages/core/src/pipeline/pipeline.subscriber.ts @@ -1,76 +1,78 @@ -import { EventSubscriber } from "typeorm"; -import { Pipeline } from "./pipeline.entity"; -import { BaseEntityEventSubscriber } from "../core/entities/subscribers/base-entity-event.subscriber"; +import { EventSubscriber } from 'typeorm'; +import { Pipeline } from './pipeline.entity'; +import { BaseEntityEventSubscriber } from '../core/entities/subscribers/base-entity-event.subscriber'; @EventSubscriber() export class PipelineSubscriber extends BaseEntityEventSubscriber { + /** + * Indicates that this subscriber only listen to Pipeline events. + */ + listenTo() { + return Pipeline; + } - /** - * Indicates that this subscriber only listen to Pipeline events. - */ - listenTo() { - return Pipeline; - } + /** + * Called after a Pipeline entity is loaded from the database. This method performs + * additional operations defined in the __after_fetch method on the loaded entity. + * + * @param entity The Pipeline entity that has been loaded. + * @returns {Promise} A promise that resolves when the post-load processing is complete. + */ + async afterEntityLoad(entity: Pipeline): Promise { + try { + this.__after_fetch(entity); + } catch (error) { + console.error( + `PipelineSubscriber: An error occurred during the afterEntityLoad process for Pipeline ID ${entity.id}:`, + error + ); + } + } - /** - * Called after a Pipeline entity is loaded from the database. This method performs - * additional operations defined in the __after_fetch method on the loaded entity. - * - * @param entity The Pipeline entity that has been loaded. - * @returns {Promise} A promise that resolves when the post-load processing is complete. - */ - async afterEntityLoad(entity: Pipeline): Promise { - try { - this.__after_fetch(entity); - } catch (error) { - console.error(`PipelineSubscriber: An error occurred during the afterEntityLoad process for Pipeline ID ${entity.id}:`, error); - } - } + /** + * Called before a Pipeline entity is inserted or created in the database. This method + * assigns pipeline ID and an index to each stage in the pipeline. + * + * @param entity The Pipeline entity about to be created. + * @returns {Promise} A promise that resolves when the pre-creation processing is complete. + */ + async beforeEntityCreate(entity: Pipeline): Promise { + try { + // Assign pipeline ID to each stage and set an incrementing index + const pipelineId = entity?.id ? { pipelineId: entity.id } : {}; + let index = 0; - /** - * Called before a Pipeline entity is inserted or created in the database. This method - * assigns pipeline ID and an index to each stage in the pipeline. - * - * @param entity The Pipeline entity about to be created. - * @returns {Promise} A promise that resolves when the pre-creation processing is complete. - */ - async beforeEntityCreate(entity: Pipeline): Promise { - try { - // Assign pipeline ID to each stage and set an incrementing index - const pipelineId = entity?.id ? { pipelineId: entity.id } : {}; - let index = 0; + entity?.stages?.forEach((stage) => { + Object.assign(stage, pipelineId, { index: ++index }); + }); + } catch (error) { + console.error('PipelineSubscriber: An error occurred during the beforeEntityCreate process:', error); + } + } - entity?.stages?.forEach((stage) => { - Object.assign(stage, pipelineId, { index: ++index }); - }); - } catch (error) { - console.error('PipelineSubscriber: An error occurred during the beforeEntityCreate process:', error); - } - } + /** + * Called after a Pipeline entity is inserted into the database. This method performs + * additional operations defined in the __after_fetch method on the newly created entity. + * + * @param entity The Pipeline entity that has been created. + * @returns {Promise} A promise that resolves when the post-creation processing is complete. + */ + async afterEntityCreate(entity: Pipeline): Promise { + try { + this.__after_fetch(entity); + } catch (error) { + console.error('PipelineSubscriber: An error occurred during the afterEntityCreate process:', error); + } + } - /** - * Called after a Pipeline entity is inserted into the database. This method performs - * additional operations defined in the __after_fetch method on the newly created entity. - * - * @param entity The Pipeline entity that has been created. - * @returns {Promise} A promise that resolves when the post-creation processing is complete. - */ - async afterEntityCreate(entity: Pipeline): Promise { - try { - this.__after_fetch(entity); - } catch (error) { - console.error('PipelineSubscriber: An error occurred during the afterEntityCreate process:', error); - } - } - - /*** - * Internal method to be used after fetching the Pipeline entity. - * - * @param entity - The fetched Pipeline entity. - */ - private __after_fetch(entity: Pipeline): void { - if (entity.stages) { - entity.stages.sort(({ index: a }, { index: b }) => a - b); - } - } + /*** + * Internal method to be used after fetching the Pipeline entity. + * + * @param entity - The fetched Pipeline entity. + */ + private __after_fetch(entity: Pipeline): void { + if (entity.stages) { + entity.stages.sort(({ index: a }, { index: b }) => a - b); + } + } } diff --git a/packages/ui-core/src/lib/core/src/services/deals/deals.service.ts b/packages/ui-core/src/lib/core/src/services/deals/deals.service.ts index 2ef0d13a4f8..1ca150f9e15 100644 --- a/packages/ui-core/src/lib/core/src/services/deals/deals.service.ts +++ b/packages/ui-core/src/lib/core/src/services/deals/deals.service.ts @@ -1,23 +1,44 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { IDeal, IDealCreateInput, IDealFindInput } from '@gauzy/contracts'; -import { API_PREFIX } from '@gauzy/ui-core/common'; import { firstValueFrom } from 'rxjs'; +import { ID, IDeal, IDealCreateInput, IDealFindInput, IPagination } from '@gauzy/contracts'; +import { API_PREFIX, toParams } from '@gauzy/ui-core/common'; import { Service } from '../crud/service'; @Injectable() export class DealsService extends Service { - public constructor(protected http: HttpClient) { + constructor(readonly http: HttpClient) { super({ http, basePath: `${API_PREFIX}/deals` }); } - getAll(findInput?: IDealFindInput, relations?: string[]): Promise { - const data = JSON.stringify({ relations, findInput }); - return firstValueFrom(this.http.get(`${this.basePath}`, { params: { data } })); + /** + * Fetch all deals with optional relations and filter conditions + * + * @param relations Array of relation names to include in the result + * @param where Filter conditions for fetching deals + * @returns A promise of paginated deals + */ + getAll(relations?: string[], where?: IDealFindInput): Promise> { + return firstValueFrom( + this.http.get>(`${this.basePath}`, { + params: toParams({ where, relations }) + }) + ); } - getOne(id: string, findInput?: IDealFindInput, relations?: string[]): Promise { - const data = JSON.stringify({ relations, findInput }); - return firstValueFrom(this.http.get(`${this.basePath}/${id}`, { params: { data } })); + /** + * Fetch a deal by its ID with optional relations and filter conditions + * + * @param id The ID of the deal to fetch + * @param where Filter conditions for fetching the deal + * @param relations Array of relation names to include in the result + * @returns A promise of the fetched deal + */ + getById(id: ID, where?: IDealFindInput, relations: string[] = []): Promise { + return firstValueFrom( + this.http.get(`${this.basePath}/${id}`, { + params: toParams({ where, relations }) + }) + ); } } diff --git a/packages/ui-core/src/lib/core/src/services/pipeline/pipelines.service.ts b/packages/ui-core/src/lib/core/src/services/pipeline/pipelines.service.ts index 56e0ad29dc3..0c0fee15f93 100644 --- a/packages/ui-core/src/lib/core/src/services/pipeline/pipelines.service.ts +++ b/packages/ui-core/src/lib/core/src/services/pipeline/pipelines.service.ts @@ -1,8 +1,8 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { firstValueFrom } from 'rxjs'; -import { IDeal, IPagination, IPipeline, IPipelineCreateInput, IPipelineFindInput } from '@gauzy/contracts'; -import { API_PREFIX, Store } from '@gauzy/ui-core/common'; +import { firstValueFrom, Observable } from 'rxjs'; +import { ID, IDeal, IPagination, IPipeline, IPipelineCreateInput, IPipelineFindInput } from '@gauzy/contracts'; +import { API_PREFIX, Store, toParams } from '@gauzy/ui-core/common'; import { Service } from '../crud/service'; @Injectable() @@ -11,17 +11,46 @@ export class PipelinesService extends Service> { - const data = JSON.stringify({ relations, findInput }); + /** + * Fetches all pipelines with optional relations and filtering conditions. + * + * @param relations - An optional array of relation names to include in the response. + * @param where - Optional filtering conditions. + * @returns A promise that resolves with the paginated pipelines. + */ + getAll(relations?: string[], where?: IPipelineFindInput): Promise> { return firstValueFrom( this.http.get>(`${this.basePath}`, { - params: { data } + params: toParams({ where, relations }) }) ); } - public findDeals(id: string, findInput?: IPipelineFindInput): Promise> { - const data = JSON.stringify({ findInput }); - return firstValueFrom(this.http.get>(`${this.basePath}/${id}/deals`, { params: { data } })); + /** + * Fetches a pipeline by its ID with optional relations. + * + * @param id - The ID of the pipeline to fetch. + * @param relations - An array of relation names to include in the response. + * @returns A promise that resolves with the pipeline. + */ + getById(id: ID, where?: IPipelineFindInput, relations: string[] = []): Observable { + return this.http.get(`${this.basePath}/${id}`, { + params: toParams({ where, relations }) + }); + } + + /** + * Find deals associated with a specific pipeline + * + * @param pipelineId The ID of the pipeline + * @param where Filter conditions for fetching the deals + * @returns A promise of paginated deals + */ + getPipelineDeals(pipelineId: ID, where?: IPipelineFindInput): Promise> { + return firstValueFrom( + this.http.get>(`${this.basePath}/${pipelineId}/deals`, { + params: toParams({ where }) + }) + ); } } diff --git a/packages/ui-core/src/lib/shared/src/user/forms/delete-confirmation/delete-confirmation.component.ts b/packages/ui-core/src/lib/shared/src/user/forms/delete-confirmation/delete-confirmation.component.ts index dd788e5841a..c86e978bfed 100644 --- a/packages/ui-core/src/lib/shared/src/user/forms/delete-confirmation/delete-confirmation.component.ts +++ b/packages/ui-core/src/lib/shared/src/user/forms/delete-confirmation/delete-confirmation.component.ts @@ -6,31 +6,21 @@ import { NbDialogRef } from '@nebular/theme'; template: ` - +
{{ 'FORM.CONFIRM' | translate }}
{{ 'FORM.DELETE_CONFIRMATION.SURE' | translate }} {{ recordType | translate }} - {{ - 'FORM.DELETE_CONFIRMATION.RECORD' | translate - }}? + {{ 'FORM.DELETE_CONFIRMATION.RECORD' | translate }} ? - @@ -41,9 +31,8 @@ import { NbDialogRef } from '@nebular/theme'; export class DeleteConfirmationComponent { recordType: string; isRecord: boolean = true; - constructor( - protected dialogRef: NbDialogRef - ) {} + + constructor(protected readonly dialogRef: NbDialogRef) {} close() { this.dialogRef.close();