From a808c677bdce7f975f973e0e5b1f06d2b5c1c499 Mon Sep 17 00:00:00 2001 From: Cyril Joseph Date: Sun, 3 Aug 2025 18:27:24 -0300 Subject: [PATCH] process carnet and param country message updates --- package-lock.json | 11 + package.json | 3 +- src/app/app.routes.server.ts | 5 + src/app/app.routes.ts | 5 + src/app/carnet/add/add-carnet.component.html | 53 ++ src/app/carnet/add/add-carnet.component.scss | 11 + src/app/carnet/add/add-carnet.component.ts | 90 +++ .../application/application.component.html | 32 + .../application/application.component.scss | 102 +++ .../application/application.component.ts | 118 +++ .../carnet/edit/edit-carnet.component.html | 53 ++ .../carnet/edit/edit-carnet.component.scss | 0 src/app/carnet/edit/edit-carnet.component.ts | 102 +++ src/app/carnet/goods/goods.component.html | 240 ++++++ src/app/carnet/goods/goods.component.scss | 168 +++++ src/app/carnet/goods/goods.component.ts | 460 ++++++++++++ src/app/carnet/holder/holder.component.html | 2 + src/app/carnet/holder/holder.component.scss | 0 src/app/carnet/holder/holder.component.ts | 52 ++ .../shipping/contact-dialog.component.ts | 86 +++ .../carnet/shipping/shipping.component.html | 366 +++++++++ .../carnet/shipping/shipping.component.scss | 105 +++ src/app/carnet/shipping/shipping.component.ts | 709 ++++++++++++++++++ .../terms-conditions.component.html | 112 +++ .../terms-conditions.component.scss | 84 +++ .../terms-conditions.component.ts | 92 +++ .../travel-plan/travel-plan.component.html | 151 ++++ .../travel-plan/travel-plan.component.scss | 143 ++++ .../travel-plan/travel-plan.component.ts | 317 ++++++++ .../carnet/view/view-carnet.component.html | 42 ++ .../carnet/view/view-carnet.component.scss | 8 + src/app/carnet/view/view-carnet.component.ts | 46 ++ .../secured-header.component.html | 8 +- .../core/models/carnet/application-detail.ts | 6 + src/app/core/models/carnet/fee.ts | 10 + src/app/core/models/carnet/goods.ts | 20 + src/app/core/models/carnet/holder.ts | 16 + .../core/models/carnet/shipping-address.ts | 10 + .../core/models/carnet/shipping-contact.ts | 15 + src/app/core/models/carnet/shipping.ts | 24 + src/app/core/models/carnet/travel-plan.ts | 17 + src/app/core/models/delivery-method.ts | 5 + src/app/core/models/delivery-type.ts | 2 + src/app/core/models/formofsecurity.ts | 6 + src/app/core/models/holder/basic-detail.ts | 18 + src/app/core/models/holder/contact.ts | 19 + src/app/core/models/holder/holder-filter.ts | 3 + src/app/core/models/payment-type.ts | 5 + src/app/core/models/unitofmeasure.ts | 5 + .../carnet/application-detail.service.ts | 36 + .../core/services/carnet/carnet.service.ts | 59 ++ src/app/core/services/carnet/goods.service.ts | 120 +++ .../core/services/carnet/holder.service.ts | 51 ++ .../core/services/carnet/insurance.service.ts | 9 + .../core/services/carnet/shipping.service.ts | 157 ++++ .../services/carnet/travel-plan.service.ts | 93 +++ .../core/services/common/common.service.ts | 57 +- .../services/holder/basic-detail.service.ts | 90 +++ .../core/services/holder/contact.service.ts | 90 +++ .../core/services/holder/holder.service.ts | 54 ++ src/app/holder/add/add-holder.component.html | 20 + src/app/holder/add/add-holder.component.scss | 0 src/app/holder/add/add-holder.component.ts | 55 ++ .../basic-details.component.html | 158 ++++ .../basic-details.component.scss | 111 +++ .../basic-details/basic-details.component.ts | 227 ++++++ .../holder/contacts/contacts.component.html | 266 +++++++ .../holder/contacts/contacts.component.scss | 183 +++++ src/app/holder/contacts/contacts.component.ts | 257 +++++++ .../holder/edit/edit-holder.component.html | 23 + .../holder/edit/edit-holder.component.scss | 18 + src/app/holder/edit/edit-holder.component.ts | 42 ++ .../search/search-holder.component.html | 126 ++++ .../search/search-holder.component.scss | 116 +++ .../holder/search/search-holder.component.ts | 266 +++++++ src/app/home/home.component.html | 58 +- src/app/home/home.component.scss | 42 +- src/app/home/home.component.ts | 10 +- .../manage-country.component.html | 103 +++ .../manage-country.component.scss | 108 +++ .../manage-country.component.ts | 156 ++++ src/environments/environment.development.ts | 3 +- src/environments/environment.ts | 3 +- 83 files changed, 7071 insertions(+), 53 deletions(-) create mode 100644 src/app/carnet/add/add-carnet.component.html create mode 100644 src/app/carnet/add/add-carnet.component.scss create mode 100644 src/app/carnet/add/add-carnet.component.ts create mode 100644 src/app/carnet/application/application.component.html create mode 100644 src/app/carnet/application/application.component.scss create mode 100644 src/app/carnet/application/application.component.ts create mode 100644 src/app/carnet/edit/edit-carnet.component.html create mode 100644 src/app/carnet/edit/edit-carnet.component.scss create mode 100644 src/app/carnet/edit/edit-carnet.component.ts create mode 100644 src/app/carnet/goods/goods.component.html create mode 100644 src/app/carnet/goods/goods.component.scss create mode 100644 src/app/carnet/goods/goods.component.ts create mode 100644 src/app/carnet/holder/holder.component.html create mode 100644 src/app/carnet/holder/holder.component.scss create mode 100644 src/app/carnet/holder/holder.component.ts create mode 100644 src/app/carnet/shipping/contact-dialog.component.ts create mode 100644 src/app/carnet/shipping/shipping.component.html create mode 100644 src/app/carnet/shipping/shipping.component.scss create mode 100644 src/app/carnet/shipping/shipping.component.ts create mode 100644 src/app/carnet/terms-conditions/terms-conditions.component.html create mode 100644 src/app/carnet/terms-conditions/terms-conditions.component.scss create mode 100644 src/app/carnet/terms-conditions/terms-conditions.component.ts create mode 100644 src/app/carnet/travel-plan/travel-plan.component.html create mode 100644 src/app/carnet/travel-plan/travel-plan.component.scss create mode 100644 src/app/carnet/travel-plan/travel-plan.component.ts create mode 100644 src/app/carnet/view/view-carnet.component.html create mode 100644 src/app/carnet/view/view-carnet.component.scss create mode 100644 src/app/carnet/view/view-carnet.component.ts create mode 100644 src/app/core/models/carnet/application-detail.ts create mode 100644 src/app/core/models/carnet/fee.ts create mode 100644 src/app/core/models/carnet/goods.ts create mode 100644 src/app/core/models/carnet/holder.ts create mode 100644 src/app/core/models/carnet/shipping-address.ts create mode 100644 src/app/core/models/carnet/shipping-contact.ts create mode 100644 src/app/core/models/carnet/shipping.ts create mode 100644 src/app/core/models/carnet/travel-plan.ts create mode 100644 src/app/core/models/delivery-method.ts create mode 100644 src/app/core/models/formofsecurity.ts create mode 100644 src/app/core/models/holder/basic-detail.ts create mode 100644 src/app/core/models/holder/contact.ts create mode 100644 src/app/core/models/holder/holder-filter.ts create mode 100644 src/app/core/models/payment-type.ts create mode 100644 src/app/core/models/unitofmeasure.ts create mode 100644 src/app/core/services/carnet/application-detail.service.ts create mode 100644 src/app/core/services/carnet/carnet.service.ts create mode 100644 src/app/core/services/carnet/goods.service.ts create mode 100644 src/app/core/services/carnet/holder.service.ts create mode 100644 src/app/core/services/carnet/insurance.service.ts create mode 100644 src/app/core/services/carnet/shipping.service.ts create mode 100644 src/app/core/services/carnet/travel-plan.service.ts create mode 100644 src/app/core/services/holder/basic-detail.service.ts create mode 100644 src/app/core/services/holder/contact.service.ts create mode 100644 src/app/core/services/holder/holder.service.ts create mode 100644 src/app/holder/add/add-holder.component.html create mode 100644 src/app/holder/add/add-holder.component.scss create mode 100644 src/app/holder/add/add-holder.component.ts create mode 100644 src/app/holder/basic-details/basic-details.component.html create mode 100644 src/app/holder/basic-details/basic-details.component.scss create mode 100644 src/app/holder/basic-details/basic-details.component.ts create mode 100644 src/app/holder/contacts/contacts.component.html create mode 100644 src/app/holder/contacts/contacts.component.scss create mode 100644 src/app/holder/contacts/contacts.component.ts create mode 100644 src/app/holder/edit/edit-holder.component.html create mode 100644 src/app/holder/edit/edit-holder.component.scss create mode 100644 src/app/holder/edit/edit-holder.component.ts create mode 100644 src/app/holder/search/search-holder.component.html create mode 100644 src/app/holder/search/search-holder.component.scss create mode 100644 src/app/holder/search/search-holder.component.ts create mode 100644 src/app/param/manage-country/manage-country.component.html create mode 100644 src/app/param/manage-country/manage-country.component.scss create mode 100644 src/app/param/manage-country/manage-country.component.ts diff --git a/package-lock.json b/package-lock.json index bad5f70..53704f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@angular/router": "^20.0.5", "@angular/ssr": "^20.0.4", "chart.js": "^4.3.0", + "date-fns": "^4.1.0", "express": "^4.18.2", "ng2-charts": "^8.0.0", "ngx-cookie-service": "^20.0.1", @@ -4645,6 +4646,16 @@ "dev": true, "license": "MIT" }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/date-format": { "version": "4.0.14", "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", diff --git a/package.json b/package.json index e62eeb6..ce2af0e 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@angular/router": "^20.0.5", "@angular/ssr": "^20.0.4", "chart.js": "^4.3.0", + "date-fns": "^4.1.0", "express": "^4.18.2", "ng2-charts": "^8.0.0", "ngx-cookie-service": "^20.0.1", @@ -47,4 +48,4 @@ "karma-jasmine-html-reporter": "~2.1.0", "typescript": "~5.8.3" } -} \ No newline at end of file +} diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts index 959b2db..082105a 100644 --- a/src/app/app.routes.server.ts +++ b/src/app/app.routes.server.ts @@ -10,7 +10,12 @@ export const serverRoutes: ServerRoute[] = [ { path: ':appId/preparer/:id', renderMode: RenderMode.Client }, { path: ':appId/add-preparer', renderMode: RenderMode.Client }, { path: ':appId/table-record', renderMode: RenderMode.Client }, + { path: ':appId/country-messages', renderMode: RenderMode.Client }, { path: ':appId/add-preparer', renderMode: RenderMode.Client }, + { path: ':appId/edit-carnet/:headerid', renderMode: RenderMode.Client }, + { path: ':appId/view-carnet/:headerid', renderMode: RenderMode.Client }, + { path: ':appId/add-holder', renderMode: RenderMode.Client }, + { path: ':appId/edit-holder/:holderid', renderMode: RenderMode.Client }, { path: '**', renderMode: RenderMode.Prerender diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index ba68ece..42b4a27 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -19,6 +19,11 @@ export const routes: Routes = [ { path: 'add-preparer', loadComponent: () => import('./preparer/add/add-preparer.component').then(m => m.AddPreparerComponent) }, { path: 'table-record', loadComponent: () => import('./param/manage-table-record/manage-table-record.component').then(m => m.ManageTableRecordComponent) }, { path: 'param-record/:id', loadComponent: () => import('./param/manage-param-record/manage-param-record.component').then(m => m.ManageParamRecordComponent) }, + { path: 'country-messages', loadComponent: () => import('./param/manage-country/manage-country.component').then(m => m.ManageCountryComponent) }, + { path: 'edit-carnet/:headerid', loadComponent: () => import('./carnet/edit/edit-carnet.component').then(m => m.EditCarnetComponent) }, + { path: 'view-carnet/:headerid', loadComponent: () => import('./carnet/view/view-carnet.component').then(m => m.ViewCarnetComponent) }, + { path: 'add-holder', loadComponent: () => import('./holder/add/add-holder.component').then(m => m.AddHolderComponent) }, + { path: 'edit-holder/:holderid', loadComponent: () => import('./holder/edit/edit-holder.component').then(m => m.EditHolderComponent) }, { path: '', redirectTo: 'home', pathMatch: 'full' } ], canActivate: [AuthGuard, AppIdGuard] diff --git a/src/app/carnet/add/add-carnet.component.html b/src/app/carnet/add/add-carnet.component.html new file mode 100644 index 0000000..431e46c --- /dev/null +++ b/src/app/carnet/add/add-carnet.component.html @@ -0,0 +1,53 @@ +
+ + + + + + Application Name + + + + + + + Holder Selection + + + + + + + Goods Section + + + + + + + Travel Plan + + + + + + + Shipping & Payment + + + + + + + done + + +
\ No newline at end of file diff --git a/src/app/carnet/add/add-carnet.component.scss b/src/app/carnet/add/add-carnet.component.scss new file mode 100644 index 0000000..c7e2a38 --- /dev/null +++ b/src/app/carnet/add/add-carnet.component.scss @@ -0,0 +1,11 @@ +// .client-carnet-container { +// // padding: 24px; +// // max-width: 1200px; +// // margin: 0 auto; + +// .actions { +// margin-top: 24px; +// display: flex; +// justify-content: flex-start; +// } +// } \ No newline at end of file diff --git a/src/app/carnet/add/add-carnet.component.ts b/src/app/carnet/add/add-carnet.component.ts new file mode 100644 index 0000000..85277e5 --- /dev/null +++ b/src/app/carnet/add/add-carnet.component.ts @@ -0,0 +1,90 @@ +import { Component, inject, ViewChild } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { AngularMaterialModule } from '../../shared/module/angular-material.module'; +import { CommonModule } from '@angular/common'; +import { StepperSelectionEvent } from '@angular/cdk/stepper'; +import { ApplicationComponent } from "../application/application.component"; +import { HolderComponent } from '../holder/holder.component'; +import { GoodsComponent } from '../goods/goods.component'; +import { TravelPlanComponent } from '../travel-plan/travel-plan.component'; +import { ShippingComponent } from '../shipping/shipping.component'; +import { UserPreferencesService } from '../../core/services/user-preference.service'; +import { UserPreferences } from '../../core/models/user-preference'; + +@Component({ + selector: 'app-add-carnet', + imports: [AngularMaterialModule, CommonModule, ReactiveFormsModule, + ApplicationComponent, HolderComponent, GoodsComponent, TravelPlanComponent, + ShippingComponent], + templateUrl: './add-carnet.component.html', + styleUrl: './add-carnet.component.scss' +}) +export class AddCarnetComponent { + currentStep = 0; + isLinear = true; + applicationType: 'new' | 'additional' | 'duplicate' | 'extend' | null = 'new'; + isEditMode = false; + headerid: number = 0; + applicationName: string = ''; + userPreferences: UserPreferences; + allSectionsCompleted: boolean = false; + + // Track completion of each step + stepsCompleted = { + applicationDetail: false, + holderSelection: false, + goodsSection: false, + travelPlan: false, + shipping: false + }; + + @ViewChild(ShippingComponent, { static: false }) + private shippingComponent!: ShippingComponent; + + constructor(userPrefenceService: UserPreferencesService) { + this.userPreferences = userPrefenceService.getPreferences(); + } + + onStepChange(event: StepperSelectionEvent): void { + this.currentStep = event.selectedIndex; + } + + onApplicationDetailCreated(data: { headerid: number, applicationName: string }): void { + this.headerid = data.headerid; + this.applicationName = data.applicationName; + this.stepsCompleted.applicationDetail = true; + this.isLinear = false; // Disable linear mode after application detail is created + } + + onHolderSelectionSaved(completed: boolean): void { + this.stepsCompleted.holderSelection = completed; + this.isAllSectionsCompleted(); + } + + onHolderSelectionUpdated(updated: boolean): void { + if (updated) { + this.shippingComponent?.refreshShippingData(); + } + } + + onGoodsSectionSaved(completed: boolean): void { + this.stepsCompleted.goodsSection = completed; + this.isAllSectionsCompleted(); + } + + onTravelPlanSaved(completed: boolean): void { + this.stepsCompleted.travelPlan = completed; + this.isAllSectionsCompleted(); + } + + onShippingSaved(completed: boolean): void { + this.stepsCompleted.shipping = completed; + this.isAllSectionsCompleted(); + } + + isAllSectionsCompleted(): void { + this.allSectionsCompleted = this.stepsCompleted.applicationDetail + && this.stepsCompleted.holderSelection && this.stepsCompleted.goodsSection + && this.stepsCompleted.shipping && this.stepsCompleted.travelPlan; + } +} diff --git a/src/app/carnet/application/application.component.html b/src/app/carnet/application/application.component.html new file mode 100644 index 0000000..342eb28 --- /dev/null +++ b/src/app/carnet/application/application.component.html @@ -0,0 +1,32 @@ +
+ + +
+ +
+ +
+
+ + Name + + + Name is required + + + Maximum 50 characters allowed + + +
+ +
+ +
+
+
+
+
\ No newline at end of file diff --git a/src/app/carnet/application/application.component.scss b/src/app/carnet/application/application.component.scss new file mode 100644 index 0000000..5c09e32 --- /dev/null +++ b/src/app/carnet/application/application.component.scss @@ -0,0 +1,102 @@ +.application-details-container { + display: flex; + flex-direction: column; + gap: 24px; + width: 100%; + + .details-card { + overflow: hidden; + transition: all 0.3s ease; + position: relative; + background: none; + box-shadow: none; + + mat-card-content { + padding: inherit 0; + } + + .loading-shade { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.7); + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + } + + .details-form { + display: flex; + flex-direction: column; + gap: 16px; + } + + .form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 16px; + align-items: start; + + .name { + grid-column: span 2; + } + + .lookup-code { + grid-column: span 1; + } + + .address1, + .address2 { + grid-column: span 3; + } + + .city, + .state, + .country, + .zip { + grid-column: span 1; + } + + .carnet-issuing-region, + .revenue-location { + grid-column: span 1; + } + } + + .form-actions { + display: flex; + justify-content: flex-end; + gap: 16px; + } + + mat-form-field { + width: 100%; + } + } +} + +@media (max-width: 960px) { + .application-details-container { + .details-card { + .form-row { + grid-template-columns: 1fr; + + .name, + .lookup-code, + .address1, + .address2, + .city, + .state, + .zip, + .country, + .carnet-issuing-region, + .revenue-location { + grid-column: span 1; + } + } + } + } +} \ No newline at end of file diff --git a/src/app/carnet/application/application.component.ts b/src/app/carnet/application/application.component.ts new file mode 100644 index 0000000..fa99e4d --- /dev/null +++ b/src/app/carnet/application/application.component.ts @@ -0,0 +1,118 @@ +import { Component, EventEmitter, inject, Input, Output } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ApplicationDetailService } from '../../core/services/carnet/application-detail.service'; +import { NotificationService } from '../../core/services/common/notification.service'; +import { ApiErrorHandlerService } from '../../core/services/common/api-error-handler.service'; +import { ApplicationDetail } from '../../core/models/carnet/application-detail'; +import { Subject } from 'rxjs'; +import { AngularMaterialModule } from '../../shared/module/angular-material.module'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute } from '@angular/router'; + +@Component({ + selector: 'app-application', + imports: [AngularMaterialModule, CommonModule, ReactiveFormsModule], + templateUrl: './application.component.html', + styleUrl: './application.component.scss' +}) +export class ApplicationComponent { + @Input() isEditMode = false; + @Input() isViewMode = false; + @Input() headerid: number = 0; + @Input() applicationName: string = ''; + + @Output() applicationCreated = new EventEmitter<{ headerid: number, applicationName: string }>(); + + applicationDetailsForm: FormGroup; + isLoading = false; + disableSaveButton = false; + + private destroy$ = new Subject(); + + private fb = inject(FormBuilder); + private applicationDetailService = inject(ApplicationDetailService); + private notificationService = inject(NotificationService); + private errorHandler = inject(ApiErrorHandlerService); + private route = inject(ActivatedRoute); + + constructor() { + this.applicationDetailsForm = this.createForm(); + } + + ngOnInit(): void { + // Patch edit form data + if (this.applicationName) { + this.patchFormData(); + + // this.applicationDetailService.getApplicationDetailsById(this.headerid).subscribe({ + // next: (applicationDetail: ApplicationDetail) => { + // if (applicationDetail?.applicationId > 0) { + // this.patchFormData(applicationDetail); + // } + // this.isLoading = false; + // }, + // error: (error: any) => { + // let errorMessage = this.errorHandler.handleApiError(error, 'Failed to load application details'); + // this.notificationService.showError(errorMessage); + // this.isLoading = false; + // console.error('Error loading application details:', error); + // } + // }); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + createForm(): FormGroup { + return this.fb.group({ + name: ['', [Validators.required, Validators.maxLength(50)]], + }); + } + + patchFormData(): void { + this.applicationDetailsForm.patchValue({ + name: this.applicationName, + }); + + if (this.isEditMode) { + this.applicationDetailsForm.get('name')?.disable(); + this.disableSaveButton = true; + } + } + + // Convenience getter for easy access to form fields + get f() { + return this.applicationDetailsForm.controls; + } + + saveApplicationDetails(): void { + if (this.applicationDetailsForm.invalid) { + this.applicationDetailsForm.markAllAsTouched(); + return; + } + + const applicationDetailData: ApplicationDetail = this.applicationDetailsForm.value; + + if (!this.isEditMode && this.headerid == 0) { + this.isLoading = true; + this.applicationDetailService.createApplicationDetails(applicationDetailData).subscribe({ + next: (applicationData: any) => { + this.notificationService.showSuccess(`Application details added successfully`); + this.applicationCreated.emit({ headerid: +applicationData.HEADERID, applicationName: applicationDetailData.name }); + this.applicationDetailsForm.get('name')?.disable(); + this.disableSaveButton = true; + this.isLoading = false; + }, + error: (error) => { + let errorMessage = this.errorHandler.handleApiError(error, `Failed to add application details`); + this.notificationService.showError(errorMessage); + console.error('Error saving application details:', error); + this.isLoading = false; + } + }); + } + } +} diff --git a/src/app/carnet/edit/edit-carnet.component.html b/src/app/carnet/edit/edit-carnet.component.html new file mode 100644 index 0000000..d9aabc0 --- /dev/null +++ b/src/app/carnet/edit/edit-carnet.component.html @@ -0,0 +1,53 @@ +
+ + + + + + Application Name + + + + + + + Holder Selection + + + + + + + Goods Section + + + + + + + Travel Plan + + + + + + + Shipping & Payment + + + + + + + done + + +
\ No newline at end of file diff --git a/src/app/carnet/edit/edit-carnet.component.scss b/src/app/carnet/edit/edit-carnet.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/carnet/edit/edit-carnet.component.ts b/src/app/carnet/edit/edit-carnet.component.ts new file mode 100644 index 0000000..5cb1750 --- /dev/null +++ b/src/app/carnet/edit/edit-carnet.component.ts @@ -0,0 +1,102 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject, ViewChild } from '@angular/core'; +import { AngularMaterialModule } from '../../shared/module/angular-material.module'; +import { ReactiveFormsModule } from '@angular/forms'; +import { ApplicationComponent } from '../application/application.component'; +import { StepperSelectionEvent } from '@angular/cdk/stepper'; +import { GoodsComponent } from '../goods/goods.component'; +import { HolderComponent } from '../holder/holder.component'; +import { ActivatedRoute } from '@angular/router'; +import { ShippingComponent } from '../shipping/shipping.component'; +import { TravelPlanComponent } from '../travel-plan/travel-plan.component'; +import { UserPreferences } from '../../core/models/user-preference'; +import { UserPreferencesService } from '../../core/services/user-preference.service'; + +@Component({ + selector: 'app-edit-carnet', + imports: [AngularMaterialModule, CommonModule, ReactiveFormsModule, + ApplicationComponent, HolderComponent, GoodsComponent, TravelPlanComponent, ShippingComponent], + templateUrl: './edit-carnet.component.html', + styleUrl: './edit-carnet.component.scss' +}) +export class EditCarnetComponent { + currentStep = 0; + isLinear = false; + applicationType: 'new' | 'additional' | 'duplicate' | 'extend' | null = 'new'; + isEditMode = true; // Set to true for edit mode + headerid: number = 0; + userPreferences: UserPreferences; + applicationName: string = ''; + allSectionsCompleted: boolean = false; + + @ViewChild(ShippingComponent, { static: false }) + private shippingComponent!: ShippingComponent; + + // Track completion of each step + stepsCompleted = { + applicationDetail: true, + holderSelection: false, + goodsSection: false, + travelPlan: false, + shipping: false + }; + + private route = inject(ActivatedRoute); + + constructor(userPrefenceService: UserPreferencesService) { + this.userPreferences = userPrefenceService.getPreferences(); + } + + ngOnInit(): void { + const idParam = this.route.snapshot.paramMap.get('headerid'); + this.headerid = idParam ? parseInt(idParam, 10) : 0; + + this.applicationName = this.route.snapshot.queryParamMap.get('applicationname') || ''; + + let returnTo = this.route.snapshot.queryParamMap.get('return'); + + if (returnTo === 'holder') { + this.currentStep = 1; + } else if (returnTo === 'shipping') { + this.currentStep = 4; + } + } + + onStepChange(event: StepperSelectionEvent): void { + this.currentStep = event.selectedIndex; + } + + onHolderSelectionSaved(completed: boolean): void { + this.stepsCompleted.holderSelection = completed; + this.isAllSectionsCompleted(); + } + + onGoodsSectionSaved(completed: boolean): void { + this.stepsCompleted.goodsSection = completed; + this.isAllSectionsCompleted(); + } + + onHolderSelectionUpdated(updated: boolean): void { + if (updated) { + this.shippingComponent?.refreshShippingData(); + } + + this.isAllSectionsCompleted(); + } + + onTravelPlanSaved(completed: boolean): void { + this.stepsCompleted.travelPlan = completed; + this.isAllSectionsCompleted(); + } + + onShippingSaved(completed: boolean): void { + this.stepsCompleted.shipping = completed; + this.isAllSectionsCompleted(); + } + + isAllSectionsCompleted() { + this.allSectionsCompleted = this.stepsCompleted.applicationDetail + && this.stepsCompleted.holderSelection && this.stepsCompleted.goodsSection + && this.stepsCompleted.shipping && this.stepsCompleted.travelPlan; + } +} diff --git a/src/app/carnet/goods/goods.component.html b/src/app/carnet/goods/goods.component.html new file mode 100644 index 0000000..963033e --- /dev/null +++ b/src/app/carnet/goods/goods.component.html @@ -0,0 +1,240 @@ +
+ +
+ +
+
+ Goods to be imported as + Commercial Sample + Professional Equipment + Exhibitions and Fair + + + At least one must be selected + +
+
+ +
+
+ Road Vehicles + used? + Horse used? +
+
+ + +
+ + Authorized Representative(s) + + + Authorized Representative(s) is required + + +
+
+ +
+

Goods Items

+
+ +
+ + +
+
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Item Number{{ item.itemNumber }}Description{{ item.description }}Pieces{{ item.pieces }}Weight{{ formatDecimalDisplay(item.weight, 4) }}Unit Of Measure{{ getUnitOfMeasureLabel(item.unitOfMeasure) }}Value{{ formatDecimalDisplay(item.value, 2) | currency}}Country of Origin{{ getCountryLabel(item.countryOfOrigin) }}Actions + + +
+ info + No items added +
+ + +
+ + +
+
+
+

{{ isEditing ? 'Edit Item' : 'Add New Item' }}

+
+ +
+ + Item Number + + + Item Number is required + + + + + Description + + + Description is required + + +
+ +
+ + Pieces + + + Pieces is required + + + Must be at least 1 + + + + + Weight + + + Weight is required + + + Must be positive + + + Maximum 4 decimal places allowed + + + + + Unit of Measure + + + {{ unit.name }} + + + + Unit of measure is required + + +
+ +
+ + Value + + + Value is required + + + Must be positive + + + Maximum 2 decimal places allowed + + + + + Country of Origin + + + {{ country.name }} + + + + Country of origin is required + + +
+ +
+ + +
+
+
+ +
+ +
+
\ No newline at end of file diff --git a/src/app/carnet/goods/goods.component.scss b/src/app/carnet/goods/goods.component.scss new file mode 100644 index 0000000..c70ca04 --- /dev/null +++ b/src/app/carnet/goods/goods.component.scss @@ -0,0 +1,168 @@ +.goods-container { + + .form-section { + margin-bottom: 0.75rem; + } + + .checkbox-group { + display: flex; + flex-wrap: wrap; + gap: 16px; + + mat-label { + margin-top: 5px; + color: var(--mat-checkbox-label-text-color, var(--mat-sys-on-surface)); + font-family: var(--mat-checkbox-label-text-font, var(--mat-sys-body-medium-font)); + line-height: var(--mat-checkbox-label-text-line-height, var(--mat-sys-body-medium-line-height)); + font-size: var(--mat-checkbox-label-text-size, var(--mat-sys-body-medium-size)); + letter-spacing: var(--mat-checkbox-label-text-tracking, var(--mat-sys-body-medium-tracking)); + font-weight: var(--mat-checkbox-label-text-weight, var(--mat-sys-body-medium-weight)); + } + } + + .full-width { + width: 100%; + } + + .table-actions { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; + + h3 { + margin: 0; + color: var(--mat-sys-primary); + font-weight: 500; + } + + .actions { + display: flex; + gap: 16px; + align-items: center; + } + } + + .upload-section { + display: flex; + align-items: center; + gap: 8px; + + span { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .table-container { + position: relative; + overflow-x: auto; + + .loading-shade { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1; + } + + mat-table { + width: 100%; + + mat-icon { + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + transform: scale(1.1); + } + } + + .mat-column-actions { + width: 180px; + text-align: center; + } + } + + .no-data-message { + text-align: center; + padding: 0.9rem; + color: rgba(0, 0, 0, 0.54); + + mat-icon { + font-size: 1rem; + width: 1rem; + height: 1rem; + margin-bottom: -3px; + } + } + + mat-paginator { + border-top: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 0 0 8px 8px; + padding-top: 4px; + } + } + + .form-container { + // position: fixed; + // top: 0; + // right: 0; + // bottom: 0; + // width: 400px; + // background: white; + // box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); + // padding: 24px; + // overflow-y: auto; + // z-index: 100; + + .form-header { + margin-bottom: 24px; + + h3 { + margin: 0; + color: var(--mat-sys-primary); + font-weight: 500; + } + } + + .form-row { + display: flex; + gap: 16px; + margin-bottom: 16px; + align-items: start; + + mat-form-field { + flex: 1; + } + + .description { + flex: 2; + } + + .country { + flex: 2; + } + } + + .form-actions { + display: flex; + justify-content: flex-end; + gap: 16px; + margin-top: 24px; + } + } + + .form-actions { + display: flex; + justify-content: flex-end; + margin-top: 0.9rem; + } +} \ No newline at end of file diff --git a/src/app/carnet/goods/goods.component.ts b/src/app/carnet/goods/goods.component.ts new file mode 100644 index 0000000..2659b9a --- /dev/null +++ b/src/app/carnet/goods/goods.component.ts @@ -0,0 +1,460 @@ +import { Component, EventEmitter, inject, Input, Output, ViewChild } from '@angular/core'; +import { AngularMaterialModule } from '../../shared/module/angular-material.module'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { GoodsService } from '../../core/services/carnet/goods.service'; +import { NotificationService } from '../../core/services/common/notification.service'; +import * as XLSX from 'xlsx'; +import { CustomPaginator } from '../../shared/custom-paginator'; +import { MatDialog } from '@angular/material/dialog'; +import { MatPaginatorIntl, MatPaginator } from '@angular/material/paginator'; +import { MatSort } from '@angular/material/sort'; +import { MatTableDataSource } from '@angular/material/table'; +import { ConfirmDialogComponent } from '../../shared/components/confirm-dialog/confirm-dialog.component'; +import { UserPreferences } from '../../core/models/user-preference'; +import { ApiErrorHandlerService } from '../../core/services/common/api-error-handler.service'; +import { Country } from '../../core/models/country'; +import { UnitOfMeasure } from '../../core/models/unitofmeasure'; +import { CommonService } from '../../core/services/common/common.service'; +import { finalize, Subject, takeUntil } from 'rxjs'; +import { Goods, GoodsItem } from '../../core/models/carnet/goods'; + +@Component({ + selector: 'app-goods', + imports: [AngularMaterialModule, CommonModule, ReactiveFormsModule], + templateUrl: './goods.component.html', + styleUrl: './goods.component.scss', + providers: [{ provide: MatPaginatorIntl, useClass: CustomPaginator }], +}) +export class GoodsComponent { + @Input() headerid: number = 0; + @Input() isViewMode = false; + @Input() userPreferences: UserPreferences = {}; + @Input() isEditMode: boolean = false; + @Output() completed = new EventEmitter(); + + @ViewChild(MatPaginator) paginator!: MatPaginator; + @ViewChild(MatSort) sort!: MatSort; + + // Table configuration + displayedColumns: string[] = ['itemNumber', 'description', 'pieces', 'weight', 'unitOfMeasure', 'value', 'countryOfOrigin', 'actions']; + dataSource = new MatTableDataSource(); + + // Form controls + goodsForm: FormGroup; + itemForm: FormGroup; + + // UI state + isEditing = false; + currentItem: GoodsItem | null = null; + isLoading = false; + changeInProgress = false; + showItemForm = false; + fileToUpload: File | null = null; + isProcessing = false; + + // Data + countries: Country[] = []; + unitsOfMeasures: UnitOfMeasure[] = []; + + private destroy$ = new Subject(); + + private fb = inject(FormBuilder); + private goodsService = inject(GoodsService); + private notificationService = inject(NotificationService); + private dialog = inject(MatDialog); + private errorHandler = inject(ApiErrorHandlerService); + private commonService = inject(CommonService); + + constructor() { + // Main form for goods section + this.goodsForm = this.fb.group({ + commercialSample: [false], + professionalEquipment: [false], + exhibitionsFair: [false], + roadVehiclesUsed: [false], + horseUsed: [false], + authorizedRepresentatives: ['', Validators.required] + }, { validator: this.atLeastOneRequired } + ); + + // Form for individual items + this.itemForm = this.fb.group({ + itemNumber: ['', Validators.required], + description: ['', Validators.required], + pieces: [0, [Validators.required, Validators.min(1)]], + weight: [0, [Validators.required, Validators.min(1), Validators.pattern(/^\d+(\.\d{1,4})?$/)]], + unitOfMeasure: ['', Validators.required], + value: [0, [Validators.required, Validators.min(1), Validators.pattern(/^\d+(\.\d{1,2})?$/)]], + countryOfOrigin: ['US', Validators.required] + }); + } + + ngAfterViewInit() { + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + } + + ngOnInit(): void { + this.loadCountries(); + this.loadUnitOfMeasures(); + + if (this.headerid > 0) { + this.isLoading = true; + this.goodsService.getGoodDetailsByHeaderId(this.headerid).pipe(finalize(() => { + this.isLoading = false; + })).subscribe({ + next: (goodsDetail: Goods) => { + if (goodsDetail) { + this.patchFormData(goodsDetail); + this.completed.emit(this.goodsForm.valid && this.dataSource.data.length > 0); + } + }, + error: (error: any) => { + let errorMessage = this.errorHandler.handleApiError(error, 'Failed to load good details'); + this.notificationService.showError(errorMessage); + console.error('Error loading good details:', error); + } + }); + + this.loadGoodsItems(); + } + } + + loadGoodsItems(): void { + this.isLoading = true; + + this.goodsService.getGoodsItemsByHeaderId(this.headerid).pipe(finalize(() => { + this.isLoading = false; + })).subscribe({ + next: (items: GoodsItem[]) => { + this.dataSource.data = items; + this.completed.emit(this.goodsForm.valid && this.dataSource.data.length > 0); + }, + error: (error: any) => { + let errorMessage = this.errorHandler.handleApiError(error, 'Failed to load goods items'); + this.notificationService.showError(errorMessage); + console.error('Error loading goods items:', error); + } + }); + } + + loadCountries(): void { + this.commonService.getCountries() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (countries) => { + this.countries = countries; + }, + error: (error) => { + console.error('Failed to load countries', error); + } + }); + } + + loadUnitOfMeasures(): void { + this.commonService.getUnitOfMeasures() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (units) => { + this.unitsOfMeasures = units; + }, + error: (error) => { + console.error('Failed to load unit of measures', error); + } + }); + } + + patchFormData(data: Goods): void { + this.goodsForm.patchValue({ + commercialSample: data.commercialSample || false, + professionalEquipment: data.professionalEquipment || false, + exhibitionsFair: data.exhibitionsFair || false, + roadVehiclesUsed: data.roadVehiclesUsed || false, + horseUsed: data.horseUsed || false, + authorizedRepresentatives: data.authorizedRepresentatives || '' + }); + } + + // Add new item to the table + addNewItem(): void { + this.showItemForm = true; + this.isEditing = false; + this.currentItem = null; + this.itemForm.reset({ + pieces: 0, + weight: 0, + value: 0, + countryOfOrigin: 'US', + }); + this.itemForm.markAsUntouched(); + } + + // Edit existing item + editItem(item: GoodsItem): void { + this.showItemForm = true; + this.isEditing = true; + this.currentItem = item; + this.itemForm.patchValue({ + itemNumber: item.itemNumber, + description: item.description, + pieces: item.pieces, + weight: item.weight, + unitOfMeasure: item.unitOfMeasure || 'kg', + value: item.value, + countryOfOrigin: item.countryOfOrigin || '' + }); + } + + saveItem(): void { + if (this.itemForm.invalid || this.goodsForm.invalid) { + this.itemForm.markAllAsTouched(); + this.goodsForm.markAllAsTouched(); + return; + } + + this.onSubmit(); + + const itemData: GoodsItem = this.itemForm.value; + + // create a goods items array with a single item + let itemDataArray: GoodsItem[] = []; + itemDataArray.push(itemData); + + this.changeInProgress = true; + + const saveObservable = this.isEditing && this.currentItem + ? this.goodsService.updateGoodsItem(this.headerid, itemDataArray) + : this.goodsService.addGoodsItem(this.headerid, itemDataArray); + + saveObservable.pipe(finalize(() => { + this.changeInProgress = false; + })).subscribe({ + next: () => { + this.notificationService.showSuccess(`Goods ${this.isEditing ? 'updated' : 'added'} successfully`); + this.loadGoodsItems(); + this.cancelEdit(); + this.completed.emit(this.goodsForm.valid && this.dataSource.data.length > 0); + }, + error: (error) => { + let errorMessage = this.errorHandler.handleApiError(error, `Failed to ${this.isEditing ? 'update' : 'add'} goods items`); + this.notificationService.showError(errorMessage); + console.error('Error saving goods item:', error); + } + }); + } + + // Delete item with confirmation + deleteItem(item: any): void { + this.currentItem = item; + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '350px', + data: { + title: 'Confirm Delete', + message: 'Are you sure you want to delete this item?', + confirmText: 'Delete', + cancelText: 'Cancel' + } + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.goodsService.deleteGoodsItem(this.headerid, this.currentItem!).subscribe({ + next: () => { + this.notificationService.showSuccess('Item deleted successfully'); + this.loadGoodsItems(); + this.cancelEdit(); + }, + error: (error) => { + let errorMessage = this.errorHandler.handleApiError(error, 'Failed to delete item'); + this.notificationService.showError(errorMessage); + console.error('Error deleting item:', error); + } + }); + } + }); + } + + // Handle file selection for upload + fileUpload(event: any): void { + const file: File = event.target.files[0]; + + this.processExcelFile(file) + // .then(response => { + // // console.log('File uploaded successfully:', response); + // // Handle success (e.g., update UI) + // }) + .catch(error => { + console.error('Error processing file:', error); + this.notificationService.showError('Failed to upload file'); + }); + } + + async processExcelFile(file: File): Promise { + + if (file) { + const validTypes = [ + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'text/csv' + ]; + + if (!validTypes.includes(file.type)) { + this.notificationService.showError('Please upload only Excel (XLSX, XLS) or CSV files'); + return; + } + + this.fileToUpload = file; + if (!this.fileToUpload || !this.headerid) return; + + this.isProcessing = true; + + try { + const data = await this.readExcelFile(this.fileToUpload); + const items = this.parseExcelData(data); + + if (items.length === 0) { + this.notificationService.showError('No valid items found in the file'); + this.isProcessing = false; + return; + } + + // items.map(item => { + // item.countryOfOrigin = this.getCountryValue(item.countryOfOrigin); + // item.unitOfMeasure = this.getUnitOfMeasureValue(item.unitOfMeasure); + // }); + + this.changeInProgress = true; + this.goodsService.addGoodsItem(this.headerid, items).pipe(finalize(() => { + this.changeInProgress = false; + })).subscribe({ + next: () => { + this.notificationService.showSuccess(`Goods uploaded successfully`); + this.loadGoodsItems(); + this.cancelEdit(); + this.completed.emit(this.goodsForm.valid && this.dataSource.data.length > 0); + this.fileToUpload = null; + }, + error: (error) => { + let errorMessage = this.errorHandler.handleApiError(error, `Failed to save the upload goods items`); + this.notificationService.showError(errorMessage); + console.error('Error saving goods items:', error); + this.fileToUpload = null; + } + }); + } catch (error) { + throw new Error(error instanceof Error ? error.message : 'Failed to process file'); + } finally { + this.isProcessing = false; + } + } + } + + // Read Excel file + private readExcelFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e: any) => { + try { + const data = new Uint8Array(e.target.result); + const workbook = XLSX.read(data, { type: 'array' }); + const firstSheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[firstSheetName]; + const jsonData = XLSX.utils.sheet_to_json(worksheet); + resolve(jsonData); + } catch (error) { + reject(error); + } + }; + + reader.onerror = (error) => reject(error); + reader.readAsArrayBuffer(file); + }); + } + + // Parse Excel data into our format + private parseExcelData(data: any[]): any[] { + return data.map(row => ({ + itemNumber: row['Item Number'] || row['itemNumber'] || '', + description: row['Description'] || row['description'] || '', + pieces: Number(row['Pieces'] || row['pieces'] || 0), + weight: Number(row['Weight'] || row['weight'] || 0), + unitOfMeasure: row['Unit of Measure'] || row['unit of measure'] || row['unitofmeasure'] || row['UnitOfMeasure'], + value: Number(row['Value'] || row['value'] || 0), + countryOfOrigin: row['Country Of Origin'] || row['Country of Origin'] || row['country of origin'] || row['countryoforigin'] || row['CountryOfOrigin'] || '' + })).filter(item => + item.itemNumber && + item.description && + item.pieces > 0 + ); + } + + // Save all goods data + onSubmit(): void { + if (this.goodsForm.invalid) { + this.goodsForm.markAllAsTouched(); + return; + } + + const formData = this.goodsForm.value; + this.changeInProgress = true; + + this.goodsService.saveGoodsData(this.headerid, formData).pipe(finalize(() => { + this.changeInProgress = false; + })).subscribe({ + next: () => { + this.notificationService.showSuccess('Goods information saved successfully'); + this.completed.emit(this.goodsForm.valid && this.dataSource.data.length > 0); + }, + error: (error) => { + let errorMessage = this.errorHandler.handleApiError(error, 'Failed to save goods information'); + this.notificationService.showError(errorMessage); + console.error('Error saving goods:', error); + } + }); + } + + // Cancel item editing + cancelEdit(): void { + this.showItemForm = false; + this.isEditing = false; + this.currentItem = null; + this.itemForm.reset(); + } + + private atLeastOneRequired(formGroup: FormGroup) { + return (formGroup.get('commercialSample')?.value || + formGroup.get('professionalEquipment')?.value || + formGroup.get('exhibitionsFair')?.value) ? null : { atLeastOneRequired: true }; + } + + getUnitOfMeasureLabel(value: string): string { + const unit = this.unitsOfMeasures.find(u => u.value === value); + return unit ? unit.name : value; + } + + getCountryLabel(value: string): string { + const country = this.countries.find(c => c.value === value); + return country ? country.name : value; + } + + getUnitOfMeasureValue(name: string): string { + const unit = this.unitsOfMeasures.find(u => u.name === name); + return unit ? unit.value : name; + } + + getCountryValue(name: string): string { + const country = this.countries.find(c => c.name === name); + return country ? country.value : name; + } + + formatDecimalDisplay(value: number, decimalPoints: number): string { + if (value === null || value === undefined) return ''; + + // Format to show up to n decimal places, removing trailing zeros + const parts = value.toString().split('.'); + if (parts.length === 1) return parts[0]; // No decimals + return `${parts[0]}.${parts[1].substring(0, decimalPoints).replace(/0+$/, '')}`; + } +} diff --git a/src/app/carnet/holder/holder.component.html b/src/app/carnet/holder/holder.component.html new file mode 100644 index 0000000..71e6104 --- /dev/null +++ b/src/app/carnet/holder/holder.component.html @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/src/app/carnet/holder/holder.component.scss b/src/app/carnet/holder/holder.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/carnet/holder/holder.component.ts b/src/app/carnet/holder/holder.component.ts new file mode 100644 index 0000000..ff852d7 --- /dev/null +++ b/src/app/carnet/holder/holder.component.ts @@ -0,0 +1,52 @@ +import { Component, EventEmitter, inject, Input, Output } from '@angular/core'; +import { UserPreferences } from '../../core/models/user-preference'; +import { SearchHolderComponent } from '../../holder/search/search-holder.component'; +import { HolderService } from '../../core/services/carnet/holder.service'; +import { NotificationService } from '../../core/services/common/notification.service'; +import { Holder } from '../../core/models/carnet/holder'; +import { ApiErrorHandlerService } from '../../core/services/common/api-error-handler.service'; + +@Component({ + selector: 'app-holder', + imports: [SearchHolderComponent], + templateUrl: './holder.component.html', + styleUrl: './holder.component.scss' +}) +export class HolderComponent { + @Input() isViewMode = false; + @Input() headerid: number = 0; + @Input() applicationName: string = ''; + @Input() userPreferences: UserPreferences = {}; + @Input() isEditMode: boolean = false; + @Output() completed = new EventEmitter(); + @Output() updated = new EventEmitter(); + + selectedHolderId: number = 0; + + private holdersService = inject(HolderService); + private notificationService = inject(NotificationService); + private errorHandler = inject(ApiErrorHandlerService); + + ngOnInit(): void { + if (this.headerid > 0) { + this.holdersService.getHolder(this.headerid).subscribe({ + next: (holder: Holder) => { + if (holder) { + this.selectedHolderId = holder.holderid; + this.completed.emit(true); + } + }, + error: (error: any) => { + let errorMessage = this.errorHandler.handleApiError(error, 'Failed to get holder details'); + this.notificationService.showError(errorMessage); + console.error('Error getting holder details:', error); + } + }); + } + } + + onHolderSelectionSaved(completed: boolean): void { + this.completed.emit(completed); + this.updated.emit(true); // to update dependent data + } +} diff --git a/src/app/carnet/shipping/contact-dialog.component.ts b/src/app/carnet/shipping/contact-dialog.component.ts new file mode 100644 index 0000000..a870f41 --- /dev/null +++ b/src/app/carnet/shipping/contact-dialog.component.ts @@ -0,0 +1,86 @@ +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { AngularMaterialModule } from '../../shared/module/angular-material.module'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-contact-dialog', + imports: [AngularMaterialModule, CommonModule, FormsModule], + template: ` +

Select Contact

+ + + + {{getContactLabel(contact)}} + + + + + + + + `, + styles: [` + mat-radio-group { + display: flex; + flex-direction: column; + gap: 12px; + } + mat-radio-button { + margin: 4px 0; + } + `] +}) +export class ContactDialogComponent { + selectedContact: any | null = null; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: { contacts: any[], currentContact: any } + ) { + this.selectedContact = data.currentContact; + } + + get contacts() { + return this.data.contacts; + } + + onSelect(): void { + this.dialogRef.close(this.selectedContact); + } + + onCancel(): void { + this.dialogRef.close(); + } + + getContactLabel(contact: any): string { + + if (!contact) { + return 'No contact information available'; + } + + // Build name parts, filtering out null/undefined/empty strings + const nameParts = [ + contact.firstName, + contact.middleInitial, + contact.lastName + ].filter(part => part != null && part.trim() !== ''); + + // Build contact info parts + const contactInfoParts = [ + contact.email, + contact.phone + ].filter(part => part != null && part.trim() !== ''); + + // Combine all non-empty parts + const allParts = [ + nameParts.join(' '), // Join name parts with single spaces + ...contactInfoParts // Add email and phone if they exist + ].filter(part => part.trim() !== ''); + + return allParts.join(', ') || ''; + } +} \ No newline at end of file diff --git a/src/app/carnet/shipping/shipping.component.html b/src/app/carnet/shipping/shipping.component.html new file mode 100644 index 0000000..247e96c --- /dev/null +++ b/src/app/carnet/shipping/shipping.component.html @@ -0,0 +1,366 @@ +
+
+ +
+ +
+ +
+

Insurance & Bond

+
+ + Do you need insurance for your + goods? + Do you need Lost document + protection? + + + Form Of Security + + + {{ formOfSecurity.name }} + + + + Form Of Security is required + + +
+
+ + +
+

Ship To

+ + Preparer + Holder + 3rd Party + + + + +
+
+

{{getAddressLabel()}}

+

{{getContactLabel()}}

+
+
+ + +
+
+
+
+
+

Shipping Address

+ +
+ + Company Name + + + Company name is required + + + Maximum 100 characters allowed + + +
+ +
+ + Address Line 1 + + + Address is required + + + Maximum 100 characters allowed + + +
+ +
+ + Address Line 2 (Optional) + + + Maximum 100 characters allowed + + +
+ + +
+ + City + + + City is required + + + Maximum 50 characters allowed + + + + + Country + + + {{ country.name }} + + + + Country is required + + + + + State/Province + + + {{ state.name }} + + + + State is required + + + + + ZIP/Postal Code + + + ZIP/Postal code is required + + + Please enter a valid 5-digit US ZIP code + + + Please enter a valid postal code (e.g., A1B2C3) + + +
+ +
+ +
+
+ +
+

Contact Information

+
+ + First Name + + person + + First name is required + + + Maximum 50 characters allowed + + + + + Middle Initial + + + Only 1 character allowed + + + + + Last Name + + person + + Last name is required + + + Maximum 50 characters allowed + + +
+
+ + Title + + work + + Title is required + + + Maximum 100 characters allowed + + +
+ +
+ + Phone + + phone + + Phone is required + + + Please enter a valid phone number (10-15 digits) + + + + + Mobile + + smartphone + + Mobile is required + + + Please enter a valid mobile number (10-15 digits) + + +
+ +
+ + Fax + + fax + + Please enter a valid fax number (10-15 digits) + + + + + Email + + email + + Email is required + + + Please enter a valid email address + + + Maximum 100 characters allowed + + +
+
+ + Reference Number + + +
+
+ + Notes + + +
+ +
+ +
+
+
+ + +
+

Delivery

+
+ + Delivery Type + + + {{ deliveryType.name }} + + + + Delivery Type is required + + + {{ deliveryEstimate }} + + + + Delivery Method + + + {{ deliveryMethod.name }} + + + + Delivery Method is required + + +
+ +
+ + Courier Account Number + + + Required when using customer courier + + +
+
+ + +
+

Payment Method

+
+ + Payment Method + + + {{ paymentType.name }} + + + + Payment Method is required + + +
+ +
+ +
+ + + + + + + +
+
+
\ No newline at end of file diff --git a/src/app/carnet/shipping/shipping.component.scss b/src/app/carnet/shipping/shipping.component.scss new file mode 100644 index 0000000..543ca56 --- /dev/null +++ b/src/app/carnet/shipping/shipping.component.scss @@ -0,0 +1,105 @@ +.shipping-container { + padding-top: 0.5rem; + + .section { + margin-bottom: 1rem; + padding: 16px; + border: 1px solid #e0e0e0; + border-radius: 4px; + + h3 { + margin: 0 0 0.9rem 0; + color: var(--mat-sys-primary); + font-weight: 500; + } + + h4 { + margin: 16px 0 8px; + color: var(--mat-sys-primary); + font-weight: 500; + } + + .mat-mdc-card-content:first-child { + padding: 0 16px; + } + } + + .checkbox-group { + display: flex; + flex-direction: row; + gap: 8px; + + .formofsecurity { + margin-left: 1rem; + min-width: 300px; + } + } + + .radio-group { + display: flex; + gap: 16px; + margin-bottom: 0.9rem; + } + + .form-row { + display: flex; + gap: 16px; + margin-bottom: 0.5rem; + align-items: start; + + mat-form-field { + flex: 1; + margin-bottom: 6px; + + &.full-width { + flex: 100%; + } + } + + .small-field { + max-width: 120px; + } + } + + .form-actions { + display: flex; + justify-content: flex-end; + gap: 16px; + margin-top: 0.9rem; + } + + .presaved-address { + padding: 10px; + display: flex; + flex-direction: row; + gap: 8px; + font-size: 0.875rem; + + p { + margin: 0; + margin-bottom: 4px; + } + + .presaved-address-actions { + margin-top: 8px; + } + } + + .delivery-estimate { + color: #28a745; + font-weight: 500; + } +} + +@media (max-width: 768px) { + .shipping-container { + .form-row { + flex-direction: column; + gap: 8px; + + mat-form-field { + width: 100%; + } + } + } +} \ No newline at end of file diff --git a/src/app/carnet/shipping/shipping.component.ts b/src/app/carnet/shipping/shipping.component.ts new file mode 100644 index 0000000..4fdc336 --- /dev/null +++ b/src/app/carnet/shipping/shipping.component.ts @@ -0,0 +1,709 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, inject, Input, Output } from '@angular/core'; +import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { NotificationService } from '../../core/services/common/notification.service'; +import { AngularMaterialModule } from '../../shared/module/angular-material.module'; +import { ShippingService } from '../../core/services/carnet/shipping.service'; +import { ApiErrorHandlerService } from '../../core/services/common/api-error-handler.service'; +import { CommonService } from '../../core/services/common/common.service'; +import { Shipping } from '../../core/models/carnet/shipping'; +import { Country } from '../../core/models/country'; +import { State } from '../../core/models/state'; +import { ZipCodeValidator } from '../../shared/validators/zipcode-validator'; +import { finalize, forkJoin, map, of, Subject, switchMap, takeUntil, throwError } from 'rxjs'; +import { DeliveryType } from '../../core/models/delivery-type'; +import { DeliveryMethod } from '../../core/models/delivery-method'; +import { PaymentType } from '../../core/models/payment-type'; +import { format, isAfter, isWeekend, addBusinessDays } from 'date-fns'; +import { MatDialog } from '@angular/material/dialog'; +import { ShippingContact } from '../../core/models/carnet/shipping-contact'; +import { ShippingAddress } from '../../core/models/carnet/shipping-address'; +import { ContactDialogComponent } from './contact-dialog.component'; +import { HolderService } from '../../core/services/carnet/holder.service'; +import { Holder } from '../../core/models/carnet/holder'; +import { NavigationService } from '../../core/services/common/navigation.service'; +import { StorageService } from '../../core/services/common/storage.service'; +import { FormOfSecurity } from '../../core/models/formofsecurity'; +import { CarnetService } from '../../core/services/carnet/carnet.service'; +import { environment } from '../../../environments/environment'; + +@Component({ + selector: 'app-shipping', + imports: [AngularMaterialModule, CommonModule, ReactiveFormsModule], + templateUrl: './shipping.component.html', + styleUrl: './shipping.component.scss' +}) +export class ShippingComponent { + @Input() headerid: number = 0; + @Input() isEditMode = false; + @Input() isViewMode = false; + @Input() enableSubmitButton = false; + @Input() applicationName: string = ''; + + @Output() completed = new EventEmitter(); + + private fb = inject(FormBuilder); + private dialog = inject(MatDialog); + + private shippingService = inject(ShippingService); + private notificationService = inject(NotificationService); + private errorHandler = inject(ApiErrorHandlerService); + private commonService = inject(CommonService); + private holderService = inject(HolderService); + private navigationService = inject(NavigationService); + private storageService = inject(StorageService); + private carnetService = inject(CarnetService); + + shippingForm: FormGroup; + isLoading = false; + changeInProgress = false; + showAddressForm = false; + showContactForm = false; + deliveryEstimate: string = ''; + holderid: number = 0; + holder: Holder | undefined | null = null; + showSubmitButton: boolean = environment.appType === 'client'; + showProcessButton: boolean = environment.appType === 'service-provider'; + + countriesHasStates = ['US', 'CA', 'MX']; + countries: Country[] = []; + states: State[] = []; + deliveryTypes: DeliveryType[] = []; + deliveryMethods: DeliveryMethod[] = []; + paymentTypes: PaymentType[] = []; + formOfSecurities: FormOfSecurity[] = []; + govFormOfSecurities: FormOfSecurity[] = []; + nonGovFormOfSecurities: FormOfSecurity[] = []; + shippingFromDB: Shipping | null = null; + + private destroy$ = new Subject(); + + preparerAddress: ShippingAddress | null | undefined = null; + holderAddress: ShippingAddress | null | undefined = null; + preparerContact: ShippingContact | null | undefined = null; + preparerContacts: ShippingContact[] = []; + holderContact: ShippingContact | null | undefined = null; + holderContacts: ShippingContact[] = []; + + constructor() { + this.shippingForm = this.fb.group({ + // needsBond: [false], + needsInsurance: [false], + needsLostDocProtection: [false], + formOfSecurity: ['', Validators.required], + shipTo: ['PREPARER', Validators.required], + address: this.fb.group({ + companyName: [''], + address1: [''], + address2: [''], + city: [''], + state: [''], + country: ['US'], + zip: [''], + }), + contact: this.fb.group({ + firstName: [''], + lastName: [''], + middleInitial: [''], + title: [''], + phone: [''], + mobile: [''], + fax: [''], + email: [''], + refNumber: [''], + notes: [''] + }), + deliveryType: ['', Validators.required], + deliveryMethod: ['', Validators.required], + courierAccount: [''], + paymentMethod: ['', Validators.required], + paymentNotes: [''] + }); + + this.shippingForm.get('deliveryType')?.valueChanges.subscribe(() => { + this.calculateDeliveryEstimate(); + }); + + } + + ngOnInit(): void { + this.loadCountries(); + this.loadDeliveryTypes(); + this.loadDeliveryMethods(); + this.loadPaymentTypes(); + this.loadFormOfSecurities(); + + if (this.headerid && this.headerid > 0) { + this.loadShippingData(); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + onSubmit(): void { + if (this.shippingForm.invalid) { + this.shippingForm.markAllAsTouched(); + return; + } + + this.changeInProgress = true; + const shippingData: Shipping = this.shippingForm.value; + + if (shippingData.shipTo !== '3RDPARTY') { + shippingData.address = shippingData.shipTo === 'PREPARER' ? this.preparerAddress ?? undefined : this.holderAddress ?? undefined; + shippingData.contact = shippingData.shipTo === 'PREPARER' ? this.preparerContact ?? undefined : this.holderContact ?? undefined; + } + + this.shippingService.saveShippingDetails(this.headerid, shippingData).pipe(finalize(() => { + this.changeInProgress = false; + })).subscribe({ + next: () => { + this.notificationService.showSuccess('Shipping & payments information saved successfully'); + this.completed.emit(true); + }, + error: (error) => { + let errorMessage = this.errorHandler.handleApiError(error, 'Failed to save shipping and payment information'); + this.notificationService.showError(errorMessage); + } + }); + } + + onDeliveryTypeChange(): void { + this.calculateDeliveryEstimate(); + } + + onDeliveryMethodChange(deliveryMethod: string): void { + const courierControl = this.shippingForm.get('courierAccount'); + if (deliveryMethod === 'CLC') { + courierControl?.setValidators([Validators.required]); + } else { + courierControl?.clearValidators(); + } + courierControl?.updateValueAndValidity(); + } + + onCountryChange(country: string): void { + this.shippingForm.get('address.state')?.reset(); + + if (country) { + this.loadStates(country); + } + + this.shippingForm.get('address.zip')?.updateValueAndValidity(); + } + + public refreshShippingData(): void { + if (this.headerid > 0) { + this.loadAddressContactData(); + } + } + + editAddressForm(): void { + this.showAddressForm = true; + let shipTo = this.shippingForm.get('shipTo')?.value; + + if (shipTo === 'PREPARER' && this.preparerAddress) { + this.shippingForm.get('address')?.patchValue(this.preparerAddress); + this.loadStates(this.preparerAddress.country); + } else if (shipTo === 'HOLDER' && this.holderAddress) { + this.shippingForm.get('address')?.patchValue(this.holderAddress); + this.loadStates(this.holderAddress.country); + } + } + + cancelEditAddressForm(): void { + this.showAddressForm = false; + this.shippingForm.get('address')?.reset({ country: 'US' }); + this.loadStates('US'); + } + + onShipToChange(event: any): void { + const shipTo = event.value; + this.showAddressForm = false; + this.showContactForm = false; + + this.updateAddressContactValidation(shipTo); + + if (shipTo === '3RDPARTY') { + this.shippingForm.get('contact')?.reset(); + this.shippingForm.get('address')?.reset({ country: 'US' }); + this.loadStates('US'); + this.showAddressForm = true; + this.showContactForm = true; + } + } + + loadCountries(): void { + this.commonService.getCountries() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (countries) => { + this.countries = countries; + }, + error: (error) => { + console.error('Failed to load countries', error); + } + }); + } + + loadDeliveryTypes(): void { + this.commonService.getDeliveryTypes() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (deliveryTypes) => { + this.deliveryTypes = deliveryTypes; + }, + error: (error) => { + console.error('Failed to load delivery types', error); + } + }); + } + + loadDeliveryMethods(): void { + this.commonService.getDeliveryMethods() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (deliveryMethods) => { + this.deliveryMethods = deliveryMethods; + }, + error: (error) => { + console.error('Failed to load delivery methods', error); + } + }); + } + + loadPaymentTypes(): void { + this.commonService.getPaymentTypes() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (paymentTypes) => { + this.paymentTypes = paymentTypes; + }, + error: (error) => { + console.error('Failed to load payment types', error); + } + }); + } + + loadStates(country: string): void { + this.isLoading = true; + country = this.countriesHasStates.includes(country) ? country : 'FN'; + this.commonService.getStates(country) + .pipe(takeUntil(this.destroy$), + finalize(() => { + this.isLoading = false; + })).subscribe({ + next: (states) => { + this.states = states; + const stateControl = this.shippingForm.get('contact.state'); + if (this.countriesHasStates.includes(country)) { + stateControl?.enable(); + } else { + stateControl?.disable(); + stateControl?.setValue('FN'); + } + }, + error: (error) => { + console.error('Failed to load states', error); + } + }); + } + + loadFormOfSecurities(): void { + this.commonService.getFormOfSecurities() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (fos) => { + this.govFormOfSecurities = fos.filter(f => f.isGov); + this.nonGovFormOfSecurities = fos.filter(f => !f.isGov); + }, + error: (error) => { + console.error('Failed to load form of securities', error); + } + }); + } + + loadShippingData(): void { + this.isLoading = true; + this.shippingService.getShippingData(this.headerid).pipe( + finalize(() => { + this.isLoading = false; + }) + ).subscribe({ + next: (results: Shipping) => { + + if (!results) { // do nothing if empty + return; + } + this.shippingFromDB = results; + this.patchShippingData(); + this.loadAddressContactData(); + }, + error: (error: any) => { + console.error('Error loading shipping data', error); + const errorMessage = this.errorHandler.handleApiError(error, 'Failed to load shipping data'); + this.notificationService.showError(errorMessage); + } + }); + } + + loadAddressContactData(): void { + this.isLoading = true; + + this.holderService.getHolder(this.headerid).pipe( + switchMap((holder: Holder) => { + if (!holder?.holderid) { + return of(undefined); + } + + this.holder = holder; + this.formOfSecurities = holder.holderType?.trim() === 'GOV' ? this.govFormOfSecurities : this.nonGovFormOfSecurities; + + return forkJoin({ + holderContacts: this.shippingService.getHolderContactsById(holder.holderid), + holderAddress: this.shippingService.getHolderAddress(holder), + preparerAddress: this.shippingService.getPreparerAddress(), + preparerContacts: this.shippingService.getPreparerContacts() + }).pipe( + // Combine the forkJoin results with the original holder object + map((apiResults: any) => ({ ...apiResults, holder })) + ); + }), + finalize(() => this.isLoading = false) + ).subscribe({ + next: (results) => { + + if (!results && !this.shippingFromDB) { // do nothing if empty + return; + } + + this.holderid = results.holder.holderid; + this.holderContacts = results.holderContacts; + this.holderAddress = results.holderAddress; + this.preparerContacts = results.preparerContacts; + this.preparerAddress = results.preparerAddress; + + // Set holder contact + if (this.shippingFromDB?.shipTo === 'HOLDER' && this.shippingFromDB?.contact?.contactid) { + this.holderContact = this.holderContacts.find(hc => hc.contactid === this.shippingFromDB?.contact?.contactid); + } else { + this.holderContact = this.holderContacts?.[0]; + } + + // Set preparer contact + if (this.shippingFromDB?.shipTo === 'PREPARER' && this.shippingFromDB?.contact?.contactid) { + this.preparerContact = this.preparerContacts.find(pc => pc.contactid === this.shippingFromDB?.contact?.contactid); + } else { + this.preparerContact = this.preparerContacts.find(pc => pc.defaultContact); + } + this.patchAddressContactData(); + }, + error: (error: any) => { + console.error('Error loading shipping data', error); + const errorMessage = this.errorHandler.handleApiError(error, 'Failed to load shipping data'); + this.notificationService.showError(errorMessage); + } + }); + } + + patchShippingData(): void { + if (!this.shippingFromDB) { + return; + } + + this.shippingForm.patchValue({ + // needsBond: shipping.needsBond, + needsInsurance: this.shippingFromDB.needsInsurance, + needsLostDocProtection: this.shippingFromDB.needsLostDocProtection, + formOfSecurity: this.shippingFromDB.formOfSecurity, + shipTo: this.shippingFromDB.shipTo, + deliveryType: this.shippingFromDB.deliveryType, + deliveryMethod: this.shippingFromDB.deliveryMethod, + courierAccount: this.shippingFromDB.courierAccount, + paymentMethod: this.shippingFromDB.paymentMethod + }); + + if (this.shippingFromDB.deliveryMethod === 'CLC') { + this.onDeliveryMethodChange(this.shippingFromDB.deliveryMethod); + } + + this.calculateDeliveryEstimate(); + this.shippingForm.markAsUntouched(); + this.completed.emit(this.shippingForm.valid); + } + + patchAddressContactData(): void { + + if (!this.shippingFromDB) { + return + } + + if (this.shippingFromDB.address?.country) { + this.loadStates(this.shippingFromDB.address?.country); + } + + let formOfSecurityControl = this.shippingForm.get('formOfSecurity'); + if (this.holder?.holderType.trim() === 'GOV') { + formOfSecurityControl?.setValue(this.govFormOfSecurities?.[0].value); + formOfSecurityControl?.disable(); + } else { + formOfSecurityControl?.enable(); + } + + this.calculateDeliveryEstimate(); + this.updateAddressContactValidation(this.shippingFromDB.shipTo); + + if (this.shippingFromDB.shipTo === '3RDPARTY') { + this.showAddressForm = true; + this.showContactForm = true; + + const addressGroup = this.shippingForm.get('address') as FormGroup; + const contactGroup = this.shippingForm.get('contact') as FormGroup; + + addressGroup.patchValue({ + addressid: this.shippingFromDB.address?.addressid, + companyName: this.shippingFromDB.address?.companyName, + address1: this.shippingFromDB.address?.address1, + address2: this.shippingFromDB.address?.address2, + city: this.shippingFromDB.address?.city, + state: this.shippingFromDB.address?.state, + zip: this.shippingFromDB.address?.zip, + country: this.shippingFromDB.address?.country + }) + + contactGroup.patchValue({ + contactid: this.shippingFromDB.contact?.contactid, + firstName: this.shippingFromDB.contact?.firstName, + lastName: this.shippingFromDB.contact?.lastName, + middleInitial: this.shippingFromDB.contact?.middleInitial, + title: this.shippingFromDB.contact?.title, + phone: this.shippingFromDB.contact?.phone, + mobile: this.shippingFromDB.contact?.mobile, + fax: this.shippingFromDB.contact?.fax, + email: this.shippingFromDB.contact?.email, + refNumber: this.shippingFromDB.contact?.refNumber, + notes: this.shippingFromDB.contact?.notes, + }) + } + this.shippingForm.markAsUntouched(); + this.completed.emit(this.shippingForm.valid); + } + + updateAddressContactValidation(shipTo: string): void { + const addressGroup = this.shippingForm.get('address') as FormGroup; + const contactGroup = this.shippingForm.get('contact') as FormGroup; + + if (shipTo === '3RDPARTY') { + Object.keys(addressGroup.controls).forEach(key => { + if (key === 'companyName') { + addressGroup.get(key)?.setValidators([Validators.required, Validators.maxLength(100)]); + } else if (key === 'address1') { + addressGroup.get(key)?.setValidators([Validators.required, Validators.maxLength(100)]); + } else if (key === 'address2') { + addressGroup.get(key)?.setValidators([Validators.maxLength(100)]); + } else if (key === 'city') { + addressGroup.get(key)?.setValidators([Validators.required, Validators.maxLength(50)]); + } else if (key === 'state') { + addressGroup.get(key)?.setValidators(Validators.required); + } else if (key === 'country') { + addressGroup.get(key)?.setValidators(Validators.required); + } else if (key === 'zip') { + addressGroup.get(key)?.setValidators([Validators.required, ZipCodeValidator('country')]); + } + addressGroup.get(key)?.updateValueAndValidity(); + }); + Object.keys(contactGroup.controls).forEach(key => { + if (key === 'firstName') { + contactGroup.get(key)?.setValidators([Validators.required, Validators.maxLength(50)]); + } else if (key === 'lastName') { + contactGroup.get(key)?.setValidators([Validators.required, Validators.maxLength(50)]); + } else if (key === 'middleInitial') { + contactGroup.get(key)?.setValidators([Validators.maxLength(1)]); + } else if (key === 'title') { + contactGroup.get(key)?.setValidators([Validators.required, Validators.maxLength(100)]); + } else if (key === 'phone') { + contactGroup.get(key)?.setValidators([Validators.required, Validators.pattern(/^[0-9\-\(\)]{10,15}$/)]); + } else if (key === 'mobile') { + contactGroup.get(key)?.setValidators([Validators.required, Validators.pattern(/^[0-9\-\(\)]{10,15}$/)]); + } else if (key === 'fax') { + contactGroup.get(key)?.setValidators([Validators.pattern(/^[0-9\-\(\)]{10,15}$/)]); + } else if (key === 'email') { + contactGroup.get(key)?.setValidators([Validators.required, Validators.email, Validators.maxLength(100)]); + } + contactGroup.get(key)?.updateValueAndValidity(); + }); + } else { + Object.keys(addressGroup.controls).forEach(key => { + addressGroup.get(key)?.clearValidators(); + addressGroup.get(key)?.updateValueAndValidity(); + }); + Object.keys(contactGroup.controls).forEach(key => { + contactGroup.get(key)?.clearValidators(); + contactGroup.get(key)?.updateValueAndValidity(); + }); + } + } + + getAddressLabel(): string { + let shipTo = this.shippingForm.get('shipTo')?.value; + + if (shipTo === 'PREPARER' && this.preparerAddress) { + return `${this.preparerAddress.companyName}, ${this.preparerAddress.address1}, + ${this.preparerAddress.city}, ${this.preparerAddress.state}, ${this.preparerAddress.zip}, + ${this.preparerAddress.country}`; + } + + if (shipTo === 'HOLDER' && this.holderAddress) { + return `${this.holderAddress.companyName}, ${this.holderAddress.address1}, + ${this.holderAddress.city}, ${this.holderAddress.state}, ${this.holderAddress.zip}, + ${this.holderAddress.country}`; + } + + return ''; + } + + getContactLabel(): string { + const shipTo = this.shippingForm.get('shipTo')?.value; + const contact = shipTo === 'PREPARER' ? this.preparerContact : + shipTo === 'HOLDER' ? this.holderContact : null; + + if (!contact) { + return 'No contact information available'; + } + + // Build name parts, filtering out null/undefined/empty strings + const nameParts = [ + contact.firstName, + contact.middleInitial, + contact.lastName + ].filter(part => part != null && part.trim() !== ''); + + // Build contact info parts + const contactInfoParts = [ + contact.email, + contact.phone + ].filter(part => part != null && part.trim() !== ''); + + // Combine all non-empty parts + const allParts = [ + nameParts.join(' '), // Join name parts with single spaces + ...contactInfoParts // Add email and phone if they exist + ].filter(part => part.trim() !== ''); + + return allParts.join(', ') || ''; + } + + calculateDeliveryEstimate(): void { + const deliveryType = this.shippingForm.get('deliveryType')?.value; + const deliveryTypeObj = this.deliveryTypes.find(dt => dt.value === deliveryType); + const daysToDelivery: number = Number(deliveryTypeObj?.daysToDelivery) !== 0 ? Number(deliveryTypeObj?.daysToDelivery) : 3; + const cutOffTime: number = Number(deliveryTypeObj?.cutOffTime) !== 0 ? Number(deliveryTypeObj?.cutOffTime) : 16; + + const now = new Date(); + const cutoffTime = new Date(); + cutoffTime.setHours(cutOffTime, 0, 0, 0); + + let deliveryDate: Date; + let message = 'Estimated delivery: '; + const isAfterCutoff = isAfter(now, cutoffTime); + const isWeekendDay = isWeekend(now); + + switch (deliveryType) { + case 'SAME': + if (isAfterCutoff || isWeekendDay) { + deliveryDate = addBusinessDays(now, 1); + message += format(deliveryDate, 'EEEE, MMMM do, yyyy'); + } else { + message += format(now, 'EEEE, MMMM do, yyyy'); + } + break; + + case 'STD': + // Standard is 3 business days + if (isAfterCutoff || isWeekendDay) { + // If after cutoff or weekend, add 4 business days + deliveryDate = addBusinessDays(now, daysToDelivery); + } else { + // Before cutoff on weekday, add 3 business days + deliveryDate = addBusinessDays(now, daysToDelivery - 1); + } + + message += format(deliveryDate, 'EEEE, MMMM do, yyyy'); + break; + + case 'NBD': + // Next business day for pickup + if (isAfterCutoff || isWeekendDay) { + // If after cutoff or weekend, add 2 business days + deliveryDate = addBusinessDays(now, daysToDelivery); + } else { + // Before cutoff on weekday, add 1 business day + deliveryDate = addBusinessDays(now, daysToDelivery); + } + + message += format(deliveryDate, 'EEEE, MMMM do, yyyy'); + break; + + default: + message = ''; + } + + this.deliveryEstimate = message; + } + + selectContact(): void { + let shipTo = this.shippingForm.get('shipTo')?.value; + + const contacts = shipTo === 'PREPARER' ? this.preparerContacts : this.holderContacts; + const currentContact = shipTo === 'PREPARER' ? this.preparerContact : this.holderContact; + + const dialogRef = this.dialog.open(ContactDialogComponent, { + width: '500px', + data: { contacts: contacts, currentContact: currentContact } + }); + + dialogRef.afterClosed().subscribe(selectedItem => { + if (selectedItem) { + const selectedContact = contacts.find(c => c.contactid === selectedItem.contactid); + if (shipTo === 'PREPARER') { + this.preparerContact = selectedContact; + } else { + this.holderContact = selectedContact; + } + } + }); + } + + submitApplication(): void { + if (this.headerid) { + let currentApplicationDetails = { headerid: this.headerid, applicationName: this.applicationName } + this.storageService.set("currentapplication", currentApplicationDetails); + } + + this.navigationService.navigate(["terms"]) + } + + returnToHome(): void { + this.navigationService.navigate(["home"]) + } + + processApplication(): void { + this.changeInProgress = true; + this.carnetService.processApplication(this.headerid).pipe(finalize(() => { + this.changeInProgress = false; + })).subscribe({ + next: () => { + this.notificationService.showSuccess('Application processed successfully'); + this.navigationService.navigate(["home"]); + }, + error: (error) => { + let errorMessage = this.errorHandler.handleApiError(error, 'Failed to submit application'); + this.notificationService.showError(errorMessage); + console.error('Error submitting the application', error); + } + }); + } +} \ No newline at end of file diff --git a/src/app/carnet/terms-conditions/terms-conditions.component.html b/src/app/carnet/terms-conditions/terms-conditions.component.html new file mode 100644 index 0000000..e18b6ba --- /dev/null +++ b/src/app/carnet/terms-conditions/terms-conditions.component.html @@ -0,0 +1,112 @@ +
+ + + ATA Carnet Terms and Conditions + + + + + + +

1. Definitions

+

ATA Carnet: An international customs document that permits the tax-free and duty-free + temporary export and import of goods for up to one year.

+

Holder: The individual or entity in whose name the carnet is issued and who is + responsible for complying with all terms.

+

Goods: Merchandise, equipment, or products covered by the carnet.

+ +

2. Carnet Usage

+

2.1 The carnet may be used for:

+
    +
  • Commercial samples
  • +
  • Professional equipment
  • +
  • Goods for exhibitions and fairs
  • +
  • Goods for scientific, educational, or cultural purposes
  • +
+ +

2.2 The carnet cannot be used for:

+
    +
  • Consumable or disposable items
  • +
  • Goods for processing or repair
  • +
  • Goods intended for sale or permanent export
  • +
  • Prohibited or restricted items under any applicable laws
  • +
+ +

3. Holder Responsibilities

+

3.1 The holder must:

+
    +
  • Present the carnet to customs when crossing borders
  • +
  • Ensure all goods listed in the carnet are returned by the expiration date
  • +
  • Pay all applicable duties and taxes if goods are not re-exported
  • +
  • Notify the issuing association immediately of any lost or stolen carnets
  • +
+ +

4. Validity Period

+

4.1 The carnet is valid for one year from the date of issue.

+

4.2 Goods must be re-exported before the carnet expires.

+

4.3 Extensions may be granted in exceptional circumstances with approval from all relevant customs + authorities.

+ +

5. Customs Procedures

+

5.1 The holder must present the carnet to customs:

+
    +
  • When first exporting goods from the home country
  • +
  • When entering each foreign country
  • +
  • When re-exporting goods from each foreign country
  • +
  • When finally re-importing goods to the home country
  • +
+ +

6. Security Requirements

+

6.1 The issuing association may require security up to 40% of the total value of goods.

+

6.2 Security will be refunded when the carnet is fully discharged.

+

6.3 The security may be forfeited if terms are violated.

+ +

7. Liability and Insurance

+

7.1 The holder is solely responsible for:

+
    +
  • All customs duties and taxes if goods are not re-exported
  • +
  • Any damage to goods while in transit
  • +
  • Compliance with all import/export regulations
  • +
+

7.2 We recommend obtaining comprehensive insurance coverage for all goods.

+ +

8. Fees and Charges

+

8.1 The following fees apply:

+
    + +
  • Basic fee: {{estimatedFees.basicFee | currency}}
  • +
  • Counterfoil Fee: {{estimatedFees.counterFoilFee | currency}} +
  • +
  • Continuation sheet fee: + {{estimatedFees.continuationSheetFee | currency}}
  • +
  • Expedited fee: {{estimatedFees.expeditedFee | currency}}
  • +
  • Shipping fee: {{estimatedFees.shippingFee | currency}}
  • +
  • Bond Premium: {{estimatedFees.bondPremium | currency}}
  • +
  • Cargo Premium: {{estimatedFees.cargoPremium | currency}}
  • +
  • LDI Premium: {{estimatedFees.ldiPremium | currency}}
  • +
+ +

9. Dispute Resolution

+

9.1 Any disputes shall be resolved through arbitration in the jurisdiction of the issuing association. +

+

9.2 The holder agrees to be bound by the arbitration decision.

+ +

10. Governing Law

+

These terms shall be governed by the laws of the country where the carnet was issued.

+ + + I have read and agree to all terms and conditions above + +
+ + + + + + + +
+
\ No newline at end of file diff --git a/src/app/carnet/terms-conditions/terms-conditions.component.scss b/src/app/carnet/terms-conditions/terms-conditions.component.scss new file mode 100644 index 0000000..dcaa108 --- /dev/null +++ b/src/app/carnet/terms-conditions/terms-conditions.component.scss @@ -0,0 +1,84 @@ +.terms-container { + display: flex; + justify-content: center; + align-items: center; +} + +.terms-card { + width: 100%; + max-height: 90vh; + display: flex; + flex-direction: column; +} + +mat-card-header { + justify-content: center; + padding: 16px 0; +} + +.terms-content { + flex: 1; + overflow-y: auto; + padding: 16px; + font-size: 0.875rem; + + h3 { + color: var(--mat-sys-primary); + margin-top: 24px; + margin-bottom: 12px; + } + + p { + padding-left: 16px; + } + + ul { + padding-left: 56px; + } + + li { + margin-bottom: 8px; + } + + strong { + font-weight: 500; + } +} + +.read-checkbox { + margin: 24px 0; + display: block; + padding: 8px 0; + border-top: 1px solid #eee; + border-bottom: 1px solid #eee; +} + +// Custom scrollbar +.terms-content::-webkit-scrollbar { + width: 8px; +} + +.terms-content::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 4px; +} + +.terms-content::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; +} + +.terms-content::-webkit-scrollbar-thumb:hover { + background: #555; +} + +// Responsive adjustments +@media (max-width: 768px) { + .terms-container { + padding: 12px; + } + + .terms-card { + max-height: 95vh; + } +} \ No newline at end of file diff --git a/src/app/carnet/terms-conditions/terms-conditions.component.ts b/src/app/carnet/terms-conditions/terms-conditions.component.ts new file mode 100644 index 0000000..46af8fa --- /dev/null +++ b/src/app/carnet/terms-conditions/terms-conditions.component.ts @@ -0,0 +1,92 @@ + +import { Component, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ApiErrorHandlerService } from '../../core/services/common/api-error-handler.service'; +import { NavigationService } from '../../core/services/common/navigation.service'; +import { NotificationService } from '../../core/services/common/notification.service'; +import { StorageService } from '../../core/services/common/storage.service'; +import { CarnetService } from '../../core/services/carnet/carnet.service'; +import { Fees } from '../../core/models/carnet/fee'; +import { finalize } from 'rxjs'; + +@Component({ + selector: 'app-terms-conditions', + imports: [MatCardModule, + MatButtonModule, + MatDividerModule, + MatCheckboxModule, + CommonModule, + FormsModule], + templateUrl: './terms-conditions.component.html', + styleUrl: './terms-conditions.component.scss' +}) +export class TermsConditionsComponent { + + currentDate = new Date(); + hasReadAllTerms = false; + currentApplicationDetails: { headerid: number, applicationName: string } | null = null; + changeInProgress: boolean = false; + estimatedFees: Fees = {}; + + private notificationService = inject(NotificationService); + private errorHandler = inject(ApiErrorHandlerService); + private storageService = inject(StorageService); + private navigationService = inject(NavigationService); + private carnetService = inject(CarnetService); + + constructor() { + this.currentApplicationDetails = this.storageService.get<{ headerid: number, applicationName: string }>('currentapplication') + } + + ngOnInit(): void { + if (this.currentApplicationDetails?.headerid) { + this.carnetService.getEstimatedFees(this.currentApplicationDetails?.headerid).subscribe({ + next: (data: Fees) => { + if (data) { + this.estimatedFees = data; + } + }, + error: (error: any) => { + let errorMessage = this.errorHandler.handleApiError(error, 'Failed to get estimated fees'); + this.notificationService.showError(errorMessage); + console.error('Error getting estimated fees:', error); + } + }); + } + } + + onAccept(): void { + this.changeInProgress = true; + this.carnetService.submitApplication(this.currentApplicationDetails?.headerid!).pipe(finalize(() => { + this.changeInProgress = false; + })).subscribe({ + next: () => { + this.notificationService.showSuccess('Application submitted successfully'); + this.storageService.removeItem('currentapplication'); + this.navigationService.navigate(["home"]); + }, + error: (error) => { + let errorMessage = this.errorHandler.handleApiError(error, 'Failed to submit application'); + this.notificationService.showError(errorMessage); + console.error('Error submitting the application', error); + } + }); + } + + onDecline(): void { + this.storageService.removeItem('currentapplication'); + this.navigationService.navigate(["edit-carnet", this.currentApplicationDetails?.headerid], + { + state: { isEditMode: true }, + queryParams: { + applicationname: this.currentApplicationDetails?.applicationName, + return: 'shipping' + } + }) + } +} \ No newline at end of file diff --git a/src/app/carnet/travel-plan/travel-plan.component.html b/src/app/carnet/travel-plan/travel-plan.component.html new file mode 100644 index 0000000..889b45c --- /dev/null +++ b/src/app/carnet/travel-plan/travel-plan.component.html @@ -0,0 +1,151 @@ +
+
+ +
+
+ +
+ + No of times entering & leaving USA + + + Required + + + Must be between 1-99 + + +
+ +
+ +
+

Visit Countries

+ + + + {{country.name}} + + +
+ + +
+ + No of times + + + Must be between 1-99 + + + +
+ + +
+
+ + +
+
+

Selected

+ + + {{country.name}} ({{country.visits}}) + + + No visit countries selected + + +
+
+
+ +
+ +
+

Transit Countries

+ + + {{country.name}} + + +
+ + +
+ + No of times + + + Must be between 1-99 + + + +
+ + +
+
+ + +
+
+

Selected

+ + + {{country.name}} ({{country.visits}}) + + + No transit countries selected + + +
+
+
+ +
+ +
+
+ Total Visits: + {{totalVisits}} +
+
+ Total Transits: + {{totalTransits}} +
+
+ + +
+ +
+
\ No newline at end of file diff --git a/src/app/carnet/travel-plan/travel-plan.component.scss b/src/app/carnet/travel-plan/travel-plan.component.scss new file mode 100644 index 0000000..dd3a1c3 --- /dev/null +++ b/src/app/carnet/travel-plan/travel-plan.component.scss @@ -0,0 +1,143 @@ +.travel-plan-container { + padding: 0.75rem 0 0 0; + // max-width: 1200px; + margin: 0 auto; + + // .form-row { + // margin-bottom: 24px; + // max-width: 300px; + // } + + + .form-row { + // display: flex; + // gap: 16px; + margin-bottom: 16px; + //align-items: start; + + // mat-form-field { + // flex: 1; + // } + } + + .usa-entries { + width: 300px; + } + + .country-selection-container { + display: flex; + gap: 24px; + margin-bottom: 12px; + border: 1px solid; + border-radius: 4px; + padding: 20px; + + @media (max-width: 768px) { + flex-direction: column; + } + } + + .available-countries, + .selected-countries { + flex: 1; + border: 1px solid #e0e0e0; + border-radius: 4px; + padding: 16px; + overflow: hidden; + + h3 { + margin: 0 0 16px 0; + font-size: 0.875rem; + font-weight: 500; + } + + mat-selection-list { + height: 150px; + overflow-y: auto; + padding: 0; + + mat-list-item { + .empty-message { + font-style: italic; + } + } + } + } + + .controls { + display: flex; + flex-direction: column; + gap: 16px; + width: 150px; + padding: 16px; + + .action-buttons { + display: flex; + flex-direction: column; + gap: 8px; + + button { + width: 100%; + display: flex; + // gap: 8px; + align-items: center; + } + + mat-icon { + margin: 0; + } + + button.icon-end { + flex-direction: row-reverse; + } + } + } + + .selected-countries { + display: flex; + flex-direction: column; + gap: 16px; + + .visit-countries, + .transit-countries { + flex: 1; + } + } + + .form-actions { + display: flex; + justify-content: flex-end; + padding-top: 12px; + border-top: 1px solid #e0e0e0; + justify-content: space-between; + + .totals-section { + display: flex; + gap: 24px; + font-size: 0.875rem; + + .total-item { + display: flex; + gap: 8px; + align-items: center; + + .total-value { + font-weight: 500; + } + } + } + } +} + +@media (max-width: 768px) { + .travel-container { + .form-row { + flex-direction: column; + gap: 8px; + + mat-form-field { + width: 100%; + } + } + } +} \ No newline at end of file diff --git a/src/app/carnet/travel-plan/travel-plan.component.ts b/src/app/carnet/travel-plan/travel-plan.component.ts new file mode 100644 index 0000000..4ef21fe --- /dev/null +++ b/src/app/carnet/travel-plan/travel-plan.component.ts @@ -0,0 +1,317 @@ +import { Component, EventEmitter, inject, Input, Output, SimpleChanges } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { finalize, forkJoin } from 'rxjs'; +import { TravelPlanService } from '../../core/services/carnet/travel-plan.service'; +import { ApiErrorHandlerService } from '../../core/services/common/api-error-handler.service'; +import { NotificationService } from '../../core/services/common/notification.service'; +import { Country, TravelEntry, TravelPlan } from '../../core/models/carnet/travel-plan'; +import { AngularMaterialModule } from '../../shared/module/angular-material.module'; +import { CommonModule } from '@angular/common'; +import { MatListModule } from '@angular/material/list'; +import { MatDialog } from '@angular/material/dialog'; +import { ConfirmDialogComponent } from '../../shared/components/confirm-dialog/confirm-dialog.component'; + +@Component({ + selector: 'app-travel-plan', + imports: [AngularMaterialModule, ReactiveFormsModule, CommonModule, MatListModule], + templateUrl: './travel-plan.component.html', + styleUrl: './travel-plan.component.scss' +}) +export class TravelPlanComponent { + @Input() headerid: number = 0; + @Input() isViewMode = false; + + @Output() completed = new EventEmitter(); + + private fb = inject(FormBuilder); + private dialog = inject(MatDialog); + + private travelPlanService = inject(TravelPlanService); + private notificationService = inject(NotificationService); + private errorHandler = inject(ApiErrorHandlerService); + + travelForm: FormGroup; + isLoading = false; + changeInProgress = false; + visitsCount = 1; + transitsCount = 1; + + countries: Country[] = []; + selectedVisitCountries: TravelEntry[] = []; + selectedTransitCountries: TravelEntry[] = []; + + // Currently selected country in the listbox + selectedAvailableVisitCountry: Country | null = null; + selectedAvailableTransitCountry: Country | null = null; + selectedVisitCountry: Country | null = null; + selectedTransitCountry: Country | null = null; + + constructor() { + this.travelForm = this.fb.group({ + usaEntries: ['', [Validators.required, Validators.min(1), Validators.max(99)]], + visitsInput: ['', [Validators.min(1), Validators.max(99)]], + transitsInput: ['', [Validators.min(1), Validators.max(99)]], + }); + } + + ngOnInit(): void { + if (this.headerid) { + this.loadTravelPlan(); + } + } + + ngOnChanges(changes: SimpleChanges) { + if (changes['headerid'] && this.headerid > 0) { + this.loadTravelPlan(); + } + } + + onAvailableVisitCountrySelection(event: any) { + this.selectedAvailableVisitCountry = event.value; + } + + onVisitCountrySelection(event: any) { + this.selectedVisitCountry = event.value; + } + + onAvailableTransitCountrySelection(event: any) { + this.selectedAvailableTransitCountry = event.value; + } + + onTransitCountrySelection(event: any) { + this.selectedTransitCountry = event.value; + } + + // Add selected country to visits + addVisitCountry(): void { + if (!this.selectedAvailableVisitCountry) return; + + if (this.selectedAvailableVisitCountry.actionType === 'FYI') { + this.fyiAction(this.selectedAvailableVisitCountry); + } else if (this.selectedAvailableVisitCountry.actionType === 'WARNING') { + this.warningAction(this.selectedAvailableVisitCountry); + } else if (this.selectedAvailableVisitCountry.actionType === 'ACTION') { + this.takeAction(this.selectedAvailableVisitCountry); + } else { + this.addVisitCountryToCollection(); + } + } + + addVisitCountryToCollection(): void { + if (!this.selectedAvailableVisitCountry) return; + + const existingIndex = this.selectedVisitCountries.findIndex( + c => c.value === this.selectedAvailableVisitCountry!.value + ); + + if (existingIndex === -1) { + this.selectedVisitCountries.push({ + ...this.selectedAvailableVisitCountry, + visits: this.visitsCount + }); + } + + this.visitsCount = 1; + } + + fyiAction(country: Country): void { + + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '560px', + data: { + title: 'For your information', + message: country.actionMessage, + confirmText: 'Ok', + cancelText: 'Cancel' + } + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.addVisitCountryToCollection(); + } + }); + } + + warningAction(country: Country): void { + + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '560px', + data: { + title: 'Warning', + message: country.actionMessage, + confirmText: 'Ok', + cancelText: 'Cancel' + } + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.addVisitCountryToCollection(); + } + }); + } + + takeAction(country: Country): void { + + if (this.IsCountryExists('transit', country)) { + this.addVisitCountryToCollection(); + return; + } + + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '560px', + data: { + title: 'Act', + message: country.actionMessage, + confirmText: 'Ok', + cancelText: 'Cancel' + } + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.transitsCount = this.visitsCount; + this.selectedAvailableTransitCountry = this.selectedAvailableVisitCountry; + this.addVisitCountryToCollection(); + this.addTransitCountryToCollection(); + } + }); + } + + // Add selected country to transits + addTransitCountry(): void { + if (!this.selectedAvailableTransitCountry) return; + this.addTransitCountryToCollection(); + } + + addTransitCountryToCollection(): void { + if (!this.selectedAvailableTransitCountry) return; + + const existingIndex = this.selectedTransitCountries.findIndex( + c => c.value === this.selectedAvailableTransitCountry!.value + ); + + if (existingIndex === -1) { + this.selectedTransitCountries.push({ + ...this.selectedAvailableTransitCountry, + visits: this.transitsCount + }); + } + this.transitsCount = 1; + } + + IsCountryExists(countryType: string, country: Country): boolean { + + const existingIndex = countryType === 'transit' ? this.selectedTransitCountries.findIndex( + c => c.value === country!.value + ) : this.selectedVisitCountries.findIndex( + c => c.value === country!.value + ); + + return existingIndex > -1; + } + + // Remove country from visits + removeVisitCountry(): void { + if (!this.selectedVisitCountry) return; + + this.selectedVisitCountries = this.selectedVisitCountries.filter( + c => c.value !== this.selectedVisitCountry!.value + ); + } + + // Remove country from transits + removeTransitCountry(): void { + if (!this.selectedTransitCountry) return; + + this.selectedTransitCountries = this.selectedTransitCountries.filter( + c => c.value !== this.selectedTransitCountry!.value + ); + } + + // Calculate total visits + get totalVisits(): number { + return this.selectedVisitCountries.reduce((sum, country) => sum + country.visits, 0); + } + + // Calculate total transits + get totalTransits(): number { + return this.selectedTransitCountries.reduce((sum, country) => sum + country.visits, 0); + } + + // Check if a country is already selected (visit) + isVisitCountrySelected(country: Country): boolean { + return this.selectedVisitCountries.some(c => c.value === country.value) + } + + // Check if a country is already selected (transit) + isTransitCountrySelected(country: Country): boolean { + return this.selectedTransitCountries.some(c => c.value === country.value); + } + + loadTravelPlan(): void { + this.isLoading = true; + + forkJoin({ + countries: this.travelPlanService.getCountriesAndMessages(), + travelPlanData: this.travelPlanService.getTravelPlan(this.headerid) + }).pipe(finalize(() => { + this.isLoading = false; + })).subscribe({ + next: (results) => { + this.countries = results.countries as Country[]; + this.patchTravelPlanData(results.travelPlanData); + }, + error: (error) => { + console.error('Error loading travel plan data', error); + let errorMessage = this.errorHandler.handleApiError(error, 'Failed to load travel plan data'); + this.notificationService.showError(errorMessage); + } + }); + } + + private patchTravelPlanData(plan: TravelPlan): void { + + this.selectedVisitCountries = plan?.entries; + this.selectedTransitCountries = plan?.transits; + + // Patch form values + this.travelForm.patchValue({ + usaEntries: plan?.usSets, + visitsInput: 1, + transitsInput: 1 // Reset to default + }); + + this.completed.emit(this.travelForm.valid); + } + + onSubmit(): void { + if (this.travelForm.invalid) { + this.travelForm.markAllAsTouched(); + return; + } + + const travelPlan: TravelPlan = { + usSets: this.travelForm.value.usaEntries, + entries: this.selectedVisitCountries, + transits: this.selectedTransitCountries + }; + + this.changeInProgress = true; + + this.travelPlanService.saveTravelPlan(this.headerid, travelPlan).pipe(finalize(() => { + this.changeInProgress = false; + })).subscribe({ + next: () => { + this.notificationService.showSuccess('Travel plan saved successfully'); + this.completed.emit(true); + }, + error: (error) => { + let errorMessage = this.errorHandler.handleApiError(error, 'Failed to save travel plan'); + this.notificationService.showError(errorMessage); + console.error('Error saving travel plan data : ', error); + } + }); + } +} diff --git a/src/app/carnet/view/view-carnet.component.html b/src/app/carnet/view/view-carnet.component.html new file mode 100644 index 0000000..6b9b54e --- /dev/null +++ b/src/app/carnet/view/view-carnet.component.html @@ -0,0 +1,42 @@ +
+ + +
+ + + + Application Name + + + + + + + + Holder Selection + + + + + + + + Goods Section + + + + + + + Travel Plan + + + + + + Shipping & Payment + + + + + \ No newline at end of file diff --git a/src/app/carnet/view/view-carnet.component.scss b/src/app/carnet/view/view-carnet.component.scss new file mode 100644 index 0000000..bb26aa7 --- /dev/null +++ b/src/app/carnet/view/view-carnet.component.scss @@ -0,0 +1,8 @@ +.carnet-headers-align .mat-expansion-panel-header-description { + justify-content: space-between; + align-items: center; +} + +.carnet-headers-align .mat-mdc-form-field+.mat-mdc-form-field { + margin-left: 8px; +} \ No newline at end of file diff --git a/src/app/carnet/view/view-carnet.component.ts b/src/app/carnet/view/view-carnet.component.ts new file mode 100644 index 0000000..ebbedc5 --- /dev/null +++ b/src/app/carnet/view/view-carnet.component.ts @@ -0,0 +1,46 @@ +import { afterNextRender, Component, inject, viewChild } from '@angular/core'; +import { AngularMaterialModule } from '../../shared/module/angular-material.module'; +import { MatAccordion } from '@angular/material/expansion'; +import { UserPreferences } from '../../core/models/user-preference'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute } from '@angular/router'; +import { UserPreferencesService } from '../../core/services/user-preference.service'; +import { ReactiveFormsModule } from '@angular/forms'; +import { ApplicationComponent } from '../application/application.component'; +import { GoodsComponent } from '../goods/goods.component'; +import { HolderComponent } from '../holder/holder.component'; +import { ShippingComponent } from '../shipping/shipping.component'; +import { TravelPlanComponent } from '../travel-plan/travel-plan.component'; + +@Component({ + selector: 'app-view-carnet', + imports: [AngularMaterialModule, CommonModule, ReactiveFormsModule, + ApplicationComponent, HolderComponent, GoodsComponent, TravelPlanComponent, ShippingComponent], + templateUrl: './view-carnet.component.html', + styleUrl: './view-carnet.component.scss' +}) +export class ViewCarnetComponent { + accordion = viewChild.required(MatAccordion); + isViewMode = true; + headerid: number = 0; + applicationName: string = ''; + userPreferences: UserPreferences; + + private route = inject(ActivatedRoute); + + constructor(userPrefenceService: UserPreferencesService) { + this.userPreferences = userPrefenceService.getPreferences(); + + afterNextRender(() => { + // Open all panels + this.accordion().openAll(); + }); + } + + ngOnInit(): void { + const idParam = this.route.snapshot.paramMap.get('headerid'); + this.headerid = idParam ? parseInt(idParam, 10) : 0; + + this.applicationName = this.route.snapshot.queryParamMap.get('applicationname') || ''; + } +} diff --git a/src/app/common/secured-header/secured-header.component.html b/src/app/common/secured-header/secured-header.component.html index 7721448..ce0ba80 100644 --- a/src/app/common/secured-header/secured-header.component.html +++ b/src/app/common/secured-header/secured-header.component.html @@ -12,8 +12,12 @@ - - + + + + + +
+ +
+ + + + \ No newline at end of file diff --git a/src/app/holder/basic-details/basic-details.component.scss b/src/app/holder/basic-details/basic-details.component.scss new file mode 100644 index 0000000..54759d5 --- /dev/null +++ b/src/app/holder/basic-details/basic-details.component.scss @@ -0,0 +1,111 @@ +.basic-details-container { + display: flex; + flex-direction: column; + gap: 24px; + width: 100%; + + .details-card { + overflow: hidden; + transition: all 0.3s ease; + position: relative; + background: none; + box-shadow: none; + + .loading-shade { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.7); + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + } + + .details-form { + display: flex; + flex-direction: column; + gap: 8px; + } + + .form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 16px; + align-items: start; + + .name { + grid-column: span 2; + } + + .lookup-code { + grid-column: span 1; + } + + .holder-number { + grid-column: span 1; + } + + .address1, + .address2 { + grid-column: span 3; + } + + .city, + .state, + .country, + .zip { + grid-column: span 1; + } + + .mat-mdc-radio-group { + padding-bottom: 12px; + + mat-label { + color: var(--mat-sys-on-surface); + font-family: var(--mat-sys-body-medium-font); + line-height: var(--mat-sys-body-medium-line-height); + font-size: var(--mat-sys-body-medium-size); + letter-spacing: var(--mat-sys-body-medium-tracking); + font-weight: var(--mat-sys-medium-font-weight); + } + } + + } + + .form-actions { + display: flex; + justify-content: flex-end; + gap: 16px; + } + + mat-form-field { + width: 100%; + } + } +} + +@media (max-width: 960px) { + .basic-details-container { + .details-card { + .form-row { + grid-template-columns: 1fr; + + .name, + .lookup-code, + .address1, + .address2, + .city, + .state, + .zip, + .country { + grid-column: span 1; + } + } + + + } + } +} \ No newline at end of file diff --git a/src/app/holder/basic-details/basic-details.component.ts b/src/app/holder/basic-details/basic-details.component.ts new file mode 100644 index 0000000..3af8d21 --- /dev/null +++ b/src/app/holder/basic-details/basic-details.component.ts @@ -0,0 +1,227 @@ +import { Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { AngularMaterialModule } from '../../shared/module/angular-material.module'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { State } from '../../core/models/state'; +import { Region } from '../../core/models/region'; +import { NotificationService } from '../../core/services/common/notification.service'; +import { ApiErrorHandlerService } from '../../core/services/common/api-error-handler.service'; +import { ZipCodeValidator } from '../../shared/validators/zipcode-validator'; +import { CommonService } from '../../core/services/common/common.service'; +import { finalize, Subject, takeUntil } from 'rxjs'; +import { Country } from '../../core/models/country'; +import { BasicDetailService } from '../../core/services/holder/basic-detail.service'; +import { BasicDetail } from '../../core/models/holder/basic-detail'; +import { StorageService } from '../../core/services/common/storage.service'; +import { NavigationService } from '../../core/services/common/navigation.service'; + +@Component({ + selector: 'app-basic-detail', + imports: [AngularMaterialModule, ReactiveFormsModule, CommonModule], + templateUrl: './basic-details.component.html', + styleUrl: './basic-details.component.scss' +}) +export class BasicDetailComponent implements OnInit, OnDestroy { + + @Input() isEditMode = false; + @Input() holderid: number = 0; + + @Output() holderIdCreated = new EventEmitter(); + + basicDetailsForm: FormGroup; + countries: Country[] = []; + regions: Region[] = []; + states: State[] = []; + currentApplicationDetails: { headerid: number, applicationName: string } | null = null; + + isLoading = false; + changeInProgress = false; + countriesHasStates = ['US', 'CA', 'MX']; + + private destroy$ = new Subject(); + + private fb = inject(FormBuilder); + private basicDetailService = inject(BasicDetailService); + private notificationService = inject(NotificationService); + private commonService = inject(CommonService); + private errorHandler = inject(ApiErrorHandlerService); + private storageService = inject(StorageService); + private navigationService = inject(NavigationService); + + constructor() { + this.basicDetailsForm = this.createForm(); + this.currentApplicationDetails = this.storageService.get<{ headerid: number, applicationName: string }>('currentapplication') + } + + createForm(): FormGroup { + return this.fb.group({ + holderName: ['', [Validators.required, Validators.maxLength(100)]], + dbaName: ['', [Validators.maxLength(20)]], + holderNumber: ['', [Validators.required, Validators.maxLength(20)]], + holderType: ['', [Validators.required, Validators.maxLength(20)]], + uscibMember: [false, [Validators.required]], + // govAgency: [false, [Validators.required]], + address1: ['', [Validators.required, Validators.maxLength(100)]], + address2: ['', Validators.maxLength(100)], + city: ['', [Validators.required, Validators.maxLength(50)]], + state: ['', Validators.required], + country: ['US', Validators.required], + zip: ['', [Validators.required, ZipCodeValidator('country')]] + }); + } + + ngOnInit(): void { + this.loadLookupData(); + + if (this.holderid > 0) { + this.isLoading = true; + this.basicDetailService.getBasicDetailByHolderId(this.holderid).pipe(finalize(() => { + this.isLoading = false; + })).subscribe({ + next: (basicDetail: BasicDetail) => { + this.patchFormData(basicDetail); + // this.holderName.emit(basicDetail.holderName); + }, + error: (error: any) => { + let errorMessage = this.errorHandler.handleApiError(error, 'Failed to load basic details'); + this.notificationService.showError(errorMessage); + console.error('Error loading basic details:', error); + } + }); + } else { + this.loadStates('US'); // Load states for default country + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + onCountryChange(country: string): void { + this.basicDetailsForm.get('state')?.reset(); + + if (country) { + this.loadStates(country); + } + + this.basicDetailsForm.get('zip')?.updateValueAndValidity(); + } + + saveBasicDetails() { + if (this.basicDetailsForm.invalid) { + this.basicDetailsForm.markAllAsTouched(); + return; + } + + const basicDetailData: BasicDetail = this.basicDetailsForm.value; + + const saveObservable = this.isEditMode && this.holderid > 0 + ? this.basicDetailService.updateBasicDetails(this.holderid, basicDetailData) + : this.basicDetailService.createBasicDetail(basicDetailData); + + this.changeInProgress = true; + + saveObservable.pipe(finalize(() => { + this.changeInProgress = false; + })).subscribe({ + next: (basicData: any) => { + this.notificationService.showSuccess(`Basic details ${this.isEditMode ? 'updated' : 'added'} successfully`); + + if (!this.isEditMode) { + this.holderIdCreated.emit(basicData.HOLDERID); + + // change to edit mode after creation + this.isEditMode = true; + this.holderid = basicData.HOLDERID; + } + + // if (this.isEditMode) { + // this.holderName.emit(basicDetailData.holderName); + // } + }, + error: (error: any) => { + let errorMessage = this.errorHandler.handleApiError(error, `Failed to ${this.isEditMode ? 'update' : 'add'} basic details`); + this.notificationService.showError(errorMessage); + console.error('Error saving basic details:', error); + } + }); + } + + updateStateControl(controlName: string, country: string): void { + const stateControl = this.basicDetailsForm.get(controlName); + if (this.countriesHasStates.includes(country)) { + stateControl?.enable(); + } else { + stateControl?.disable(); + stateControl?.setValue('FN'); + } + } + + patchFormData(data: BasicDetail): void { + + this.basicDetailsForm.patchValue({ + holderName: data.holderName, + dbaName: data.dbaName, + holderNumber: data.holderNumber, + holderType: data.holderType, + uscibMember: data.uscibMember, + // govAgency: data.govAgency, + address1: data.address1, + address2: data.address2, + city: data.city, + state: data.state, + country: data.country, + zip: data.zip + }) + + if (data.country) { + this.loadStates(data.country); + } + } + + get f() { + return this.basicDetailsForm.controls; + } + + loadLookupData(): void { + + this.commonService.getCountries() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (countries) => { + this.countries = countries; + }, + error: (error) => { + console.error('Failed to load countries', error); + } + }); + } + + loadStates(country: string): void { + country = this.countriesHasStates.includes(country) ? country : 'FN'; + this.commonService.getStates(country) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (states) => { + this.states = states; + this.updateStateControl('state', country); + }, + error: (error) => { + console.error('Failed to load states', error); + } + }); + } + + goBackToCarnetApplication(): void { + this.storageService.removeItem('currentapplication') + this.navigationService.navigate(["edit-carnet", this.currentApplicationDetails?.headerid], + { + state: { isEditMode: true }, + queryParams: { + applicationname: this.currentApplicationDetails?.applicationName, + return: 'holder' + } + }) + } +} diff --git a/src/app/holder/contacts/contacts.component.html b/src/app/holder/contacts/contacts.component.html new file mode 100644 index 0000000..a685f12 --- /dev/null +++ b/src/app/holder/contacts/contacts.component.html @@ -0,0 +1,266 @@ +
+
+ + Show Inactive Contacts + + + + + +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
First Name{{ contact.firstName }}Last Name{{ contact.lastName }}Title{{ contact.title }}Phone{{ contact.phone | phone }}Mobile{{ contact.mobile | phone }}Email{{ contact.email }}Actions +
+ + + +
+ +
+ info + No records available +
+ + +
+ + +
+
+
+

{{ isEditing ? 'Edit Contact' : 'Add New Contact' }}

+
+ +
+ + First Name + + person + + First name is required + + + Maximum 50 characters allowed + + + + + Middle Initial + + + Only 1 character allowed + + + + + Last Name + + person + + Last name is required + + + Maximum 50 characters allowed + + +
+ +
+ + + Title + + work + + Title is required + + + Maximum 100 characters allowed + + +
+ +
+ + Phone + + phone + + Phone is required + + + Please enter a valid phone number (10-15 digits) + + + + + Mobile + + smartphone + + Mobile is required + + + Please enter a valid mobile number (10-15 digits) + + +
+ +
+ + Fax + + fax + + Please enter a valid fax number (10-15 digits) + + + + + Email + + email + + Email is required + + + Please enter a valid email address + + + Maximum 100 characters allowed + + +
+ +
+
+
+ +
+ +
+ {{contactReadOnlyFields.lastChangedBy || 'N/A'}} +
+
+ +
+ +
+ {{contactReadOnlyFields.isInactive === true ? 'Yes' : 'No' }} +
+
+
+
+ + +
+ +
+ {{(contactReadOnlyFields.lastChangedDate | date:'mediumDate':'UTC') || 'N/A'}} +
+
+ + +
+ +
+ {{(contactReadOnlyFields.inactivatedDate | date:'mediumDate':'UTC') || 'N/A'}} +
+
+
+
+
+ +
+ + +
+
+
+
\ No newline at end of file diff --git a/src/app/holder/contacts/contacts.component.scss b/src/app/holder/contacts/contacts.component.scss new file mode 100644 index 0000000..36fc225 --- /dev/null +++ b/src/app/holder/contacts/contacts.component.scss @@ -0,0 +1,183 @@ +.contacts-container { + padding: 24px; + display: flex; + flex-direction: column; + gap: 24px; + + .actions-bar { + clear: both; + margin-bottom: -16px; + + mat-slide-toggle { + transform: scale(0.8); + margin-left: -0.5rem; + } + + button { + float: right; + margin-left: 16px; + } + } + + .table-container { + position: relative; + overflow: auto; + border-radius: 8px; + + .loading-shade { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.7); + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + } + + mat-table { + width: 100%; + + mat-icon { + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + transform: scale(1.1); + } + } + + .mat-column-actions { + width: 180px; + text-align: center; + } + + .mat-column-defaultContact { + width: 80px; + text-align: center; + } + } + + .no-data-message { + text-align: center; + padding: 0.9rem; + color: rgba(0, 0, 0, 0.54); + + mat-icon { + font-size: 1rem; + width: 1rem; + height: 1rem; + margin-bottom: -3px; + } + } + + mat-paginator { + border-top: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 0 0 8px 8px; + padding-top: 4px; + } + } + + .form-container { + background-color: white; + padding: 24px; + border-radius: 8px; + margin-top: 16px; + + .form-header { + margin-bottom: 24px; + + h3 { + margin: 0; + color: var(--mat-sys-primary); + font-weight: 500; + } + } + + form { + display: flex; + flex-direction: column; + gap: 16px; + + .form-row { + display: flex; + gap: 16px; + align-items: start; + + mat-form-field { + flex: 1; + } + + .small-field { + max-width: 120px; + } + } + + .form-actions { + display: flex; + justify-content: flex-end; + gap: 16px; + margin-top: 16px; + } + + .top-divider { + padding-top: 0.5rem; + border-top: 1px solid #eee; + } + + .bottom-divider { + padding-bottom: 0.5rem; + border-bottom: 1px solid #eee; + } + + .readonly-section { + + .readonly-fields { + display: flex; + gap: 2rem; + + .field-column { + flex: 1; + display: flex; + flex-direction: column; + gap: 1.5rem; + } + } + + .readonly-field { + label { + display: block; + font-size: 0.875rem; + color: #666; + margin-bottom: 0.25rem; + } + + .readonly-value { + padding: 0.25rem; + font-size: 0.875rem; + display: flex; + align-items: center; + } + } + } + } + } +} + +// Responsive adjustments +@media (max-width: 768px) { + .contacts-container { + padding: 16px; + + .form-row { + flex-direction: column; + gap: 16px !important; + + .small-field { + max-width: 100% !important; + } + } + } +} \ No newline at end of file diff --git a/src/app/holder/contacts/contacts.component.ts b/src/app/holder/contacts/contacts.component.ts new file mode 100644 index 0000000..2a0c7f2 --- /dev/null +++ b/src/app/holder/contacts/contacts.component.ts @@ -0,0 +1,257 @@ +import { Component, EventEmitter, inject, Input, Output, ViewChild } from '@angular/core'; +import { AngularMaterialModule } from '../../shared/module/angular-material.module'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { PhonePipe } from '../../shared/pipes/phone.pipe'; +import { CommonModule } from '@angular/common'; +import { MatPaginator, MatPaginatorIntl } from '@angular/material/paginator'; +import { MatSort } from '@angular/material/sort'; +import { MatTableDataSource } from '@angular/material/table'; +import { UserPreferences } from '../../core/models/user-preference'; +import { NotificationService } from '../../core/services/common/notification.service'; +import { ApiErrorHandlerService } from '../../core/services/common/api-error-handler.service'; +import { ContactService } from '../../core/services/holder/contact.service'; +import { Contact } from '../../core/models/holder/contact'; +import { CustomPaginator } from '../../shared/custom-paginator'; +import { MatDialog } from '@angular/material/dialog'; +import { ConfirmDialogComponent } from '../../shared/components/confirm-dialog/confirm-dialog.component'; +import { NavigationService } from '../../core/services/common/navigation.service'; +import { StorageService } from '../../core/services/common/storage.service'; +import { finalize } from 'rxjs'; + +@Component({ + selector: 'app-contacts', + imports: [AngularMaterialModule, ReactiveFormsModule, PhonePipe, CommonModule], + templateUrl: './contacts.component.html', + styleUrl: './contacts.component.scss', + providers: [{ provide: MatPaginatorIntl, useClass: CustomPaginator }], +}) +export class ContactsComponent { + @Input() isEditMode: boolean = false; + @Input() holderid: number = 0; + @Input() userPreferences: UserPreferences = {}; + + @Output() hasContacts = new EventEmitter(); + + @ViewChild(MatPaginator) paginator!: MatPaginator; + @ViewChild(MatSort) sort!: MatSort; + + displayedColumns: string[] = ['firstName', 'lastName', 'title', 'phone', 'mobile', 'email', 'actions']; + dataSource = new MatTableDataSource(); + contactForm: FormGroup; + + isEditing = false; + currentContactId: number | null = null; + isLoading = false; + changeInProgress = false; + showForm = false; + showInactiveContacts = false; + contacts: Contact[] = []; + currentApplicationDetails: { headerid: number, applicationName: string } | null = null; + + contactReadOnlyFields: any = { + lastChangedDate: null, + lastChangedBy: null, + isInactive: null, + inactivatedDate: null + }; + + private fb = inject(FormBuilder); + private dialog = inject(MatDialog); + private contactService = inject(ContactService); + private notificationService = inject(NotificationService); + private errorHandler = inject(ApiErrorHandlerService); + private navigationService = inject(NavigationService); + private storageService = inject(StorageService); + + constructor() { + this.contactForm = this.fb.group({ + firstName: ['', [Validators.required, Validators.maxLength(50)]], + lastName: ['', [Validators.required, Validators.maxLength(50)]], + middleInitial: ['', [Validators.maxLength(1)]], + title: ['', [Validators.required, Validators.maxLength(100)]], + phone: ['', [Validators.required, Validators.pattern(/^[0-9\-\(\)]{10,15}$/)]], + mobile: ['', [Validators.required, Validators.pattern(/^[0-9\-\(\)]{10,15}$/)]], + fax: ['', [Validators.pattern(/^[0-9\-\(\)]{10,15}$/)]], + email: ['', [Validators.required, Validators.email, Validators.maxLength(100)]], + }); + + this.currentApplicationDetails = this.storageService.get<{ headerid: number, applicationName: string }>('currentapplication') + } + + ngOnInit(): void { + if (this.holderid > 0) { + this.loadContacts(); + } + } + + ngAfterViewInit() { + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + } + + loadContacts(): void { + this.isLoading = true; + + this.contactService.getContactsById(this.holderid).pipe(finalize(() => { + this.isLoading = false; + })).subscribe({ + next: (contacts: Contact[]) => { + this.contacts = contacts; + this.renderContacts(); + }, + error: (error: any) => { + let errorMessage = this.errorHandler.handleApiError(error, 'Failed to load contacts'); + this.notificationService.showError(errorMessage); + console.error('Error loading contacts:', error); + } + }); + } + + addNewContact(): void { + this.showForm = true; + this.isEditing = false; + this.currentContactId = null; + this.contactForm.reset(); + } + + editContact(contact: Contact): void { + this.showForm = true; + this.isEditing = true; + this.currentContactId = contact.holdercontactid; + + this.contactForm.patchValue({ + firstName: contact.firstName, + lastName: contact.lastName, + middleInitial: contact.middleInitial, + title: contact.title, + phone: contact.phone, + mobile: contact.mobile, + fax: contact.fax, + email: contact.email + }); + + this.contactReadOnlyFields.lastChangedDate = contact.lastUpdatedDate ?? contact.dateCreated; + this.contactReadOnlyFields.lastChangedBy = contact.lastUpdatedBy ?? contact.createdBy; + this.contactReadOnlyFields.isInactive = contact.isInactive; + this.contactReadOnlyFields.inactivatedDate = contact.inactivatedDate; + } + + saveContact(): void { + if (this.contactForm.invalid && !(this.holderid > 0)) { + this.contactForm.markAllAsTouched(); + return; + } + + const contactData: Contact = this.contactForm.value; + + const saveObservable = this.isEditing && (this.currentContactId! > 0) + ? this.contactService.updateContact(this.currentContactId!, contactData) + : this.contactService.createContact(this.holderid, contactData); + + this.changeInProgress = true; + + saveObservable.pipe(finalize(() => { + this.changeInProgress = false; + })).subscribe({ + next: () => { + this.notificationService.showSuccess(`Contact ${this.isEditing ? 'updated' : 'added'} successfully`); + this.loadContacts(); + this.cancelEdit(); + this.hasContacts.emit(true); + }, + error: (error) => { + let errorMessage = this.errorHandler.handleApiError(error, `Failed to ${this.isEditing ? 'update' : 'add'} contact`); + this.notificationService.showError(errorMessage); + console.error('Error saving contact:', error); + } + }); + } + + toggleShowInactiveContacts(): void { + this.showInactiveContacts = !this.showInactiveContacts; + this.renderContacts(); + } + + renderContacts(): void { + if (this.showInactiveContacts) { + this.dataSource.data = this.contacts; + } else { + this.dataSource.data = this.contacts.filter(contact => !contact.isInactive); + } + } + + cancelEdit(): void { + this.showForm = false; + this.isEditing = false; + this.currentContactId = null; + this.contactForm.reset(); + } + + inactivateContact(contactId: number): void { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '350px', + data: { + title: 'Confirm Inactivation', + message: 'Are you sure you want to inactivate this contact?', + confirmText: 'Yes', + cancelText: 'Cancel' + } + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.contactService.inactivateHolderContact(contactId).subscribe({ + next: () => { + this.notificationService.showSuccess('Contact inactivated successfully'); + this.loadContacts(); + }, + error: (error) => { + let errorMessage = this.errorHandler.handleApiError(error, 'Failed to inactivate contact'); + this.notificationService.showError(errorMessage); + console.error('Error inactivating contact:', error); + } + }); + } + }); + } + + reactivateContact(contactId: number): void { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '350px', + data: { + title: 'Confirm Reactivation', + message: 'Are you sure you want to reactivate this contact?', + confirmText: 'Yes', + cancelText: 'Cancel' + } + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.contactService.reactivateHolderContact(contactId).subscribe({ + next: () => { + this.notificationService.showSuccess('Contact reactivated successfully'); + this.loadContacts(); + }, + error: (error) => { + let errorMessage = this.errorHandler.handleApiError(error, 'Failed to reactivate contact'); + this.notificationService.showError(errorMessage); + console.error('Error reactivating contact:', error); + } + }); + } + }); + } + + goBackToCarnetApplication(): void { + this.storageService.removeItem('currentapplication') + this.navigationService.navigate(["edit-carnet", this.currentApplicationDetails?.headerid], + { + state: { isEditMode: true }, + queryParams: { + applicationname: this.currentApplicationDetails?.applicationName, + return: 'holder' + } + }) + } +} diff --git a/src/app/holder/edit/edit-holder.component.html b/src/app/holder/edit/edit-holder.component.html new file mode 100644 index 0000000..16a606f --- /dev/null +++ b/src/app/holder/edit/edit-holder.component.html @@ -0,0 +1,23 @@ + + +
+ + +
+ + + + Basic Details + + + + + + + Contacts + + + + + \ No newline at end of file diff --git a/src/app/holder/edit/edit-holder.component.scss b/src/app/holder/edit/edit-holder.component.scss new file mode 100644 index 0000000..8dfbefe --- /dev/null +++ b/src/app/holder/edit/edit-holder.component.scss @@ -0,0 +1,18 @@ +// .page-header { +// margin: 0.5rem 0px; +// color: var(--mat-sys-primary); +// font-weight: 500; +// } + +.holder-action-buttons { + padding-bottom: 20px; +} + +.holder-headers-align .mat-expansion-panel-header-description { + justify-content: space-between; + align-items: center; +} + +.holder-headers-align .mat-mdc-form-field+.mat-mdc-form-field { + margin-left: 8px; +} \ No newline at end of file diff --git a/src/app/holder/edit/edit-holder.component.ts b/src/app/holder/edit/edit-holder.component.ts new file mode 100644 index 0000000..fb80399 --- /dev/null +++ b/src/app/holder/edit/edit-holder.component.ts @@ -0,0 +1,42 @@ +import { CommonModule } from '@angular/common'; +import { afterNextRender, Component, inject, viewChild } from '@angular/core'; +import { AngularMaterialModule } from '../../shared/module/angular-material.module'; +import { MatAccordion } from '@angular/material/expansion'; +import { UserPreferences } from '../../core/models/user-preference'; +import { ActivatedRoute } from '@angular/router'; +import { UserPreferencesService } from '../../core/services/user-preference.service'; +import { BasicDetailComponent } from '../basic-details/basic-details.component'; +import { ContactsComponent } from '../contacts/contacts.component'; + +@Component({ + selector: 'app-edit-holder', + imports: [AngularMaterialModule, CommonModule, BasicDetailComponent, ContactsComponent], + templateUrl: './edit-holder.component.html', + styleUrl: './edit-holder.component.scss' +}) +export class EditHolderComponent { + accordion = viewChild.required(MatAccordion); + isEditMode = true; + holderid = 0; + //holderName: string | null = null; + userPreferences: UserPreferences; + + private route = inject(ActivatedRoute); + + constructor(userPrefenceService: UserPreferencesService) { + this.userPreferences = userPrefenceService.getPreferences(); + afterNextRender(() => { + // Open all panels + this.accordion().openAll(); + }); + } + + ngOnInit(): void { + const idParam = this.route.snapshot.paramMap.get('holderid'); + this.holderid = idParam ? parseInt(idParam, 10) : 0; + } + + // onHolderNameUpdate(event: string): void { + // this.holderName = event; + // } +} diff --git a/src/app/holder/search/search-holder.component.html b/src/app/holder/search/search-holder.component.html new file mode 100644 index 0000000..3ffe5dd --- /dev/null +++ b/src/app/holder/search/search-holder.component.html @@ -0,0 +1,126 @@ +
+ +
+
+
+
+ + Holder Name + + search + +
+
+ +
+ + + + +
+
+
+ +
+
+ +
+ +
+ + Show Inactive Holders + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Holder Name{{ holder.holderName }}DBA Name{{ holder.dbaName || '--'}}Address{{ getAddressLabel(holder) }}USCIB Member{{ holder.uscibMember ? 'Yes' : 'No' }}Holder Type{{ holder.holderType }}Actions + +
+ + + + + + + +
+
+ info + No records found matching your criteria +
+ + + +
+ +
+ +
+
\ No newline at end of file diff --git a/src/app/holder/search/search-holder.component.scss b/src/app/holder/search/search-holder.component.scss new file mode 100644 index 0000000..3a2dbd5 --- /dev/null +++ b/src/app/holder/search/search-holder.component.scss @@ -0,0 +1,116 @@ +.page-header { + margin: 0.5rem 0 0; + color: var(--mat-sys-primary); + font-weight: 500; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 16px; + margin-top: 16px; +} + +.manage-holder-container { + display: flex; + flex-direction: column; + gap: 1rem; + width: 100%; + + .search-fields { + display: flex; + flex-direction: column; + gap: 16px; + + .form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 16px; + align-items: start; + + .name { + grid-column: span 2; + } + } + } + + .search-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.9rem; + + button { + display: flex; + align-items: center; + } + } + + .results-section { + position: relative; + overflow: auto; + border-radius: 8px; + + .loading-shade { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.7); + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + } + + mat-slide-toggle { + transform: scale(0.8); + margin-left: -0.5rem; + } + + .selected-holder { + color: #28a745; + font-weight: 500; + } + + .actions-icons { + display: flex; + } + + .no-data-message { + text-align: center; + padding: 0.9rem; + color: rgba(0, 0, 0, 0.54); + + mat-icon { + font-size: 1rem; + width: 1rem; + height: 1rem; + margin-bottom: -3px; + } + } + + mat-paginator { + border-top: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 0 0 8px 8px; + padding-top: 4px; + } + } +} + +@media (max-width: 960px) { + .manage-holder-container { + .search-form { + .search-fields { + .form-row { + grid-template-columns: 1fr; + + .name { + grid-column: span 1; + } + } + } + } + } +} \ No newline at end of file diff --git a/src/app/holder/search/search-holder.component.ts b/src/app/holder/search/search-holder.component.ts new file mode 100644 index 0000000..7323ff2 --- /dev/null +++ b/src/app/holder/search/search-holder.component.ts @@ -0,0 +1,266 @@ +import { Component, EventEmitter, inject, Input, Output, SimpleChanges, ViewChild } from '@angular/core'; +import { AngularMaterialModule } from '../../shared/module/angular-material.module'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { NavigationService } from '../../core/services/common/navigation.service'; +import { UserPreferencesService } from '../../core/services/user-preference.service'; +import { MatTableDataSource } from '@angular/material/table'; +import { NotificationService } from '../../core/services/common/notification.service'; +import { ApiErrorHandlerService } from '../../core/services/common/api-error-handler.service'; +import { UserPreferences } from '../../core/models/user-preference'; +import { MatSort } from '@angular/material/sort'; +import { MatPaginator, MatPaginatorIntl } from '@angular/material/paginator'; +import { HolderService as HolderService } from '../../core/services/holder/holder.service'; +import { HolderService as CarnetApplicationHolderService } from '../../core/services/carnet/holder.service'; +import { CustomPaginator } from '../../shared/custom-paginator'; +import { BasicDetail } from '../../core/models/holder/basic-detail'; +import { HolderFilter } from '../../core/models/holder/holder-filter'; +import { ConfirmDialogComponent } from '../../shared/components/confirm-dialog/confirm-dialog.component'; +import { MatDialog } from '@angular/material/dialog'; +import { StorageService } from '../../core/services/common/storage.service'; +import { finalize } from 'rxjs'; + +@Component({ + selector: 'app-holder-search', + imports: [AngularMaterialModule, ReactiveFormsModule, CommonModule], + templateUrl: './search-holder.component.html', + styleUrl: './search-holder.component.scss', + providers: [{ provide: MatPaginatorIntl, useClass: CustomPaginator }], +}) +export class SearchHolderComponent { + private _paginator!: MatPaginator; + + @ViewChild(MatPaginator, { static: false }) + set paginator(value: MatPaginator) { + this._paginator = value; + this.dataSource.paginator = value; + } + get paginator(): MatPaginator { + return this._paginator; + } + + @ViewChild(MatSort, { static: false }) + set sort(value: MatSort) { + this.dataSource.sort = value; + } + + @Input() isViewMode = false; + @Input() headerid: number = 0; + @Input() selectedHolderId: number = 0; + @Input() applicationName: string = ''; + + @Output() holderSelectionCompleted = new EventEmitter(); + + showInactiveHolders: boolean = false; + holders: BasicDetail[] = [] + isLoading: boolean = false; + changeInProgress: boolean = false; + userPreferences: UserPreferences; + searchForm: FormGroup; + + private fb = inject(FormBuilder); + private holderService = inject(HolderService); + private navigationService = inject(NavigationService); + private carnetHolderService = inject(CarnetApplicationHolderService); + private errorHandler = inject(ApiErrorHandlerService); + private notificationService = inject(NotificationService); + private storageService = inject(StorageService); + private dialog = inject(MatDialog); + + dataSource = new MatTableDataSource([]); + + ngAfterViewInit() { + // This is different from other pages to show the item selected in the table. + //this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + } + + displayedColumns: string[] = ['holderName', 'dbaName', 'address', 'uscibMember', 'holderType', 'actions']; + + constructor( + userPrefenceService: UserPreferencesService + ) { + this.userPreferences = userPrefenceService.getPreferences(); + this.searchForm = this.createSearchForm(); + } + + createSearchForm(): FormGroup { + return this.fb.group({ + holderName: [''] + }); + } + + ngOnInit(): void { + this.searchHolders(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['selectedHolderId'] && this.paginator) { + if (this.selectedHolderId && this.dataSource.data.length) { + this.goToItemPage(this.selectedHolderId); + } + } + } + + onSearch(): void { + this.searchHolders(); + } + + saveHolderSelection(): void { + this.changeInProgress = true; + const saveObservable = this.carnetHolderService.saveApplicationHolder(this.headerid, this.selectedHolderId ? Number(this.selectedHolderId) : 0); + + saveObservable.pipe(finalize(() => { + this.changeInProgress = false; + })).subscribe({ + next: (basicData: any) => { + this.notificationService.showSuccess(`Holder updated successfully`); + this.holderSelectionCompleted.emit(true); + }, + error: (error: any) => { + let errorMessage = this.errorHandler.handleApiError(error, `Failed to update holder details`); + this.notificationService.showError(errorMessage); + console.error('Error saving holder details:', error); + } + }); + } + + toggleShowInactiveHolders(): void { + this.showInactiveHolders = !this.showInactiveHolders; + this.renderHolders(); + } + + searchHolders(): void { + this.isLoading = true; + const filterData: HolderFilter = this.searchForm.value; + + this.holderService.getHolders(filterData).pipe(finalize(() => { + this.isLoading = false; + })).subscribe({ + next: (holders: BasicDetail[]) => { + this.dataSource.data = this.holders = holders; + this.renderHolders() + }, + error: (error: any) => { + let errorMessage = this.errorHandler.handleApiError(error, 'Failed to search holders'); + this.notificationService.showError(errorMessage); + console.error('Error loading holders:', error); + } + }); + } + + renderHolders() { + if (this.showInactiveHolders) { + this.dataSource.data = this.holders; + } else { + this.dataSource.data = this.holders.filter((holder: any) => !holder?.isInactive); + } + } + + addNewHolder(): void { + if (this.headerid) { + let currentApplicationDetails = { headerid: this.headerid, applicationName: this.applicationName } + this.storageService.set("currentapplication", currentApplicationDetails); + } + + this.navigationService.navigate(["add-holder"]) + } + + onEdit(id: string) { + if (this.headerid) { + let currentApplicationDetails = { headerid: this.headerid, applicationName: this.applicationName } + this.storageService.set("currentapplication", currentApplicationDetails); + } + + this.navigationService.navigate(['edit-holder', id]); + } + + inactivateHolder(holderid: number): void { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '350px', + data: { + title: 'Confirm Inactivation', + message: 'Are you sure you want to inactivate this holder?', + confirmText: 'Yes', + cancelText: 'Cancel' + } + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.holderService.inactivateHolder(holderid).subscribe({ + next: () => { + this.notificationService.showSuccess('Holder inactivated successfully'); + this.searchHolders(); + }, + error: (error) => { + let errorMessage = this.errorHandler.handleApiError(error, 'Failed to inactivate holder'); + this.notificationService.showError(errorMessage); + console.error('Error inactivating holder:', error); + } + }); + } + }); + } + + reactivateHolder(holderid: number): void { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '350px', + data: { + title: 'Confirm Reactivation', + message: 'Are you sure you want to reactivate this holder?', + confirmText: 'Yes', + cancelText: 'Cancel' + } + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.holderService.reactivateHolder(holderid).subscribe({ + next: () => { + this.notificationService.showSuccess('Holder reactivated successfully'); + this.searchHolders(); + }, + error: (error) => { + let errorMessage = this.errorHandler.handleApiError(error, 'Failed to reactivate holder'); + this.notificationService.showError(errorMessage); + console.error('Error reactivating holder:', error); + } + }); + } + }); + } + + onClearSearch(): void { + this.searchForm.reset(); + this.searchHolders(); + } + + onHolderSelection(holder: any): void { + this.selectedHolderId = holder.holderid; + } + + getAddressLabel(holder: BasicDetail): string { + + const parts = [ + holder.address1, + holder.address2, + holder.city, + holder.state, + holder.zip, + holder.country + ]; + + // Filter out any empty, null, or undefined parts and then join them. + return parts.filter(part => part).join(', '); + } + + goToItemPage(holderid: number): void { + const itemIndex = this.dataSource.data.findIndex(dataItem => dataItem.holderid === holderid); + if (itemIndex > -1 && this.paginator) { + const targetPageIndex = Math.floor(itemIndex / this.paginator.pageSize); + this.paginator.pageIndex = targetPageIndex; + this.dataSource.paginator = this.paginator; + } + } +} diff --git a/src/app/home/home.component.html b/src/app/home/home.component.html index 6e450b1..72be847 100644 --- a/src/app/home/home.component.html +++ b/src/app/home/home.component.html @@ -86,32 +86,38 @@ Actions - - - - - +
+ + + + + +
diff --git a/src/app/home/home.component.scss b/src/app/home/home.component.scss index 8b48ece..6d68383 100644 --- a/src/app/home/home.component.scss +++ b/src/app/home/home.component.scss @@ -37,7 +37,7 @@ justify-content: center; } - mat-table { + table { width: 100%; mat-icon { @@ -48,25 +48,35 @@ transform: scale(1.1); } } - } - .no-data-message { - text-align: center; - padding: 0.9rem; - color: rgba(0, 0, 0, 0.54); + .mat-column-actions { + width: 160px; - mat-icon { - font-size: 1rem; - width: 1rem; - height: 1rem; - margin-bottom: -3px; + .action-buttons { + width: 100%; + display: flex; + gap: 4px; + } } - } - mat-paginator { - border-top: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 0 0 8px 8px; - padding-top: 4px; + .no-data-message { + text-align: center; + padding: 0.9rem; + color: rgba(0, 0, 0, 0.54); + + mat-icon { + font-size: 1rem; + width: 1rem; + height: 1rem; + margin-bottom: -3px; + } + } + + mat-paginator { + border-top: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 0 0 8px 8px; + padding-top: 4px; + } } } } \ No newline at end of file diff --git a/src/app/home/home.component.ts b/src/app/home/home.component.ts index 7186071..67983cc 100644 --- a/src/app/home/home.component.ts +++ b/src/app/home/home.component.ts @@ -165,8 +165,8 @@ export class HomeComponent { } processCarnet(item: any): void { - if (item && item.headerId) { - this.navigateTo(['edit-carnet', item.headerId], { + if (item && item.HEADERID) { + this.navigateTo(['edit-carnet', item.HEADERID], { queryParams: { applicationname: item.applicationName } }); } else { @@ -175,8 +175,8 @@ export class HomeComponent { } viewCarnet(item: any): void { - if (item && item.headerId) { - this.navigateTo(['view-carnet', item.headerId], { + if (item && item.HEADERID) { + this.navigateTo(['view-carnet', item.HEADERID], { queryParams: { applicationname: item.applicationName } }); } else { @@ -185,7 +185,7 @@ export class HomeComponent { } printCarnet(item: any): void { - if (item && item.headerId) { + if (item && item.HEADERID) { } else { this.notificationService.showError('Invalid carnet data'); } diff --git a/src/app/param/manage-country/manage-country.component.html b/src/app/param/manage-country/manage-country.component.html new file mode 100644 index 0000000..36c8541 --- /dev/null +++ b/src/app/param/manage-country/manage-country.component.html @@ -0,0 +1,103 @@ + +
+ + +
+
+
+ + Country + + + {{ country.paramDesc }} + + + +
+ +
+ + Param Type + + + Param Type is required + + + Maximum 10 characters allowed + + + + + Description + + + Param Description is required + + + Maximum 100 characters allowed + + + + +
+ +
+ + Additional Value 1 + + + Maximum 20 characters allowed + + +
+ +
+ + + Additional Value 2 + + + Maximum 20 characters allowed + + + + + Additional Value 3 + + + Maximum 20 characters allowed + + +
+ +
+ + + Additional Value 4 + + + Maximum 20 characters allowed + + + + + Additional Value 5 + + + Maximum 20 characters allowed + + +
+ +
+ + + +
+
+
+ +
\ No newline at end of file diff --git a/src/app/param/manage-country/manage-country.component.scss b/src/app/param/manage-country/manage-country.component.scss new file mode 100644 index 0000000..a564d2b --- /dev/null +++ b/src/app/param/manage-country/manage-country.component.scss @@ -0,0 +1,108 @@ +.page-header { + margin: 0.5rem 0px; + color: var(--mat-sys-primary); + font-weight: 500; +} + +.manage-container { + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; +} + +.form-container { + background-color: white; + padding: 24px; + border-radius: 8px; + margin-top: 16px; + + .form-header { + margin-bottom: 24px; + + h3 { + margin: 0; + color: var(--mat-sys-primary); + font-weight: 500; + } + } + + form { + display: flex; + flex-direction: column; + gap: 16px; + + .form-row { + display: flex; + gap: 16px; + align-items: start; + + mat-form-field { + flex: 1; + } + + .small-field { + max-width: 120px; + } + } + + .form-actions { + display: flex; + justify-content: flex-end; + gap: 16px; + margin-top: 16px; + } + + .readonly-section { + padding-top: 0.5rem; + border-top: 1px solid #eee; + + .readonly-fields { + display: flex; + gap: 2rem; + + .field-column { + flex: 1; + display: flex; + flex-direction: column; + gap: 1.5rem; + } + } + + .readonly-field { + label { + display: block; + font-size: 0.875rem; + color: #666; + margin-bottom: 0.25rem; + } + + .readonly-value { + padding: 0.25rem; + font-size: 0.875rem; + display: flex; + align-items: center; + } + } + } + } +} + +.results-section { + position: relative; + overflow: auto; + border-radius: 8px; + + .loading-shade { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.7); + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + } +} \ No newline at end of file diff --git a/src/app/param/manage-country/manage-country.component.ts b/src/app/param/manage-country/manage-country.component.ts new file mode 100644 index 0000000..8fcb78d --- /dev/null +++ b/src/app/param/manage-country/manage-country.component.ts @@ -0,0 +1,156 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { ReactiveFormsModule, FormGroup, Validators, FormBuilder } from '@angular/forms'; +import { finalize } from 'rxjs'; +import { ParamProperties } from '../../core/models/param/parameters'; +import { ApiErrorHandlerService } from '../../core/services/common/api-error-handler.service'; +import { NotificationService } from '../../core/services/common/notification.service'; +import { ParamService } from '../../core/services/param/param.service'; +import { AngularMaterialModule } from '../../shared/module/angular-material.module'; +import { MatSelectChange } from '@angular/material/select'; + +@Component({ + selector: 'app-manage-country', + imports: [AngularMaterialModule, CommonModule, ReactiveFormsModule], + templateUrl: './manage-country.component.html', + styleUrl: './manage-country.component.scss' +}) +export class ManageCountryComponent { + + isLoading: boolean = false; + changeInProgress: boolean = false; + countries: ParamProperties[] = []; + currentCountryId: number | null = null; + currentCountry: ParamProperties | undefined | null = null; + currentParamid: number | null = null; + + private paramService = inject(ParamService); + private errorHandler = inject(ApiErrorHandlerService); + private notificationService = inject(NotificationService); + private fb = inject(FormBuilder); + + countryForm: FormGroup; + + constructor() { + this.countryForm = this.initializeForm(); + } + + initializeForm(): FormGroup { + return this.fb.group({ + paramType: ['', [Validators.required, Validators.maxLength(10)]], + paramValue: ['', [Validators.required, Validators.maxLength(20)]], + paramDesc: ['', [Validators.required, Validators.maxLength(100)]], + addlParamValue1: ['', [Validators.maxLength(20)]], + addlParamValue2: ['', [Validators.maxLength(20)]], + addlParamValue3: ['', [Validators.maxLength(20)]], + addlParamValue4: ['', [Validators.maxLength(20)]], + addlParamValue5: ['', [Validators.maxLength(20)]], + }); + } + + ngOnInit(): void { + this.getCountries(); + } + + onCountrySelectionChanged(event: MatSelectChange) { + console.log(event); + this.currentCountry = this.countries.find(c => c.paramId === +event.value); + this.currentCountryId = this.currentCountry?.paramId ?? 0; + + this.patchCountryData(); + } + + getCountries(): void { + this.isLoading = true; + this.paramService.getParameters('014').pipe(finalize(() => { + this.isLoading = false; + })).subscribe({ + next: (paramData: ParamProperties[]) => { + this.countries = paramData; + this.currentCountry = this.countries.find(c => c.paramValue === 'US'); + this.currentCountryId = this.currentCountry?.paramId ?? 0; + + this.patchCountryData(); + }, + error: (error: any) => { + let errorMessage = this.errorHandler.handleApiError(error, 'Failed to get countries'); + this.notificationService.showError(errorMessage); + console.error('Error loading countries :', error); + } + }); + } + + patchCountryData(): void { + if (!this.currentCountry) { + return; + } + + this.countryForm.patchValue({ + paramType: this.currentCountry.paramType, + paramValue: this.currentCountry.paramValue, + paramDesc: this.currentCountry.paramDesc, + addlParamValue1: this.currentCountry.addlParamValue1, + addlParamValue2: this.currentCountry.addlParamValue2, + addlParamValue3: this.currentCountry.addlParamValue3, + addlParamValue4: this.currentCountry.addlParamValue4, + addlParamValue5: this.currentCountry.addlParamValue5 + }); + } + + saveRecord(): void { + if (this.countryForm.invalid) { + this.countryForm.markAllAsTouched(); + return; + } + + const paramData: ParamProperties = this.countryForm.value; + paramData.paramId = this.currentParamid ?? 0; + paramData.sortSeq = 1; + + const saveObservable = this.paramService.updateParamRecord(paramData as ParamProperties); + + this.changeInProgress = true; + saveObservable.pipe(finalize(() => { + this.changeInProgress = false; + })).subscribe({ + next: () => { + this.notificationService.showSuccess(`Country message updated successfully`); + }, + error: (error) => { + let errorMessage = this.errorHandler.handleApiError(error, `Failed to update country message`); + this.notificationService.showError(errorMessage); + console.error('Error saving country message:', error); + } + }); + } + + inActivateParamRecord(paramId: number): void { + this.paramService.inActivateParamRecord(paramId).subscribe( + { + next: (data: any) => { + this.notificationService.showSuccess(`Country message inactivated successfully`); + }, + error: (error: any) => { + let errorMessage = this.errorHandler.handleApiError(error, `Failed to inactivate country message`); + this.notificationService.showError(errorMessage); + console.error('Error inactivating country message:', error); + } + } + ); + } + + reActivateParamRecord(paramId: number): void { + this.paramService.reActivateParamRecord(paramId).subscribe( + { + next: (data: any) => { + this.notificationService.showSuccess(`Country message reactivated successfully`); + }, + error: (error: any) => { + let errorMessage = this.errorHandler.handleApiError(error, `Failed to reactivate country message`); + this.notificationService.showError(errorMessage); + console.error('Error reactivating country message:', error); + } + } + ); + } +} \ No newline at end of file diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index ee5c116..d5f289c 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -1,5 +1,6 @@ export const environment = { production: false, apiUrl: 'https://dev.alphaomegainfosys.com/test-api', - apiDb: 'oracle' + apiDb: 'oracle', + appType: 'service-provider' }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index ea91a24..15c2a0c 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,5 +1,6 @@ export const environment = { production: true, apiUrl: 'https://dev.alphaomegainfosys.com/test-api', - apiDb: 'oracle' + apiDb: 'oracle', + appType: 'service-provider' }; \ No newline at end of file