Skip to content

Commit 8abc9aa

Browse files
authored
Fix tender provider invitation follow-ups (#259)
1 parent f75a322 commit 8abc9aa

4 files changed

Lines changed: 274 additions & 64 deletions

File tree

src/app/features/tenders/pages/tender-list/tender-list.component.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,29 @@ describe('TenderListComponent', () => {
111111
expect(badge.textContent?.trim()).toBe('Tender Closed');
112112
});
113113

114+
it('adds status explanations to tender badges for mouseover', () => {
115+
fixture.detectChanges();
116+
117+
component.selectedRole = UI_ROLES.BUYER;
118+
component.loading = false;
119+
component.error = null;
120+
component.filteredQuotes = [
121+
{
122+
id: 'quote-1',
123+
category: QUOTE_CATEGORIES.COORDINATOR,
124+
description: 'Tender waiting for providers',
125+
quoteItem: [{ state: QUOTE_STATUSES.IN_PROGRESS }],
126+
} as Quote,
127+
];
128+
129+
fixture.detectChanges();
130+
131+
const badge = fixture.nativeElement.querySelector('.status-badge') as HTMLElement;
132+
133+
expect(badge.getAttribute('title')).toContain('The invited providers now have time to accept or decline the invite to the tender');
134+
expect(badge.getAttribute('title')).toContain('Available actions: View provider responses, send messages, monitor progress, or start the tender');
135+
});
136+
114137
it('keeps provider tender status badges readable in the table row', () => {
115138
fixture.detectChanges();
116139

src/app/features/tenders/pages/tender-list/tender-list.component.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { ChatModalComponent } from 'src/app/shared/chat-modal/chat-modal.compone
1919
import { AttachmentModalComponent } from 'src/app/shared/attachment-modal/attachment-modal.component';
2020
import { CreateTenderModalComponent } from 'src/app/shared/create-tender-modal/create-tender-modal.component';
2121
import { UI_ROLES, API_ROLES, UiRole, toApiRole } from 'src/app/models/roles.constants';
22-
import { QUOTE_CATEGORIES, QUOTE_STATUSES, TENDER_COORDINATOR_STATUSES_LABELS, TENDER_RELATED_QUOTES_LABELS_CUSTOMER, TENDER_RELATED_QUOTES_LABELS_PROVIDER } from 'src/app/models/quote.constants';
22+
import { COORDINATOR_STATUS_MESSAGES, QUOTE_CATEGORIES, QUOTE_STATUSES, TENDERING_STATUS_MESSAGES, TENDER_COORDINATOR_STATUSES_LABELS, TENDER_RELATED_QUOTES_LABELS_CUSTOMER, TENDER_RELATED_QUOTES_LABELS_PROVIDER } from 'src/app/models/quote.constants';
2323

2424
@Component({
2525
selector: 'app-quote-list',
@@ -235,6 +235,8 @@ import { QUOTE_CATEGORIES, QUOTE_STATUSES, TENDER_COORDINATOR_STATUSES_LABELS, T
235235
<!-- Status -->
236236
<div class="col-span-2 min-w-0" data-testid="provider-tender-status-cell">
237237
<span class="status-badge max-w-full truncate rounded-full px-2 text-xs font-semibold leading-5"
238+
[title]="getStatusTooltip(quote)"
239+
[attr.aria-label]="getStatusTooltip(quote)"
238240
[ngClass]="getStateClass(getQuoteItemState(quote))">
239241
{{ getStatusLabel(quote) }}
240242
</span>
@@ -302,6 +304,8 @@ import { QUOTE_CATEGORIES, QUOTE_STATUSES, TENDER_COORDINATOR_STATUSES_LABELS, T
302304
<!-- Status -->
303305
<div class="col-span-2 min-w-0">
304306
<span class="status-badge max-w-full truncate rounded-full px-2 text-xs font-semibold leading-5"
307+
[title]="getStatusTooltip(quote)"
308+
[attr.aria-label]="getStatusTooltip(quote)"
305309
[ngClass]="getStateClass(getQuoteItemState(quote))">
306310
{{ getStatusLabel(quote) }}
307311
</span>
@@ -431,6 +435,8 @@ import { QUOTE_CATEGORIES, QUOTE_STATUSES, TENDER_COORDINATOR_STATUSES_LABELS, T
431435
<!-- Status -->
432436
<div class="col-span-2 min-w-0">
433437
<span class="status-badge max-w-full truncate rounded-full px-2 py-0.5 text-xs font-semibold leading-5"
438+
[title]="getStatusTooltip(relatedQuote)"
439+
[attr.aria-label]="getStatusTooltip(relatedQuote)"
434440
[ngClass]="getStateClass(getQuoteItemState(relatedQuote))">
435441
{{ getStatusLabel(relatedQuote) }}
436442
</span>
@@ -1795,6 +1801,31 @@ export class TenderListComponent implements OnInit {
17951801
}
17961802
}
17971803

1804+
getStatusTooltip(quote: Quote): string {
1805+
const state = this.getPrimaryState(quote);
1806+
const role = this.selectedRole === UI_ROLES.BUYER ? 'buyer' : 'provider';
1807+
const fallback = this.getStatusLabel(quote);
1808+
const messages = quote.category === QUOTE_CATEGORIES.COORDINATOR
1809+
? COORDINATOR_STATUS_MESSAGES
1810+
: quote.category === QUOTE_CATEGORIES.TENDER
1811+
? TENDERING_STATUS_MESSAGES
1812+
: null;
1813+
const statusInfo = messages?.[state]?.[role];
1814+
1815+
if (!statusInfo) {
1816+
return fallback;
1817+
}
1818+
1819+
const explanation = statusInfo.explanation === '...' ? '' : statusInfo.explanation;
1820+
const availableActions = statusInfo.availableActions === '...' ? '' : statusInfo.availableActions;
1821+
const tooltipParts = [
1822+
explanation,
1823+
availableActions ? `Available actions: ${availableActions}` : ''
1824+
].filter(Boolean);
1825+
1826+
return tooltipParts.length > 0 ? tooltipParts.join('\n') : fallback;
1827+
}
1828+
17981829
/**
17991830
* Map coordinator quote status from backend (TMF) to frontend (GUI) display
18001831
* Only for coordinator quotes

src/app/shared/create-tender-modal/create-tender-modal.component.spec.ts

Lines changed: 126 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,50 @@
1-
import { ComponentFixture, TestBed } from '@angular/core/testing';
1+
import { ComponentFixture, TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
22
import { Router } from '@angular/router';
3-
import { of } from 'rxjs';
3+
import { Subject, of, throwError } from 'rxjs';
44
import { CreateTenderModalComponent } from './create-tender-modal.component';
55
import { QuoteService } from 'src/app/features/quotes/services/quote.service';
66
import { NotificationService } from 'src/app/services/notification.service';
77
import { LocalStorageService } from 'src/app/services/local-storage.service';
8-
import { ProviderService } from 'src/app/services/provider.service';
8+
import { Provider, ProviderService } from 'src/app/services/provider.service';
99
import { ApiServiceService } from 'src/app/services/product-service.service';
1010
import { AccountServiceService } from 'src/app/services/account-service.service';
1111

1212
describe('CreateTenderModalComponent', () => {
1313
let fixture: ComponentFixture<CreateTenderModalComponent>;
1414
let component: CreateTenderModalComponent;
15+
let quoteService: jasmine.SpyObj<QuoteService>;
16+
let notificationService: jasmine.SpyObj<NotificationService>;
17+
let providerService: jasmine.SpyObj<ProviderService>;
1518

1619
beforeEach(async () => {
20+
quoteService = jasmine.createSpyObj<QuoteService>('QuoteService', [
21+
'addAttachmentToQuote',
22+
'createTenderingQuote',
23+
'deleteQuote',
24+
'getTenderingQuotesByUser',
25+
'updateQuoteDate',
26+
]);
27+
notificationService = jasmine.createSpyObj<NotificationService>('NotificationService', ['showError', 'showSuccess']);
28+
providerService = jasmine.createSpyObj<ProviderService>('ProviderService', [
29+
'getProviderCountryOptions',
30+
'getProvidersForTender',
31+
'getProvidersForTenderNew',
32+
]);
33+
34+
quoteService.getTenderingQuotesByUser.and.returnValue(of([]));
35+
providerService.getProviderCountryOptions.and.returnValue(of([]));
36+
providerService.getProvidersForTenderNew.and.returnValue(of([]));
37+
providerService.getProvidersForTender.and.returnValue(of([]));
38+
1739
await TestBed.configureTestingModule({
1840
imports: [CreateTenderModalComponent],
1941
providers: [
20-
{ provide: QuoteService, useValue: {} },
21-
{ provide: NotificationService, useValue: { showError: jasmine.createSpy('showError'), showSuccess: jasmine.createSpy('showSuccess') } },
42+
{ provide: QuoteService, useValue: quoteService },
43+
{ provide: NotificationService, useValue: notificationService },
2244
{ provide: LocalStorageService, useValue: { getObject: () => ({}) } },
23-
{ provide: ProviderService, useValue: { getProviderCountryOptions: () => of([]) } },
24-
{ provide: ApiServiceService, useValue: {} },
25-
{ provide: AccountServiceService, useValue: {} },
45+
{ provide: ProviderService, useValue: providerService },
46+
{ provide: ApiServiceService, useValue: { getDefaultCategories: () => Promise.resolve([]), getCategoriesByParentId: () => Promise.resolve([]) } },
47+
{ provide: AccountServiceService, useValue: { getOrgInfo: () => Promise.resolve({ tradingName: 'Known Provider' }) } },
2648
{ provide: Router, useValue: { navigate: jasmine.createSpy('navigate') } },
2749
],
2850
}).compileComponents();
@@ -104,4 +126,100 @@ describe('CreateTenderModalComponent', () => {
104126
expect(selectedRow.className).toContain('bg-[#EBF0F7]');
105127
expect(selectedRow.className).toContain('border-l-[#1f4fbf]');
106128
});
129+
130+
it('shows the uploaded PDF in the provider step summary immediately after step 2 is saved', fakeAsync(() => {
131+
const selectedFile = new File(['pdf'], 'Tender-request.pdf', { type: 'application/pdf' });
132+
quoteService.updateQuoteDate.and.returnValue(of({ id: 'coordinator-1' } as any));
133+
quoteService.addAttachmentToQuote.and.returnValue(of({
134+
id: 'coordinator-1',
135+
quoteItem: [{
136+
attachment: [{
137+
name: 'Tender-request.pdf',
138+
mimeType: 'application/pdf',
139+
content: 'cGRm',
140+
size: { amount: 3 },
141+
}],
142+
}],
143+
} as any));
144+
145+
component.isOpen = true;
146+
component.customerId = 'customer-1';
147+
component.createdQuoteId = 'coordinator-1';
148+
component.tenderTitle = 'Tender with attachment';
149+
component.requestedCompletionDate = '2026-06-01';
150+
component.expectedCompletionDate = '2026-06-10';
151+
component.selectedPdfFile = selectedFile;
152+
153+
component.proceedToProviderSelection();
154+
flushMicrotasks();
155+
fixture.detectChanges();
156+
157+
const summary = fixture.nativeElement.querySelector('[aria-label="Tender setup summary"]');
158+
159+
expect(component.tenderCreationStep).toBe(3);
160+
expect(component.pdfAttachmentSet).toBeTrue();
161+
expect(component.existingAttachment?.name).toBe('Tender-request.pdf');
162+
expect(summary.textContent).toContain('Tender-request.pdf');
163+
}));
164+
165+
it('reloads provider candidates on filter changes without entering the invite-saving state', () => {
166+
const providers$ = new Subject<Provider[]>();
167+
providerService.getProvidersForTenderNew.and.returnValue(providers$);
168+
component.isOpen = true;
169+
component.customerId = 'customer-1';
170+
component.currentUserId = 'customer-1';
171+
component.createdQuoteId = 'coordinator-1';
172+
component.tenderCreationStep = 3;
173+
174+
component.emitFilters();
175+
176+
expect(component.tenderLoading).toBeFalse();
177+
expect(component.providerInviteSaving).toBeFalse();
178+
expect(quoteService.createTenderingQuote).not.toHaveBeenCalled();
179+
180+
providers$.next([]);
181+
providers$.complete();
182+
});
183+
184+
it('removes saved providers from the candidate list after manual invite', fakeAsync(() => {
185+
const selectedProvider = { id: 'provider-1', tradingName: 'Provider One' } as Provider;
186+
const otherProvider = { id: 'provider-2', tradingName: 'Provider Two' } as Provider;
187+
spyOn(component.tenderUpdated, 'emit');
188+
quoteService.createTenderingQuote.and.returnValue(of({ id: 'quote-1' } as any));
189+
component.currentUserId = 'customer-1';
190+
component.createdQuoteId = 'coordinator-1';
191+
component.tenderProviders = [selectedProvider, otherProvider];
192+
component._safeInvitedList = [selectedProvider];
193+
component.selectedProviders = new Set(['provider-1']);
194+
component.availableProviders = [
195+
{ provider: selectedProvider, selected: true },
196+
{ provider: otherProvider, selected: false },
197+
];
198+
199+
component.saveProvidersList();
200+
flushMicrotasks();
201+
202+
expect(component.invitedProviders.map(invited => invited.provider.id)).toEqual(['provider-1']);
203+
expect(component.selectedProviders.size).toBe(0);
204+
expect(component.availableProviders.map(candidate => candidate.provider.id)).toEqual(['provider-2']);
205+
expect(component.tenderUpdated.emit).toHaveBeenCalled();
206+
}));
207+
208+
it('treats a gateway timeout while removing an invited provider as a completed delete and refreshes candidates', () => {
209+
const provider = { id: 'provider-1', tradingName: 'Provider One' } as Provider;
210+
quoteService.deleteQuote.and.returnValue(throwError(() => ({ status: 504, statusText: 'OK' })));
211+
component.currentUserId = 'customer-1';
212+
component.createdQuoteId = 'coordinator-1';
213+
component.invitedProviders = [{ provider, quoteId: 'quote-1' }];
214+
component.tenderProviders = [provider];
215+
component.availableProviders = [];
216+
217+
component.removeInvitedProvider('quote-1', 'provider-1');
218+
component.genericConfirmCallback?.();
219+
220+
expect(component.invitedProviders).toEqual([]);
221+
expect(component.availableProviders.map(candidate => candidate.provider.id)).toEqual(['provider-1']);
222+
expect(notificationService.showSuccess).toHaveBeenCalledWith('Provider invitation removed successfully');
223+
expect(notificationService.showError).not.toHaveBeenCalled();
224+
});
107225
});

0 commit comments

Comments
 (0)