diff --git a/src/app/features/quotes/pages/quote-list/quote-list.component.ts b/src/app/features/quotes/pages/quote-list/quote-list.component.ts index 91c0f28e..5d8c76e2 100644 --- a/src/app/features/quotes/pages/quote-list/quote-list.component.ts +++ b/src/app/features/quotes/pages/quote-list/quote-list.component.ts @@ -92,11 +92,7 @@ import { environment } from 'src/environments/environment'; class="form-select rounded-md border-gray-300 dark:bg-gray-700 dark:border-gray-800 dark:text-white shadow-sm focus:border-indigo-500 focus:ring-indigo-500" > - - - - - + @@ -605,6 +601,20 @@ export class QuoteListComponent implements OnInit { }); } + get filterStatusOptions(): { value: string; label: string }[] { + const labels = this.selectedRole === 'customer' + ? TAILORED_STATUSES_LABELS_CUSTOMER + : TAILORED_STATUSES_LABELS_PROVIDER; + return [ + { value: QUOTE_STATUSES.PENDING, label: labels.PENDING }, + { value: QUOTE_STATUSES.IN_PROGRESS, label: labels.IN_PROGRESS }, + { value: QUOTE_STATUSES.APPROVED, label: labels.APPROVED }, + { value: QUOTE_STATUSES.ACCEPTED, label: labels.ACCEPTED }, + { value: QUOTE_STATUSES.CANCELLED, label: labels.CANCELLED }, + { value: QUOTE_STATUSES.REJECTED, label: labels.REJECTED }, + ]; + } + createQuote() { this.router.navigate(['/quotes/new']); } @@ -1058,8 +1068,9 @@ export class QuoteListComponent implements OnInit { switch (actionType) { case 'viewDetails': - case 'chat': return isCancelled; // Only disabled for cancelled quotes + case 'chat': + return false; // Chat is always available — users should be able to communicate regardless of quote status case 'addAttachment': case 'cancel': return isFinalized; // Disabled for both accepted and cancelled diff --git a/src/app/features/tenders/pages/tender-list/tender-list.component.ts b/src/app/features/tenders/pages/tender-list/tender-list.component.ts index c7d34be7..f78c9384 100644 --- a/src/app/features/tenders/pages/tender-list/tender-list.component.ts +++ b/src/app/features/tenders/pages/tender-list/tender-list.component.ts @@ -112,12 +112,7 @@ import { QUOTE_CATEGORIES, QUOTE_STATUSES, TENDER_COORDINATOR_STATUSES_LABELS, T class="form-select rounded-md border-gray-300 dark:bg-gray-700 dark:border-gray-800 dark:text-white shadow-sm focus:border-indigo-500 focus:ring-indigo-500" > - - - - - - + @@ -940,6 +935,19 @@ export class TenderListComponent implements OnInit { } } + get filterStatusOptions(): { value: string; label: string }[] { + const labels = this.selectedRole === UI_ROLES.BUYER + ? TENDER_COORDINATOR_STATUSES_LABELS + : TENDER_RELATED_QUOTES_LABELS_PROVIDER; + return [ + { value: QUOTE_STATUSES.PENDING, label: labels.PENDING }, + { value: QUOTE_STATUSES.IN_PROGRESS, label: labels.IN_PROGRESS }, + { value: QUOTE_STATUSES.APPROVED, label: labels.APPROVED }, + { value: QUOTE_STATUSES.ACCEPTED, label: labels.ACCEPTED }, + { value: QUOTE_STATUSES.CANCELLED, label: labels.CANCELLED }, + { value: QUOTE_STATUSES.REJECTED, label: labels.REJECTED }, + ]; + } viewDetails(quote: Quote) { this.selectedQuoteId = quote.id!; @@ -1769,8 +1777,9 @@ export class TenderListComponent implements OnInit { switch (actionType) { case 'viewDetails': - case 'chat': return isCancelled; // Only disabled for cancelled quotes + case 'chat': + return false; // Chat is always available — users should be able to communicate regardless of quote status case 'addAttachment': case 'cancel': return isFinalized; // Disabled for both accepted and cancelled diff --git a/src/app/services/provider.service.ts b/src/app/services/provider.service.ts index ee79ccc3..e3554269 100644 --- a/src/app/services/provider.service.ts +++ b/src/app/services/provider.service.ts @@ -95,11 +95,9 @@ export class ProviderService { if (Array.isArray(response)) return response as Provider[]; if (response?.data && Array.isArray(response.data)) return response.data as Provider[]; return []; - }), - catchError((error) => { - console.warn('Providers for tender (new) API failed:', error); - return of([]); }) + // No catchError here — callers must handle HTTP errors themselves so they can + // distinguish a genuine empty search result from a failed request. ); } diff --git a/src/app/shared/create-tender-modal/create-tender-modal.component.ts b/src/app/shared/create-tender-modal/create-tender-modal.component.ts index 1c376aaa..c8ea3727 100644 --- a/src/app/shared/create-tender-modal/create-tender-modal.component.ts +++ b/src/app/shared/create-tender-modal/create-tender-modal.component.ts @@ -8,6 +8,7 @@ import { QuoteService } from 'src/app/features/quotes/services/quote.service'; import { NotificationService } from 'src/app/services/notification.service'; import { LocalStorageService } from 'src/app/services/local-storage.service'; import { ProviderService, Provider } from 'src/app/services/provider.service'; +import { AccountServiceService } from 'src/app/services/account-service.service'; import { Tender, TenderAttachment } from 'src/app/models/tender.model'; import { LoginInfo } from 'src/app/models/interfaces'; import { API_ROLES } from 'src/app/models/roles.constants'; @@ -436,6 +437,7 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { private notificationService = inject(NotificationService); private localStorage = inject(LocalStorageService); private providerService = inject(ProviderService); + private accountService = inject(AccountServiceService); private router = inject(Router); // Properties for tender creation modal @@ -520,8 +522,8 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { } } - // Load filter options - this.loadFilterOptions(); + // Filter options (categories, countries, compliance levels) and the provider list + // are loaded lazily in proceedToProviderSelection() when the user enters step 3. } ngOnChanges(changes: SimpleChanges) { @@ -648,6 +650,8 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { // Move to Step 2: Date fields this.tenderCreationStep = 2; + // Notify the parent dashboard so the new tender appears in the list immediately + this.tenderUpdated.emit(); }, error: (error) => { console.error('Error creating tender:', error); @@ -825,6 +829,16 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { this.tenderLoading = false; this.notificationService.showSuccess('Tender details saved successfully!'); this.tenderCreationStep = 3; + + // Reset any previously active filters silently (emitEvent:false avoids + // triggering the valueChanges → emitFilters → loadTenderProviders chain) + this.orgFilters = { categories: [], countries: [], complianceLevels: [] }; + this.countriesCtrl.setValue([], { emitEvent: false }); + this.categoriesCtrl.setValue([], { emitEvent: false }); + this.complianceLevelsCtrl.setValue([], { emitEvent: false }); + + // Load filter criteria and the provider list in parallel + this.loadFilterOptions(); this.loadTenderProviders(); }, error: (error: any) => { @@ -836,51 +850,29 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { } /** - * Load providers from the provider API + * Load providers from the search API. + * An empty result set is valid (no providers match the active filters). + * Only falls back to the full party-organisation list when the search + * endpoint returns an actual HTTP error. */ loadTenderProviders() { this.tenderLoading = true; this.tenderError = null; - console.log('Loading providers from API...'); - this.providerService.getProvidersForTenderNew(this.orgFilters).subscribe({ next: (providers) => { - // If search endpoint returns empty or fails, fallback to basic endpoint - if (!providers || providers.length === 0) { - console.log('Search returned no providers, trying fallback endpoint...'); - this.providerService.getProvidersForTender().subscribe({ - next: (fallbackProviders) => { - this.tenderProviders = fallbackProviders; - console.log('Fallback loaded providers:', fallbackProviders.length); - this.tenderLoading = false; - this.updateAvailableProviders(); - - if (this.tenderCreationStep === 3) { - this.loadInvitedProviders(); - } - }, - error: (fallbackErr) => { - this.tenderError = 'Failed to load providers from both endpoints: ' + (fallbackErr.message || 'Unknown error'); - this.tenderLoading = false; - console.error('Fallback endpoint also failed:', fallbackErr); - } - }); - } else { - this.tenderProviders = providers; - console.log('Search loaded providers:', providers.length); - this.tenderLoading = false; - this.updateAvailableProviders(); - - // After providers are loaded, load invited providers (if in edit mode) - if (this.tenderCreationStep === 3) { - this.loadInvitedProviders(); - } + this.tenderProviders = providers ?? []; + console.log('Search loaded providers:', this.tenderProviders.length); + this.tenderLoading = false; + this.updateAvailableProviders(); + + if (this.tenderCreationStep === 3) { + this.loadInvitedProviders(); } }, error: (err) => { - // Search endpoint failed completely, try fallback - console.warn('Search endpoint failed, trying fallback...', err); + // HTTP error from the search endpoint — fall back to the full organisation list + console.warn('Search endpoint returned an error, falling back to full provider list:', err); this.providerService.getProvidersForTender().subscribe({ next: (fallbackProviders) => { this.tenderProviders = fallbackProviders; @@ -895,7 +887,7 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { error: (fallbackErr) => { this.tenderError = 'Failed to load providers: ' + (fallbackErr.message || 'Unknown error'); this.tenderLoading = false; - console.error('Error loading tender providers:', fallbackErr); + console.error('Fallback endpoint also failed:', fallbackErr); } }); } @@ -987,7 +979,13 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { } /** - * Load already invited providers by fetching tendering quotes with the coordinator quote's externalId + * Load already invited providers by fetching tendering quotes with the coordinator quote's externalId. + * + * Name resolution priority: + * 1. Match the provider's org URN (tender.selectedProviders[0]) against the already-loaded + * tenderProviders list — this covers the common case with no extra API calls. + * 2. Fall back to AccountServiceService.getOrgInfo() for providers not in the cached list + * (e.g. when reopening the modal without navigating to the provider-search step first). */ loadInvitedProviders() { if (!this.createdQuoteId || !this.currentUserId) { @@ -998,34 +996,46 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { console.log('Loading invited providers for externalId:', this.createdQuoteId); this.tenderLoading = true; - + this.quoteService.getTenderingQuotesByUser(this.currentUserId, API_ROLES.BUYER).subscribe({ - next: (tenders) => { + next: async (tenders) => { console.log('Received tenders:', tenders); - - // Clear existing invited providers - this.invitedProviders = []; - - // Filter tenders that match our createdQuoteId as externalId - const matchingTenders = tenders.filter(t => t.external_id === this.createdQuoteId); - - // Convert to invited providers format - matchingTenders.forEach(tender => { - // TODO: Get proper provider info once provider service is available - const provider: Provider = { - id: tender.provider || undefined, - tradingName: tender.provider || 'Unknown Provider' - }; - - if (tender.id) { - this.invitedProviders.push({ - provider: provider, - quoteId: tender.id - }); - console.log('Added invited provider:', provider.tradingName, 'with quote ID:', tender.id); - } - }); - + + // Filter tenders that match our coordinator quote as their parent + const matchingTenders = tenders.filter(t => t.external_id === this.createdQuoteId && !!t.id); + + // Resolve provider display names, then populate invitedProviders + const entries = await Promise.all( + matchingTenders.map(async (tender) => { + // The org URN lives in selectedProviders[0] (mapped from relatedParty[Seller].id) + const providerOrgUrn = tender.selectedProviders?.[0]; + + // 1. Try the already-loaded provider list first (no extra network call) + const knownProvider = providerOrgUrn + ? this.tenderProviders.find(p => p.id === providerOrgUrn) + : undefined; + + if (knownProvider) { + return { provider: knownProvider, quoteId: tender.id! }; + } + + // 2. Fall back to account service to get the trading name by org URN + let tradingName = providerOrgUrn || 'Unknown Provider'; + if (providerOrgUrn) { + try { + const org = await this.accountService.getOrgInfo(providerOrgUrn); + tradingName = org?.tradingName || org?.name || providerOrgUrn; + } catch { + // Network error — keep the URN as a recognisable fallback + } + } + + const provider: Provider = { id: providerOrgUrn, tradingName }; + return { provider, quoteId: tender.id! }; + }) + ); + + this.invitedProviders = entries; console.log('Total invited providers loaded:', this.invitedProviders.length); this.tenderLoading = false; }, @@ -1155,8 +1165,33 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { this.tenderLoading = false; }, error: (error) => { - console.error('Error deleting quote:', error); - this.notificationService.showError('Failed to remove provider invitation: ' + (error.message || 'Unknown error')); + // TEMPORARY WORKAROUND — sandbox environment issue: + // The TMForum/BAE backend successfully deletes the quote but then attempts to + // notify a downstream microservice (charging/events) that is unreachable in sandbox. + // This causes the BAE to return 500 {error: "Service unreachable"} AFTER the deletion + // has already completed. As a result, the HTTP 500 reaches this error handler even + // though the underlying operation succeeded. + // + // We detect this specific case (HTTP 500 + "Service unreachable" in the response body) + // and treat it as a success so the UI stays consistent with the actual backend state. + // + // TODO: Remove this workaround once the sandbox downstream service is reachable + // and the BAE no longer returns 500 on successful quote deletion. + const isKnownFalsePositive = + error.status === 500 && + error.error?.error === 'Service unreachable'; + + if (isKnownFalsePositive) { + console.warn( + '[WORKAROUND] deleteQuote returned 500 "Service unreachable" for quoteId:', quoteId, + '— quote was deleted on the backend. Removing from UI anyway.' + ); + this.invitedProviders = this.invitedProviders.filter(ip => ip.quoteId !== quoteId); + this.notificationService.showSuccess('Provider invitation removed successfully'); + } else { + console.error('Error deleting quote:', error); + this.notificationService.showError('Failed to remove provider invitation: ' + (error.message || 'Unknown error')); + } this.tenderLoading = false; } }); @@ -1245,10 +1280,11 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { } /** - * Load filter options (countries, categories, compliance levels) + * Load filter options (countries, categories, compliance levels). + * Only fetches the option lists — does NOT reset selected filter values + * or trigger a provider reload. */ private loadFilterOptions(): void { - this.clearFilters(); this.providerService.getFilterOptions().subscribe({ next: ({ categories, countries, complianceLevels }) => { this.categoriesOptions = categories ?? [];