Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions src/app/features/quotes/pages/quote-list/quote-list.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<option value="">All Statuses</option>
<option [value]="QUOTE_STATUSES.PENDING">Pending</option>
<option [value]="QUOTE_STATUSES.IN_PROGRESS">In Progress</option>
<option [value]="QUOTE_STATUSES.APPROVED">Approved</option>
<option [value]="QUOTE_STATUSES.CANCELLED">Cancelled</option>
<option [value]="QUOTE_STATUSES.ACCEPTED">Accepted</option>
<option *ngFor="let opt of filterStatusOptions" [value]="opt.value">{{ opt.label }}</option>
</select>
</div>
</div>
Expand Down Expand Up @@ -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']);
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="inProgress">In Progress</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
<option value="cancelled">Cancelled</option>
<option value="accepted">Accepted</option>
<option *ngFor="let opt of filterStatusOptions" [value]="opt.value">{{ opt.label }}</option>
</select>
</div>
</div>
Expand Down Expand Up @@ -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!;
Expand Down Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions src/app/services/provider.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
);
}

Expand Down
174 changes: 105 additions & 69 deletions src/app/shared/create-tender-modal/create-tender-modal.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) => {
Expand All @@ -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;
Expand All @@ -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);
}
});
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
},
Expand Down Expand Up @@ -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;
}
});
Expand Down Expand Up @@ -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 ?? [];
Expand Down
Loading