From 6b214d9cf98f8b6fe27fa43390f25c9b0c2d7e2d Mon Sep 17 00:00:00 2001 From: BazRoe Date: Tue, 9 Dec 2025 13:16:42 +0100 Subject: [PATCH 1/5] Implement Tenders feature with routing, module, and component setup - Added a new route for 'tenders' in the app-routing module. - Created TendersModule to encapsulate tender-related components and routes. - Introduced TendersRoutes for managing tender navigation. - Developed TenderListComponent for displaying and managing tenders. - Added role constants for API and UI roles to facilitate role-based access. - Established Tender model to represent tender data structure. - Implemented CreateTenderModalComponent for creating and managing tender submissions. - Updated header component to include navigation to the new Tenders section. --- src/app/app-routing.module.ts | 6 + .../tender-list/tender-list.component.ts | 1942 +++++++++++++++++ src/app/features/tenders/tenders.module.ts | 20 + src/app/features/tenders/tenders.routes.ts | 10 + src/app/models/roles.constants.ts | 41 + src/app/models/tender.model.ts | 55 + .../create-tender-modal.component.ts | 873 ++++++++ src/app/shared/header/header.component.html | 3 + 8 files changed, 2950 insertions(+) create mode 100644 src/app/features/tenders/pages/tender-list/tender-list.component.ts create mode 100644 src/app/features/tenders/tenders.module.ts create mode 100644 src/app/features/tenders/tenders.routes.ts create mode 100644 src/app/models/roles.constants.ts create mode 100644 src/app/models/tender.model.ts create mode 100644 src/app/shared/create-tender-modal/create-tender-modal.component.ts diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index a7d1239c..ad043153 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -93,6 +93,12 @@ const routes: Routes = [ component: QuoteListComponent, canActivate: [AuthGuard, quoteGuardGuard], data: { roles: [] } }, + { + path: 'tenders', + loadChildren: () => import('./features/tenders/tenders.module').then(m => m.TendersModule), + canActivate: [AuthGuard, quoteGuardGuard], + data: { roles: [] } + }, { path: 'usage-spec', component: UsageSpecsComponent, 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 new file mode 100644 index 00000000..0602cbb3 --- /dev/null +++ b/src/app/features/tenders/pages/tender-list/tender-list.component.ts @@ -0,0 +1,1942 @@ +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { Observable, forkJoin, map } from 'rxjs'; +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 { Tender, TenderAttachment } from 'src/app/models/tender.model'; +import { Quote, QuoteStateType } from 'src/app/models/quote.model'; +import { LoginInfo } from 'src/app/models/interfaces'; +import { NotificationComponent } from 'src/app/shared/notification/notification.component'; +import { ConfirmDialogComponent } from 'src/app/shared/confirm-dialog/confirm-dialog.component'; +import { QuoteDetailsModalComponent } from 'src/app/shared/quote-details-modal/quote-details-modal.component'; +import { ChatModalComponent } from 'src/app/shared/chat-modal/chat-modal.component'; +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'; + +@Component({ + selector: 'app-quote-list', + standalone: true, + imports: [ + CommonModule, + FormsModule, + NotificationComponent, + ConfirmDialogComponent, + QuoteDetailsModalComponent, + ChatModalComponent, + AttachmentModalComponent, + CreateTenderModalComponent + ], + template: ` + + +
+
+

Tenders

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + Filter by status + +
+
+ + +
+
+
+ + +
+
+
+ + + +
+
+

Error loading quotes

+

{{ error }}

+
+
+
+ + +
+
+ + + +

No quotes found

+

No orders found

+
+ + +
+
+
DETAILS
+
TITLE
+
STATUS
+
Expected Fulfillment Start Date
+
Effective Quote Completion Date
+
ATTACHMENTS
+
REQUEST
+
ACTIONS
+
+
+ + +
+
+ + +
+ + + + + +
+ + +
+ {{ quote.description || '(no title)' }} +
+ + +
+ + {{ getQuoteItemState(quote) }} + +
+ + +
+ {{ quote.expectedFulfillmentStartDate | date:'dd/MM/yyyy' }} +
+ + +
+ {{ quote.effectiveQuoteCompletionDate | date:'dd/MM/yyyy' }} +
+ + +
+ +
+ + + +
+ + + +
+ + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+

Related Provider Quotes

+ + +
+
+ Loading related quotes... +
+ + +
+ +
+
+
Details
+
Provider
+
Status
+
Attachments
+
Actions
+
+
+ + +
+
+ +
+ +
+ + +
+ {{ getProviderName(relatedQuote) }} +
+ + +
+ + {{ getQuoteItemState(relatedQuote) }} + +
+ + +
+ +
+ + +
+ + + + + + + + +
+
+
+
+ + +
+ + + +

No related provider quotes found

+
+
+
+
+
+
+ + + + + +
+
+
+

Update Quote State

+
+
+ + +
+
+
+ + +
+
+
+
+ + + + + + + + + + + +
+
+
+

Broadcast Message

+ +
+ + +
+
+
+
+ + + + `, + styles: [` + .status-badge { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; + } + + .status-pending { + @apply bg-yellow-100 text-yellow-800; + } + + .status-inProgress { + @apply bg-blue-100 text-blue-800; + } + + .status-approved { + @apply bg-green-100 text-green-800; + } + + .status-rejected { + @apply bg-red-100 text-red-800; + } + + .status-cancelled { + @apply bg-gray-100 text-gray-800; + } + + .status-accepted { + @apply bg-teal-100 text-teal-800; + } + + .status-unknown { + @apply bg-gray-100 text-gray-600; + } + + .status-draft { + @apply bg-yellow-100 text-yellow-800; + } + + .status-pre-launched { + @apply bg-blue-100 text-blue-800; + } + + .status-launched { + @apply bg-green-100 text-green-800; + } + + .status-closed { + @apply bg-gray-100 text-gray-800; + } + + .rotate-180 { + transform: rotate(180deg); + } + `] +}) +export class TenderListComponent implements OnInit { + private router = inject(Router); + private quoteService = inject(QuoteService); + private localStorage = inject(LocalStorageService); + private notificationService = inject(NotificationService); + + quotes: Quote[] = []; + filteredQuotes: Quote[] = []; + loading = false; + error: string | null = null; + showDeleteConfirm = false; + deleteConfirmMessage = ''; + quoteToDelete: Quote | null = null; + + // State update modal + showStateUpdate = false; + quoteToUpdate: Quote | null = null; + selectedState: QuoteStateType | null = null; + availableStates: QuoteStateType[] = ['pending', 'inProgress', 'approved', 'rejected', 'cancelled', 'accepted']; + + // Role management + selectedRole: UiRole = UI_ROLES.BUYER; + currentUserId: string | null = null; + + // Expose constants to template + readonly UI_ROLES = UI_ROLES; + + // Filtering + statusFilter: string = ''; + + // Quote Details Modal + showQuoteDetailsModal = false; + selectedQuoteId: string | null = null; + + // Chat Modal + showChatModal = false; + selectedChatQuoteId: string | null = null; + + // Attachment Modal + showAttachmentModal = false; + selectedAttachmentQuote: Quote | null = null; + + // Broadcast Message Modal + showBroadcastModal = false; + broadcastForCoordinatorId: string | null = null; + broadcastMessage: string = ''; + isBroadcastSending = false; + + // Create Tender Modal + showCreateTenderModal = false; + + // Expanded rows for coordinator quotes + expandedQuoteIds: Set = new Set(); + relatedQuotesMap: Map = new Map(); + loadingRelatedQuotes: Set = new Set(); + + // Coordinator quote states cache + coordinatorQuoteStatesMap: Map = new Map(); + loadingCoordinatorStates: Set = new Set(); + + + + ngOnInit() { + // Get user ID from localStorage (BAE Frontend pattern) + let aux = this.localStorage.getObject('login_items') as LoginInfo; + if (aux && aux.logged_as == aux.id) { + this.currentUserId = aux.partyId; + } else if (aux && aux.logged_as) { + let loggedOrg = aux.organizations.find((element: { id: any; }) => element.id == aux.logged_as); + this.currentUserId = loggedOrg?.partyId; + } + + if (this.currentUserId) { + this.loadQuotes(); + } else { + this.error = 'User not authenticated'; + } + } + + loadQuotes() { + if (!this.currentUserId) { + this.error = 'User not authenticated'; + return; + } + + this.loading = true; + this.error = null; + + // Use specific API endpoints based on role + let quotesObservable: Observable; + + if (this.selectedRole === UI_ROLES.BUYER) { + // Buyer view: Get coordinator quotes they created + quotesObservable = this.quoteService.getCoordinatorQuotesByUser(this.currentUserId).pipe( + map((tenders: Tender[]) => tenders.map((t: Tender) => this.mapTenderToQuote(t))) + ); + } else { + // Seller/Provider view: Get tendering quotes they received + quotesObservable = this.quoteService.getTenderingQuotesByUser(this.currentUserId, toApiRole(this.selectedRole)).pipe( + map((tenders: Tender[]) => tenders.map((t: Tender) => this.mapTenderToQuote(t))) + ); + } + + quotesObservable.subscribe({ + next: (quotes: Quote[]) => { + // Use quotes as-is to preserve quoteItem.state + this.quotes = quotes; + + // Debug: Log quote states and externalId + console.log(`Loaded ${this.quotes.length} quotes as ${this.selectedRole}`); + console.log(`Current user ID: ${this.currentUserId}`); + + this.quotes.forEach(quote => { + console.log(`Quote ${this.extractShortId(quote.id)}:`, { + category: quote.category, + state: quote.state, + quoteItemState: this.getQuoteItemState(quote), + externalId: quote.externalId, + id: quote.id, + relatedParty: quote.relatedParty + }); + + // For provider view, check if this quote is related to current user + if (this.selectedRole === UI_ROLES.SELLER) { + const isRelatedToUser = quote.relatedParty?.some(party => + party.id === this.currentUserId && party.role?.toLowerCase() === UI_ROLES.SELLER + ); + console.log(` -> Quote ${this.extractShortId(quote.id)} related to current provider? ${isRelatedToUser}`); + } + }); + + // If in seller mode, load coordinator states for tendering quotes + if (this.selectedRole === UI_ROLES.SELLER) { + this.loadCoordinatorStatesForTenderingQuotes(); + } + + this.filterQuotesByStatus(); + this.loading = false; + }, + error: (error: any) => { + console.error('Failed to load quotes:', error); + this.error = 'Failed to load quotes. Please try again.'; + this.loading = false; + } + }); + } + + private mapTenderToQuote(tender: Tender): Quote { + return { + id: tender.id, + href: '', + description: tender.tenderNote || '', + quoteDate: tender.createdAt || new Date().toISOString(), + effectiveQuoteCompletionDate: tender.effectiveQuoteCompletionDate, + expectedFulfillmentStartDate: tender.expectedFulfillmentStartDate, + state: this.mapTenderStateToQuoteState(tender.state), + category: tender.category, + externalId: tender.external_id, + relatedParty: tender.selectedProviders.map(id => ({ + id, + role: 'Seller', + name: tender.provider, + '@referredType': 'Organization' + })), + // Provide a minimal quoteItem array carrying the state so the UI can display it. + // We intentionally cast to any to avoid enforcing the full TMF structure here. + quoteItem: [ + { state: this.mapTenderStateToQuoteState(tender.state) } as any + ], + note: [] + }; + } + + private mapTenderStateToQuoteState(tenderState: 'draft' | 'pre-launched' | 'pending' | 'sent' | 'closed'): QuoteStateType { + switch (tenderState) { + case 'draft': return 'inProgress'; + case 'pending': return 'pending'; + case 'sent': return 'approved'; + case 'closed': return 'accepted'; + default: return 'inProgress'; + } + } + + refreshQuotes() { + this.loadQuotes(); + } + + selectRole(role: UiRole) { + this.selectedRole = role; + this.loadQuotes(); + } + + getRoleTabClass(role: UiRole): string { + return this.selectedRole === role + ? 'bg-white dark:bg-gray-600 text-indigo-600 dark:text-blue-400 shadow-sm' + : 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-200'; + } + + filterQuotesByStatus() { + if (!this.statusFilter) { + this.filteredQuotes = [...this.quotes]; + } else { + this.filteredQuotes = this.quotes.filter(quote => { + const primaryState = this.getPrimaryState(quote); + return primaryState === this.statusFilter; + }); + } + } + + + viewDetails(quote: Quote) { + this.selectedQuoteId = quote.id!; + this.showQuoteDetailsModal = true; + } + + editTender(quote: Quote) { + // Extract attachment from quoteItem if it exists + let attachment: TenderAttachment | undefined = undefined; + if (Array.isArray(quote.quoteItem) && quote.quoteItem.length > 0) { + const firstItem = quote.quoteItem[0]; + if (Array.isArray(firstItem.attachment) && firstItem.attachment.length > 0) { + const att = firstItem.attachment[0]; + attachment = { + name: att.name || 'attachment.pdf', + mimeType: att.mimeType || 'application/pdf', + content: att.content || '', + size: att.size?.amount + }; + console.log('Extracted attachment for edit:', attachment.name); + } + } + + // Convert Quote to Tender format for editing + const tender: Tender = { + id: quote.id, + category: quote.category === 'coordinator' ? 'coordinator' : 'tendering', + state: this.mapQuoteStateToTenderState(quote.state), + responseDeadline: quote.expectedFulfillmentStartDate || quote.effectiveQuoteCompletionDate || new Date().toISOString(), + tenderNote: quote.description || '', + attachment: attachment, + selectedProviders: quote.relatedParty?.filter(p => p.role?.toLowerCase() === 'seller').map(p => p.id) || [], + effectiveQuoteCompletionDate: quote.effectiveQuoteCompletionDate, + expectedFulfillmentStartDate: quote.expectedFulfillmentStartDate + }; + + console.log('Navigating to edit tender with data:', tender); + + // Navigate to providers page with tender data + this.router.navigate(['/providers'], { + state: { tender } + }); + } + + private mapQuoteStateToTenderState(quoteState: QuoteStateType | undefined): 'draft' | 'pre-launched' | 'pending' | 'sent' | 'closed' { + if (!quoteState) return 'draft'; + + switch (quoteState) { + case 'inProgress': return 'draft'; + case 'pending': return 'pending'; + case 'approved': return 'sent'; + case 'accepted': + case 'cancelled': + case 'rejected': return 'closed'; + default: return 'draft'; + } + } + + viewQuote(quote: Quote) { + this.selectedQuoteId = quote.id!; + this.showQuoteDetailsModal = true; + } + + closeQuoteDetailsModal() { + this.showQuoteDetailsModal = false; + this.selectedQuoteId = null; + } + + closeChatModal() { + this.showChatModal = false; + this.selectedChatQuoteId = null; + } + + closeAttachmentModal() { + this.showAttachmentModal = false; + this.selectedAttachmentQuote = null; + } + + // ============================= + // Broadcast Message Handlers + // ============================= + openBroadcastModal(coordinatorQuote: Quote) { + if (!coordinatorQuote.id) return; + this.broadcastForCoordinatorId = coordinatorQuote.id; + this.broadcastMessage = ''; + this.showBroadcastModal = true; + } + + closeBroadcastModal() { + this.showBroadcastModal = false; + this.broadcastForCoordinatorId = null; + this.broadcastMessage = ''; + this.isBroadcastSending = false; + } + + // Create Tender Modal Methods + openCreateTenderModal() { + if (!this.currentUserId) { + this.notificationService.showError('You must be logged in to create a tender.'); + return; + } + this.showCreateTenderModal = true; + } + + closeCreateTenderModal() { + this.showCreateTenderModal = false; + } + + onTenderCreated(tender: Tender) { + console.log('Tender created successfully:', tender); + // Refresh the quotes list to show the new tender + this.loadQuotes(); + this.notificationService.showSuccess('Tender created successfully!'); + } + + sendBroadcastMessage() { + if (!this.broadcastForCoordinatorId || !this.currentUserId || !this.broadcastMessage) { + return; + } + + const confirmSend = confirm('Are you sure you want to broadcast this message to all the invited providers?'); + if (!confirmSend) return; + + this.isBroadcastSending = true; + + // Ensure related quotes are loaded + 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); + if (coordinator) { + this.loadRelatedQuotes(coordinator); + } + } + + const quotesToMessage = this.getRelatedQuotes(this.broadcastForCoordinatorId).filter(q => q.category === 'tender'); + if (quotesToMessage.length === 0) { + this.notificationService.showError('No related provider quotes found to broadcast to.'); + this.isBroadcastSending = false; + return; + } + + const requests = quotesToMessage.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; + } + }); + } + + onAttachmentUploaded(updatedQuote: Quote) { + // Update the quote in the list + const index = this.quotes.findIndex(q => q.id === updatedQuote.id); + if (index !== -1) { + this.quotes[index] = updatedQuote; + this.filterQuotesByStatus(); + } + + // 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') { + console.log('Provider uploaded PDF, updating quote status to approved:', updatedQuote.id); + + this.quoteService.updateQuoteStatus(updatedQuote.id!, 'approved').subscribe({ + next: (approvedQuote: Quote) => { + // Update the quote again with the new status + const approvedIndex = this.quotes.findIndex(q => q.id === approvedQuote.id); + if (approvedIndex !== -1) { + this.quotes[approvedIndex] = approvedQuote; + this.filterQuotesByStatus(); + } + + const shortId = this.extractShortId(updatedQuote.id); + console.log('Quote status automatically updated to approved after PDF upload'); + this.notificationService.showSuccess(`Quote ${shortId} has been approved after PDF upload.`); + }, + error: (error: any) => { + console.error('Error updating quote status to approved:', error); + this.notificationService.showError(`Error updating quote status: ${error?.message || 'Unknown error'}`); + } + }); + } + } + + + + + + + + updateQuoteState(quote: Quote) { + this.quoteToUpdate = quote; + this.selectedState = quote.state || null; + this.showStateUpdate = true; + } + + confirmStateUpdate() { + if (this.quoteToUpdate && this.selectedState) { + this.quoteService.updateQuoteState(this.quoteToUpdate.id!, this.selectedState).subscribe({ + next: (updatedQuote: Quote) => { + const index = this.quotes.findIndex(q => q.id === updatedQuote.id); + if (index !== -1) { + this.quotes[index] = updatedQuote; + this.filterQuotesByStatus(); + } + this.showStateUpdate = false; + this.notificationService.showSuccess('Quote state updated successfully'); + }, + error: (error: any) => { + console.error('Failed to update quote state:', error); + this.notificationService.showError('Failed to update quote state'); + } + }); + } + } + + confirmDelete(quote: Quote) { + this.quoteToDelete = quote; + this.deleteConfirmMessage = `Are you sure you want to delete Quote ${this.extractShortId(quote.id)}? This action cannot be undone.`; + this.showDeleteConfirm = true; + } + + deleteQuote() { + if (this.quoteToDelete) { + this.quoteService.deleteQuote(this.quoteToDelete.id!).subscribe({ + next: () => { + this.quotes = this.quotes.filter(q => q.id !== this.quoteToDelete!.id); + this.filterQuotesByStatus(); + this.showDeleteConfirm = false; + this.notificationService.showSuccess('Quote deleted successfully'); + }, + error: (error: any) => { + console.error('Failed to delete quote:', error); + this.notificationService.showError('Failed to delete quote'); + } + }); + } + } + + // Quote action methods (migrated from QuoteRow.js) + openChat(quote: Quote) { + // Open chat modal for messaging + this.selectedChatQuoteId = quote.id!; + this.showChatModal = true; + } + + downloadAttachment(quote: Quote) { + try { + this.quoteService.downloadAttachment(quote); + this.notificationService.showSuccess('Download started'); + } catch (error: any) { + console.error('Error downloading attachment:', error); + this.notificationService.showError(error.message || 'Error downloading attachment'); + } + } + + /** + * Download the buyer's request PDF from the coordinator quote referenced by a tender quote + */ + downloadCustomerRequest(tenderQuote: Quote) { + const coordinatorId = tenderQuote.externalId || tenderQuote.id; + if (!coordinatorId) { + this.notificationService.showError('No coordinator reference found for this quote.'); + return; + } + + this.quoteService.getQuoteById(coordinatorId).subscribe({ + next: (coordinator: Quote) => { + try { + // Reuse download logic by wrapping the attachment into a Quote-like structure + if (!coordinator.quoteItem || coordinator.quoteItem.length === 0 || + !coordinator.quoteItem[0].attachment || coordinator.quoteItem[0].attachment.length === 0) { + this.notificationService.showError(`No buyer's request attachment found on coordinator quote.`); + return; + } + // Use existing helper that handles both Tender and Quote types + this.quoteService.downloadAttachment(coordinator); + this.notificationService.showSuccess(`Download started`); + } catch (err: any) { + console.error('Error downloading buyer request:', err); + this.notificationService.showError(err.message || 'Error downloading buyer request'); + } + }, + error: (error: Error) => { + console.error('Failed to fetch coordinator quote for download:', error); + this.notificationService.showError('Failed to fetch coordinator quote'); + } + }); + } + + addAttachment(quote: Quote) { + // Open the attachment modal + this.selectedAttachmentQuote = quote; + this.showAttachmentModal = true; + } + + 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.`); + }, + error: (error: Error) => { + console.error('Error accepting tendering quote:', error); + this.notificationService.showError(`Error accepting tender request: ${error.message || 'Unknown error'}`); + } + }); + } + + 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.`); + }, + error: (error: Error) => { + console.error('Error cancelling tendering quote:', error); + this.notificationService.showError(`Error cancelling tender request: ${error.message || 'Unknown error'}`); + } + }); + } + + 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).`); + }, + error: (error: Error) => { + console.error('[TEST] Error starting tender:', error); + this.notificationService.showError(`Error starting tender: ${error.message || 'Unknown error'}`); + } + }); + } + + 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).`); + }, + error: (error: Error) => { + console.error('[TEST] Error closing tender:', error); + this.notificationService.showError(`Error closing tender: ${error.message || 'Unknown error'}`); + } + }); + } + + 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.`); + }, + error: (error: any) => { + console.error('Error accepting quotation:', error); + this.notificationService.showError(`Error accepting quotation: ${error?.message || 'Unknown error'}`); + } + }); + } + + 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; + + console.log('Buyer accepting tender:', quote.id); + + // Helper: find coordinator id (map key) that contains this related quote + const findCoordinatorKeyForRelated = (): string | null => { + // Prefer externalId if present + if (quote.externalId) return quote.externalId; + for (const [coordId, relatedList] of this.relatedQuotesMap.entries()) { + if (relatedList.some(r => r.id === quote.id)) return coordId; + } + return null; + }; + + const coordinatorKey = findCoordinatorKeyForRelated(); + + // First accept selected quote + this.quoteService.updateQuoteStatus(quote.id!, 'accepted').subscribe({ + next: (acceptedQuote: Quote) => { + // If we have a coordinator group, reject all others in parallel + if (coordinatorKey) { + 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'; + }); + + if (toReject.length > 0) { + const rejectCalls = toReject.map(sib => this.quoteService.updateQuoteStatus(sib.id!, 'rejected')); + forkJoin(rejectCalls).subscribe({ + next: (rejectedQuotes: Quote[]) => { + // Update related quotes map with returned objects + const current = this.relatedQuotesMap.get(coordinatorKey) || []; + const updatedList = current.map(item => { + if (item.id === acceptedQuote.id) return acceptedQuote; + const updated = rejectedQuotes.find(r => r.id === item.id); + return updated ? updated : item; + }); + this.relatedQuotesMap.set(coordinatorKey, updatedList); + + // UI notify + this.notificationService.showSuccess(`Tender ${shortId} accepted. ${rejectedQuotes.length} other quote(s) have been rejected.`); + }, + error: (err: Error) => { + console.error('Error rejecting sibling quotes:', err); + this.notificationService.showError(`Accepted the selected quote, but failed rejecting other quotes: ${err.message || 'Unknown error'}`); + } + }); + } else { + // No siblings to reject; still update the accepted one in the list + const current = this.relatedQuotesMap.get(coordinatorKey) || []; + const updatedList = current.map(item => item.id === acceptedQuote.id ? acceptedQuote : item); + this.relatedQuotesMap.set(coordinatorKey, updatedList); + this.notificationService.showSuccess(`Tender ${shortId} has been accepted successfully.`); + } + } else { + // Not in related table context; fall back to updating main list if present + const index = this.quotes.findIndex(q => q.id === acceptedQuote.id); + if (index !== -1) { + this.quotes[index] = acceptedQuote; + this.filterQuotesByStatus(); + } + this.notificationService.showSuccess(`Tender ${shortId} has been accepted successfully.`); + } + }, + error: (error: any) => { + console.error('Error accepting tender:', error); + this.notificationService.showError(`Error accepting tender: ${error?.message || 'Unknown error'}`); + } + }); + } + + 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.`); + }, + error: (error: any) => { + console.error('Error rejecting tender:', error); + this.notificationService.showError(`Error rejecting tender: ${error?.message || 'Unknown error'}`); + } + }); + } + + // 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.`); + }, + error: (error: any) => { + console.error('Error cancelling quote:', error); + this.notificationService.showError(`Error cancelling quote: ${error?.message || 'Unknown error'}`); + } + }); + } + + // Utility methods (migrated from QuoteRow.js) + extractShortId(id: string | undefined): string { + if (!id) return 'N/A'; + // Extract last 8 characters or return full ID if shorter + return id.length > 8 ? id.slice(-8) : id; + } + + getPrimaryState(quote: Quote): string { + // First try quoteItem state (this is where the actual state is stored) + 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'; + } + + 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) { + if (item && (item as any).state) { + state = (item as any).state as string; + break; + } + } + } + + // Apply mapping only for coordinator quotes + if (quote.category === 'coordinator') { + return this.mapCoordinatorStatusToGUI(state); + } + + return state; + } + + /** + * Map coordinator quote status from backend (TMF) to frontend (GUI) display + * Only for coordinator quotes + */ + mapCoordinatorStatusToGUI(backendStatus: string): string { + const mapping: { [key: string]: string } = { + 'pending': 'draft', + 'inProgress': 'pre-launched', + 'approved': 'launched', + 'accepted': 'closed', + 'cancelled': 'cancelled', + 'rejected': 'rejected' + }; + return mapping[backendStatus] || backendStatus; + } + + hasAttachment(quote: Quote): boolean { + return Array.isArray(quote.quoteItem) && + quote.quoteItem.some(qi => qi.attachment && qi.attachment.length > 0); + } + + getAttachmentName(quote: Quote): string { + if (!Array.isArray(quote.quoteItem)) { + return ''; + } + + for (const item of quote.quoteItem) { + if (item.attachment && item.attachment.length > 0) { + return item.attachment[0].name || 'attachment.pdf'; + } + } + + return ''; + } + + 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'; + } + + 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'; + } + + isQuoteFinalized(quote: Quote): boolean { + return this.isQuoteCancelled(quote) || this.isQuoteAccepted(quote); + } + + isActionDisabled(quote: Quote, actionType: string): boolean { + const isCancelled = this.isQuoteCancelled(quote); + const isAccepted = this.isQuoteAccepted(quote); + const isFinalized = this.isQuoteFinalized(quote); + + switch (actionType) { + case 'viewDetails': + case 'chat': + return isCancelled; // Only disabled for cancelled quotes + case 'addAttachment': + case 'cancel': + return isFinalized; // Disabled for both accepted and cancelled + case 'downloadAttachment': + return isCancelled; // Only disabled for cancelled quotes, customers can download when accepted + case 'accept': + // Accept button is only for providers when quote is pending + // It should not be disabled by finalization since it only shows when pending + return false; + case 'acceptCustomer': + // Buyer accept button is only for buyers when quote is approved + // It should not be disabled by finalization since it only shows when approved + return false; + case 'acceptTender': + case 'rejectTender': + // Tender accept/reject buttons are only for buyers when tender quote is approved + // They should not be disabled by finalization since they only show when approved + return false; + case 'addRequestedDate': + case 'addExpectedDate': + return true; + default: + return false; + } + } + + getButtonClass(quote: Quote, actionType: string): string { + const baseClass = 'px-2 py-1 text-xs font-medium transition-colors rounded border'; + + if (this.isActionDisabled(quote, actionType)) { + return `${baseClass} text-gray-400 cursor-not-allowed border-gray-200`; + } + + switch (actionType) { + case 'viewDetails': + return `${baseClass} text-blue-600 hover:text-blue-800 border-blue-200 hover:bg-blue-50`; + default: + return `${baseClass} text-indigo-600 hover:text-indigo-800 border-indigo-200 hover:bg-indigo-50`; + } + } + + getIconButtonClass(quote: Quote, actionType: string, normalColor: string): string { + const baseClass = 'p-1.5 text-xs cursor-pointer rounded hover:bg-gray-100 transition-colors'; + + if (this.isActionDisabled(quote, actionType)) { + return `${baseClass} text-gray-400 cursor-not-allowed hover:bg-transparent`; + } + + return `${baseClass} ${normalColor}`; + } + + getActionTitle(quote: Quote, actionType: string): string { + if (this.isActionDisabled(quote, actionType)) { + const status = this.isQuoteCancelled(quote) ? 'cancelled' : 'accepted'; + return `Action disabled - quote is ${status}`; + } + return ''; + } + + 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' + }; + + return stateMap[state] || state; + } + + getStateClass(state: string): string { + switch (state) { + case 'pending': + return 'status-pending'; + case 'inProgress': + return 'status-inProgress'; + case 'approved': + return 'status-approved'; + case 'rejected': + return 'status-rejected'; + case 'cancelled': + return 'status-cancelled'; + case 'accepted': + return 'status-accepted'; + // Coordinator quote states (mapped) + case 'draft': + return 'status-draft'; + case 'pre-launched': + return 'status-pre-launched'; + case 'launched': + return 'status-launched'; + case 'closed': + return 'status-closed'; + default: + return 'status-unknown'; + } + } + + canUpdateState(state: QuoteStateType | undefined): boolean { + return state !== 'cancelled' && state !== 'accepted'; + } + + // ======================================== + // EXPAND/COLLAPSE RELATED QUOTES + // ======================================== + + /** + * Check if a coordinator quote is expandable + * (not in pending status, which displays as "draft") + */ + isCoordinatorExpandable(quote: Quote): boolean { + if (quote.category !== 'coordinator') { + return false; + } + + const state = this.getPrimaryState(quote); + + // Expandable if NOT pending (backend state "pending" = GUI display "draft") + // All other states (inProgress/pre-launched, approved/launched, etc.) are expandable + return state !== 'pending'; + } + + /** + * Check if a quote row is expanded + */ + isExpanded(quoteId: string | undefined): boolean { + return quoteId ? this.expandedQuoteIds.has(quoteId) : false; + } + + /** + * Toggle expand/collapse for a coordinator quote + */ + toggleExpand(quote: Quote): void { + if (!quote.id) return; + + const isCurrentlyExpanded = this.expandedQuoteIds.has(quote.id); + + if (isCurrentlyExpanded) { + // Collapse + console.log(`Collapsing quote ${this.extractShortId(quote.id)}`); + this.expandedQuoteIds.delete(quote.id); + } else { + // Expand - fetch related quotes if not already loaded + console.log(`Expanding quote ${this.extractShortId(quote.id)}, externalId: ${quote.externalId}`); + this.expandedQuoteIds.add(quote.id); + + if (!this.relatedQuotesMap.has(quote.id)) { + console.log('Fetching related quotes...'); + this.loadRelatedQuotes(quote); + } else { + console.log(`Using cached ${this.relatedQuotesMap.get(quote.id)?.length} related quotes`); + } + } + } + + /** + * Load related tendering quotes for a coordinator quote + */ + private loadRelatedQuotes(coordinatorQuote: Quote): void { + if (!coordinatorQuote.id || !this.currentUserId) { + console.error('Cannot load related quotes: missing id or userId', { + id: coordinatorQuote.id, + userId: this.currentUserId + }); + return; + } + + // For coordinator quotes, use the quote's own ID as the externalId + // because tendering quotes are created with the coordinator quote ID as their externalId + const externalIdToUse = coordinatorQuote.externalId || coordinatorQuote.id; + + console.log(`Loading related quotes for coordinator ${this.extractShortId(coordinatorQuote.id)}:`, { + userId: this.currentUserId, + role: API_ROLES.BUYER, + externalId: externalIdToUse, + coordinatorId: coordinatorQuote.id + }); + + this.loadingRelatedQuotes.add(coordinatorQuote.id); + + // Fetch tendering quotes using the coordinator quote's ID as externalId + this.quoteService.getTenderingQuotesByExternalId( + this.currentUserId, + externalIdToUse, + API_ROLES.BUYER + ).subscribe({ + next: (relatedQuotes: Quote[]) => { + this.relatedQuotesMap.set(coordinatorQuote.id!, relatedQuotes); + this.loadingRelatedQuotes.delete(coordinatorQuote.id!); + 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 => ({ + id: this.extractShortId(q.id), + provider: this.getProviderName(q), + state: this.getQuoteItemState(q) + }))); + } + }, + error: (error: Error) => { + console.error('❌ Failed to load related quotes:', error); + this.loadingRelatedQuotes.delete(coordinatorQuote.id!); + this.notificationService.showError('Failed to load related quotes'); + } + }); + } + + /** + * Get related quotes for a coordinator quote + */ + getRelatedQuotes(quoteId: string | undefined): Quote[] { + if (!quoteId) return []; + return this.relatedQuotesMap.get(quoteId) || []; + } + + /** + * Check if related quotes are loading + */ + isLoadingRelatedQuotes(quoteId: string | undefined): boolean { + return quoteId ? this.loadingRelatedQuotes.has(quoteId) : false; + } + + /** + * 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(', ')); + } + + return provider?.name || provider?.id || 'Unknown Provider'; + } + + /** + * Check if the coordinator quote allows accepting tendering quotes + * Returns true if the coordinator quote is in 'inProgress' state + */ + canAcceptTenderingQuote(tenderingQuote: Quote): boolean { + if (!tenderingQuote.externalId) { + console.warn('Tendering quote has no externalId, cannot check coordinator state'); + return false; + } + + // Check if we have the coordinator state cached + const coordinatorState = this.coordinatorQuoteStatesMap.get(tenderingQuote.externalId); + + if (!coordinatorState) { + // State not loaded yet, load it + this.loadCoordinatorQuoteState(tenderingQuote.externalId); + return false; // Don't show button until state is loaded + } + + // Allow accept only if coordinator is in 'inProgress' state + return coordinatorState === 'inProgress'; + } + + /** + * Check if the coordinator quote allows adding attachments to tendering quotes + * Returns true if the coordinator quote is in 'approved' state + */ + canAddAttachmentToTenderingQuote(tenderingQuote: Quote): boolean { + if (!tenderingQuote.externalId) { + console.warn('Tendering quote has no externalId, cannot check coordinator state'); + return false; + } + + // Check if we have the coordinator state cached + const coordinatorState = this.coordinatorQuoteStatesMap.get(tenderingQuote.externalId); + + if (!coordinatorState) { + // State not loaded yet, load it + this.loadCoordinatorQuoteState(tenderingQuote.externalId); + return false; // Don't show button until state is loaded + } + + // Allow add attachment only if coordinator is in 'approved' state + return coordinatorState === 'approved'; + } + + /** + * Load the state of a coordinator quote + */ + private loadCoordinatorQuoteState(coordinatorQuoteId: string): void { + if (this.loadingCoordinatorStates.has(coordinatorQuoteId) || + this.coordinatorQuoteStatesMap.has(coordinatorQuoteId)) { + return; // Already loading or loaded + } + + console.log(`Loading coordinator quote state for: ${this.extractShortId(coordinatorQuoteId)}`); + this.loadingCoordinatorStates.add(coordinatorQuoteId); + + this.quoteService.getQuoteById(coordinatorQuoteId).subscribe({ + 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})`); + }, + error: (error: Error) => { + console.error(`Failed to load coordinator quote state for ${this.extractShortId(coordinatorQuoteId)}:`, error); + this.loadingCoordinatorStates.delete(coordinatorQuoteId); + // Set to unknown state to prevent repeated failed attempts + this.coordinatorQuoteStatesMap.set(coordinatorQuoteId, 'unknown'); + } + }); + } + + /** + * Load coordinator states for all tendering quotes + */ + private loadCoordinatorStatesForTenderingQuotes(): void { + const externalIds = new Set(); + + // Collect unique externalIds from tendering quotes + this.quotes.forEach(quote => { + if (quote.category === 'tender' && quote.externalId) { + externalIds.add(quote.externalId); + } + }); + + // Load state for each unique coordinator quote + externalIds.forEach(externalId => { + this.loadCoordinatorQuoteState(externalId); + }); + } +} diff --git a/src/app/features/tenders/tenders.module.ts b/src/app/features/tenders/tenders.module.ts new file mode 100644 index 00000000..ab13cfff --- /dev/null +++ b/src/app/features/tenders/tenders.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { tendersRoutes } from './tenders.routes'; +import { TenderListComponent } from './pages/tender-list/tender-list.component'; +import { NotificationComponent } from 'src/app/shared/notification/notification.component'; +import { ConfirmDialogComponent } from 'src/app/shared/confirm-dialog/confirm-dialog.component'; + +@NgModule({ + imports: [ + CommonModule, + RouterModule.forChild(tendersRoutes), + TenderListComponent, + NotificationComponent, + ConfirmDialogComponent + ], + declarations: [] +}) +export class TendersModule { } + diff --git a/src/app/features/tenders/tenders.routes.ts b/src/app/features/tenders/tenders.routes.ts new file mode 100644 index 00000000..4901edc7 --- /dev/null +++ b/src/app/features/tenders/tenders.routes.ts @@ -0,0 +1,10 @@ +import { Routes } from '@angular/router'; +import { TenderListComponent } from './pages/tender-list/tender-list.component'; + +export const tendersRoutes: Routes = [ + { + path: '', + component: TenderListComponent + } +]; + diff --git a/src/app/models/roles.constants.ts b/src/app/models/roles.constants.ts new file mode 100644 index 00000000..4c186610 --- /dev/null +++ b/src/app/models/roles.constants.ts @@ -0,0 +1,41 @@ +/** + * Role constants used throughout the application + * These match the API's expected role values + */ +export const API_ROLES = { + BUYER: 'Buyer', + SELLER: 'Seller' +} as const; + +/** + * UI role constants (lowercase, used in UI logic) + */ +export const UI_ROLES = { + BUYER: 'buyer', + SELLER: 'seller' +} as const; + +/** + * Type for API roles + */ +export type ApiRole = typeof API_ROLES[keyof typeof API_ROLES]; + +/** + * Type for UI roles + */ +export type UiRole = typeof UI_ROLES[keyof typeof UI_ROLES]; + +/** + * Helper function to convert UI role to API role + */ +export function toApiRole(uiRole: UiRole): ApiRole { + return uiRole === UI_ROLES.BUYER ? API_ROLES.BUYER : API_ROLES.SELLER; +} + +/** + * Helper function to convert API role to UI role + */ +export function toUiRole(apiRole: ApiRole): UiRole { + return apiRole === API_ROLES.BUYER ? UI_ROLES.BUYER : UI_ROLES.SELLER; +} + diff --git a/src/app/models/tender.model.ts b/src/app/models/tender.model.ts new file mode 100644 index 00000000..1dc774a2 --- /dev/null +++ b/src/app/models/tender.model.ts @@ -0,0 +1,55 @@ +/** + * Tender model - Frontend representation of tender/quote objects + * Note: Backend uses "Quote" terminology, frontend uses "Tender" terminology + */ + +export interface Tender { + id?: string; + category: 'coordinator' | 'tendering'; + state: 'draft' | 'pre-launched' | 'pending' | 'sent' | 'closed'; + responseDeadline: string; + tenderNote?: string; + attachment?: TenderAttachment; + selectedProviders: string[]; + external_id?: string; // ID of parent tender (for child tenders) + provider?: string; // Provider name (for child tenders) + createdAt?: string; + updatedAt?: string; + + // Completion dates from Quote + expectedQuoteCompletionDate?: string; + requestedQuoteCompletionDate?: string; + effectiveQuoteCompletionDate?: string; + expectedFulfillmentStartDate?: string; +} + +export interface TenderAttachment { + name: string; + mimeType: string; + content: string; // Base64 encoded content + size?: number; +} + +export interface Tender_Create { + category: 'coordinator' | 'tendering'; + state: 'draft' | 'pre-launched' | 'pending' | 'sent' | 'closed'; + responseDeadline: string; + tenderNote?: string; + attachment?: TenderAttachment; + selectedProviders: string[]; + external_id?: string; + provider?: string; +} + +export interface Tender_Update { + responseDeadline?: string; + tenderNote?: string; + attachment?: TenderAttachment; + selectedProviders?: string[]; + state?: 'draft' | 'pre-launched' | 'pending' | 'sent' | 'closed'; + external_id?: string; + provider?: string; +} + +export type TenderStateType = 'draft' | 'pre-launched' | 'pending' | 'sent' | 'closed'; + 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 new file mode 100644 index 00000000..911c6421 --- /dev/null +++ b/src/app/shared/create-tender-modal/create-tender-modal.component.ts @@ -0,0 +1,873 @@ +import { Component, EventEmitter, Input, Output, inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { forkJoin, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { QuoteService } from 'src/app/features/quotes/services/quote.service'; +import { NotificationService } from 'src/app/services/notification.service'; +import { LocalStorageService } from 'src/app/services/local-storage.service'; +import { Tender, TenderAttachment } from 'src/app/models/tender.model'; +import { LoginInfo } from 'src/app/models/interfaces'; +import { API_ROLES } from 'src/app/models/roles.constants'; + +// Temporary provider interface until we have proper provider service +interface Provider { + id?: string; + tradingName?: string; + href?: string; + externalReference?: Array<{ + name?: string; + externalReferenceType?: string; + }>; +} + +@Component({ + selector: 'app-create-tender-modal', + standalone: true, + imports: [CommonModule, FormsModule], + template: ` + +
+
+
+

{{ editingTenderId ? 'Edit Tender' : 'Create New Tender' }}

+ +
+ + +
+
+ + +

This will be the main description of your tender

+
+ + +
+ + +
+
+ + +
+ +
+ +

{{ tenderTitle }}

+
+ + +
+ +
+ + +
+

Format: DD/MM/YYYY

+
+ + +
+ +
+ + +
+

Format: DD/MM/YYYY

+
+ + +
+ + + +
+
+
+ + + +
+

Current PDF:

+

{{ existingAttachment.name }}

+
+
+ Attached +
+

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' }} +

+
+ + +
+ + +
+
+ + +
+ +
+ +

{{ tenderTitle }}

+
+ + +
+

✓ Dates Set

+
+
+ Effective: + {{ formatDateForDisplay(expectedCompletionDate) }} +
+
+ Expected Fulfillment: + {{ formatDateForDisplay(requestedCompletionDate) }} +
+
+
+ + +
+

✓ PDF Attachment Set

+

{{ existingAttachment?.name || selectedPdfFile?.name }}

+
+ + +
+
+
+ + +
+

{{ tenderError }}

+
+ +
+ +
+ + +
+
+
+

+ {{ invited.provider.tradingName || 'Unnamed Provider' }} +

+

+ {{ invited.provider.externalReference?.[0]?.name }} +

+
+ +
+
+
+ + +
+ + +
+
+ + +
+ +
+

No more providers available. All providers have been invited.

+
+
+ +

+ {{ selectedProviders.size }} provider(s) selected +

+
+
+ + +
+ +
+ + + +
+
+
+
+
+ `, + styles: [] +}) +export class CreateTenderModalComponent implements OnInit { + @Input() isOpen = false; + @Input() customerId: string = ''; + @Output() closeModal = new EventEmitter(); + @Output() tenderCreated = new EventEmitter(); + + private quoteService = inject(QuoteService); + private notificationService = inject(NotificationService); + private localStorage = inject(LocalStorageService); + private router = inject(Router); + + // Properties for tender creation modal + tenderProviders: Provider[] = []; + selectedProviders: Set = new Set(); + invitedProviders: Array<{ provider: Provider; quoteId: string }> = []; + tenderLoading = false; + tenderError: string | null = null; + currentUserId: string | null = null; + + // Tender form fields - Step 1: Title only + tenderTitle: string = ''; + + // Step 2: Date fields and PDF upload + expectedCompletionDate: string = ''; + requestedCompletionDate: string = ''; + expectedDateSet: boolean = false; + requestedDateSet: boolean = false; + selectedPdfFile: File | null = null; + pdfAttachmentSet: boolean = false; + + // Edit mode + editingTenderId: string | null = null; + existingAttachment: TenderAttachment | null = null; + createdQuoteId: string | null = null; + + // Track tender creation steps + tenderCreationStep: number = 1; // 1 = Title, 2 = Dates, 3 = Providers + + ngOnInit() { + // Use customerId if provided from parent, otherwise get from localStorage + if (this.customerId) { + this.currentUserId = this.customerId; + } else { + const loginInfo = this.localStorage.getObject('login_items') as LoginInfo; + if (loginInfo && loginInfo.logged_as == loginInfo.id) { + this.currentUserId = loginInfo.partyId; + } else if (loginInfo && loginInfo.logged_as) { + const loggedOrg = loginInfo.organizations.find((element: { id: any; }) => element.id == loginInfo.logged_as); + this.currentUserId = loggedOrg?.partyId; + } + } + } + + closeTenderModal() { + this.isOpen = false; + this.tenderCreationStep = 1; + this.selectedProviders.clear(); + this.invitedProviders = []; + this.tenderProviders = []; + this.tenderError = null; + this.editingTenderId = null; + this.resetTenderForm(); + this.closeModal.emit(); + } + + resetTenderForm() { + this.tenderTitle = ''; + this.expectedCompletionDate = ''; + this.requestedCompletionDate = ''; + this.expectedDateSet = false; + this.requestedDateSet = false; + this.existingAttachment = null; + this.createdQuoteId = null; + this.selectedPdfFile = null; + this.pdfAttachmentSet = false; + this.invitedProviders = []; + } + + /** + * Step 1: Save initial tender with just title + * Calls createCoordinatorQuote API + */ + saveInitialTender() { + if (!this.tenderTitle.trim()) { + this.notificationService.showError('Tender title is required'); + return; + } + + if (!this.currentUserId) { + this.notificationService.showError('User not logged in'); + return; + } + + this.tenderLoading = true; + + this.quoteService.createCoordinatorQuote(this.currentUserId, this.tenderTitle.trim()).subscribe({ + next: (createdTender) => { + console.log('Coordinator tender created:', createdTender); + this.createdQuoteId = createdTender.id || null; + this.editingTenderId = createdTender.id || null; + this.notificationService.showSuccess('Tender created! Now set the completion dates.'); + this.tenderLoading = false; + + // Move to Step 2: Date fields + this.tenderCreationStep = 2; + }, + error: (error) => { + console.error('Error creating tender:', error); + this.notificationService.showError('Failed to create tender: ' + (error.message || 'Unknown error')); + this.tenderLoading = false; + } + }); + } + + /** + * Convert date from YYYY-MM-DD to DD-MM-YYYY format + */ + formatDateForAPI(dateString: string): string { + if (!dateString) return ''; + const [year, month, day] = dateString.split('-'); + return `${day}-${month}-${year}`; + } + + /** + * Step 2: Set expected completion date + */ + setExpectedDate() { + if (!this.expectedCompletionDate || !this.createdQuoteId) { + this.notificationService.showError('Please select a date'); + return; + } + + this.tenderLoading = true; + const formattedDate = this.formatDateForAPI(this.expectedCompletionDate); + + this.quoteService.updateQuoteDate(this.createdQuoteId, formattedDate, 'expected').subscribe({ + next: (updatedTender: any) => { + console.log('Effective completion date updated:', updatedTender); + this.expectedDateSet = true; + this.notificationService.showSuccess('Effective completion date set successfully!'); + this.tenderLoading = false; + }, + error: (error: any) => { + console.error('Error setting effective date:', error); + this.notificationService.showError('Failed to set effective date: ' + (error.message || 'Unknown error')); + this.tenderLoading = false; + } + }); + } + + /** + * Step 2: Set requested completion date + */ + setRequestedDate() { + if (!this.requestedCompletionDate || !this.createdQuoteId) { + this.notificationService.showError('Please select a date'); + return; + } + + this.tenderLoading = true; + const formattedDate = this.formatDateForAPI(this.requestedCompletionDate); + + this.quoteService.updateQuoteDate(this.createdQuoteId, formattedDate, 'requested').subscribe({ + next: (updatedTender: any) => { + console.log('Expected fulfillment start date updated:', updatedTender); + this.requestedDateSet = true; + this.notificationService.showSuccess('Expected fulfillment start date set successfully!'); + this.tenderLoading = false; + }, + error: (error: any) => { + console.error('Error setting expected fulfillment date:', error); + this.notificationService.showError('Failed to set expected fulfillment date: ' + (error.message || 'Unknown error')); + this.tenderLoading = false; + } + }); + } + + /** + * Step 2: Handle PDF file selection + */ + onPdfFileSelected(event: Event) { + const target = event.target as HTMLInputElement; + const file = target.files?.[0]; + + if (file) { + if (file.type !== 'application/pdf') { + this.notificationService.showError('Please select a valid PDF file'); + this.selectedPdfFile = null; + target.value = ''; + return; + } + this.selectedPdfFile = file; + console.log('PDF file selected:', file.name); + } else { + this.selectedPdfFile = null; + } + } + + /** + * Step 2: Upload PDF attachment + */ + setPdfAttachment() { + if (!this.selectedPdfFile || !this.createdQuoteId) { + this.notificationService.showError('Please select a PDF file'); + return; + } + + this.tenderLoading = true; + + this.quoteService.addAttachmentToQuote(this.createdQuoteId, this.selectedPdfFile, '').subscribe({ + next: (updatedQuote: any) => { + console.log('PDF attachment uploaded:', updatedQuote); + this.pdfAttachmentSet = true; + this.existingAttachment = updatedQuote.attachment || null; + this.notificationService.showSuccess('PDF attachment uploaded successfully!'); + this.tenderLoading = false; + }, + error: (error: any) => { + console.error('Error uploading PDF:', error); + this.notificationService.showError('Failed to upload PDF: ' + (error.message || 'Unknown error')); + this.tenderLoading = false; + } + }); + } + + /** + * Check if all Step 2 fields are completed + */ + isStep2Complete(): boolean { + return this.expectedDateSet && this.requestedDateSet && this.pdfAttachmentSet; + } + + /** + * Proceed from Step 2 to Step 3 (Provider Selection) + */ + proceedToProviderSelection() { + if (!this.isStep2Complete()) { + this.notificationService.showError('Please complete all date and PDF fields first'); + return; + } + + // Move to Step 3 + this.tenderCreationStep = 3; + + // Load providers for selection (will automatically load invited providers after) + this.loadTenderProviders(); + } + + /** + * TODO: Load providers - needs to be implemented with proper provider service + * For now using placeholder + */ + loadTenderProviders() { + this.tenderLoading = true; + this.tenderError = null; + + // TODO: Replace with actual provider service call + // For now, just load invited providers + console.warn('Provider loading not yet implemented - needs provider service'); + this.tenderProviders = []; + this.tenderLoading = false; + + // Load invited providers + if (this.tenderCreationStep === 3) { + this.loadInvitedProviders(); + } + } + + toggleProviderSelection(providerId: string) { + if (this.selectedProviders.has(providerId)) { + this.selectedProviders.delete(providerId); + } else { + this.selectedProviders.add(providerId); + } + } + + /** + * Load already invited providers by fetching tendering quotes with the coordinator quote's externalId + */ + loadInvitedProviders() { + if (!this.createdQuoteId || !this.currentUserId) { + console.log('No coordinator quote ID or user ID, skipping invited providers load'); + return; + } + + console.log('Loading invited providers for externalId:', this.createdQuoteId); + + this.tenderLoading = true; + + this.quoteService.getTenderingQuotesByUser(this.currentUserId, API_ROLES.BUYER).subscribe({ + next: (tenders) => { + console.log('Received tenders:', tenders); + + // Clear existing invited providers + this.invitedProviders = []; + + // Filter tenders that match our createdQuoteId as externalId + const matchingTenders = tenders.filter(t => t.external_id === this.createdQuoteId); + + // Convert to invited providers format + matchingTenders.forEach(tender => { + // TODO: Get proper provider info once provider service is available + const provider: Provider = { + id: tender.provider || undefined, + tradingName: tender.provider || 'Unknown Provider' + }; + + if (tender.id) { + this.invitedProviders.push({ + provider: provider, + quoteId: tender.id + }); + console.log('Added invited provider:', provider.tradingName, 'with quote ID:', tender.id); + } + }); + + console.log('Total invited providers loaded:', this.invitedProviders.length); + this.tenderLoading = false; + }, + error: (error) => { + console.error('Error loading invited providers:', error); + this.tenderLoading = false; + } + }); + } + + /** + * Go back from Step 3 to Step 2 + */ + backToStep2() { + this.tenderCreationStep = 2; + } + + /** + * Format date from YYYY-MM-DD to DD/MM/YYYY for display + */ + formatDateForDisplay(dateString: string): string { + if (!dateString) return ''; + const parts = dateString.split('-'); + if (parts.length === 3) { + const [year, month, day] = parts; + return `${day}/${month}/${year}`; + } + return dateString; + } + + /** + * Get available providers (excluding already invited ones) + */ + getAvailableProviders(): Provider[] { + const invitedProviderIds = new Set(this.invitedProviders.map(ip => ip.provider.id)); + return this.tenderProviders.filter(p => p.id && !invitedProviderIds.has(p.id)); + } + + /** + * Step 3: Save providers list by creating tendering quotes for selected providers + */ + saveProvidersList() { + if (this.selectedProviders.size === 0) { + this.notificationService.showError('Please select at least one provider'); + return; + } + + if (!this.createdQuoteId || !this.currentUserId) { + this.notificationService.showError('Coordinator quote not found. Please start over.'); + return; + } + + this.tenderLoading = true; + const providerIds = Array.from(this.selectedProviders); + const customerMessage = this.tenderTitle; + + console.log('Creating tendering quotes for providers:', providerIds); + + // Create tendering quotes for multiple providers + this.quoteService.createMultipleTenderingQuotes( + this.currentUserId, + providerIds, + this.createdQuoteId, + customerMessage + ).subscribe({ + next: (createdTenders) => { + console.log('Tendering quotes created:', createdTenders); + + // Add to invited providers list + createdTenders.forEach((tender, index) => { + if (tender.id) { + const provider = this.tenderProviders.find(p => p.id === providerIds[index]); + if (provider) { + this.invitedProviders.push({ + provider: provider, + quoteId: tender.id + }); + } + } + }); + + // Clear selection + this.selectedProviders.clear(); + + this.notificationService.showSuccess(`${providerIds.length} provider(s) invited successfully!`); + this.tenderLoading = false; + }, + error: (error) => { + console.error('Error creating tendering quotes:', error); + this.notificationService.showError('Failed to invite providers: ' + (error.message || 'Unknown error')); + this.tenderLoading = false; + } + }); + } + + /** + * Remove an invited provider by deleting their tendering quote + */ + 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; + }, + error: (error) => { + console.error('Error deleting quote:', error); + this.notificationService.showError('Failed to remove provider invitation: ' + (error.message || 'Unknown error')); + this.tenderLoading = false; + } + }); + } + + /** + * Step 3: Finalize and complete tender creation + */ + finalizeTender() { + if (this.invitedProviders.length === 0) { + this.notificationService.showError('Please invite at least one provider first'); + return; + } + + if (!this.createdQuoteId) { + this.notificationService.showError('Coordinator quote not found'); + return; + } + + if (!confirm('Are you sure you want to finalize the tender?')) { + return; + } + + this.tenderLoading = true; + + // Get the coordinator quote to extract the dates + this.quoteService.getQuoteById(this.createdQuoteId).pipe( + switchMap(coordinatorQuote => { + console.log('Coordinator quote retrieved:', coordinatorQuote); + + const formattedEffectiveDate = this.formatDateForAPI(this.expectedCompletionDate); + const formattedExpectedFulfillmentDate = this.formatDateForAPI(this.requestedCompletionDate); + + console.log(`Copying dates to ${this.invitedProviders.length} provider quotes`); + + // Create array of date update observables for all invited provider quotes + const dateUpdateObservables = this.invitedProviders.flatMap(invitedProvider => { + const quoteId = invitedProvider.quoteId; + + return [ + this.quoteService.updateQuoteDate(quoteId, formattedEffectiveDate, 'expected'), + this.quoteService.updateQuoteDate(quoteId, formattedExpectedFulfillmentDate, 'requested') + ]; + }); + + if (dateUpdateObservables.length === 0) { + return of([]); + } + + return forkJoin(dateUpdateObservables); + }), + switchMap(dateUpdateResults => { + console.log(`Successfully updated dates for ${dateUpdateResults.length / 2} provider quotes`); + + // Update coordinator quote status to "inProgress" + return this.quoteService.updateQuoteStatus(this.createdQuoteId!, 'inProgress'); + }) + ).subscribe({ + next: (updatedQuote: any) => { + console.log('Coordinator quote status updated to inProgress:', updatedQuote); + + this.notificationService.showSuccess('Dates copied to all provider quotes and notifications sent to providers'); + + this.tenderLoading = false; + this.closeTenderModal(); + + // Emit success - parent component will refresh the list + this.tenderCreated.emit(updatedQuote); + }, + error: (error: any) => { + console.error('Error finalizing tender:', error); + this.notificationService.showError('Failed to finalize tender: ' + (error.message || 'Unknown error')); + this.tenderLoading = false; + } + }); + } +} diff --git a/src/app/shared/header/header.component.html b/src/app/shared/header/header.component.html index b508ee32..76b3374b 100644 --- a/src/app/shared/header/header.component.html +++ b/src/app/shared/header/header.component.html @@ -180,6 +180,9 @@
  • +
  • + +
  • }
  • Date: Wed, 10 Dec 2025 13:50:58 +0100 Subject: [PATCH 2/5] Enhance Quote and Tender Management Features - Added a new grid template column for 16 columns in Tailwind configuration. - Refactored QuoteService to include HTTP options for API calls, ensuring consistent headers across requests. - Introduced new methods in QuoteService for managing tender-specific operations, including creating and retrieving tender quotes. - Updated TenderListComponent to accommodate changes in quote structure and display additional information. - Implemented provider service for fetching provider data, enhancing the tender creation process. - Enhanced CreateTenderModalComponent to support editing existing tenders and loading provider data dynamically. --- .../features/quotes/services/quote.service.ts | 294 +++++++++++++++++- .../tender-list/tender-list.component.ts | 97 +++--- src/app/services/provider.service.ts | 89 ++++++ .../create-tender-modal.component.ts | 135 ++++++-- tailwind.config.js | 1 + 5 files changed, 534 insertions(+), 82 deletions(-) create mode 100644 src/app/services/provider.service.ts diff --git a/src/app/features/quotes/services/quote.service.ts b/src/app/features/quotes/services/quote.service.ts index e52b45d9..131c271a 100644 --- a/src/app/features/quotes/services/quote.service.ts +++ b/src/app/features/quotes/services/quote.service.ts @@ -1,16 +1,40 @@ import { Injectable } from '@angular/core'; -import { HttpClient, HttpParams } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http'; +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 { environment } from '../../../../environments/environment'; @Injectable({ providedIn: 'root' }) export class QuoteService { - private apiUrl = environment.quoteApi; - constructor(private http: HttpClient) {} + // Use getter to always get the current value (in case it's updated by app-init) + // If quoteApi is a relative path, prepend BASE_URL + private get apiUrl(): string { + const quoteApi = environment.quoteApi; + // If it's already an absolute URL, use it as-is + if (quoteApi.startsWith('http://') || quoteApi.startsWith('https://')) { + return quoteApi; + } + // Otherwise, prepend BASE_URL for relative paths + return `${environment.BASE_URL}${quoteApi}`; + } + + // Get HTTP headers with Bearer token for quote API calls + private get httpOptions() { + return { + headers: new HttpHeaders({ + 'Content-Type': 'application/json' + }) + }; + } + + constructor(private http: HttpClient) { + console.log('🔍 [DEBUG] QuoteService constructor - BASE_URL:', environment.BASE_URL); + } // TMF 648 Quote Management API methods @@ -19,7 +43,7 @@ export class QuoteService { * POST /quote */ createQuote(quote: Quote_Create): Observable { - return this.http.post(`${this.apiUrl}/quote`, quote); + return this.http.post(`${this.apiUrl}/quote`, quote, this.httpOptions); } /** @@ -32,7 +56,7 @@ export class QuoteService { providerIdRef: string; productOfferingId: string; }): Observable { - return this.http.post(`${this.apiUrl}${environment.quoteEndpoints.createQuote}`, requestData); + return this.http.post(`${this.apiUrl}${environment.quoteEndpoints.createQuote}`, requestData, this.httpOptions); } /** @@ -45,7 +69,7 @@ export class QuoteService { if (offset !== undefined) params = params.set('offset', offset.toString()); if (limit !== undefined) params = params.set('limit', limit.toString()); - return this.http.get(`${this.apiUrl}/quote`, { params }); + return this.http.get(`${this.apiUrl}/quote`, { params, ...this.httpOptions }); } /** @@ -59,7 +83,7 @@ export class QuoteService { // URL encode the ID to handle special characters like colons const encodedId = encodeURIComponent(id); console.log('Retrieving quote with URL:', `${this.apiUrl}/quoteById/${encodedId}`); - return this.http.get(`${this.apiUrl}/quoteById/${encodedId}`, { params }); + return this.http.get(`${this.apiUrl}/quoteById/${encodedId}`, { params, ...this.httpOptions }); } /** @@ -69,7 +93,7 @@ export class QuoteService { patchQuote(id: string, quote: Quote_Update): Observable { const encodedId = encodeURIComponent(id); console.log('Updating quote with URL:', `${this.apiUrl}/updateQuoteStatus/${encodedId}`); - return this.http.patch(`${this.apiUrl}/updateQuoteStatus/${encodedId}`, quote); + return this.http.patch(`${this.apiUrl}/updateQuoteStatus/${encodedId}`, quote, this.httpOptions); } /** @@ -79,7 +103,7 @@ export class QuoteService { deleteQuote(id: string): Observable { const encodedId = encodeURIComponent(id); console.log('Deleting quote with URL:', `${this.apiUrl}/quote/${encodedId}`); - return this.http.delete(`${this.apiUrl}/quote/${encodedId}`); + return this.http.delete(`${this.apiUrl}/quote/${encodedId}`, this.httpOptions); } // Convenience methods for common operations @@ -123,7 +147,7 @@ export class QuoteService { const encodedId = encodeURIComponent(id); console.log('Adding note to quote with URL:', `${this.apiUrl}/addNoteToQuote/${encodedId}`); - return this.http.patch(`${this.apiUrl}/addNoteToQuote/${encodedId}`, null, { params }); + return this.http.patch(`${this.apiUrl}/addNoteToQuote/${encodedId}`, null, { params, ...this.httpOptions }); } /** @@ -153,7 +177,7 @@ export class QuoteService { const encodedUserId = encodeURIComponent(userId); console.log('Getting quotes by user with URL:', `${this.apiUrl}/quoteByUser/${encodedUserId}`); - return this.http.get(`${this.apiUrl}/quoteByUser/${encodedUserId}`, { params }); + return this.http.get(`${this.apiUrl}/quoteByUser/${encodedUserId}`, { params, ...this.httpOptions }); } /** @@ -208,14 +232,20 @@ export class QuoteService { console.log('Updating quote status with URL:', `${this.apiUrl}/updateQuoteStatus/${encodedId}`); console.log('Status value:', status); - return this.http.patch(`${this.apiUrl}/updateQuoteStatus/${encodedId}`, null, { params }); + return this.http.patch(`${this.apiUrl}/updateQuoteStatus/${encodedId}`, null, { params, ...this.httpOptions }); } /** * Update quote completion date using the specific updateQuoteDate endpoint * PATCH /updateQuoteDate/{id}?date={date}&dateType={dateType} + * + * Date types: + * - 'requested' → requestedQuoteCompletionDate + * - 'expected' → expectedQuoteCompletionDate + * - 'effective' → effectiveQuoteCompletionDate + * - 'expectedFulfillment' → expectedFulfillmentStartDate */ - updateQuoteDate(id: string, date: string, dateType: 'requested' | 'expected'): Observable { + updateQuoteDate(id: string, date: string, dateType: 'requested' | 'expected' | 'effective' | 'expectedFulfillment'): Observable { let params = new HttpParams(); params = params.set('date', date); params = params.set('dateType', dateType); @@ -224,7 +254,7 @@ export class QuoteService { console.log('Updating quote date with URL:', `${this.apiUrl}/updateQuoteDate/${encodedId}`); console.log('Date:', date, 'DateType:', dateType); - return this.http.patch(`${this.apiUrl}/updateQuoteDate/${encodedId}`, null, { params }); + return this.http.patch(`${this.apiUrl}/updateQuoteDate/${encodedId}`, null, { params, ...this.httpOptions }); } /** @@ -236,6 +266,11 @@ export class QuoteService { /** * Add attachment to quote (file upload) + * PATCH /addAttachmentToQuote/{id} + * + * @param id - Quote ID + * @param file - PDF file (max 100MB) + * @param description - Optional description of the attachment */ addAttachmentToQuote(id: string, file: File, description?: string): Observable { const formData = new FormData(); @@ -245,7 +280,7 @@ export class QuoteService { } const encodedId = encodeURIComponent(id); - console.log('Adding attachment to quote with URL:', `${this.apiUrl}/addAttachmentToQuote/${encodedId}`); + return this.http.patch(`${this.apiUrl}/addAttachmentToQuote/${encodedId}`, formData); } @@ -347,4 +382,231 @@ export class QuoteService { updateQuote(id: string, quote: Partial): Observable { return this.patchQuote(id, quote as Quote_Update); } + + // ======================================== + // TENDERING-SPECIFIC METHODS + // ======================================== + + /** + * Create a coordinator quote (for managing tendering processes) + * POST /quoteManagement/tendering/createCoordinatorQuote + */ + createCoordinatorQuote(customerIdRef: string, customerMessage: string): Observable { + const payload = { + customerMessage, + customerIdRef + }; + + const fullUrl = `${this.apiUrl}/tendering/createCoordinatorQuote`; + console.log('🔍 [DEBUG] QuoteService.createCoordinatorQuote:'); + console.log('🔍 [DEBUG] this.apiUrl:', this.apiUrl); + console.log('🔍 [DEBUG] Full URL being called:', fullUrl); + console.log('🔍 [DEBUG] environment.quoteApi:', environment.quoteApi); + + return this.http.post(fullUrl, payload, this.httpOptions).pipe( + map(quote => this.mapQuoteToTender(quote)) + ); + } + + /** + * Create a tendering quote (child tender for specific provider) + * POST /quoteManagement/tendering/createQuote + */ + createTenderingQuote( + customerIdRef: string, + providerIdRef: string, + externalId: string, + customerMessage?: string + ): Observable { + const payload = { + customerMessage: customerMessage || '', + customerIdRef, + providerIdRef, + externalId + }; + + return this.http.post(`${this.apiUrl}/tendering/createQuote`, payload, this.httpOptions).pipe( + map(quote => this.mapQuoteToTender(quote)) + ); + } + + /** + * Create multiple tendering quotes for multiple providers + */ + createMultipleTenderingQuotes( + customerIdRef: string, + providerIds: string[], + externalId: string, + customerMessage?: string + ): Observable { + const requests = providerIds.map(providerId => + this.createTenderingQuote(customerIdRef, providerId, externalId, customerMessage) + ); + + return forkJoin(requests); + } + + /** + * Get coordinator tenders for a user + * GET /quoteManagement/tendering/coordinatorQuotes/{userId} + */ + getCoordinatorQuotesByUser(userId: string): Observable { + const encodedUserId = encodeURIComponent(userId); + console.log('Getting coordinator quotes for user:', encodedUserId); + return this.http.get(`${this.apiUrl}/tendering/coordinatorQuotes/${encodedUserId}`, this.httpOptions).pipe( + map(quotes => quotes.map(quote => this.mapQuoteToTender(quote))) + ); + } + + /** + * Get tendering quotes for a user by role + * GET /quoteManagement/tendering/quotes/{userId}?role={role}&externalId={externalId} + */ + getTenderingQuotesByUser( + userId: string, + role: ApiRole = API_ROLES.SELLER, + externalId?: string + ): Observable { + const encodedUserId = encodeURIComponent(userId); + let params = new HttpParams().set('role', role); + + if (externalId) { + params = params.set('externalId', externalId); + } + + console.log('Getting tendering quotes for user:', encodedUserId, 'role:', role, 'externalId:', externalId); + return this.http.get(`${this.apiUrl}/tendering/quotes/${encodedUserId}`, { params, ...this.httpOptions }).pipe( + map(quotes => quotes.map(quote => this.mapQuoteToTender(quote))) + ); + } + + /** + * Get tendering quotes by external ID + * GET /quoteManagement/tendering/quotes/{userId}?role={role}&externalId={externalId} + */ + getTenderingQuotesByExternalId(userId: string, externalId: string, role: ApiRole): Observable { + const encodedUserId = encodeURIComponent(userId); + let params = new HttpParams() + .set('role', role) + .set('externalId', externalId); + + console.log('Getting tendering quotes by external ID:', externalId); + return this.http.get(`${this.apiUrl}/tendering/quotes/${encodedUserId}`, { params, ...this.httpOptions }); + } + + /** + * Broadcast message to all tendering quotes with the same external ID + * POST /quoteManagement/tendering/broadcastMessage + */ + broadcastMessage(externalId: string, userId: string, messageContent: string): Observable { + const payload = { + externalId, + userId, + messageContent + }; + + console.log('Broadcasting message to external ID:', externalId); + return this.http.post(`${this.apiUrl}/tendering/broadcastMessage`, payload, this.httpOptions); + } + + /** + * Update tender status (alias for updateQuoteStatus but returns Tender) + */ + updateTenderStatus(id: string, status: string): Observable { + return this.updateQuoteStatus(id, status).pipe( + map(quote => this.mapQuoteToTender(quote)) + ); + } + + // ======================================== + // MAPPING METHODS + // ======================================== + + /** + * Map backend Quote to frontend Tender model + */ + private mapQuoteToTender(quote: Quote): Tender { + // Extract response deadline from quote + const responseDeadline = quote.expectedFulfillmentStartDate || + quote.effectiveQuoteCompletionDate || + new Date().toISOString(); + + // Extract tender title from quote.description (this is where the title is saved) + const tenderNote = quote.description || undefined; + + // Extract attachment from quote items + let attachment = undefined; + if (quote.quoteItem && quote.quoteItem.length > 0) { + const firstItem = quote.quoteItem[0]; + if (firstItem.attachment && firstItem.attachment.length > 0) { + const att = firstItem.attachment[0]; + attachment = { + name: att.name || 'attachment.pdf', + mimeType: att.mimeType || 'application/pdf', + content: att.content || '', + size: att.size?.amount + }; + } + } + + // Extract selected providers from related parties + const selectedProviders = quote.relatedParty + ?.filter(party => party.role?.toLowerCase() === API_ROLES.SELLER.toLowerCase()) + .map(party => party.id) || []; + + // Map quote category to tender category + let category: 'coordinator' | 'tendering' = 'coordinator'; + if (quote.category === 'tender') { + category = 'tendering'; + } else if (quote.category === 'coordinator') { + category = 'coordinator'; + } + + // Extract state from quoteItem (this is where the actual state is stored) + let quoteItemState: string = 'pending'; + if (quote.quoteItem && quote.quoteItem.length > 0) { + const firstItem = quote.quoteItem[0]; + quoteItemState = (firstItem as any).state || quote.state || 'pending'; + } else if (quote.state) { + quoteItemState = quote.state; + } + + // Map quote state to tender state + // Backend states → Tender states → GUI display: + // - pending → draft → 'draft' + // - inProgress → pre-launched → 'pre-launched' + // - 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 + const external_id = quote.externalId; + const provider = quote.relatedParty + ?.find(party => party.role?.toLowerCase() === API_ROLES.SELLER.toLowerCase()) + ?.name; + + return { + id: quote.id, + category, + state, + responseDeadline, + tenderNote, + attachment, + selectedProviders, + external_id, + provider, + createdAt: quote.quoteDate, + updatedAt: quote.quoteDate, + effectiveQuoteCompletionDate: quote.effectiveQuoteCompletionDate, + expectedFulfillmentStartDate: quote.expectedFulfillmentStartDate, + expectedQuoteCompletionDate: quote.expectedQuoteCompletionDate, + requestedQuoteCompletionDate: quote.requestedQuoteCompletionDate + }; + } } \ No newline at end of file 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 0602cbb3..d4e1e906 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 @@ -33,7 +33,7 @@ import { UI_ROLES, API_ROLES, UiRole, toApiRole } from 'src/app/models/roles.con template: ` -
    +

    Tenders

    @@ -138,21 +138,21 @@ import { UI_ROLES, API_ROLES, UiRole, toApiRole } from 'src/app/models/roles.con
    -
    +
    DETAILS
    -
    TITLE
    -
    STATUS
    -
    Expected Fulfillment Start Date
    -
    Effective Quote Completion Date
    +
    TITLE
    +
    STATUS
    +
    TENDER START DATE
    +
    TENDER END DATE
    ATTACHMENTS
    REQUEST
    -
    ACTIONS
    +
    ACTIONS
    -
    @@ -186,12 +186,12 @@ import { UI_ROLES, API_ROLES, UiRole, toApiRole } from 'src/app/models/roles.con
    -
    +
    {{ quote.description || '(no title)' }}
    -
    +
    {{ getQuoteItemState(quote) }} @@ -268,25 +268,31 @@ import { UI_ROLES, API_ROLES, UiRole, toApiRole } from 'src/app/models/roles.con
    -
    +
    @@ -641,8 +647,10 @@ import { UI_ROLES, API_ROLES, UiRole, toApiRole } from 'src/app/models/roles.con `, styles: [` @@ -749,6 +757,7 @@ export class TenderListComponent implements OnInit { // Create Tender Modal showCreateTenderModal = false; + tenderToEdit: Tender | null = null; // Expanded rows for coordinator quotes expandedQuoteIds: Set = new Set(); @@ -847,6 +856,19 @@ export class TenderListComponent implements OnInit { } private mapTenderToQuote(tender: Tender): Quote { + // Build quoteItem with state and attachment (if exists) + const quoteItem: any = { state: this.mapTenderStateToQuoteState(tender.state) }; + + // Include attachment if present in tender + if (tender.attachment) { + quoteItem.attachment = [{ + name: tender.attachment.name, + mimeType: tender.attachment.mimeType, + content: tender.attachment.content, + size: tender.attachment.size ? { amount: tender.attachment.size, units: 'bytes' } : undefined + }]; + } + return { id: tender.id, href: '', @@ -855,7 +877,8 @@ export class TenderListComponent implements OnInit { effectiveQuoteCompletionDate: tender.effectiveQuoteCompletionDate, expectedFulfillmentStartDate: tender.expectedFulfillmentStartDate, state: this.mapTenderStateToQuoteState(tender.state), - category: tender.category, + // Map category back: 'tendering' -> 'tender', 'coordinator' -> 'coordinator' + category: tender.category === 'tendering' ? 'tender' : tender.category, externalId: tender.external_id, relatedParty: tender.selectedProviders.map(id => ({ id, @@ -863,22 +886,20 @@ export class TenderListComponent implements OnInit { name: tender.provider, '@referredType': 'Organization' })), - // Provide a minimal quoteItem array carrying the state so the UI can display it. - // We intentionally cast to any to avoid enforcing the full TMF structure here. - quoteItem: [ - { state: this.mapTenderStateToQuoteState(tender.state) } as any - ], + // Provide a minimal quoteItem array carrying the state and attachment + quoteItem: [quoteItem], note: [] }; } private mapTenderStateToQuoteState(tenderState: 'draft' | 'pre-launched' | 'pending' | 'sent' | 'closed'): QuoteStateType { switch (tenderState) { - case 'draft': return 'inProgress'; + case 'draft': return 'pending'; // draft → pending → GUI shows 'draft' + case 'pre-launched': return 'inProgress'; // pre-launched → inProgress → GUI shows 'pre-launched' case 'pending': return 'pending'; - case 'sent': return 'approved'; - case 'closed': return 'accepted'; - default: return 'inProgress'; + case 'sent': return 'approved'; // sent → approved → GUI shows 'launched' + case 'closed': return 'accepted'; // closed → accepted → GUI shows 'closed' + default: return 'pending'; } } @@ -927,15 +948,15 @@ export class TenderListComponent implements OnInit { content: att.content || '', size: att.size?.amount }; - console.log('Extracted attachment for edit:', attachment.name); } } // Convert Quote to Tender format for editing + const primaryState = this.getPrimaryState(quote) as QuoteStateType; const tender: Tender = { id: quote.id, category: quote.category === 'coordinator' ? 'coordinator' : 'tendering', - state: this.mapQuoteStateToTenderState(quote.state), + state: this.mapQuoteStateToTenderState(primaryState), responseDeadline: quote.expectedFulfillmentStartDate || quote.effectiveQuoteCompletionDate || new Date().toISOString(), tenderNote: quote.description || '', attachment: attachment, @@ -944,21 +965,18 @@ export class TenderListComponent implements OnInit { expectedFulfillmentStartDate: quote.expectedFulfillmentStartDate }; - console.log('Navigating to edit tender with data:', tender); - - // Navigate to providers page with tender data - this.router.navigate(['/providers'], { - state: { tender } - }); + // Open the Create Tender Modal in edit mode + this.tenderToEdit = tender; + this.showCreateTenderModal = true; } private mapQuoteStateToTenderState(quoteState: QuoteStateType | undefined): 'draft' | 'pre-launched' | 'pending' | 'sent' | 'closed' { if (!quoteState) return 'draft'; switch (quoteState) { - case 'inProgress': return 'draft'; - case 'pending': return 'pending'; - case 'approved': return 'sent'; + case 'pending': return 'draft'; // pending → draft + case 'inProgress': return 'pre-launched'; // inProgress → pre-launched + case 'approved': return 'sent'; // approved → sent (launched) case 'accepted': case 'cancelled': case 'rejected': return 'closed'; @@ -1014,6 +1032,7 @@ export class TenderListComponent implements OnInit { closeCreateTenderModal() { this.showCreateTenderModal = false; + this.tenderToEdit = null; } onTenderCreated(tender: Tender) { @@ -1023,6 +1042,11 @@ export class TenderListComponent implements OnInit { this.notificationService.showSuccess('Tender created successfully!'); } + onTenderUpdated() { + // Refresh the quotes list to show updated tender + this.loadQuotes(); + } + sendBroadcastMessage() { if (!this.broadcastForCoordinatorId || !this.currentUserId || !this.broadcastMessage) { return; @@ -1940,3 +1964,4 @@ export class TenderListComponent implements OnInit { }); } } + diff --git a/src/app/services/provider.service.ts b/src/app/services/provider.service.ts new file mode 100644 index 00000000..bf3165fd --- /dev/null +++ b/src/app/services/provider.service.ts @@ -0,0 +1,89 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable, map, catchError, of } from 'rxjs'; +import { environment } from '../../environments/environment'; + +export interface Provider { + id?: string; + href?: string; + tradingName?: string; + externalReference?: Array<{ + externalReferenceType?: string; + name?: string; + }>; +} + +@Injectable({ + providedIn: 'root' +}) +export class ProviderService { + private http = inject(HttpClient); + private readonly endpoint = `${environment.BASE_URL}/party/v4/organization`; + //TODO FOR DEV ONLY + //private readonly endpoint = `${environment.BASE_URL}/party/organization`; + + getProviders(params: { fields?: string; offset?: number; limit?: number } = {}): Observable { + let httpParams = new HttpParams(); + if (params.fields) { + httpParams = httpParams.set('fields', params.fields); + } + if (params.offset !== undefined) { + httpParams = httpParams.set('offset', params.offset.toString()); + } + if (params.limit !== undefined) { + httpParams = httpParams.set('limit', params.limit.toString()); + } + + const url = `${this.endpoint}${httpParams.toString() ? '?' + httpParams.toString() : ''}`; + + return this.http.get(url).pipe( + map(response => { + return Array.isArray(response) ? response : []; + }), + catchError((error) => { + console.warn('Provider API failed:', error); + return of([]); + }) + ); + } + + getProviderById(id: string): Observable { + const targetUrl = `${this.endpoint}/${id}`; + + // Use CORS proxy if calling external DOME API directly + const isExternalUrl = targetUrl.startsWith('https://'); + const url = isExternalUrl ? `https://api.allorigins.win/get?url=${encodeURIComponent(targetUrl)}` : targetUrl; + + return this.http.get(url).pipe( + map(response => { + // Parse CORS proxy response if used + return isExternalUrl ? JSON.parse(response.contents) : response; + }), + catchError((error) => { + console.warn('Provider by ID API failed:', error); + throw error; + }) + ); + } + + getProvidersForTender(): Observable { + const targetUrl = this.endpoint; + + // Use CORS proxy if calling external DOME API directly + const isExternalUrl = targetUrl.startsWith('https://'); + const url = isExternalUrl ? `https://api.allorigins.win/get?url=${encodeURIComponent(targetUrl)}` : targetUrl; + + return this.http.get(url).pipe( + map(response => { + // Parse CORS proxy response if used + const data = isExternalUrl ? JSON.parse(response.contents) : response; + return Array.isArray(data) ? data : []; + }), + catchError((error) => { + console.warn('Providers for tender API failed:', error); + return of([]); + }) + ); + } +} + 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 911c6421..ee9f2b13 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 @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, Output, inject, OnInit } from '@angular/core'; +import { Component, EventEmitter, Input, Output, inject, OnInit, OnChanges, SimpleChanges } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; @@ -7,21 +7,11 @@ import { switchMap } from 'rxjs/operators'; import { QuoteService } from 'src/app/features/quotes/services/quote.service'; import { NotificationService } from 'src/app/services/notification.service'; import { LocalStorageService } from 'src/app/services/local-storage.service'; +import { ProviderService, Provider } from 'src/app/services/provider.service'; import { Tender, TenderAttachment } from 'src/app/models/tender.model'; import { LoginInfo } from 'src/app/models/interfaces'; import { API_ROLES } from 'src/app/models/roles.constants'; -// Temporary provider interface until we have proper provider service -interface Provider { - id?: string; - tradingName?: string; - href?: string; - externalReference?: Array<{ - name?: string; - externalReferenceType?: string; - }>; -} - @Component({ selector: 'app-create-tender-modal', standalone: true, @@ -358,15 +348,18 @@ interface Provider { `, styles: [] }) -export class CreateTenderModalComponent implements OnInit { +export class CreateTenderModalComponent implements OnInit, OnChanges { @Input() isOpen = false; @Input() customerId: string = ''; + @Input() tenderToEdit: Tender | null = null; @Output() closeModal = new EventEmitter(); @Output() tenderCreated = new EventEmitter(); + @Output() tenderUpdated = new EventEmitter(); private quoteService = inject(QuoteService); private notificationService = inject(NotificationService); private localStorage = inject(LocalStorageService); + private providerService = inject(ProviderService); private router = inject(Router); // Properties for tender creation modal @@ -411,6 +404,57 @@ export class CreateTenderModalComponent implements OnInit { } } + ngOnChanges(changes: SimpleChanges) { + // Check if tenderToEdit has changed and is not null + if (changes['tenderToEdit'] && this.tenderToEdit) { + this.loadTenderForEdit(this.tenderToEdit); + } + // Also check if modal was just opened and we have a tender to edit + if (changes['isOpen'] && this.isOpen && this.tenderToEdit) { + this.loadTenderForEdit(this.tenderToEdit); + } + } + + loadTenderForEdit(tender: Tender) { + // Set basic fields + this.editingTenderId = tender.id || null; + this.createdQuoteId = tender.id || null; + this.tenderTitle = tender.tenderNote || ''; + + // Set dates if they exist + if (tender.expectedFulfillmentStartDate) { + this.requestedCompletionDate = this.formatDateForInput(tender.expectedFulfillmentStartDate); + this.requestedDateSet = true; + } + + if (tender.effectiveQuoteCompletionDate) { + this.expectedCompletionDate = this.formatDateForInput(tender.effectiveQuoteCompletionDate); + this.expectedDateSet = true; + } + + // Set attachment if exists + if (tender.attachment) { + this.existingAttachment = tender.attachment; + this.pdfAttachmentSet = true; + } + + // Always start at step 2 when clicking EDIT to ensure proper initialization and API calls + this.tenderCreationStep = 2; + } + + private formatDateForInput(isoDate: string): string { + try { + const date = new Date(isoDate); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } catch (error) { + console.error('Error formatting date:', error); + return ''; + } + } + closeTenderModal() { this.isOpen = false; this.tenderCreationStep = 1; @@ -493,12 +537,13 @@ export class CreateTenderModalComponent implements OnInit { this.tenderLoading = true; const formattedDate = this.formatDateForAPI(this.expectedCompletionDate); - this.quoteService.updateQuoteDate(this.createdQuoteId, formattedDate, 'expected').subscribe({ + this.quoteService.updateQuoteDate(this.createdQuoteId, formattedDate, 'effective').subscribe({ next: (updatedTender: any) => { - console.log('Effective completion date updated:', updatedTender); this.expectedDateSet = true; this.notificationService.showSuccess('Effective completion date set successfully!'); this.tenderLoading = false; + // Emit update event so parent can refresh the tender list + this.tenderUpdated.emit(); }, error: (error: any) => { console.error('Error setting effective date:', error); @@ -520,12 +565,13 @@ export class CreateTenderModalComponent implements OnInit { this.tenderLoading = true; const formattedDate = this.formatDateForAPI(this.requestedCompletionDate); - this.quoteService.updateQuoteDate(this.createdQuoteId, formattedDate, 'requested').subscribe({ + this.quoteService.updateQuoteDate(this.createdQuoteId, formattedDate, 'expectedFulfillment').subscribe({ next: (updatedTender: any) => { - console.log('Expected fulfillment start date updated:', updatedTender); this.requestedDateSet = true; this.notificationService.showSuccess('Expected fulfillment start date set successfully!'); this.tenderLoading = false; + // Emit update event so parent can refresh the tender list + this.tenderUpdated.emit(); }, error: (error: any) => { console.error('Error setting expected fulfillment date:', error); @@ -569,9 +615,29 @@ export class CreateTenderModalComponent implements OnInit { this.quoteService.addAttachmentToQuote(this.createdQuoteId, this.selectedPdfFile, '').subscribe({ next: (updatedQuote: any) => { - console.log('PDF attachment uploaded:', updatedQuote); this.pdfAttachmentSet = true; - this.existingAttachment = updatedQuote.attachment || null; + + // Extract attachment from quoteItem (where it's actually stored) + if (updatedQuote.quoteItem && updatedQuote.quoteItem.length > 0) { + const firstItem = updatedQuote.quoteItem[0]; + if (firstItem.attachment && firstItem.attachment.length > 0) { + const att = firstItem.attachment[0]; + this.existingAttachment = { + name: att.name || 'attachment.pdf', + mimeType: att.mimeType || 'application/pdf', + content: att.content || '', + size: att.size?.amount + }; + } + } + + // Reset the file input to show the updated state + this.selectedPdfFile = null; + const fileInput = document.getElementById('pdfFile') as HTMLInputElement; + if (fileInput) { + fileInput.value = ''; + } + this.notificationService.showSuccess('PDF attachment uploaded successfully!'); this.tenderLoading = false; }, @@ -607,23 +673,32 @@ export class CreateTenderModalComponent implements OnInit { } /** - * TODO: Load providers - needs to be implemented with proper provider service - * For now using placeholder + * Load providers from the provider API */ loadTenderProviders() { this.tenderLoading = true; this.tenderError = null; - // TODO: Replace with actual provider service call - // For now, just load invited providers - console.warn('Provider loading not yet implemented - needs provider service'); - this.tenderProviders = []; - this.tenderLoading = false; + console.log('Loading providers from API...'); - // Load invited providers - if (this.tenderCreationStep === 3) { - this.loadInvitedProviders(); - } + this.providerService.getProvidersForTender().subscribe({ + next: (providers) => { + console.log('Providers loaded successfully:', providers.length); + this.tenderProviders = providers; + this.tenderLoading = false; + + // Load invited providers after providers are loaded + if (this.tenderCreationStep === 3) { + this.loadInvitedProviders(); + } + }, + error: (error) => { + console.error('Error loading providers:', error); + this.tenderError = 'Failed to load providers. Please try again.'; + this.tenderProviders = []; + this.tenderLoading = false; + } + }); } toggleProviderSelection(providerId: string) { diff --git a/tailwind.config.js b/tailwind.config.js index 85b30dbb..acdd6b6e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -56,6 +56,7 @@ module.exports = { }, gridTemplateColumns: { + '16': 'repeat(16, minmax(0, 1fr))', '60/40': '60% 40%', '80/20': '80% 20%', '40/60': '40% 60%', From 9b91e01fb89e6cb56dae3911347e654c9d266df4 Mon Sep 17 00:00:00 2001 From: BazRoe Date: Thu, 11 Dec 2025 15:27:42 +0100 Subject: [PATCH 3/5] removed "rejected" from quote list filters --- src/app/features/quotes/pages/quote-list/quote-list.component.ts | 1 - 1 file changed, 1 deletion(-) 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 5984c296..57eafcaf 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 @@ -76,7 +76,6 @@ import { LoginInfo } from 'src/app/models/interfaces'; - From 4802e955c89c1fe8515a472e3824804d632475ac Mon Sep 17 00:00:00 2001 From: BazRoe Date: Fri, 12 Dec 2025 17:04:12 +0100 Subject: [PATCH 4/5] Added advanced Search engine developed by Eurodyn - Introduced new interfaces for FilterOptions and SearchOrganizationsFilters to manage filtering criteria. - Implemented methods in ProviderService to fetch filter options and providers based on selected filters. - Enhanced CreateTenderModalComponent to include multi-select dropdowns for countries, categories, and compliance levels, allowing users to filter providers effectively. - Updated environment configurations to include the searchOrganizationsEndpoint for API calls. --- src/app/models/filter-options.model.ts | 5 + .../search-organizations-filters.model.ts | 37 ++ src/app/services/provider.service.ts | 58 +++- .../create-tender-modal.component.ts | 318 +++++++++++++++--- src/environments/environment.development.ts | 3 + src/environments/environment.production.ts | 2 + src/environments/environment.ts | 2 + 7 files changed, 379 insertions(+), 46 deletions(-) create mode 100644 src/app/models/filter-options.model.ts create mode 100644 src/app/models/search-organizations-filters.model.ts diff --git a/src/app/models/filter-options.model.ts b/src/app/models/filter-options.model.ts new file mode 100644 index 00000000..da464c39 --- /dev/null +++ b/src/app/models/filter-options.model.ts @@ -0,0 +1,5 @@ +export interface FilterOptions { + categories: string[]; + countries: string[]; + complianceLevels: string[]; +} diff --git a/src/app/models/search-organizations-filters.model.ts b/src/app/models/search-organizations-filters.model.ts new file mode 100644 index 00000000..98a13ede --- /dev/null +++ b/src/app/models/search-organizations-filters.model.ts @@ -0,0 +1,37 @@ +export interface SearchOrganizationsFilters { + categories: string[]; + countries: string[]; + complianceLevels: string[]; +} + +const regionNames = new Intl.DisplayNames(['en'], { type: 'region' }); + +const codeAliases: Record = { + EL: 'GR', // Greece + UK: 'GB', // United Kingdom +}; + +export function countryName(code: string | null | undefined): string { + if( code?.length !== 2 ) return code ?? ''; + if (!code) return ''; + const upper = code.toUpperCase(); + const normalized = codeAliases?.[upper] ?? upper; + return regionNames.of(normalized) ?? upper; +} + +export function complianceLevelsName(code: string | null | undefined): string { + if (!code) return ''; + + const upper = code.toUpperCase(); + + const map: Record = { + 'BL': 'Baseline', + 'P': 'Professional', + 'P+': 'Professional+', + }; + + return map[upper] ?? upper; +} + + + diff --git a/src/app/services/provider.service.ts b/src/app/services/provider.service.ts index bf3165fd..8ff504af 100644 --- a/src/app/services/provider.service.ts +++ b/src/app/services/provider.service.ts @@ -1,7 +1,9 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; -import { Observable, map, catchError, of } from 'rxjs'; +import { Observable, map, catchError, of, forkJoin } from 'rxjs'; import { environment } from '../../environments/environment'; +import { FilterOptions } from '../models/filter-options.model'; +import { SearchOrganizationsFilters } from '../models/search-organizations-filters.model'; export interface Provider { id?: string; @@ -18,7 +20,7 @@ export interface Provider { }) export class ProviderService { private http = inject(HttpClient); - private readonly endpoint = `${environment.BASE_URL}/party/v4/organization`; + private readonly endpoint = `${environment.BASE_URL}/party/party/v4/organization`; //TODO FOR DEV ONLY //private readonly endpoint = `${environment.BASE_URL}/party/organization`; @@ -85,5 +87,57 @@ export class ProviderService { }) ); } + + + getProvidersForTenderNew(filters: SearchOrganizationsFilters): Observable { + const url = environment.searchOrganizationsEndpoint; + + return this.http.post(url, filters).pipe( + map((response) => { + if (Array.isArray(response)) return response as Provider[]; + if (response?.data && Array.isArray(response.data)) return response.data as Provider[]; + return []; + }), + catchError((error) => { + console.warn('Providers for tender (new) API failed:', error); + return of([]); + }) + ); + } + + //Methods for the search engine + getFilterOptions(): Observable { + const base = environment.searchOrganizationsEndpoint.replace(/\/searchOrganizations$/, ''); + const categories$ = this.http.get(`${base}/categories`).pipe( + map(res => (Array.isArray(res) ? res : Array.isArray(res?.data) ? res.data : [])), + catchError(err => { + console.warn('Categories API failed:', err); + return of([]); + }) + ); + + const countries$ = this.http.get(`${base}/countries`).pipe( + map(res => (Array.isArray(res) ? res : Array.isArray(res?.data) ? res.data : [])), + catchError(err => { + console.warn('Countries API failed:', err); + return of([]); + }) + ); + + const complianceLevels$ = this.http.get(`${base}/complianceLevels`).pipe( + map(res => (Array.isArray(res) ? res : Array.isArray(res?.data) ? res.data : [])), + catchError(err => { + console.warn('ComplianceLevels API failed:', err); + return of([]); + }) + ); + + return forkJoin({ + categories: categories$, + countries: countries$, + complianceLevels: complianceLevels$, + }); + } + } 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 ee9f2b13..f275a0e8 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,11 +11,14 @@ 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 { SearchOrganizationsFilters, countryName, complianceLevelsName } from 'src/app/models/search-organizations-filters.model'; +import { FormControl } from '@angular/forms'; +import { ReactiveFormsModule } from '@angular/forms'; @Component({ selector: 'app-create-tender-modal', standalone: true, - imports: [CommonModule, FormsModule], + imports: [CommonModule, FormsModule, ReactiveFormsModule], template: `
    @@ -263,8 +266,79 @@ import { API_ROLES } from 'src/app/models/roles.constants'; Select Providers to Invite +
    + +
    + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    +
    +
    +
    -
    + + +
    + +
    -
    -

    No more providers available. All providers have been invited.

    +
    + +

    + No more providers available. All providers have been invited. +

    +
    + +

    + No filters Selected. Adjust Countries/Categories and click Search. +

    +
    @@ -370,6 +453,31 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { tenderError: string | null = null; currentUserId: string | null = null; + // Filter options + countriesOptions: string[] = []; + categoriesOptions: string[] = []; + complianceLevelsOptions: string[] = []; + _safeInvitedList: Provider[] = []; + + // Form controls for filters + countriesCtrl = new FormControl([], { nonNullable: true }); + categoriesCtrl = new FormControl([], { nonNullable: true }); + complianceLevelsCtrl = new FormControl([], { nonNullable: true }); + + // Default organization search filters + orgFilters: SearchOrganizationsFilters = { + categories: [], + countries: [], + complianceLevels: [] + }; + + // Helper functions for display + complianceLevelsName = complianceLevelsName; + countryName = countryName; + + // Available providers list + availableProviders: Provider[] = []; + // Tender form fields - Step 1: Title only tenderTitle: string = ''; @@ -402,6 +510,9 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { this.currentUserId = loggedOrg?.partyId; } } + + // Load filter options + this.loadFilterOptions(); } ngOnChanges(changes: SimpleChanges) { @@ -681,32 +792,107 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { console.log('Loading providers from API...'); - this.providerService.getProvidersForTender().subscribe({ + this.providerService.getProvidersForTenderNew(this.orgFilters).subscribe({ next: (providers) => { - console.log('Providers loaded successfully:', providers.length); this.tenderProviders = providers; this.tenderLoading = false; + this.updateAvailableProviders(); - // Load invited providers after providers are loaded + // After providers are loaded, load invited providers (if in edit mode) if (this.tenderCreationStep === 3) { this.loadInvitedProviders(); } }, - error: (error) => { - console.error('Error loading providers:', error); - this.tenderError = 'Failed to load providers. Please try again.'; - this.tenderProviders = []; + error: (err) => { + this.tenderError = 'Failed to load providers: ' + (err.message || 'Unknown error'); this.tenderLoading = false; + console.error('Error loading tender providers:', err); } }); } + /** + * Emit filter changes and reload providers + */ + emitFilters(): void { + const newFilters: SearchOrganizationsFilters = { + countries: this.countriesCtrl.value ?? [], + categories: this.categoriesCtrl.value ?? [], + complianceLevels: this.complianceLevelsCtrl.value ?? [] + }; + console.log(newFilters); + this.orgFilters = newFilters; + this.loadTenderProviders(); + } + + /** + * Are any filters currently active? + */ + hasActiveFilters(): boolean { + const hasCountries = (this.orgFilters.countries?.length ?? 0) == 0; + const hasCategories = (this.orgFilters.categories?.length ?? 0) == 0; + const hasComplianceLevels = (this.orgFilters.complianceLevels?.length ?? 0) == 0; + + return hasCountries && hasCategories && hasComplianceLevels; + } + + /** + * Clear all filters + */ + clearFilters() { + // Reset both controls to empty arrays (and emit change) + this.countriesCtrl.setValue([], { emitEvent: true }); + this.categoriesCtrl.setValue([], { emitEvent: true }); + this.complianceLevelsCtrl.setValue([], { emitEvent: true }); + + // If you rely on (change) only, also call emit explicitly: + this.emitFilters(); + } + toggleProviderSelection(providerId: string) { - if (this.selectedProviders.has(providerId)) { - this.selectedProviders.delete(providerId); + // find in local safe list (which stores { provider, quoteId }) + const idx = this._safeInvitedList.findIndex(x => x?.id === providerId); + + if (idx >= 0) { + // UNCHECK → remove from local safe list + this._safeInvitedList.splice(idx, 1); } else { - this.selectedProviders.add(providerId); + // CHECK → add to local safe list + const p = this.tenderProviders.find(tp => tp.id === providerId); + if (p) { + this._safeInvitedList.push(p); + } } + + // Re-derive selectedProviders + available list in one place + this.rebuildSelectionAndAvailable(); + } + + private rebuildSelectionAndAvailable(): Provider[] { + // 1) selectedProviders = IDs from local safe list + this.selectedProviders = new Set( + this._safeInvitedList + .map(x => x?.id) + .filter((id): id is string => !!id) + ); + + // 2) all IDs that must be excluded from availability (server invited + locally selected) + const excludeIds = new Set([ + ...this.invitedProviders + .map(ip => ip?.provider?.id) + .filter((id): id is string => !!id), + ...Array.from(this.selectedProviders), + ]); + + // 3) compute available list + const available = this.tenderProviders + .filter(p => !!p?.id && !excludeIds.has(p.id!)) + .map(p => ({ ...p } as Provider)); + + // keep a cached copy if you want to bind directly in template + this.availableProviders = available; + + return available; } /** @@ -779,12 +965,19 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { return dateString; } + /** + * Update available providers list + */ + updateAvailableProviders(): void { + this.availableProviders = this.getAvailableProviders(); + } + /** * Get available providers (excluding already invited ones) */ getAvailableProviders(): Provider[] { - const invitedProviderIds = new Set(this.invitedProviders.map(ip => ip.provider.id)); - return this.tenderProviders.filter(p => p.id && !invitedProviderIds.has(p.id)); + // Simple and clean — everything is handled by the helper + return this.rebuildSelectionAndAvailable(); } /** @@ -807,41 +1000,45 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { console.log('Creating tendering quotes for providers:', providerIds); - // Create tendering quotes for multiple providers - this.quoteService.createMultipleTenderingQuotes( - this.currentUserId, - providerIds, - this.createdQuoteId, - customerMessage - ).subscribe({ - next: (createdTenders) => { - console.log('Tendering quotes created:', createdTenders); - + // Create tendering quotes one by one to capture individual quote IDs + const requests = providerIds.map(providerId => { + const provider = this._safeInvitedList.find(p => p.id === providerId); + + return this.quoteService.createTenderingQuote( + this.currentUserId!, + providerId, + this.createdQuoteId!, + customerMessage + ).toPromise().then(tender => { + if (!tender || !tender.id || !provider) { + throw new Error('Failed to create quote for provider'); + } + return { + provider: provider, + quoteId: tender.id + }; + }); + }); + + Promise.all(requests) + .then(results => { + console.log('Tendering quotes created:', results); + // Add to invited providers list - createdTenders.forEach((tender, index) => { - if (tender.id) { - const provider = this.tenderProviders.find(p => p.id === providerIds[index]); - if (provider) { - this.invitedProviders.push({ - provider: provider, - quoteId: tender.id - }); - } - } - }); - - // Clear selection + this.invitedProviders.push(...results); + + // Clear selection and safe list this.selectedProviders.clear(); - + this._safeInvitedList = []; + this.notificationService.showSuccess(`${providerIds.length} provider(s) invited successfully!`); this.tenderLoading = false; - }, - error: (error) => { + }) + .catch(error => { console.error('Error creating tendering quotes:', error); this.notificationService.showError('Failed to invite providers: ' + (error.message || 'Unknown error')); this.tenderLoading = false; - } - }); + }); } /** @@ -945,4 +1142,37 @@ export class CreateTenderModalComponent implements OnInit, OnChanges { } }); } + + /** + * Load filter options (countries, categories, compliance levels) + */ + private loadFilterOptions(): void { + this.clearFilters(); + this.providerService.getFilterOptions().subscribe({ + next: ({ categories, countries, complianceLevels }) => { + this.categoriesOptions = categories ?? []; + this.countriesOptions = countries ?? []; + this.complianceLevelsOptions = complianceLevels ?? []; + }, + error: (err) => { + console.warn('Failed to load filter options', err); + } + }); + } + + /** + * Toggle selection in multi-select dropdown + */ + toggleFromSelect(ctrl: FormControl, value: string, event: MouseEvent) { + event.preventDefault(); // stop native multi-select behavior + event.stopPropagation(); + + const cur = ctrl.value ?? []; + const next = cur.includes(value) + ? cur.filter(v => v !== value) + : [...cur, value]; + + ctrl.setValue(next); + } } + diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index 0128d711..0ea16661 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -19,6 +19,9 @@ export const environment = { BILLING: '/billing', CHARGING: '/charging', + searchOrganizationsEndpoint: 'http://dome-search-svc.search-engine.svc.cluster.local:8080/api/searchOrganizations', + //searchOrganizationsEndpoint: 'org-api/searchOrganizations', + CUSTOMER_BILLING:'/customerBill', CONSUMER_BILLING_URL: 'http://localhost:8640', INVOICE_LIMIT: 100, diff --git a/src/environments/environment.production.ts b/src/environments/environment.production.ts index 2d25a751..6b93637f 100644 --- a/src/environments/environment.production.ts +++ b/src/environments/environment.production.ts @@ -18,6 +18,8 @@ export const environment = { BILLING: '/billing', CHARGING: '/charging', + searchOrganizationsEndpoint: 'http://dome-search-svc.search-engine.svc.cluster.local:8080/api/searchOrganizations', + CONSUMER_BILLING_URL: 'http://localhost:8640', INVOICE_LIMIT: 100, diff --git a/src/environments/environment.ts b/src/environments/environment.ts index c164ab9d..28532d5f 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -19,6 +19,8 @@ export const environment = { CHARGING: '/charging', BILLING: '/billing', + searchOrganizationsEndpoint: 'http://dome-search-svc.search-engine.svc.cluster.local:8080/api/searchOrganizations', + CUSTOMER_BILLING:'/customerBill', CONSUMER_BILLING_URL: 'http://localhost:8640', //API PAGINATION From 355d5a62d560989193f5d59c36cababaa7b0bea2 Mon Sep 17 00:00:00 2001 From: BazRoe Date: Sat, 13 Dec 2025 16:07:50 +0100 Subject: [PATCH 5/5] Update URL to call TMF party organizations --- src/app/services/provider.service.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app/services/provider.service.ts b/src/app/services/provider.service.ts index b3cda2c7..ee79ccc3 100644 --- a/src/app/services/provider.service.ts +++ b/src/app/services/provider.service.ts @@ -20,9 +20,7 @@ export interface Provider { }) export class ProviderService { private http = inject(HttpClient); - private readonly endpoint = `${environment.BASE_URL}/party/v4/organization`; - //TODO FOR DEV ONLY - //private readonly endpoint = `${environment.BASE_URL}/party/organization`; + private readonly endpoint = `${environment.BASE_URL}/party/organization`; getProviders(params: { fields?: string; offset?: number; limit?: number } = {}): Observable { let httpParams = new HttpParams();