From b2bbb694235bff78b24a28078d64966c48e62ce6 Mon Sep 17 00:00:00 2001 From: BazRoe Date: Wed, 18 Feb 2026 00:50:43 +0100 Subject: [PATCH 1/3] Changes on tender management, copied visual changes from the tailored, adjusted the rest to be consistent with tailored changes - Updated the Tender List component to improve the UI, including a new dashboard title and a link to the tender process guide. - Introduced new constants for quote and tender categories in the quote.constants file. - Added detailed status messages for tendering and coordinator quotes to improve user guidance. - Enhanced the Create Tender Modal with step-by-step instructions and improved input handling for dates and file uploads. - Updated the Quote Details Modal to conditionally display information based on quote categories, ensuring relevant details are shown for tailored, tendering, and coordinator quotes. - Improved the Confirm Dialog component's z-index for better visibility. These changes aim to streamline the tender creation and management process, providing users with clearer instructions and a more intuitive interface. --- .../tender-list/tender-list.component.ts | 1125 ++++++++++------- src/app/models/quote.constants.ts | 165 ++- .../confirm-dialog.component.ts | 2 +- .../create-tender-modal.component.ts | 335 +++-- .../quote-details-modal.component.ts | 429 ++++++- 5 files changed, 1408 insertions(+), 648 deletions(-) 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 d4e1e906..fb34824a 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 @@ -2,7 +2,8 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; -import { Observable, forkJoin, map } from 'rxjs'; +import { Observable, forkJoin, map, of, catchError } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; import { QuoteService } from '../../../quotes/services/quote.service'; import { LocalStorageService } from 'src/app/services/local-storage.service'; import { NotificationService } from 'src/app/services/notification.service'; @@ -16,6 +17,7 @@ import { ChatModalComponent } from 'src/app/shared/chat-modal/chat-modal.compone import { AttachmentModalComponent } from 'src/app/shared/attachment-modal/attachment-modal.component'; import { CreateTenderModalComponent } from 'src/app/shared/create-tender-modal/create-tender-modal.component'; import { UI_ROLES, API_ROLES, UiRole, toApiRole } from 'src/app/models/roles.constants'; +import { QUOTE_CATEGORIES, TENDER_CATEGORIES } from 'src/app/models/quote.constants'; @Component({ selector: 'app-quote-list', @@ -35,7 +37,20 @@ import { UI_ROLES, API_ROLES, UiRole, toApiRole } from 'src/app/models/roles.con
-

Tenders

+
+

Tenders Dashboard

+ + + + + +
- -
-
-
DETAILS
+ +
+
+
REQUEST DATE
+
CUSTOMER
+
TITLE
+
STATUS
+
ACTIONS
+
+
+ + +
+
+
EXPAND
TITLE
STATUS
TENDER START DATE
TENDER END DATE
ATTACHMENTS
-
REQUEST
-
ACTIONS
+
ACTIONS
- -
-
- - -
- - - - - -
- - -
- {{ quote.description || '(no title)' }} -
- - -
- - {{ getQuoteItemState(quote) }} - -
- - -
- {{ quote.expectedFulfillmentStartDate | date:'dd/MM/yyyy' }} -
- - -
- {{ quote.effectiveQuoteCompletionDate | date:'dd/MM/yyyy' }} -
- - -
- -
+ + +
+
+ + +
+ {{ quote.quoteDate | date:'dd-MM-yyyy' }} +
+ + +
+ {{ getBuyerName(quote) }} +
+ + +
+ {{ getTruncatedTitle(quote.description) }} +
+ + +
+ + {{ getQuoteItemState(quote) }} + +
+ + +
+ - + +
- - - -
- - -
- -
- - -
- - - - - - - - - - - - - - - - - - - +
+ + + + +
+
+ + +
+ +
- + +
+ {{ getTruncatedTitle(quote.description) }} +
+ + +
+ + {{ getQuoteItemState(quote) }} + +
+ + +
+ {{ quote.expectedFulfillmentStartDate | date:'dd/MM/yyyy' }} +
+ + +
+ {{ quote.effectiveQuoteCompletionDate | date:'dd/MM/yyyy' }} +
+ + +
+
+ +
+
+ + +
+ - + - + - - + + - - - - + + - +
-
-
+
-

Related Provider Quotes

+

Related Provider Quotes

-
- Loading related quotes... +
+ Loading related quotes...
- -
+ +
-
-
-
Details
-
Provider
+
+
+
Provider
Status
-
Attachments
-
Actions
+
Attachments
+
Actions
-
+ [class.border-gray-200]="!last" + [class.dark:border-gray-600]="!last">
- -
- -
- -
+
{{ getProviderName(relatedQuote) }}
- +
- + -
+
- -
+ +
- - - - +
@@ -529,8 +453,8 @@ import { UI_ROLES, API_ROLES, UiRole, toApiRole } from 'src/app/models/roles.con
-
+
@@ -539,6 +463,7 @@ import { UI_ROLES, API_ROLES, UiRole, toApiRole } from 'src/app/models/roles.con
+
@@ -553,6 +478,17 @@ import { UI_ROLES, API_ROLES, UiRole, toApiRole } from 'src/app/models/roles.con (cancel)="showDeleteConfirm = false" > + + +
@@ -595,7 +531,10 @@ import { UI_ROLES, API_ROLES, UiRole, toApiRole } from 'src/app/models/roles.con @@ -679,7 +618,7 @@ import { UI_ROLES, API_ROLES, UiRole, toApiRole } from 'src/app/models/roles.con } .status-accepted { - @apply bg-teal-100 text-teal-800; + @apply bg-emerald-100 text-emerald-800; } .status-unknown { @@ -733,6 +672,7 @@ export class TenderListComponent implements OnInit { // Expose constants to template readonly UI_ROLES = UI_ROLES; + readonly QUOTE_CATEGORIES = QUOTE_CATEGORIES; // Filtering statusFilter: string = ''; @@ -753,6 +693,14 @@ export class TenderListComponent implements OnInit { showBroadcastModal = false; broadcastForCoordinatorId: string | null = null; broadcastMessage: string = ''; + + // Generic Confirmation Dialog + showGenericConfirm = false; + genericConfirmTitle = ''; + genericConfirmMessage = ''; + genericConfirmButtonText = 'Confirm'; + genericConfirmButtonClass = 'px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500'; + genericConfirmCallback: (() => void) | null = null; isBroadcastSending = false; // Create Tender Modal @@ -839,13 +787,24 @@ export class TenderListComponent implements OnInit { } }); - // If in seller mode, load coordinator states for tendering quotes + // If in seller mode, load coordinator states for tendering quotes BEFORE filtering if (this.selectedRole === UI_ROLES.SELLER) { - this.loadCoordinatorStatesForTenderingQuotes(); + this.loadCoordinatorStatesForTenderingQuotes().subscribe({ + next: () => { + console.log('All coordinator states loaded, filtering quotes...'); + this.filterQuotesByStatus(); + this.loading = false; + }, + error: (error) => { + console.error('Error loading coordinator states:', error); + this.filterQuotesByStatus(); + this.loading = false; + } + }); + } else { + this.filterQuotesByStatus(); + this.loading = false; } - - this.filterQuotesByStatus(); - this.loading = false; }, error: (error: any) => { console.error('Failed to load quotes:', error); @@ -878,7 +837,7 @@ export class TenderListComponent implements OnInit { expectedFulfillmentStartDate: tender.expectedFulfillmentStartDate, state: this.mapTenderStateToQuoteState(tender.state), // Map category back: 'tendering' -> 'tender', 'coordinator' -> 'coordinator' - category: tender.category === 'tendering' ? 'tender' : tender.category, + category: tender.category === TENDER_CATEGORIES.TENDERING ? QUOTE_CATEGORIES.TENDER : tender.category, externalId: tender.external_id, relatedParty: tender.selectedProviders.map(id => ({ id, @@ -919,14 +878,50 @@ export class TenderListComponent implements OnInit { } filterQuotesByStatus() { + let quotesToFilter: Quote[]; + if (!this.statusFilter) { - this.filteredQuotes = [...this.quotes]; + quotesToFilter = [...this.quotes]; } else { - this.filteredQuotes = this.quotes.filter(quote => { + quotesToFilter = this.quotes.filter(quote => { const primaryState = this.getPrimaryState(quote); return primaryState === this.statusFilter; }); } + + // For providers: filter out tendering quotes whose coordinator is still in draft status + if (this.selectedRole === UI_ROLES.SELLER) { + this.filteredQuotes = quotesToFilter.filter(quote => { + // Only filter tendering quotes (note: backend sends 'tendering' but it's mapped to 'tender' in Quote model) + if (quote.category !== QUOTE_CATEGORIES.TENDER) { + console.log(`[FILTER] Quote ${this.extractShortId(quote.id)} - category: ${quote.category}, keeping (not tender)`); + return true; + } + + // Check if we have the coordinator state + if (!quote.externalId) { + console.log(`[FILTER] Tendering quote ${this.extractShortId(quote.id)} - no externalId, keeping`); + return true; // Keep if no externalId + } + + const coordinatorState = this.coordinatorQuoteStatesMap.get(quote.externalId); + console.log(`[FILTER] Tendering quote ${this.extractShortId(quote.id)} - coordinator: ${this.extractShortId(quote.externalId)}, state: ${coordinatorState || 'NOT_LOADED'}`); + + // If coordinator state not loaded yet, hide it (should be loaded by loadCoordinatorStatesForTenderingQuotes) + if (!coordinatorState) { + console.log(`[FILTER] Coordinator state not loaded yet for ${this.extractShortId(quote.externalId)}, hiding`); + return false; + } + + // Filter out if coordinator is in 'pending' state (which maps to 'draft' in GUI) + const shouldShow = coordinatorState !== 'pending'; + console.log(`[FILTER] Coordinator state is ${coordinatorState}, ${shouldShow ? 'SHOWING' : 'HIDING'} quote`); + return shouldShow; + }); + + } else { + this.filteredQuotes = quotesToFilter; + } } @@ -955,7 +950,7 @@ export class TenderListComponent implements OnInit { const primaryState = this.getPrimaryState(quote) as QuoteStateType; const tender: Tender = { id: quote.id, - category: quote.category === 'coordinator' ? 'coordinator' : 'tendering', + category: quote.category === QUOTE_CATEGORIES.COORDINATOR ? TENDER_CATEGORIES.COORDINATOR : TENDER_CATEGORIES.TENDERING, state: this.mapQuoteStateToTenderState(primaryState), responseDeadline: quote.expectedFulfillmentStartDate || quote.effectiveQuoteCompletionDate || new Date().toISOString(), tenderNote: quote.description || '', @@ -994,6 +989,64 @@ export class TenderListComponent implements OnInit { this.selectedQuoteId = null; } + /** + * Show generic confirmation dialog + */ + showConfirmation( + title: string, + message: string, + callback: () => void, + buttonText: string = 'Confirm', + buttonClass: string = 'px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500' + ) { + this.genericConfirmTitle = title; + this.genericConfirmMessage = message; + this.genericConfirmButtonText = buttonText; + this.genericConfirmButtonClass = buttonClass; + this.genericConfirmCallback = () => { + callback(); + this.showGenericConfirm = false; + }; + this.showGenericConfirm = true; + } + + /** + * Convert UI role to modal role format ('customer' or 'seller') + */ + getModalUserRole(): 'customer' | 'seller' { + return this.selectedRole === UI_ROLES.BUYER ? 'customer' : 'seller'; + } + + /** + * Handle quote updates from the details modal + */ + onQuoteUpdated(updatedQuote: Quote) { + // Check if this is a related (tendering) quote inside an expanded coordinator view + for (const [coordinatorId, relatedList] of this.relatedQuotesMap.entries()) { + if (relatedList.some(q => q.id === updatedQuote.id)) { + const coordinator = this.quotes.find(q => q.id === coordinatorId); + if (coordinator) { + this.loadRelatedQuotes(coordinator); + } + const shortId = this.extractShortId(updatedQuote.id); + const state = this.getPrimaryState(updatedQuote); + this.notificationService.showSuccess(`Quote ${shortId} has been updated to ${state}.`); + return; + } + } + + // Otherwise update in the main list + const index = this.quotes.findIndex(q => q.id === updatedQuote.id); + if (index !== -1) { + this.quotes[index] = updatedQuote; + this.filterQuotesByStatus(); + } + + const shortId = this.extractShortId(updatedQuote.id); + const state = this.getPrimaryState(updatedQuote); + this.notificationService.showSuccess(`Quote ${shortId} has been updated to ${state}.`); + } + closeChatModal() { this.showChatModal = false; this.selectedChatQuoteId = null; @@ -1052,13 +1105,20 @@ export class TenderListComponent implements OnInit { return; } - const confirmSend = confirm('Are you sure you want to broadcast this message to all the invited providers?'); - if (!confirmSend) return; + this.showConfirmation( + 'Broadcast Message', + 'Are you sure you want to broadcast this message to all the invited providers?', + () => this.executeBroadcastMessage(), + 'Send', + 'px-4 py-2 text-sm font-medium text-white bg-fuchsia-600 border border-transparent rounded-md hover:bg-fuchsia-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-fuchsia-500' + ); + } + private executeBroadcastMessage() { this.isBroadcastSending = true; // Ensure related quotes are loaded - const related = this.getRelatedQuotes(this.broadcastForCoordinatorId); + const related = this.getRelatedQuotes(this.broadcastForCoordinatorId!); if (!related || related.length === 0) { // Try to load if not present, then send const coordinator = this.quotes.find(q => q.id === this.broadcastForCoordinatorId); @@ -1067,7 +1127,7 @@ export class TenderListComponent implements OnInit { } } - const quotesToMessage = this.getRelatedQuotes(this.broadcastForCoordinatorId).filter(q => q.category === 'tender'); + const quotesToMessage = this.getRelatedQuotes(this.broadcastForCoordinatorId!).filter(q => q.category === QUOTE_CATEGORIES.TENDER); if (quotesToMessage.length === 0) { this.notificationService.showError('No related provider quotes found to broadcast to.'); this.isBroadcastSending = false; @@ -1236,143 +1296,189 @@ export class TenderListComponent implements OnInit { acceptTenderingQuote(quote: Quote) { const shortId = this.extractShortId(quote.id); - const confirmAccept = confirm(`Are you sure you want to accept this tender request?`); - - if (!confirmAccept) { - return; - } - console.log('Accepting tendering quote:', quote.id); - - this.quoteService.updateQuoteStatus(quote.id!, 'inProgress').subscribe({ - next: (updatedQuote: Quote) => { - const index = this.quotes.findIndex(q => q.id === updatedQuote.id); - if (index !== -1) { - this.quotes[index] = updatedQuote; - this.filterQuotesByStatus(); - } - console.log('Tendering quote successfully accepted'); - this.notificationService.showSuccess(`Tender request ${shortId} has been accepted and is now in progress.`); + this.showConfirmation( + 'Accept Tender Request', + 'Are you sure you want to accept this tender request?', + () => { + console.log('Accepting tendering quote:', quote.id); + + this.quoteService.updateQuoteStatus(quote.id!, 'inProgress').subscribe({ + next: (updatedQuote: Quote) => { + const index = this.quotes.findIndex(q => q.id === updatedQuote.id); + if (index !== -1) { + this.quotes[index] = updatedQuote; + this.filterQuotesByStatus(); + } + console.log('Tendering quote successfully accepted'); + this.notificationService.showSuccess(`Tender request ${shortId} has been accepted and is now in progress.`); + }, + error: (error: Error) => { + console.error('Error accepting tendering quote:', error); + this.notificationService.showError(`Error accepting tender request: ${error.message || 'Unknown error'}`); + } + }); }, - error: (error: Error) => { - console.error('Error accepting tendering quote:', error); - this.notificationService.showError(`Error accepting tender request: ${error.message || 'Unknown error'}`); - } - }); + 'Accept', + 'px-4 py-2 text-sm font-medium text-white bg-green-600 border border-transparent rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500' + ); } cancelTenderingQuote(quote: Quote) { const shortId = this.extractShortId(quote.id); - const confirmCancel = confirm(`Are you sure you want to cancel this tender request?\n\nThis action cannot be undone.`); - - if (!confirmCancel) { - return; - } - console.log('Cancelling tendering quote:', quote.id); - - this.quoteService.updateQuoteStatus(quote.id!, 'cancelled').subscribe({ - next: (updatedQuote: Quote) => { - const index = this.quotes.findIndex(q => q.id === updatedQuote.id); - if (index !== -1) { - this.quotes[index] = updatedQuote; - this.filterQuotesByStatus(); - } - console.log('Tendering quote successfully cancelled'); - this.notificationService.showSuccess(`Tender request ${shortId} has been cancelled.`); + this.showConfirmation( + 'Cancel Tender Request', + 'Are you sure you want to cancel this tender request?\n\nThis action cannot be undone.', + () => { + console.log('Cancelling tendering quote:', quote.id); + + this.quoteService.updateQuoteStatus(quote.id!, 'cancelled').subscribe({ + next: (updatedQuote: Quote) => { + const index = this.quotes.findIndex(q => q.id === updatedQuote.id); + if (index !== -1) { + this.quotes[index] = updatedQuote; + this.filterQuotesByStatus(); + } + console.log('Tendering quote successfully cancelled'); + this.notificationService.showSuccess(`Tender request ${shortId} has been cancelled.`); + }, + error: (error: Error) => { + console.error('Error cancelling tendering quote:', error); + this.notificationService.showError(`Error cancelling tender request: ${error.message || 'Unknown error'}`); + } + }); }, - error: (error: Error) => { - console.error('Error cancelling tendering quote:', error); - this.notificationService.showError(`Error cancelling tender request: ${error.message || 'Unknown error'}`); - } - }); + 'Cancel Request', + 'px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500' + ); } simulateStartTender(quote: Quote) { const shortId = this.extractShortId(quote.id); - const confirmStart = confirm(`[TEST] Simulate starting the tender?\n\nThis will update the status from "pre-launched" to "launched".`); - - if (!confirmStart) { - return; - } - - console.log('[TEST] Starting tender (updating to approved):', quote.id); - - this.quoteService.updateQuoteStatus(quote.id!, 'approved').subscribe({ - next: (updatedQuote: Quote) => { - const index = this.quotes.findIndex(q => q.id === updatedQuote.id); - if (index !== -1) { - this.quotes[index] = updatedQuote; - this.filterQuotesByStatus(); - } - console.log('[TEST] Tender successfully started (status updated to approved/launched)'); - this.notificationService.showSuccess(`Tender ${shortId} has been started successfully (status: launched).`); + const todayFormatted = this.getTodayForAPI(); + + this.showConfirmation( + 'Start Tender', + `Are you sure you want to start this tender?\n\nWarning: The Tender Start Date will be updated to today (${todayFormatted}).`, + () => { + this.quoteService.updateQuoteStatus(quote.id!, 'approved').pipe( + switchMap((updatedQuote: Quote) => { + const index = this.quotes.findIndex(q => q.id === updatedQuote.id); + if (index !== -1) { + this.quotes[index] = updatedQuote; + this.filterQuotesByStatus(); + } + return this.quoteService.updateQuoteDate(quote.id!, todayFormatted, 'expectedFulfillment'); + }) + ).subscribe({ + next: (updatedQuote: Quote) => { + const index = this.quotes.findIndex(q => q.id === updatedQuote.id); + if (index !== -1) { + this.quotes[index] = updatedQuote; + this.filterQuotesByStatus(); + } + this.notificationService.showSuccess(`Tender ${shortId} started. Start date set to today.`); + }, + error: (error: Error) => { + console.error('Error starting tender:', error); + this.notificationService.showError(`Error starting tender: ${error.message || 'Unknown error'}`); + } + }); }, - error: (error: Error) => { - console.error('[TEST] Error starting tender:', error); - this.notificationService.showError(`Error starting tender: ${error.message || 'Unknown error'}`); - } - }); + 'Start Tender', + 'px-4 py-2 text-sm font-medium text-white bg-orange-600 border border-transparent rounded-md hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500' + ); } simulateCloseTender(quote: Quote) { const shortId = this.extractShortId(quote.id); - const confirmClose = confirm(`[TEST] Simulate closing the tender?\n\nThis will update the status from "launched" to "closed".`); - - if (!confirmClose) { - return; - } - - console.log('[TEST] Closing tender (updating to accepted):', quote.id); - - this.quoteService.updateQuoteStatus(quote.id!, 'accepted').subscribe({ - next: (updatedQuote: Quote) => { - const index = this.quotes.findIndex(q => q.id === updatedQuote.id); - if (index !== -1) { - this.quotes[index] = updatedQuote; - this.filterQuotesByStatus(); - } - console.log('[TEST] Tender successfully closed (status updated to accepted/closed)'); - this.notificationService.showSuccess(`Tender ${shortId} has been closed successfully (status: closed).`); + const todayFormatted = this.getTodayForAPI(); + + this.showConfirmation( + 'Close Tender', + `Are you sure you want to close this tender?\n\nWarning: The Tender End Date will be updated to today (${todayFormatted}).`, + () => { + this.quoteService.updateQuoteStatus(quote.id!, 'accepted').pipe( + switchMap((updatedQuote: Quote) => { + const index = this.quotes.findIndex(q => q.id === updatedQuote.id); + if (index !== -1) { + this.quotes[index] = updatedQuote; + this.filterQuotesByStatus(); + } + return this.quoteService.updateQuoteDate(quote.id!, todayFormatted, 'effective'); + }) + ).subscribe({ + next: (updatedQuote: Quote) => { + const index = this.quotes.findIndex(q => q.id === updatedQuote.id); + if (index !== -1) { + this.quotes[index] = updatedQuote; + this.filterQuotesByStatus(); + } + this.notificationService.showSuccess(`Tender ${shortId} closed. End date set to today.`); + }, + error: (error: Error) => { + console.error('Error closing tender:', error); + this.notificationService.showError(`Error closing tender: ${error.message || 'Unknown error'}`); + } + }); }, - error: (error: Error) => { - console.error('[TEST] Error closing tender:', error); - this.notificationService.showError(`Error closing tender: ${error.message || 'Unknown error'}`); - } - }); + 'Close Tender', + 'px-4 py-2 text-sm font-medium text-white bg-purple-600 border border-transparent rounded-md hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500' + ); + } + + private getTodayForAPI(): string { + const today = new Date(); + const day = today.getDate().toString().padStart(2, '0'); + const month = (today.getMonth() + 1).toString().padStart(2, '0'); + const year = today.getFullYear(); + return `${day}-${month}-${year}`; } acceptQuoteCustomer(quote: Quote) { const shortId = this.extractShortId(quote.id); - const confirmAccept = confirm(`Are you sure you want to accept the quotation?`); - - if (!confirmAccept) { - return; - } - console.log('Buyer accepting quotation:', quote.id); - - this.quoteService.updateQuoteStatus(quote.id!, 'accepted').subscribe({ - next: (updatedQuote: Quote) => { - const index = this.quotes.findIndex(q => q.id === updatedQuote.id); - if (index !== -1) { - this.quotes[index] = updatedQuote; - this.filterQuotesByStatus(); - } - console.log('Quotation successfully accepted by buyer'); - this.notificationService.showSuccess(`Quotation ${shortId} has been accepted successfully.`); + this.showConfirmation( + 'Accept Quotation', + 'Are you sure you want to accept the quotation?', + () => { + console.log('Buyer accepting quotation:', quote.id); + + this.quoteService.updateQuoteStatus(quote.id!, 'accepted').subscribe({ + next: (updatedQuote: Quote) => { + const index = this.quotes.findIndex(q => q.id === updatedQuote.id); + if (index !== -1) { + this.quotes[index] = updatedQuote; + this.filterQuotesByStatus(); + } + console.log('Quotation successfully accepted by buyer'); + this.notificationService.showSuccess(`Quotation ${shortId} has been accepted successfully.`); + }, + error: (error: any) => { + console.error('Error accepting quotation:', error); + this.notificationService.showError(`Error accepting quotation: ${error?.message || 'Unknown error'}`); + } + }); }, - error: (error: any) => { - console.error('Error accepting quotation:', error); - this.notificationService.showError(`Error accepting quotation: ${error?.message || 'Unknown error'}`); - } - }); + 'Accept', + 'px-4 py-2 text-sm font-medium text-white bg-green-600 border border-transparent rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500' + ); } acceptTenderQuote(quote: Quote) { const shortId = this.extractShortId(quote.id); - const confirmAccept = confirm(`Are you sure you want to accept this quote? Every other quote in this tender will be Rejected`); - if (!confirmAccept) return; + + this.showConfirmation( + 'Accept Tender Quote', + 'Are you sure you want to accept this quote? Every other quote in this tender will be Rejected.', + () => this.executeAcceptTenderQuote(quote, shortId), + 'Accept', + 'px-4 py-2 text-sm font-medium text-white bg-green-600 border border-transparent rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500' + ); + } + + private executeAcceptTenderQuote(quote: Quote, shortId: string) { console.log('Buyer accepting tender:', quote.id); @@ -1446,61 +1552,62 @@ export class TenderListComponent implements OnInit { rejectTenderQuote(quote: Quote) { const shortId = this.extractShortId(quote.id); - const confirmReject = confirm(`Are you sure you want to reject this tender?\n\nThis action cannot be undone.`); - - if (!confirmReject) { - return; - } - console.log('Buyer rejecting tender:', quote.id); - - this.quoteService.updateQuoteStatus(quote.id!, 'rejected').subscribe({ - next: (updatedQuote: Quote) => { - const index = this.quotes.findIndex(q => q.id === updatedQuote.id); - if (index !== -1) { - this.quotes[index] = updatedQuote; - this.filterQuotesByStatus(); - } - console.log('Tender successfully rejected by buyer'); - this.notificationService.showSuccess(`Tender ${shortId} has been rejected.`); + this.showConfirmation( + 'Reject Tender', + 'Are you sure you want to reject this tender?\n\nThis action cannot be undone.', + () => { + console.log('Buyer rejecting tender:', quote.id); + + this.quoteService.updateQuoteStatus(quote.id!, 'rejected').subscribe({ + next: (updatedQuote: Quote) => { + const index = this.quotes.findIndex(q => q.id === updatedQuote.id); + if (index !== -1) { + this.quotes[index] = updatedQuote; + this.filterQuotesByStatus(); + } + console.log('Tender successfully rejected by buyer'); + this.notificationService.showSuccess(`Tender ${shortId} has been rejected.`); + }, + error: (error: any) => { + console.error('Error rejecting tender:', error); + this.notificationService.showError(`Error rejecting tender: ${error?.message || 'Unknown error'}`); + } + }); }, - error: (error: any) => { - console.error('Error rejecting tender:', error); - this.notificationService.showError(`Error rejecting tender: ${error?.message || 'Unknown error'}`); - } - }); + 'Reject', + 'px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500' + ); } - // Date picker methods - - - - cancelQuote(quote: Quote) { const shortId = this.extractShortId(quote.id); - const confirmCancel = confirm(`Are you sure you want to cancel quote ${shortId}?\n\nThis action cannot be undone and will disable all other quote actions.`); - - if (!confirmCancel) { - return; - } - console.log('Cancelling quote:', quote.id); - - this.quoteService.updateQuoteStatus(quote.id!, 'cancelled').subscribe({ - next: (updatedQuote: Quote) => { - const index = this.quotes.findIndex(q => q.id === updatedQuote.id); - if (index !== -1) { - this.quotes[index] = updatedQuote; - this.filterQuotesByStatus(); - } - console.log('Quote successfully cancelled'); - this.notificationService.showSuccess(`Quote ${shortId} has been cancelled successfully.`); + this.showConfirmation( + 'Cancel Quote', + `Are you sure you want to cancel quote ${shortId}?\n\nThis action cannot be undone and will disable all other quote actions.`, + () => { + console.log('Cancelling quote:', quote.id); + + this.quoteService.updateQuoteStatus(quote.id!, 'cancelled').subscribe({ + next: (updatedQuote: Quote) => { + const index = this.quotes.findIndex(q => q.id === updatedQuote.id); + if (index !== -1) { + this.quotes[index] = updatedQuote; + this.filterQuotesByStatus(); + } + console.log('Quote successfully cancelled'); + this.notificationService.showSuccess(`Quote ${shortId} has been cancelled successfully.`); + }, + error: (error: any) => { + console.error('Error cancelling quote:', error); + this.notificationService.showError(`Error cancelling quote: ${error?.message || 'Unknown error'}`); + } + }); }, - error: (error: any) => { - console.error('Error cancelling quote:', error); - this.notificationService.showError(`Error cancelling quote: ${error?.message || 'Unknown error'}`); - } - }); + 'Cancel Quote', + 'px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500' + ); } // Utility methods (migrated from QuoteRow.js) @@ -1538,7 +1645,7 @@ export class TenderListComponent implements OnInit { } // Apply mapping only for coordinator quotes - if (quote.category === 'coordinator') { + if (quote.category === QUOTE_CATEGORIES.COORDINATOR) { return this.mapCoordinatorStatusToGUI(state); } @@ -1728,7 +1835,7 @@ export class TenderListComponent implements OnInit { * (not in pending status, which displays as "draft") */ isCoordinatorExpandable(quote: Quote): boolean { - if (quote.category !== 'coordinator') { + if (quote.category !== QUOTE_CATEGORIES.COORDINATOR) { return false; } @@ -1865,6 +1972,18 @@ export class TenderListComponent implements OnInit { return provider?.name || provider?.id || 'Unknown Provider'; } + /** + * Get buyer name from related party (for Provider view) + */ + getBuyerName(quote: Quote): string { + if (!quote.relatedParty || quote.relatedParty.length === 0) { + return 'Unknown Customer'; + } + + const buyer = quote.relatedParty?.find(party => party.role?.toLowerCase() === 'buyer'); + return buyer?.name || buyer?.id || 'Unknown Customer'; + } + /** * Check if the coordinator quote allows accepting tendering quotes * Returns true if the coordinator quote is in 'inProgress' state @@ -1915,7 +2034,7 @@ export class TenderListComponent implements OnInit { * Load the state of a coordinator quote */ private loadCoordinatorQuoteState(coordinatorQuoteId: string): void { - if (this.loadingCoordinatorStates.has(coordinatorQuoteId) || + if (this.loadingCoordinatorStates.has(coordinatorQuoteId) || this.coordinatorQuoteStatesMap.has(coordinatorQuoteId)) { return; // Already loading or loaded } @@ -1927,14 +2046,17 @@ export class TenderListComponent implements OnInit { next: (coordinatorQuote: Quote) => { // Get the state from quoteItem const state = this.getQuoteItemState(coordinatorQuote); - + // Store the actual backend state (not the mapped GUI state) const backendState = this.getPrimaryState(coordinatorQuote); this.coordinatorQuoteStatesMap.set(coordinatorQuoteId, backendState); - + this.loadingCoordinatorStates.delete(coordinatorQuoteId); - + console.log(`Coordinator quote ${this.extractShortId(coordinatorQuoteId)} state: ${backendState} (GUI: ${state})`); + + // Note: We don't call filterQuotesByStatus() here anymore since coordinator states + // are preloaded in parallel. This method is only used as a fallback for button permission checks. }, error: (error: Error) => { console.error(`Failed to load coordinator quote state for ${this.extractShortId(coordinatorQuoteId)}:`, error); @@ -1946,22 +2068,69 @@ export class TenderListComponent implements OnInit { } /** - * Load coordinator states for all tendering quotes + * Load coordinator states for all tendering quotes in parallel + * Returns an observable that completes when all states are loaded */ - private loadCoordinatorStatesForTenderingQuotes(): void { + private loadCoordinatorStatesForTenderingQuotes(): Observable { const externalIds = new Set(); - - // Collect unique externalIds from tendering quotes + + // Collect unique externalIds from tendering quotes (mapped to 'tender' category in Quote model) this.quotes.forEach(quote => { - if (quote.category === 'tender' && quote.externalId) { + if (quote.category === QUOTE_CATEGORIES.TENDER && quote.externalId) { externalIds.add(quote.externalId); } }); - // Load state for each unique coordinator quote - externalIds.forEach(externalId => { - this.loadCoordinatorQuoteState(externalId); + // If no tendering quotes, return completed observable + if (externalIds.size === 0) { + return of(void 0); + } + + // Create an array of observables for each coordinator quote + const loadObservables = Array.from(externalIds).map(externalId => { + // Skip if already loaded or loading + if (this.coordinatorQuoteStatesMap.has(externalId)) { + return of(void 0); + } + + console.log(`Loading coordinator quote state for: ${this.extractShortId(externalId)}`); + this.loadingCoordinatorStates.add(externalId); + + return this.quoteService.getQuoteById(externalId).pipe( + map((coordinatorQuote: Quote) => { + const backendState = this.getPrimaryState(coordinatorQuote); + this.coordinatorQuoteStatesMap.set(externalId, backendState); + this.loadingCoordinatorStates.delete(externalId); + console.log(`Coordinator quote ${this.extractShortId(externalId)} state: ${backendState}`); + }), + catchError((error: Error) => { + console.error(`Failed to load coordinator quote state for ${this.extractShortId(externalId)}:`, error); + this.loadingCoordinatorStates.delete(externalId); + // Set to unknown state to prevent repeated failed attempts + this.coordinatorQuoteStatesMap.set(externalId, 'unknown'); + return of(void 0); // Continue even if one fails + }) + ); }); + + // Wait for all coordinator states to load in parallel + return forkJoin(loadObservables).pipe( + map(() => void 0) + ); + } + + /** + * Truncate title if longer than 50 characters + * @param title The title to potentially truncate + * @returns Truncated title with ellipsis if over 50 chars, or original title + */ + getTruncatedTitle(title: string | undefined): string { + if (!title) return '(no title)'; + const maxLength = 50; + if (title.length > maxLength) { + return title.substring(0, maxLength) + '...'; + } + return title; } } diff --git a/src/app/models/quote.constants.ts b/src/app/models/quote.constants.ts index 3a4f038c..65717951 100644 --- a/src/app/models/quote.constants.ts +++ b/src/app/models/quote.constants.ts @@ -1,8 +1,27 @@ /** * Quote status constants and messages used throughout the application - * + * */ +/** + * Quote category constants (frontend/Quote model) + */ +export const QUOTE_CATEGORIES = { + TAILORED: 'tailored', + TENDER: 'tender', + COORDINATOR: 'coordinator' +} as const; + +export type QuoteCategoryType = typeof QUOTE_CATEGORIES[keyof typeof QUOTE_CATEGORIES]; + +/** + * Backend Tender category constants (backend/Tender model) + */ +export const TENDER_CATEGORIES = { + TENDERING: 'tendering', + COORDINATOR: 'coordinator' +} as const; + export interface StatusInfo { explanation: string; availableActions: string; @@ -14,7 +33,7 @@ export interface StatusMessages { } /** - * Quote status explanation messages for each state and role + * Quote status explanation messages for each state and role (for TAILORED quotes) */ export const QUOTE_STATUS_MESSAGES: Record = { pending: { @@ -79,6 +98,138 @@ export const QUOTE_STATUS_MESSAGES: Record = { } }; +/** + * Tender status explanation messages for TENDERING quotes (related quotes) + */ +export const TENDERING_STATUS_MESSAGES: Record = { + pending: { + provider: { + explanation: 'The tender request has been sent to you. At this stage you can decide whether to participate or not in this tender.', + availableActions: 'Send messages to buyer (chat), accept or decline the tender request or withdraw from the tender.' + }, + buyer: { + explanation: 'The tender is created and sent to the provider. Waiting for the provider to accept or decline participation.', + availableActions: 'Send messages to provider (chat), view tender request details or cancel the request.' + } + }, + inProgress: { + provider: { + explanation: "You are participating in this tender. Provide your offer as a PDF document to attach with all required details.", + availableActions: 'Send messages to buyer (chat), upload tender response documents or withdraw from tender.' + }, + buyer: { + explanation: 'The provider is preparing their tender response. You will be notified once they submit their proposal.', + availableActions: 'Send messages to provider (chat), view tender details or cancel the tender.' + } + }, + approved: { + provider: { + explanation: 'Your offer has been submitted and is under review by the buyer.', + availableActions: 'Send messages to buyer (chat) or withdraw your tender response.' + }, + buyer: { + explanation: 'The provider has submitted their tender response. Please review the submission and all attached documents.', + availableActions: 'Send messages to provider (chat), accept the tender response, reject it, or request clarifications.' + } + }, + accepted: { + provider: { + explanation: 'Congratulations! Your tender response has been accepted. You must now proceed with creating the customized offering.', + availableActions: 'Send messages to buyer (chat).' + }, + buyer: { + explanation: 'You have accepted this offer. The provider must now proceed with creating the customized offering.', + availableActions: 'Send messages to provider (chat).' + } + }, + rejected: { + provider: { + explanation: 'Your offer was not selected for this tender.', + availableActions: 'View tender details and chat history.' + }, + buyer: { + explanation: 'You have rejected this offer.', + availableActions: 'View tender details and chat history.' + } + }, + cancelled: { + provider: { + explanation: 'This offering has been cancelled.', + availableActions: 'View tender details and chat history. No further actions available.' + }, + buyer: { + explanation: 'This offering has been cancelled.', + availableActions: 'View tender details and chat history. No further actions available.' + } + } +}; + +/** + * Tender status explanation messages for COORDINATOR quotes + */ +export const COORDINATOR_STATUS_MESSAGES: Record = { + pending: { + provider: { + explanation: '...', + availableActions: '...' + }, + buyer: { + explanation: 'Tender is still in draft', + availableActions: 'Finish draft details' + } + }, + inProgress: { + provider: { + explanation: "...", + availableActions: '...' + }, + buyer: { + explanation: 'The invited providers now have time to accept or decline the invite to the tender', + availableActions: 'View provider responses, send messages, monitor progress, or start the tender' + } + }, + approved: { + provider: { + explanation: '...', + availableActions: '...' + }, + buyer: { + explanation: 'The tendering is launched, the providers that have accepted the invitation must now provide an offer by attaching a PDF document. You must review and compare the offerings provided and will be able to accept the winning one once the End tender date has been reached and the tendering process is closed', + availableActions: 'Review responses, compare proposals, or request additional information.' + } + }, + accepted: { + provider: { + explanation: '...', + availableActions: '...' + }, + buyer: { + explanation: 'The tender is now closed. You can select the winning proposal and accept it', + availableActions: 'View tender details, communicate with winning provider' + } + }, + rejected: { + provider: { + explanation: '...', + availableActions: '...' + }, + buyer: { + explanation: '...', + availableActions: '...' + } + }, + cancelled: { + provider: { + explanation: 'This tender has been cancelled.', + availableActions: 'View tender details. No further actions available.' + }, + buyer: { + explanation: 'This tender has been cancelled.', + availableActions: 'View tender details. No further actions available.' + } + } +}; + /** * Auto-generated chat messages for status changes */ @@ -87,6 +238,11 @@ export const QUOTE_CHAT_MESSAGES = { ATTACHMENT_UPLOADED: (filename: string) => `Attachment uploaded: ${filename}` }; +/** + * Tender creation step 2 instruction message + */ +export const TENDER_STEP2_DESCRIPTION = 'You must decide and select a date for the start and the end of the tender, also you must provide a PDF document with the description of your request. Fill all of this information before going to the next step.'; + /** * Action button helper texts displayed next to buttons in quote details modal */ @@ -96,5 +252,8 @@ export const QUOTE_ACTION_BUTTON_TEXTS = { CANCEL_QUOTE_PROVIDER: 'Cancel the quote request.', REJECT_PROPOSAL_CUSTOMER: 'Reject the proposal.', CREATE_OFFER: 'Create a customized offering based on this accepted quote.', - EXPECTED_DATE_REQUIRED: 'Set an expected date for the delivery of the proposal first' + EXPECTED_DATE_REQUIRED: 'Set an expected date for the delivery of the proposal first', + + ACCEPT_TENDER_INVITE: 'Accept the invitation to the tender.', + DECLINE_TENDER_INVITE: 'Decline the invitation to the tender.' }; diff --git a/src/app/shared/confirm-dialog/confirm-dialog.component.ts b/src/app/shared/confirm-dialog/confirm-dialog.component.ts index dc3765aa..c4e90521 100644 --- a/src/app/shared/confirm-dialog/confirm-dialog.component.ts +++ b/src/app/shared/confirm-dialog/confirm-dialog.component.ts @@ -6,7 +6,7 @@ import { CommonModule } from '@angular/common'; standalone: true, imports: [CommonModule], template: ` -
+

{{ title }}

{{ message }}

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 f275a0e8..1c376aaa 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 @@ -11,14 +11,16 @@ import { ProviderService, Provider } from 'src/app/services/provider.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'; +import { TENDER_STEP2_DESCRIPTION } from 'src/app/models/quote.constants'; import { SearchOrganizationsFilters, countryName, complianceLevelsName } from 'src/app/models/search-organizations-filters.model'; import { FormControl } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms'; +import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'; @Component({ selector: 'app-create-tender-modal', standalone: true, - imports: [CommonModule, FormsModule, ReactiveFormsModule], + imports: [CommonModule, FormsModule, ReactiveFormsModule, ConfirmDialogComponent], template: `
@@ -70,54 +72,43 @@ import { ReactiveFormsModule } from '@angular/forms';
-
+
-

{{ tenderTitle }}

+

{{ tenderTitle }}

- - + + +
+

{{ TENDER_STEP2_DESCRIPTION }}

+
+ +
-
- - -
+

Format: DD/MM/YYYY

- +
-
- - -
+

Format: DD/MM/YYYY

@@ -126,7 +117,7 @@ import { ReactiveFormsModule } from '@angular/forms'; - +
@@ -143,48 +134,39 @@ import { ReactiveFormsModule } from '@angular/forms';

Upload a new file to replace the existing attachment

- -
- - -
+ +

- {{ existingAttachment ? 'Select a new file to upload or keep the current one' : 'Only PDF files allowed' }} + {{ existingAttachment ? 'Select a new file to upload or keep the current one' : 'Only PDF files allowed' }} — Max size 10MB

- -
@@ -195,7 +177,7 @@ import { ReactiveFormsModule } from '@angular/forms';
-

{{ tenderTitle }}

+

{{ tenderTitle }}

@@ -428,6 +410,17 @@ import { ReactiveFormsModule } from '@angular/forms';
+ + + `, styles: [] }) @@ -451,6 +444,14 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { invitedProviders: Array<{ provider: Provider; quoteId: string }> = []; tenderLoading = false; tenderError: string | null = null; + + // Generic Confirmation Dialog + showGenericConfirm = false; + genericConfirmTitle = ''; + genericConfirmMessage = ''; + genericConfirmButtonText = 'Confirm'; + genericConfirmButtonClass = 'px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500'; + genericConfirmCallback: (() => void) | null = null; currentUserId: string | null = null; // Filter options @@ -497,6 +498,14 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { // Track tender creation steps tenderCreationStep: number = 1; // 1 = Title, 2 = Dates, 3 = Providers + // Expose constant to template + readonly TENDER_STEP2_DESCRIPTION = TENDER_STEP2_DESCRIPTION; + + get minDate(): string { + const today = new Date(); + return today.toISOString().split('T')[0]; + } + ngOnInit() { // Use customerId if provided from parent, otherwise get from localStorage if (this.customerId) { @@ -578,6 +587,27 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { this.closeModal.emit(); } + /** + * Show generic confirmation dialog + */ + showConfirmation( + title: string, + message: string, + callback: () => void, + buttonText: string = 'Confirm', + buttonClass: string = 'px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500' + ) { + this.genericConfirmTitle = title; + this.genericConfirmMessage = message; + this.genericConfirmButtonText = buttonText; + this.genericConfirmButtonClass = buttonClass; + this.genericConfirmCallback = () => { + callback(); + this.showGenericConfirm = false; + }; + this.showGenericConfirm = true; + } + resetTenderForm() { this.tenderTitle = ''; this.expectedCompletionDate = ''; @@ -761,26 +791,48 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { } /** - * Check if all Step 2 fields are completed + * Check if all Step 2 fields are filled (drives the Next button enabled state) */ isStep2Complete(): boolean { - return this.expectedDateSet && this.requestedDateSet && this.pdfAttachmentSet; + const hasPdf = !!this.selectedPdfFile || !!this.existingAttachment; + return !!this.requestedCompletionDate && !!this.expectedCompletionDate && hasPdf; } /** - * Proceed from Step 2 to Step 3 (Provider Selection) + * Proceed from Step 2 to Step 3: saves all data sequentially then moves to provider selection */ proceedToProviderSelection() { - if (!this.isStep2Complete()) { - this.notificationService.showError('Please complete all date and PDF fields first'); - return; - } + if (!this.isStep2Complete() || !this.createdQuoteId) return; - // Move to Step 3 - this.tenderCreationStep = 3; - - // Load providers for selection (will automatically load invited providers after) - this.loadTenderProviders(); + this.tenderLoading = true; + + const formattedRequested = this.formatDateForAPI(this.requestedCompletionDate); + const formattedExpected = this.formatDateForAPI(this.expectedCompletionDate); + + // 1. Set tender start date + this.quoteService.updateQuoteDate(this.createdQuoteId, formattedRequested, 'expectedFulfillment').pipe( + // 2. Set tender end date + switchMap(() => this.quoteService.updateQuoteDate(this.createdQuoteId!, formattedExpected, 'effective')), + // 3. Upload PDF only if a new file was selected (skip if keeping existing) + switchMap(() => { + if (this.selectedPdfFile) { + return this.quoteService.addAttachmentToQuote(this.createdQuoteId!, this.selectedPdfFile, ''); + } + return of(null); + }) + ).subscribe({ + next: () => { + this.tenderLoading = false; + this.notificationService.showSuccess('Tender details saved successfully!'); + this.tenderCreationStep = 3; + this.loadTenderProviders(); + }, + error: (error: any) => { + this.tenderLoading = false; + this.notificationService.showError('Failed to save tender details. Please try again.'); + console.error('Error saving tender step 2:', error); + } + }); } /** @@ -789,24 +841,63 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { loadTenderProviders() { this.tenderLoading = true; this.tenderError = null; - + console.log('Loading providers from API...'); - + this.providerService.getProvidersForTenderNew(this.orgFilters).subscribe({ next: (providers) => { - this.tenderProviders = providers; - this.tenderLoading = false; - this.updateAvailableProviders(); - - // After providers are loaded, load invited providers (if in edit mode) - if (this.tenderCreationStep === 3) { - this.loadInvitedProviders(); + // 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(); + } } }, error: (err) => { - this.tenderError = 'Failed to load providers: ' + (err.message || 'Unknown error'); - this.tenderLoading = false; - console.error('Error loading tender providers:', err); + // Search endpoint failed completely, try fallback + console.warn('Search endpoint failed, trying fallback...', err); + 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: ' + (fallbackErr.message || 'Unknown error'); + this.tenderLoading = false; + console.error('Error loading tender providers:', fallbackErr); + } + }); } }); } @@ -1031,7 +1122,7 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { this.selectedProviders.clear(); this._safeInvitedList = []; - this.notificationService.showSuccess(`${providerIds.length} provider(s) invited successfully!`); + this.notificationService.showSuccess(`${providerIds.length} provider(s) has been saved for invite`); this.tenderLoading = false; }) .catch(error => { @@ -1047,28 +1138,32 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { removeInvitedProvider(quoteId: string, providerId: string | undefined) { if (!providerId) return; - if (!confirm('Are you sure you want to remove this provider invitation? This will delete the quote.')) { - return; - } - - this.tenderLoading = true; - - this.quoteService.deleteQuote(quoteId).subscribe({ - next: () => { - console.log('Quote deleted for provider:', providerId); - - // Remove from invited list - this.invitedProviders = this.invitedProviders.filter(ip => ip.quoteId !== quoteId); - - this.notificationService.showSuccess('Provider invitation removed successfully'); - this.tenderLoading = false; + this.showConfirmation( + 'Remove Provider', + 'Are you sure you want to remove this provider invitation? This will delete the quote.', + () => { + this.tenderLoading = true; + + this.quoteService.deleteQuote(quoteId).subscribe({ + next: () => { + console.log('Quote deleted for provider:', providerId); + + // Remove from invited list + this.invitedProviders = this.invitedProviders.filter(ip => ip.quoteId !== quoteId); + + this.notificationService.showSuccess('Provider invitation removed successfully'); + this.tenderLoading = false; + }, + error: (error) => { + console.error('Error deleting quote:', error); + this.notificationService.showError('Failed to remove provider invitation: ' + (error.message || 'Unknown error')); + this.tenderLoading = false; + } + }); }, - error: (error) => { - console.error('Error deleting quote:', error); - this.notificationService.showError('Failed to remove provider invitation: ' + (error.message || 'Unknown error')); - this.tenderLoading = false; - } - }); + 'Remove', + 'px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500' + ); } /** @@ -1085,14 +1180,20 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { return; } - if (!confirm('Are you sure you want to finalize the tender?')) { - return; - } + this.showConfirmation( + 'Finalize Tender', + 'Are you sure you want to finalize the tender? This will notify all invited providers.', + () => this.executeFinalizeTender(), + 'Finalize', + 'px-4 py-2 text-sm font-medium text-white bg-green-600 border border-transparent rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500' + ); + } + private executeFinalizeTender() { this.tenderLoading = true; // Get the coordinator quote to extract the dates - this.quoteService.getQuoteById(this.createdQuoteId).pipe( + this.quoteService.getQuoteById(this.createdQuoteId!).pipe( switchMap(coordinatorQuote => { console.log('Coordinator quote retrieved:', coordinatorQuote); diff --git a/src/app/shared/quote-details-modal/quote-details-modal.component.ts b/src/app/shared/quote-details-modal/quote-details-modal.component.ts index 76376a0a..9f6cf77e 100644 --- a/src/app/shared/quote-details-modal/quote-details-modal.component.ts +++ b/src/app/shared/quote-details-modal/quote-details-modal.component.ts @@ -3,12 +3,14 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; import { switchMap } from 'rxjs/operators'; +import { forkJoin, Observable, of } from 'rxjs'; import { QuoteService } from 'src/app/features/quotes/services/quote.service'; import { NotificationService } from '../../services/notification.service'; import { AccountServiceService } from '../../services/account-service.service'; import { ApiServiceService } from '../../services/product-service.service'; import { Quote } from '../../models/quote.model'; -import { QUOTE_STATUS_MESSAGES, QUOTE_CHAT_MESSAGES, QUOTE_ACTION_BUTTON_TEXTS } from '../../models/quote.constants'; +import { QUOTE_STATUS_MESSAGES, TENDERING_STATUS_MESSAGES, COORDINATOR_STATUS_MESSAGES, QUOTE_CHAT_MESSAGES, QUOTE_ACTION_BUTTON_TEXTS, QUOTE_CATEGORIES } from '../../models/quote.constants'; +import { API_ROLES } from '../../models/roles.constants'; import { NotificationComponent } from '../notification/notification.component'; import { ChatModalComponent } from '../chat-modal/chat-modal.component'; import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'; @@ -32,7 +34,7 @@ import { environment } from 'src/environments/environment'; >
-

Quote Details

+

{{ getModalTitle() }}

+
+ +
@@ -187,12 +219,14 @@ import { environment } from 'src/environments/environment';
- -
+ +
- -
-

{{ ACTION_TEXTS.ACCEPT_QUOTE_PROVIDER }}

+ +
+

+ {{ getQuoteCategory() === QUOTE_CATEGORIES.TENDER ? ACTION_TEXTS.ACCEPT_TENDER_INVITE : ACTION_TEXTS.ACCEPT_QUOTE_PROVIDER }} +

- -
+ +

{{ ACTION_TEXTS.ACCEPT_PROPOSAL_CUSTOMER }}

+ +
+
+ +
+

Send a message to all invited providers in this tender

+ +
+
+
+

- {{ ACTION_TEXTS.CANCEL_QUOTE_PROVIDER }} + {{ getQuoteCategory() === QUOTE_CATEGORIES.COORDINATOR ? 'Cancel this tender and all the related quotes / invites' : (getQuoteCategory() === QUOTE_CATEGORIES.TENDER && currentUserRole === 'seller' && getPrimaryState() === 'pending' ? ACTION_TEXTS.DECLINE_TENDER_INVITE : ACTION_TEXTS.CANCEL_QUOTE_PROVIDER) }}

@@ -317,6 +371,35 @@ import { environment } from 'src/environments/environment';
+ +
+
+

Broadcast Message

+

This message will be sent to all invited providers in this tender:

+ +
+ + +
+
+
+ (); quote: Quote | null = null; + coordinatorQuote: Quote | null = null; // For tendering quotes, store the coordinator quote isLoading = false; error: string | null = null; isProcessing = false; @@ -369,8 +453,14 @@ export class QuoteDetailsModalComponent implements OnInit, OnChanges { // Chat modal showChatModal = false; + // Broadcast message modal + showBroadcastModal = false; + broadcastMessage = ''; + isBroadcastSending = false; + // Expose constants to template readonly ACTION_TEXTS = QUOTE_ACTION_BUTTON_TEXTS; + readonly QUOTE_CATEGORIES = QUOTE_CATEGORIES; // Services private quoteService = inject(QuoteService); @@ -400,6 +490,7 @@ export class QuoteDetailsModalComponent implements OnInit, OnChanges { private resetState() { this.quote = null; + this.coordinatorQuote = null; this.error = null; this.buyerName = 'Loading...'; this.buyerVatId = 'N/A'; @@ -421,8 +512,27 @@ export class QuoteDetailsModalComponent implements OnInit, OnChanges { this.quoteService.getQuoteById(id).subscribe({ next: (quote) => { this.quote = quote; - this.isLoading = false; - this.enrichQuoteData(); + + // If this is a tendering quote and has a coordinator (externalId), load the coordinator quote + if (quote.category === QUOTE_CATEGORIES.TENDER && quote.externalId) { + this.quoteService.getQuoteById(quote.externalId).subscribe({ + next: (coordinatorQuote) => { + this.coordinatorQuote = coordinatorQuote; + console.log('Loaded coordinator quote:', coordinatorQuote); + this.isLoading = false; + this.enrichQuoteData(); + }, + error: (error: Error) => { + console.error('Failed to load coordinator quote:', error); + // Continue without coordinator quote + this.isLoading = false; + this.enrichQuoteData(); + } + }); + } else { + this.isLoading = false; + this.enrichQuoteData(); + } }, error: (error: Error) => { this.error = 'Failed to load quote. Please try again.'; @@ -547,6 +657,15 @@ export class QuoteDetailsModalComponent implements OnInit, OnChanges { return this.quote.state || 'unknown'; } + // Returns the state of the coordinator quote (for tendering quotes) + getCoordinatorState(): string | null { + if (!this.coordinatorQuote) return null; + if (Array.isArray(this.coordinatorQuote.quoteItem) && this.coordinatorQuote.quoteItem.length > 0) { + return this.coordinatorQuote.quoteItem[0].state || null; + } + return this.coordinatorQuote.state || null; + } + getStatusBadgeClass(): string { const state = this.getPrimaryState(); const classes: Record = { @@ -560,16 +679,47 @@ export class QuoteDetailsModalComponent implements OnInit, OnChanges { return classes[state] || 'bg-gray-100 text-gray-600'; } + getQuoteCategory(): string { + return this.quote?.category || 'tailored'; + } + + getModalTitle(): string { + const category = this.getQuoteCategory(); + return category === QUOTE_CATEGORIES.COORDINATOR ? 'Tender Details' : 'Quote Details'; + } + getStatusExplanation(): string { const state = this.getPrimaryState(); const role = this.currentUserRole === 'customer' ? 'buyer' : 'provider'; - return QUOTE_STATUS_MESSAGES[state]?.[role]?.explanation || 'Status information unavailable.'; + const category = this.getQuoteCategory(); + + let messages; + if (category === QUOTE_CATEGORIES.COORDINATOR) { + messages = COORDINATOR_STATUS_MESSAGES; + } else if (category === QUOTE_CATEGORIES.TENDER) { + messages = TENDERING_STATUS_MESSAGES; + } else { + messages = QUOTE_STATUS_MESSAGES; // tailored or default + } + + return messages[state]?.[role]?.explanation || 'Status information unavailable.'; } getAvailableActionsText(): string { const state = this.getPrimaryState(); const role = this.currentUserRole === 'customer' ? 'buyer' : 'provider'; - return QUOTE_STATUS_MESSAGES[state]?.[role]?.availableActions || ''; + const category = this.getQuoteCategory(); + + let messages; + if (category === QUOTE_CATEGORIES.COORDINATOR) { + messages = COORDINATOR_STATUS_MESSAGES; + } else if (category === QUOTE_CATEGORIES.TENDER) { + messages = TENDERING_STATUS_MESSAGES; + } else { + messages = QUOTE_STATUS_MESSAGES; // tailored or default + } + + return messages[state]?.[role]?.availableActions || ''; } isQuoteFinalized(): boolean { @@ -586,8 +736,45 @@ export class QuoteDetailsModalComponent implements OnInit, OnChanges { return attachment?.name || 'attachment.pdf'; } + hasCoordinatorAttachment(): boolean { + // Only show coordinator attachment for tendering quotes from provider side + if (this.getQuoteCategory() !== QUOTE_CATEGORIES.TENDER) return false; + return this.coordinatorQuote?.quoteItem?.some(qi => qi.attachment && qi.attachment.length > 0) || false; + } + + getCoordinatorAttachmentName(): string { + const attachment = this.coordinatorQuote?.quoteItem?.[0]?.attachment?.[0]; + return attachment?.name || 'tender-request.pdf'; + } + + getTenderStartDate(): string { + // For tender quotes, use coordinator quote dates if available + if (this.getQuoteCategory() === QUOTE_CATEGORIES.TENDER && this.coordinatorQuote) { + const date = this.coordinatorQuote.expectedFulfillmentStartDate; + return date ? new Date(date).toLocaleDateString('en-GB').replace(/\//g, '-') : '--'; + } + // For coordinator quotes, use own dates + const date = this.quote?.expectedFulfillmentStartDate; + return date ? new Date(date).toLocaleDateString('en-GB').replace(/\//g, '-') : '--'; + } + + getTenderEndDate(): string { + // For tender quotes, use coordinator quote dates if available + if (this.getQuoteCategory() === QUOTE_CATEGORIES.TENDER && this.coordinatorQuote) { + const date = this.coordinatorQuote.effectiveQuoteCompletionDate; + return date ? new Date(date).toLocaleDateString('en-GB').replace(/\//g, '-') : '--'; + } + // For coordinator quotes, use own dates + const date = this.quote?.effectiveQuoteCompletionDate; + return date ? new Date(date).toLocaleDateString('en-GB').replace(/\//g, '-') : '--'; + } + canUploadAttachment(): boolean { if (this.currentUserRole !== 'seller') return false; + // For tendering quotes, only allow upload when the coordinator is in 'approved' (launched) state + if (this.getQuoteCategory() === QUOTE_CATEGORIES.TENDER) { + return this.getCoordinatorState() === 'approved'; + } const state = this.getPrimaryState(); return state === 'inProgress' || state === 'approved'; } @@ -651,6 +838,16 @@ export class QuoteDetailsModalComponent implements OnInit, OnChanges { } } + downloadCoordinatorAttachment() { + if (!this.coordinatorQuote) return; + try { + this.quoteService.downloadAttachment(this.coordinatorQuote); + this.notificationService.showSuccess('Download started'); + } catch (error: any) { + this.notificationService.showError(error.message || 'Error downloading customer request'); + } + } + onFileSelected(event: Event) { const input = event.target as HTMLInputElement; if (!input.files?.length || !this.quote?.id) return; @@ -774,37 +971,93 @@ export class QuoteDetailsModalComponent implements OnInit, OnChanges { cancelQuote() { if (!this.quote?.id || this.isProcessing) return; - this.confirmDialogTitle = 'Cancel Quote'; - this.confirmDialogMessage = 'Are you sure you want to cancel this quote?'; - this.confirmDialogButtonText = 'Cancel Quote'; + const isCoordinator = this.getQuoteCategory() === QUOTE_CATEGORIES.COORDINATOR; + + this.confirmDialogTitle = isCoordinator ? 'Cancel Tender' : 'Cancel Quote'; + this.confirmDialogMessage = isCoordinator + ? 'Are you sure you want to cancel this tender? This will also cancel all related provider invites.' + : 'Are you sure you want to cancel this quote?'; + this.confirmDialogButtonText = isCoordinator ? 'Cancel Tender' : 'Cancel Quote'; this.confirmDialogButtonClass = 'px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500'; this.confirmDialogCallback = () => { this.isProcessing = true; - const quoteId = this.quote!.id!; - const newStatus = 'cancelled'; - - this.quoteService.updateQuoteStatus(quoteId, newStatus).pipe( - switchMap((updatedQuote) => { - this.quote = updatedQuote; - return this.quoteService.addNoteToQuote(quoteId, QUOTE_CHAT_MESSAGES.STATUS_CHANGE(newStatus), this.currentUserId); - }) - ).subscribe({ - next: () => { - this.isProcessing = false; - this.notificationService.showSuccess('Quote cancelled'); - this.quoteUpdated.emit(this.quote!); - this.closeModal(); // Close modal after successful cancellation - }, - error: (error) => { - this.isProcessing = false; - this.notificationService.showError('Failed to cancel quote'); - } - }); this.showConfirmDialog = false; + + if (isCoordinator) { + this.cancelCoordinatorWithCascade(); + } else { + this.cancelSingleQuote(this.quote!.id!); + } }; this.showConfirmDialog = true; } + private cancelSingleQuote(quoteId: string) { + const newStatus = 'cancelled'; + this.quoteService.updateQuoteStatus(quoteId, newStatus).pipe( + switchMap((updatedQuote) => { + this.quote = updatedQuote; + return this.quoteService.addNoteToQuote(quoteId, QUOTE_CHAT_MESSAGES.STATUS_CHANGE(newStatus), this.currentUserId); + }) + ).subscribe({ + next: () => { + this.isProcessing = false; + this.notificationService.showSuccess('Quote cancelled'); + this.quoteUpdated.emit(this.quote!); + this.closeModal(); + }, + error: () => { + this.isProcessing = false; + this.notificationService.showError('Failed to cancel quote'); + } + }); + } + + private cancelCoordinatorWithCascade() { + const coordinatorId = this.quote!.id!; + const newStatus = 'cancelled'; + + // Step 1: Fetch all related tendering quotes for this coordinator + this.quoteService.getTenderingQuotesByExternalId(this.currentUserId, coordinatorId, API_ROLES.BUYER).subscribe({ + next: (relatedQuotes) => { + console.log(`Found ${relatedQuotes.length} related tendering quotes to cancel`); + + // Step 2: Cancel all related tendering quotes in parallel + const cancelRelatedObservables = relatedQuotes.map(relatedQuote => + this.quoteService.updateQuoteStatus(relatedQuote.id!, newStatus) + ); + + const cancelAll$ = cancelRelatedObservables.length > 0 + ? forkJoin(cancelRelatedObservables) + : of([]); + + cancelAll$.pipe( + // Step 3: Once all related quotes are cancelled, cancel the coordinator itself + switchMap(() => this.quoteService.updateQuoteStatus(coordinatorId, newStatus)), + switchMap((updatedCoordinator) => { + this.quote = updatedCoordinator; + return this.quoteService.addNoteToQuote(coordinatorId, QUOTE_CHAT_MESSAGES.STATUS_CHANGE(newStatus), this.currentUserId); + }) + ).subscribe({ + next: () => { + this.isProcessing = false; + this.notificationService.showSuccess(`Tender and ${relatedQuotes.length} related quote(s) cancelled`); + this.quoteUpdated.emit(this.quote!); + this.closeModal(); + }, + error: () => { + this.isProcessing = false; + this.notificationService.showError('Failed to cancel tender. Some quotes may not have been cancelled.'); + } + }); + }, + error: () => { + this.isProcessing = false; + this.notificationService.showError('Failed to fetch related quotes for cancellation'); + } + }); + } + saveExpectedDate() { if (!this.quote?.id || !this.selectedExpectedDate) return; @@ -831,6 +1084,84 @@ export class QuoteDetailsModalComponent implements OnInit, OnChanges { this.router.navigate(['/my-offerings'], { state: { quoteId: this.quote.id } }); } + // Coordinator-specific actions + openBroadcastMessage() { + if (!this.quote?.id) return; + this.broadcastMessage = ''; + this.showBroadcastModal = true; + } + + closeBroadcastModal() { + this.showBroadcastModal = false; + this.broadcastMessage = ''; + this.isBroadcastSending = false; + } + + sendBroadcastMessage() { + if (!this.quote?.id || !this.currentUserId || !this.broadcastMessage) { + return; + } + + this.confirmDialogTitle = 'Broadcast Message'; + this.confirmDialogMessage = 'Are you sure you want to broadcast this message to all the invited providers?'; + this.confirmDialogButtonText = 'Send'; + this.confirmDialogButtonClass = 'px-4 py-2 text-sm font-medium text-white bg-fuchsia-600 border border-transparent rounded-md hover:bg-fuchsia-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-fuchsia-500'; + + this.confirmDialogCallback = () => { + this.executeBroadcastMessage(); + this.showConfirmDialog = false; + }; + this.showConfirmDialog = true; + } + + private executeBroadcastMessage() { + if (!this.quote?.id || !this.currentUserId || !this.broadcastMessage) return; + + this.isBroadcastSending = true; + + // Get the coordinator quote's external ID to find related quotes + const coordinatorQuoteId = this.quote.id; + + // Fetch all quotes to find related tendering quotes + this.quoteService.getAllQuotes().subscribe({ + next: (allQuotes) => { + // Find all tendering quotes that have this coordinator quote as their external_id + const relatedQuotes = allQuotes.filter(q => + q.category === QUOTE_CATEGORIES.TENDER && q.externalId === coordinatorQuoteId + ); + + if (relatedQuotes.length === 0) { + this.notificationService.showError('No related provider quotes found to broadcast to.'); + this.isBroadcastSending = false; + this.closeBroadcastModal(); + return; + } + + // Send message to each related quote + const requests = relatedQuotes.map(q => + this.quoteService.addNoteToQuote(q.id!, this.broadcastMessage, this.currentUserId!) + ) as Observable[]; + + forkJoin(requests).subscribe({ + next: () => { + this.notificationService.showSuccess('Message broadcast sent to all invited providers.'); + this.closeBroadcastModal(); + }, + error: (error: Error) => { + console.error('Failed to broadcast message:', error); + this.notificationService.showError('Failed to broadcast message.'); + this.isBroadcastSending = false; + } + }); + }, + error: (error) => { + console.error('Failed to fetch quotes:', error); + this.notificationService.showError('Failed to fetch related quotes.'); + this.isBroadcastSending = false; + } + }); + } + closeModal() { this.close.emit(); } From f48c4722508e5166dc8ffa33f51a1bc017d9d24e Mon Sep 17 00:00:00 2001 From: BazRoe Date: Mon, 23 Feb 2026 17:50:54 +0100 Subject: [PATCH 2/3] Various fixes to display users id, added constants for quote statuses and labels --- .../pages/quote-list/quote-list.component.ts | 126 ++++++----- .../features/quotes/services/quote.service.ts | 29 ++- .../tender-list/tender-list.component.ts | 205 +++++++++++++----- src/app/models/quote.constants.ts | 84 ++++++- src/app/models/tender.model.ts | 5 +- .../quote-details-modal.component.ts | 109 +++++++--- 6 files changed, 404 insertions(+), 154 deletions(-) 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 34db129b..91c0f28e 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 @@ -16,6 +16,7 @@ import { QuoteDetailsModalComponent } from 'src/app/shared/quote-details-modal/q import { ChatModalComponent } from 'src/app/shared/chat-modal/chat-modal.component'; import { AttachmentModalComponent } from 'src/app/shared/attachment-modal/attachment-modal.component'; import { LoginInfo } from 'src/app/models/interfaces'; +import { QUOTE_STATUSES, TAILORED_STATUSES_LABELS_CUSTOMER, TAILORED_STATUSES_LABELS_PROVIDER } from 'src/app/models/quote.constants'; import { environment } from 'src/environments/environment'; @Component({ @@ -91,11 +92,11 @@ 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" > - - - - - + + + + +
@@ -169,7 +170,7 @@ import { environment } from 'src/environments/environment';
- {{ getPrimaryState(quote) }} + {{ getStatusLabel(quote) }}
@@ -412,7 +413,17 @@ export class QuoteListComponent implements OnInit { showStateUpdate = false; quoteToUpdate: Quote | null = null; selectedState: QuoteStateType | null = null; - availableStates: QuoteStateType[] = ['pending', 'inProgress', 'approved', 'rejected', 'cancelled', 'accepted']; + availableStates: QuoteStateType[] = [ + QUOTE_STATUSES.PENDING, + QUOTE_STATUSES.IN_PROGRESS, + QUOTE_STATUSES.APPROVED, + QUOTE_STATUSES.REJECTED, + QUOTE_STATUSES.CANCELLED, + QUOTE_STATUSES.ACCEPTED + ]; + + // Expose constants to template + readonly QUOTE_STATUSES = QUOTE_STATUSES; // Role management selectedRole: 'customer' | 'seller' = 'customer'; @@ -469,13 +480,6 @@ export class QuoteListComponent implements OnInit { next: (quotes) => { this.quotes = quotes; - // Debug: Log quote states and product info - console.log('Loaded quotes:', quotes.length); - quotes.forEach(quote => { - console.log(`Quote ${this.extractShortId(quote.id)}: main state = "${quote.state}", primary state = "${this.getPrimaryState(quote)}"`); - const productOffering = quote.quoteItem?.[0]?.productOffering; - console.log(` - productOffering:`, productOffering); - }); // Enrich quote data with organization and product names this.enrichQuoteData(quotes); @@ -653,10 +657,10 @@ export class QuoteListComponent implements OnInit { // If the current user is a provider (seller) and the quote is in progress, // automatically update the status to 'approved' after successful PDF upload - if (this.selectedRole === 'seller' && this.getPrimaryState(updatedQuote) === 'inProgress') { + if (this.selectedRole === 'seller' && this.getPrimaryState(updatedQuote) === QUOTE_STATUSES.IN_PROGRESS) { console.log('Provider uploaded PDF, updating quote status to approved:', updatedQuote.id); - - this.quoteService.updateQuoteStatus(updatedQuote.id!, 'approved').subscribe({ + + this.quoteService.updateQuoteStatus(updatedQuote.id!, QUOTE_STATUSES.APPROVED).subscribe({ next: (approvedQuote) => { // Update the quote again with the new status const approvedIndex = this.quotes.findIndex(q => q.id === approvedQuote.id); @@ -761,7 +765,7 @@ export class QuoteListComponent implements OnInit { this.acceptConfirmCallback = () => { console.log('Accepting quote request:', quote.id); - this.quoteService.updateQuoteStatus(quote.id!, 'inProgress').subscribe({ + this.quoteService.updateQuoteStatus(quote.id!, QUOTE_STATUSES.IN_PROGRESS).subscribe({ next: (updatedQuote) => { const index = this.quotes.findIndex(q => q.id === updatedQuote.id); if (index !== -1) { @@ -787,7 +791,7 @@ export class QuoteListComponent implements OnInit { this.acceptConfirmCallback = () => { console.log('Customer accepting quotation:', quote.id); - this.quoteService.updateQuoteStatus(quote.id!, 'accepted').subscribe({ + this.quoteService.updateQuoteStatus(quote.id!, QUOTE_STATUSES.ACCEPTED).subscribe({ next: (updatedQuote) => { const index = this.quotes.findIndex(q => q.id === updatedQuote.id); if (index !== -1) { @@ -881,7 +885,7 @@ export class QuoteListComponent implements OnInit { this.cancelConfirmCallback = () => { console.log('Cancelling quote:', quote.id); - this.quoteService.updateQuoteStatus(quote.id!, 'cancelled').subscribe({ + this.quoteService.updateQuoteStatus(quote.id!, QUOTE_STATUSES.CANCELLED).subscribe({ next: (updatedQuote) => { const index = this.quotes.findIndex(q => q.id === updatedQuote.id); if (index !== -1) { @@ -989,38 +993,58 @@ export class QuoteListComponent implements OnInit { if (Array.isArray(quote.quoteItem) && quote.quoteItem.length > 0) { return quote.quoteItem[0].state || 'unknown'; } - + // Fallback to main quote state if quoteItem state is not available if (quote.state) { return quote.state; } - + return 'unknown'; } + /** + * Get user-friendly status label for a quote based on role + */ + getStatusLabel(quote: Quote): string { + const state = this.getPrimaryState(quote); + + // Use appropriate label based on user role + const labels = this.selectedRole === 'customer' + ? TAILORED_STATUSES_LABELS_CUSTOMER + : TAILORED_STATUSES_LABELS_PROVIDER; + + // Map status to label + switch (state) { + case QUOTE_STATUSES.PENDING: + return labels.PENDING; + case QUOTE_STATUSES.IN_PROGRESS: + return labels.IN_PROGRESS; + case QUOTE_STATUSES.APPROVED: + return labels.APPROVED; + case QUOTE_STATUSES.ACCEPTED: + return labels.ACCEPTED; + case QUOTE_STATUSES.CANCELLED: + return labels.CANCELLED; + case QUOTE_STATUSES.REJECTED: + return labels.REJECTED; + default: + return state; + } + } + hasAttachment(quote: Quote): boolean { return Array.isArray(quote.quoteItem) && quote.quoteItem.some(qi => qi.attachment && qi.attachment.length > 0); } isQuoteCancelled(quote: Quote): boolean { - // Check quoteItem state first (this is where the actual state is stored) - if (quote.quoteItem?.some(item => item.state === 'cancelled')) { - return true; - } - - // Fallback to main quote state - return quote.state === 'cancelled'; + // Only check quoteItem state (this is the source of truth) + return quote.quoteItem?.some(item => item.state === QUOTE_STATUSES.CANCELLED) || false; } isQuoteAccepted(quote: Quote): boolean { - // Check quoteItem state first (this is where the actual state is stored) - if (quote.quoteItem?.some(item => item.state === 'accepted')) { - return true; - } - - // Fallback to main quote state - return quote.state === 'accepted'; + // Only check quoteItem state (this is the source of truth) + return quote.quoteItem?.some(item => item.state === QUOTE_STATUSES.ACCEPTED) || false; } isQuoteFinalized(quote: Quote): boolean { @@ -1093,32 +1117,32 @@ export class QuoteListComponent implements OnInit { getStateDisplay(state: QuoteStateType | undefined): string { if (!state) return 'Unknown'; - + const stateMap: Record = { - 'pending': 'Pending', - 'inProgress': 'In Progress', - 'approved': 'Approved', - 'rejected': 'Rejected', - 'cancelled': 'Cancelled', - 'accepted': 'Accepted' + [QUOTE_STATUSES.PENDING]: 'Pending', + [QUOTE_STATUSES.IN_PROGRESS]: 'In Progress', + [QUOTE_STATUSES.APPROVED]: 'Approved', + [QUOTE_STATUSES.REJECTED]: 'Rejected', + [QUOTE_STATUSES.CANCELLED]: 'Cancelled', + [QUOTE_STATUSES.ACCEPTED]: 'Accepted' }; - + return stateMap[state] || state; } getStateClass(state: string): string { switch (state) { - case 'pending': + case QUOTE_STATUSES.PENDING: return 'status-pending'; - case 'inProgress': + case QUOTE_STATUSES.IN_PROGRESS: return 'status-inProgress'; - case 'approved': + case QUOTE_STATUSES.APPROVED: return 'status-approved'; - case 'rejected': + case QUOTE_STATUSES.REJECTED: return 'status-rejected'; - case 'cancelled': + case QUOTE_STATUSES.CANCELLED: return 'status-cancelled'; - case 'accepted': + case QUOTE_STATUSES.ACCEPTED: return 'status-accepted'; default: return 'status-unknown'; @@ -1126,6 +1150,6 @@ export class QuoteListComponent implements OnInit { } canUpdateState(state: QuoteStateType | undefined): boolean { - return state !== 'cancelled' && state !== 'accepted'; + return state !== QUOTE_STATUSES.CANCELLED && state !== QUOTE_STATUSES.ACCEPTED; } } \ No newline at end of file diff --git a/src/app/features/quotes/services/quote.service.ts b/src/app/features/quotes/services/quote.service.ts index 3a2eb091..b6263b41 100644 --- a/src/app/features/quotes/services/quote.service.ts +++ b/src/app/features/quotes/services/quote.service.ts @@ -4,6 +4,7 @@ import { Observable, map, forkJoin } from 'rxjs'; import { Quote, Quote_Create, Quote_Update, QuoteStateType } from 'src/app/models/quote.model'; import { Tender } from 'src/app/models/tender.model'; import { ApiRole, API_ROLES } from 'src/app/models/roles.constants'; +import { QUOTE_STATUSES, QUOTE_CATEGORIES } from 'src/app/models/quote.constants'; import { environment } from '../../../../environments/environment'; @Injectable({ @@ -365,11 +366,11 @@ export class QuoteService { // Helper methods for quote status checking isQuoteCancelled(quote: Quote): boolean { - return quote.quoteItem?.some(item => item.state === 'cancelled') || false; + return quote.quoteItem?.some(item => item.state === QUOTE_STATUSES.CANCELLED) || false; } isQuoteAccepted(quote: Quote): boolean { - return quote.quoteItem?.some(item => item.state === 'accepted') || false; + return quote.quoteItem?.some(item => item.state === QUOTE_STATUSES.ACCEPTED) || false; } isQuoteFinalized(quote: Quote): boolean { @@ -556,9 +557,9 @@ export class QuoteService { // Map quote category to tender category let category: 'coordinator' | 'tendering' = 'coordinator'; - if (quote.category === 'tender') { + if (quote.category === QUOTE_CATEGORIES.TENDER) { category = 'tendering'; - } else if (quote.category === 'coordinator') { + } else if (quote.category === QUOTE_CATEGORIES.COORDINATOR) { category = 'coordinator'; } @@ -578,18 +579,21 @@ export class QuoteService { // - approved → sent → 'launched' // - accepted/cancelled/rejected → closed → 'closed' let state: 'draft' | 'pre-launched' | 'pending' | 'sent' | 'closed' = 'draft'; - if (quoteItemState === 'pending') state = 'draft'; - else if (quoteItemState === 'inProgress') state = 'pre-launched'; - else if (quoteItemState === 'approved') state = 'sent'; - else if (quoteItemState === 'accepted') state = 'closed'; - else if (quoteItemState === 'cancelled') state = 'closed'; - else if (quoteItemState === 'rejected') state = 'closed'; - - // Extract external_id and provider from quote + if (quoteItemState === QUOTE_STATUSES.PENDING) state = 'draft'; + else if (quoteItemState === QUOTE_STATUSES.IN_PROGRESS) state = 'pre-launched'; + else if (quoteItemState === QUOTE_STATUSES.APPROVED) state = 'sent'; + else if (quoteItemState === QUOTE_STATUSES.ACCEPTED) state = 'closed'; + else if (quoteItemState === QUOTE_STATUSES.CANCELLED) state = 'closed'; + else if (quoteItemState === QUOTE_STATUSES.REJECTED) state = 'closed'; + + // Extract external_id, provider and buyerPartyId from quote const external_id = quote.externalId; const provider = quote.relatedParty ?.find(party => party.role?.toLowerCase() === API_ROLES.SELLER.toLowerCase()) ?.name; + const buyerPartyId = quote.relatedParty + ?.find(party => party.role?.toLowerCase() === API_ROLES.BUYER.toLowerCase()) + ?.id; return { id: quote.id, @@ -601,6 +605,7 @@ export class QuoteService { selectedProviders, external_id, provider, + buyerPartyId, createdAt: quote.quoteDate, updatedAt: quote.quoteDate, effectiveQuoteCompletionDate: quote.effectiveQuoteCompletionDate, 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 fb34824a..4c07742d 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 @@ -1,4 +1,4 @@ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; @@ -7,6 +7,7 @@ import { switchMap } from 'rxjs/operators'; import { QuoteService } from '../../../quotes/services/quote.service'; import { LocalStorageService } from 'src/app/services/local-storage.service'; import { NotificationService } from 'src/app/services/notification.service'; +import { AccountServiceService } from 'src/app/services/account-service.service'; import { Tender, TenderAttachment } from 'src/app/models/tender.model'; import { Quote, QuoteStateType } from 'src/app/models/quote.model'; import { LoginInfo } from 'src/app/models/interfaces'; @@ -17,7 +18,7 @@ import { ChatModalComponent } from 'src/app/shared/chat-modal/chat-modal.compone import { AttachmentModalComponent } from 'src/app/shared/attachment-modal/attachment-modal.component'; import { CreateTenderModalComponent } from 'src/app/shared/create-tender-modal/create-tender-modal.component'; import { UI_ROLES, API_ROLES, UiRole, toApiRole } from 'src/app/models/roles.constants'; -import { QUOTE_CATEGORIES, TENDER_CATEGORIES } from 'src/app/models/quote.constants'; +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'; @Component({ selector: 'app-quote-list', @@ -204,7 +205,7 @@ import { QUOTE_CATEGORIES, TENDER_CATEGORIES } from 'src/app/models/quote.consta
- {{ getQuoteItemState(quote) }} + {{ getStatusLabel(quote) }}
@@ -274,7 +275,7 @@ import { QUOTE_CATEGORIES, TENDER_CATEGORIES } from 'src/app/models/quote.consta
- {{ getQuoteItemState(quote) }} + {{ getStatusLabel(quote) }}
@@ -340,7 +341,7 @@ import { QUOTE_CATEGORIES, TENDER_CATEGORIES } from 'src/app/models/quote.consta
@@ -651,6 +652,7 @@ export class TenderListComponent implements OnInit { private quoteService = inject(QuoteService); private localStorage = inject(LocalStorageService); private notificationService = inject(NotificationService); + private accountService = inject(AccountServiceService); quotes: Quote[] = []; filteredQuotes: Quote[] = []; @@ -673,6 +675,7 @@ export class TenderListComponent implements OnInit { // Expose constants to template readonly UI_ROLES = UI_ROLES; readonly QUOTE_CATEGORIES = QUOTE_CATEGORIES; + readonly QUOTE_STATUSES = QUOTE_STATUSES; // Filtering statusFilter: string = ''; @@ -716,6 +719,9 @@ export class TenderListComponent implements OnInit { coordinatorQuoteStatesMap: Map = new Map(); loadingCoordinatorStates: Set = new Set(); + // Data enrichment maps + organizationNames: Map = new Map(); + ngOnInit() { @@ -763,7 +769,10 @@ export class TenderListComponent implements OnInit { next: (quotes: Quote[]) => { // Use quotes as-is to preserve quoteItem.state this.quotes = quotes; - + + // Enrich with organisation trading names + this.enrichQuoteData(quotes); + // Debug: Log quote states and externalId console.log(`Loaded ${this.quotes.length} quotes as ${this.selectedRole}`); console.log(`Current user ID: ${this.currentUserId}`); @@ -837,14 +846,21 @@ export class TenderListComponent implements OnInit { expectedFulfillmentStartDate: tender.expectedFulfillmentStartDate, state: this.mapTenderStateToQuoteState(tender.state), // Map category back: 'tendering' -> 'tender', 'coordinator' -> 'coordinator' - category: tender.category === TENDER_CATEGORIES.TENDERING ? QUOTE_CATEGORIES.TENDER : tender.category, + category: tender.category === 'tendering' ? QUOTE_CATEGORIES.TENDER : QUOTE_CATEGORIES.COORDINATOR, externalId: tender.external_id, - relatedParty: tender.selectedProviders.map(id => ({ - id, - role: 'Seller', - name: tender.provider, - '@referredType': 'Organization' - })), + relatedParty: [ + ...tender.selectedProviders.map(id => ({ + id, + role: 'Seller', + name: tender.provider, + '@referredType': 'Organization' + })), + ...(tender.buyerPartyId ? [{ + id: tender.buyerPartyId, + role: 'Buyer', + '@referredType': 'Organization' + }] : []) + ], // Provide a minimal quoteItem array carrying the state and attachment quoteItem: [quoteItem], note: [] @@ -914,7 +930,7 @@ export class TenderListComponent implements OnInit { } // Filter out if coordinator is in 'pending' state (which maps to 'draft' in GUI) - const shouldShow = coordinatorState !== 'pending'; + const shouldShow = coordinatorState !== QUOTE_STATUSES.PENDING; console.log(`[FILTER] Coordinator state is ${coordinatorState}, ${shouldShow ? 'SHOWING' : 'HIDING'} quote`); return shouldShow; }); @@ -950,7 +966,7 @@ export class TenderListComponent implements OnInit { const primaryState = this.getPrimaryState(quote) as QuoteStateType; const tender: Tender = { id: quote.id, - category: quote.category === QUOTE_CATEGORIES.COORDINATOR ? TENDER_CATEGORIES.COORDINATOR : TENDER_CATEGORIES.TENDERING, + category: quote.category === QUOTE_CATEGORIES.COORDINATOR ? 'coordinator' : 'tendering', state: this.mapQuoteStateToTenderState(primaryState), responseDeadline: quote.expectedFulfillmentStartDate || quote.effectiveQuoteCompletionDate || new Date().toISOString(), tenderNote: quote.description || '', @@ -1158,10 +1174,10 @@ export class TenderListComponent implements OnInit { // If the current user is a provider (seller) and the quote is in progress, // automatically update the status to 'approved' after successful PDF upload - if (this.selectedRole === UI_ROLES.SELLER && this.getPrimaryState(updatedQuote) === 'inProgress') { + if (this.selectedRole === UI_ROLES.SELLER && this.getPrimaryState(updatedQuote) === QUOTE_STATUSES.IN_PROGRESS) { console.log('Provider uploaded PDF, updating quote status to approved:', updatedQuote.id); - - this.quoteService.updateQuoteStatus(updatedQuote.id!, 'approved').subscribe({ + + this.quoteService.updateQuoteStatus(updatedQuote.id!, QUOTE_STATUSES.APPROVED).subscribe({ next: (approvedQuote: Quote) => { // Update the quote again with the new status const approvedIndex = this.quotes.findIndex(q => q.id === approvedQuote.id); @@ -1502,7 +1518,7 @@ export class TenderListComponent implements OnInit { const siblings = (this.relatedQuotesMap.get(coordinatorKey) || []).filter(q => q.id !== quote.id); const toReject = siblings.filter(sib => { const state = this.getPrimaryState(sib); - return state !== 'accepted' && state !== 'cancelled' && state !== 'rejected'; + return state !== QUOTE_STATUSES.ACCEPTED && state !== QUOTE_STATUSES.CANCELLED && state !== QUOTE_STATUSES.REJECTED; }); if (toReject.length > 0) { @@ -1633,7 +1649,7 @@ export class TenderListComponent implements OnInit { getQuoteItemState(quote: Quote): string { let state = 'unknown'; - + if (Array.isArray(quote.quoteItem) && quote.quoteItem.length > 0) { // Scan all items and pick the first defined state for (const item of quote.quoteItem) { @@ -1643,15 +1659,56 @@ export class TenderListComponent implements OnInit { } } } - + // Apply mapping only for coordinator quotes if (quote.category === QUOTE_CATEGORIES.COORDINATOR) { return this.mapCoordinatorStatusToGUI(state); } - + return state; } + /** + * Get user-friendly status label for a quote based on category and role + */ + getStatusLabel(quote: Quote): string { + const state = this.getPrimaryState(quote); + + // Determine which label set to use based on category and role + let labels: any; + + if (quote.category === QUOTE_CATEGORIES.COORDINATOR) { + // Coordinator quotes use their own label set + labels = TENDER_COORDINATOR_STATUSES_LABELS; + } else if (quote.category === QUOTE_CATEGORIES.TENDER) { + // Tender child quotes use labels based on user role + labels = this.selectedRole === UI_ROLES.BUYER + ? TENDER_RELATED_QUOTES_LABELS_CUSTOMER + : TENDER_RELATED_QUOTES_LABELS_PROVIDER; + } else { + // Shouldn't happen in tender list, but fallback to empty labels + return state; + } + + // Map status to label + switch (state) { + case QUOTE_STATUSES.PENDING: + return labels.PENDING; + case QUOTE_STATUSES.IN_PROGRESS: + return labels.IN_PROGRESS; + case QUOTE_STATUSES.APPROVED: + return labels.APPROVED; + case QUOTE_STATUSES.ACCEPTED: + return labels.ACCEPTED; + case QUOTE_STATUSES.CANCELLED: + return labels.CANCELLED; + case QUOTE_STATUSES.REJECTED: + return labels.REJECTED; + default: + return state; + } + } + /** * Map coordinator quote status from backend (TMF) to frontend (GUI) display * Only for coordinator quotes @@ -1689,22 +1746,22 @@ export class TenderListComponent implements OnInit { isQuoteCancelled(quote: Quote): boolean { // Check quoteItem state first (this is where the actual state is stored) - if (quote.quoteItem?.some(item => item.state === 'cancelled')) { + if (quote.quoteItem?.some(item => item.state === QUOTE_STATUSES.CANCELLED)) { return true; } - + // Fallback to main quote state - return quote.state === 'cancelled'; + return quote.state === QUOTE_STATUSES.CANCELLED; } isQuoteAccepted(quote: Quote): boolean { // Check quoteItem state first (this is where the actual state is stored) - if (quote.quoteItem?.some(item => item.state === 'accepted')) { + if (quote.quoteItem?.some(item => item.state === QUOTE_STATUSES.ACCEPTED)) { return true; } - + // Fallback to main quote state - return quote.state === 'accepted'; + return quote.state === QUOTE_STATUSES.ACCEPTED; } isQuoteFinalized(quote: Quote): boolean { @@ -1823,7 +1880,7 @@ export class TenderListComponent implements OnInit { } canUpdateState(state: QuoteStateType | undefined): boolean { - return state !== 'cancelled' && state !== 'accepted'; + return state !== QUOTE_STATUSES.CANCELLED && state !== QUOTE_STATUSES.ACCEPTED; } // ======================================== @@ -1843,7 +1900,7 @@ export class TenderListComponent implements OnInit { // Expandable if NOT pending (backend state "pending" = GUI display "draft") // All other states (inProgress/pre-launched, approved/launched, etc.) are expandable - return state !== 'pending'; + return state !== QUOTE_STATUSES.PENDING; } /** @@ -1913,6 +1970,8 @@ export class TenderListComponent implements OnInit { next: (relatedQuotes: Quote[]) => { this.relatedQuotesMap.set(coordinatorQuote.id!, relatedQuotes); this.loadingRelatedQuotes.delete(coordinatorQuote.id!); + // Enrich provider names from the raw Quote objects (relatedParty has real org IDs) + this.enrichQuoteData(relatedQuotes); console.log(`✅ Successfully loaded ${relatedQuotes.length} related quotes for coordinator ${this.extractShortId(coordinatorQuote.id)}`); if (relatedQuotes.length > 0) { console.log('Related quotes:', relatedQuotes.map(q => ({ @@ -1945,31 +2004,61 @@ export class TenderListComponent implements OnInit { return quoteId ? this.loadingRelatedQuotes.has(quoteId) : false; } + private isOrganizationId(id: string): boolean { + return id.startsWith('urn:ngsi-ld:organization:'); + } + + /** + * Fetch tradingNames for every org URN found in relatedParty across the given quotes. + * Populates organizationNames map and triggers change detection on completion. + */ + private enrichQuoteData(quotes: Quote[]): void { + const orgIds = new Set(); + + quotes.forEach(quote => { + quote.relatedParty?.forEach(party => { + if (party.id && !this.organizationNames.has(party.id) && this.isOrganizationId(party.id)) { + orgIds.add(party.id); + } + }); + }); + + if (orgIds.size === 0) return; + + const orgRequests = Array.from(orgIds).map(id => + this.accountService.getOrgInfo(id).then( + (org: any) => ({ id, name: org?.tradingName || org?.name || id }), + () => ({ id, name: id }) + ) + ); + + Promise.all(orgRequests).then(results => { + results.forEach(({ id, name }) => this.organizationNames.set(id, name)); + // Trigger change detection + this.filteredQuotes = [...this.filteredQuotes]; + }); + } + /** * Get provider name from related party */ getProviderName(quote: Quote): string { - console.log('Getting provider name for quote:', quote.id); - console.log('RelatedParty array:', quote.relatedParty); - if (!quote.relatedParty || quote.relatedParty.length === 0) { - console.warn('No relatedParty found in quote:', quote.id); return 'Unknown Provider'; } - - // Log all parties to see what roles exist - quote.relatedParty.forEach(party => { - console.log('Party:', party.id, 'Role:', party.role, 'Name:', party.name); - }); - - const provider = quote.relatedParty?.find(party => party.role?.toLowerCase() === 'seller'); - - if (!provider) { - console.warn('No seller found in relatedParty for quote:', quote.id); - console.log('Available roles:', quote.relatedParty.map(p => p.role).join(', ')); + + const provider = quote.relatedParty.find(party => party.role?.toLowerCase() === 'seller'); + + if (!provider?.id) { + return 'Unknown Provider'; } - - return provider?.name || provider?.id || 'Unknown Provider'; + + const enrichedName = this.organizationNames.get(provider.id); + if (enrichedName && enrichedName !== provider.id) { + return enrichedName; + } + + return 'Loading...'; } /** @@ -1980,8 +2069,18 @@ export class TenderListComponent implements OnInit { return 'Unknown Customer'; } - const buyer = quote.relatedParty?.find(party => party.role?.toLowerCase() === 'buyer'); - return buyer?.name || buyer?.id || 'Unknown Customer'; + const buyer = quote.relatedParty.find(party => party.role?.toLowerCase() === 'buyer'); + + if (!buyer?.id) { + return 'Unknown Customer'; + } + + const enrichedName = this.organizationNames.get(buyer.id); + if (enrichedName && enrichedName !== buyer.id) { + return enrichedName; + } + + return 'Loading...'; } /** @@ -2004,7 +2103,7 @@ export class TenderListComponent implements OnInit { } // Allow accept only if coordinator is in 'inProgress' state - return coordinatorState === 'inProgress'; + return coordinatorState === QUOTE_STATUSES.IN_PROGRESS; } /** @@ -2027,7 +2126,7 @@ export class TenderListComponent implements OnInit { } // Allow add attachment only if coordinator is in 'approved' state - return coordinatorState === 'approved'; + return coordinatorState === QUOTE_STATUSES.APPROVED; } /** diff --git a/src/app/models/quote.constants.ts b/src/app/models/quote.constants.ts index 65717951..b6f36646 100644 --- a/src/app/models/quote.constants.ts +++ b/src/app/models/quote.constants.ts @@ -15,13 +15,89 @@ export const QUOTE_CATEGORIES = { export type QuoteCategoryType = typeof QUOTE_CATEGORIES[keyof typeof QUOTE_CATEGORIES]; /** - * Backend Tender category constants (backend/Tender model) + * Quote status constants (stored in quote.quoteItem[0].state) */ -export const TENDER_CATEGORIES = { - TENDERING: 'tendering', - COORDINATOR: 'coordinator' +export const QUOTE_STATUSES = { + PENDING: 'pending', + IN_PROGRESS: 'inProgress', + APPROVED: 'approved', + ACCEPTED: 'accepted', + CANCELLED: 'cancelled', + REJECTED: 'rejected' +} as const; + +export type QuoteStatus = typeof QUOTE_STATUSES[keyof typeof QUOTE_STATUSES]; + +/** + * Tender status constants (frontend display states) + */ +export const TENDER_COORDINATOR_STATUSES_LABELS = { + PENDING: 'not-yet-submitted', + IN_PROGRESS: 'invites-sent-waiting-acceptance', + APPROVED: 'tender-started', + ACCEPTED: 'tender-closed', + CANCELLED: 'cancelled', + REJECTED: 'rejected' +} as const; + +export type TenderCoordinatorStatusesLabel = typeof TENDER_COORDINATOR_STATUSES_LABELS[keyof typeof TENDER_COORDINATOR_STATUSES_LABELS]; + +/** + * Tender status constants (frontend display states) + */ +export const TENDER_RELATED_QUOTES_LABELS_CUSTOMER = { + PENDING: 'invite-sent', + IN_PROGRESS: 'invite-accepted-by-provider', + APPROVED: 'offer-submitted-by-provider', + ACCEPTED: 'offering-accepted', + CANCELLED: 'request-canceled', + REJECTED: 'offering-rejected' } as const; +export type TenderRelatedQuotesLabelsCustomer = typeof TENDER_RELATED_QUOTES_LABELS_CUSTOMER[keyof typeof TENDER_RELATED_QUOTES_LABELS_CUSTOMER]; + +/** + * Tender status constants (frontend display states) + */ +export const TENDER_RELATED_QUOTES_LABELS_PROVIDER = { + PENDING: 'invite-received-to-tender', + IN_PROGRESS: 'invitation-accepted', + APPROVED: 'offering-submitted', + ACCEPTED: 'offering-accepted-by-customer', + CANCELLED: 'request-canceled', + REJECTED: 'offering-rejected' +} as const; + +export type TenderRelatedQuotesLabelsProvider = typeof TENDER_RELATED_QUOTES_LABELS_PROVIDER[keyof typeof TENDER_RELATED_QUOTES_LABELS_PROVIDER]; + +/** + * Tender status constants (frontend display states) + */ +export const TAILORED_STATUSES_LABELS_CUSTOMER = { + PENDING: 'request-sent-awaiting-feedback', + IN_PROGRESS: 'request-accepted-and-being-worked-on', + APPROVED: 'offering-submitted-by-provider', + ACCEPTED: 'offering-accepted', + CANCELLED: 'request-canceled', + REJECTED: 'rejected' +} as const; + +export type TailoredStatusesLabelsCustomer = typeof TAILORED_STATUSES_LABELS_CUSTOMER[keyof typeof TAILORED_STATUSES_LABELS_CUSTOMER]; + +/** + * Tender status constants (frontend display states) + */ +export const TAILORED_STATUSES_LABELS_PROVIDER = { + PENDING: 'request-received-pending-feedback', + IN_PROGRESS: 'request-accepted', + APPROVED: 'offering-submitted', + ACCEPTED: 'offering-accepted-by-customer', + CANCELLED: 'request-canceled', + REJECTED: 'rejected' +} as const; + +export type TailoredStatusesLabelsProvider = typeof TAILORED_STATUSES_LABELS_PROVIDER[keyof typeof TAILORED_STATUSES_LABELS_PROVIDER]; + export interface StatusInfo { explanation: string; availableActions: string; diff --git a/src/app/models/tender.model.ts b/src/app/models/tender.model.ts index 1dc774a2..7e96a849 100644 --- a/src/app/models/tender.model.ts +++ b/src/app/models/tender.model.ts @@ -11,8 +11,9 @@ export interface Tender { tenderNote?: string; attachment?: TenderAttachment; selectedProviders: string[]; - external_id?: string; // ID of parent tender (for child tenders) - provider?: string; // Provider name (for child tenders) + external_id?: string; // ID of parent tender (for child tenders) + provider?: string; // Provider name (for child tenders) + buyerPartyId?: string; // Buyer org URN (for child tenders, used in provider view) createdAt?: string; updatedAt?: string; diff --git a/src/app/shared/quote-details-modal/quote-details-modal.component.ts b/src/app/shared/quote-details-modal/quote-details-modal.component.ts index 9f6cf77e..7faf7755 100644 --- a/src/app/shared/quote-details-modal/quote-details-modal.component.ts +++ b/src/app/shared/quote-details-modal/quote-details-modal.component.ts @@ -9,7 +9,7 @@ import { NotificationService } from '../../services/notification.service'; import { AccountServiceService } from '../../services/account-service.service'; import { ApiServiceService } from '../../services/product-service.service'; import { Quote } from '../../models/quote.model'; -import { QUOTE_STATUS_MESSAGES, TENDERING_STATUS_MESSAGES, COORDINATOR_STATUS_MESSAGES, QUOTE_CHAT_MESSAGES, QUOTE_ACTION_BUTTON_TEXTS, QUOTE_CATEGORIES } from '../../models/quote.constants'; +import { QUOTE_STATUS_MESSAGES, TENDERING_STATUS_MESSAGES, COORDINATOR_STATUS_MESSAGES, QUOTE_CHAT_MESSAGES, QUOTE_ACTION_BUTTON_TEXTS, QUOTE_CATEGORIES, QUOTE_STATUSES, TAILORED_STATUSES_LABELS_CUSTOMER, TAILORED_STATUSES_LABELS_PROVIDER, TENDER_COORDINATOR_STATUSES_LABELS, TENDER_RELATED_QUOTES_LABELS_CUSTOMER, TENDER_RELATED_QUOTES_LABELS_PROVIDER } from '../../models/quote.constants'; import { API_ROLES } from '../../models/roles.constants'; import { NotificationComponent } from '../notification/notification.component'; import { ChatModalComponent } from '../chat-modal/chat-modal.component'; @@ -161,7 +161,7 @@ import { environment } from 'src/environments/environment';

The quote is in status:

- {{ getPrimaryState() }} + {{ getStatusLabel() }}

{{ getStatusExplanation() }}

@@ -223,7 +223,7 @@ import { environment } from 'src/environments/environment';
-
+

{{ getQuoteCategory() === QUOTE_CATEGORIES.TENDER ? ACTION_TEXTS.ACCEPT_TENDER_INVITE : ACTION_TEXTS.ACCEPT_QUOTE_PROVIDER }}

@@ -241,7 +241,7 @@ import { environment } from 'src/environments/environment';
-
+

{{ ACTION_TEXTS.ACCEPT_PROPOSAL_CUSTOMER }}

- - - -
-
-
- - - - -
-
- - -
- - -
- - -
- {{ getTruncatedTitle(quote.description) }} -
- - -
- - {{ getQuoteItemState(quote) }} - -
- - -
- {{ quote.expectedFulfillmentStartDate | date:'dd/MM/yyyy' }} -
- - -
- {{ quote.effectiveQuoteCompletionDate | date:'dd/MM/yyyy' }} -
- - -
-
- -
-
-
@@ -523,39 +352,6 @@ import { QUOTE_CATEGORIES, TENDER_CATEGORIES } from 'src/app/models/quote.consta Start - - - - - - - - -
-
- - -
-

Related Provider Quotes

-

Related Provider Quotes

- +
Loading related quotes... -
- Loading related quotes...
- -
-
-
-
Provider
Provider
Status
Attachments
Actions
-
Attachments
-
Actions
-
- [class.border-gray-200]="!last" - [class.dark:border-gray-600]="!last">
-
{{ getProviderName(relatedQuote) }}
-
- -
- - -
+
-
-
@@ -714,7 +465,6 @@ import { QUOTE_CATEGORIES, TENDER_CATEGORIES } from 'src/app/models/quote.consta
-
@@ -740,17 +490,6 @@ import { QUOTE_CATEGORIES, TENDER_CATEGORIES } from 'src/app/models/quote.consta (cancel)="showGenericConfirm = false" > - - -
@@ -758,11 +497,11 @@ import { QUOTE_CATEGORIES, TENDER_CATEGORIES } from 'src/app/models/quote.consta

Update Quote State

-