From 372dc0b702e8f96ab463ed84367c093dc57e655c Mon Sep 17 00:00:00 2001 From: BazRoe Date: Thu, 22 Jan 2026 16:49:50 +0100 Subject: [PATCH 1/2] Made various UI changes on the Tailored management system - Updated the quote list component to improve the display of quotes, including enriched data for organizations and products. - Introduced a new constants file for quote status messages and actions to standardize messaging across the application. - Enhanced the chat modal to include message timestamps and improved message formatting. - Updated the quote details modal to provide clearer information about buyers and sellers, including VAT IDs and product details. - Improved the quote request modal with better validation and user feedback for quote submissions. --- .../pages/quote-list/quote-list.component.ts | 408 ++++++---- src/app/models/quote.constants.ts | 99 +++ .../shared/chat-modal/chat-modal.component.ts | 37 +- .../quote-details-modal.component.ts | 707 +++++++++++++++++- .../quote-request-modal.component.ts | 270 +++++-- 5 files changed, 1233 insertions(+), 288 deletions(-) create mode 100644 src/app/models/quote.constants.ts 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 969e7715..5b637931 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 @@ -2,9 +2,13 @@ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; +import { forkJoin, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; import { QuoteService } from '../../services/quote.service'; -import {LocalStorageService} from "src/app/services/local-storage.service"; +import { LocalStorageService } from "src/app/services/local-storage.service"; import { NotificationService } from 'src/app/services/notification.service'; +import { AccountServiceService } from 'src/app/services/account-service.service'; +import { ApiServiceService } from 'src/app/services/product-service.service'; import { Quote, QuoteStateType } from 'src/app/models/quote.model'; import { NotificationComponent } from 'src/app/shared/notification/notification.component'; import { ConfirmDialogComponent } from 'src/app/shared/confirm-dialog/confirm-dialog.component'; @@ -12,6 +16,7 @@ import { QuoteDetailsModalComponent } from 'src/app/shared/quote-details-modal/q import { ChatModalComponent } from 'src/app/shared/chat-modal/chat-modal.component'; import { AttachmentModalComponent } from 'src/app/shared/attachment-modal/attachment-modal.component'; import { LoginInfo } from 'src/app/models/interfaces'; +import { environment } from 'src/environments/environment'; @Component({ selector: 'app-quote-list', @@ -20,9 +25,22 @@ import { LoginInfo } from 'src/app/models/interfaces'; template: ` -
+
-

Quotes

+
+

Tailored offerings Dashboard

+ + + + + +
-
-
-
ORDER ID
+
+
+
REQUEST DATE
+
{{ selectedRole === 'customer' ? 'PROVIDER' : 'CUSTOMER' }}
+
PRODUCT
STATUS
-
REQUESTED DATE
-
EXPECTED DATE
-
ACTIONS
+
ACTIONS
-
- - -
- Quote {{ extractShortId(quote.id) }} + + +
+ {{ quote.quoteDate | date:'dd-MM-yyyy' }}
- + + +
+ {{ getOtherPartyName(quote) }} +
+ + +
+ {{ getProductName(quote) }} +
+
- - -
- {{ quote.requestedQuoteCompletionDate | date:'dd/MM/yyyy' }} -
- - -
- {{ quote.expectedQuoteCompletionDate | date:'dd/MM/yyyy' }} -
- - -
- - - - + +
- - - - - - - - - - + - - - - - - - - - - - - - - - - - - - -
@@ -357,7 +259,10 @@ import { LoginInfo } from 'src/app/models/interfaces'; @@ -456,8 +361,10 @@ import { LoginInfo } from 'src/app/models/interfaces'; export class QuoteListComponent implements OnInit { private router = inject(Router); private quoteService = inject(QuoteService); - private localStorage = inject(LocalStorageService,); + private localStorage = inject(LocalStorageService); private notificationService = inject(NotificationService); + private accountService = inject(AccountServiceService); + private productService = inject(ApiServiceService); quotes: Quote[] = []; filteredQuotes: Quote[] = []; @@ -467,6 +374,10 @@ export class QuoteListComponent implements OnInit { deleteConfirmMessage = ''; quoteToDelete: Quote | null = null; + // Data enrichment maps + organizationNames: Map = new Map(); + productNames: Map = new Map(); + // State update modal showStateUpdate = false; quoteToUpdate: Quote | null = null; @@ -527,13 +438,18 @@ export class QuoteListComponent implements OnInit { this.quoteService.getQuotesByUserAndRole(this.currentUserId, this.selectedRole).subscribe({ next: (quotes) => { this.quotes = quotes; - - // Debug: Log quote states + + // Debug: Log quote states and product info console.log('Loaded quotes:', quotes.length); quotes.forEach(quote => { console.log(`Quote ${this.extractShortId(quote.id)}: main state = "${quote.state}", primary state = "${this.getPrimaryState(quote)}"`); + const productOffering = quote.quoteItem?.[0]?.productOffering; + console.log(` - productOffering:`, productOffering); }); - + + // Enrich quote data with organization and product names + this.enrichQuoteData(quotes); + this.filterQuotesByStatus(); this.loading = false; }, @@ -545,6 +461,83 @@ export class QuoteListComponent implements OnInit { }); } + /** + * Check if an ID is an organization URN that can be fetched from the API + */ + private isOrganizationId(id: string): boolean { + return id.startsWith('urn:ngsi-ld:organization:'); + } + + /** + * Enrich quote data by fetching organization names and product names + */ + private enrichQuoteData(quotes: Quote[]) { + // Collect unique organization IDs and product offering IDs + const orgIds = new Set(); + const productIds = new Set(); + + quotes.forEach(quote => { + // Collect organization IDs from relatedParty (only valid organization URNs) + quote.relatedParty?.forEach(party => { + if (party.id && !this.organizationNames.has(party.id) && this.isOrganizationId(party.id)) { + orgIds.add(party.id); + } + }); + + // Collect product offering IDs from quoteItems + quote.quoteItem?.forEach(item => { + if (item.productOffering?.id && !this.productNames.has(item.productOffering.id)) { + productIds.add(item.productOffering.id); + } + }); + }); + + // Fetch organization names in parallel + if (orgIds.size > 0) { + const orgRequests = Array.from(orgIds).map(id => { + return this.accountService.getOrgInfo(id).then( + (org: any) => ({ id, name: org?.tradingName || org?.name || id }), + () => ({ id, name: id }) // Fallback to ID on error + ); + }); + + Promise.all(orgRequests).then(results => { + results.forEach(({ id, name }) => { + this.organizationNames.set(id, name); + }); + // Trigger change detection by reassigning filteredQuotes + this.filteredQuotes = [...this.filteredQuotes]; + }); + } + + // Fetch product names in parallel + console.log('Product IDs to fetch:', Array.from(productIds)); + if (productIds.size > 0) { + const productRequests = Array.from(productIds).map(id => { + console.log('Fetching product:', id); + return this.productService.getProductById(id).then( + (product: any) => { + console.log('Product fetched:', id, product?.name); + return { id, name: product?.name || id }; + }, + (error) => { + console.error('Product fetch error:', id, error); + return { id, name: id }; // Fallback to ID on error + } + ); + }); + + Promise.all(productRequests).then(results => { + results.forEach(({ id, name }) => { + this.productNames.set(id, name); + }); + console.log('Product names map:', Array.from(this.productNames.entries())); + // Trigger change detection by reassigning filteredQuotes + this.filteredQuotes = [...this.filteredQuotes]; + }); + } + } + refreshQuotes() { this.loadQuotes(); } @@ -569,6 +562,13 @@ export class QuoteListComponent implements OnInit { return primaryState === this.statusFilter; }); } + + // Sort by quoteDate descending (newest first) + this.filteredQuotes.sort((a, b) => { + const dateA = a.quoteDate ? new Date(a.quoteDate).getTime() : 0; + const dateB = b.quoteDate ? new Date(b.quoteDate).getTime() : 0; + return dateB - dateA; + }); } createQuote() { @@ -590,6 +590,19 @@ export class QuoteListComponent implements OnInit { this.selectedQuoteId = null; } + onQuoteUpdated(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(); + } + + const shortId = this.extractShortId(updatedQuote.id); + const state = this.getPrimaryState(updatedQuote); + this.notificationService.showSuccess(`Quote ${shortId} has been updated to ${state}.`); + } + closeChatModal() { this.showChatModal = false; this.selectedChatQuoteId = null; @@ -875,6 +888,75 @@ export class QuoteListComponent implements OnInit { return id.length > 8 ? id.slice(-8) : id; } + /** + * Get the name of the other party based on the current role view. + * If viewing "as Customer" → show Provider/Seller name + * If viewing "as Provider" → show Buyer name + */ + getOtherPartyName(quote: Quote): string { + if (!quote.relatedParty || quote.relatedParty.length === 0) { + return 'Unknown'; + } + + // Determine which role to look for based on current view + // Use environment roles: BUYER_ROLE = 'Buyer', SELLER_ROLE = 'Seller' + const targetRole = this.selectedRole === 'customer' + ? environment.SELLER_ROLE + : environment.BUYER_ROLE; + + // Find the party with the target role (case-insensitive) + const party = quote.relatedParty.find(p => + p.role?.toLowerCase() === targetRole.toLowerCase() + ); + + if (!party?.id) { + return 'Unknown'; + } + + // Look up the name from the enriched data map + const enrichedName = this.organizationNames.get(party.id); + if (enrichedName && enrichedName !== party.id) { + return enrichedName; + } + + // Fallback: show loading indicator or shortened ID + return 'Loading...'; + } + + /** + * Get the product name from the quote's first quote item. + */ + getProductName(quote: Quote): string { + if (!quote.quoteItem || quote.quoteItem.length === 0) { + return 'Unknown Product'; + } + + const firstItem = quote.quoteItem[0]; + const productId = firstItem.productOffering?.id; + + // Try to get name from productOffering if available (inline in quote data) + if (firstItem.productOffering?.name) { + return firstItem.productOffering.name; + } + + // Look up the name from the enriched data map + if (productId) { + const enrichedName = this.productNames.get(productId); + if (enrichedName) { + return enrichedName; + } + // Still loading + return 'Loading...'; + } + + // Fallback to product name + if (firstItem.product?.name) { + return firstItem.product.name; + } + + return 'Unknown Product'; + } + 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) { diff --git a/src/app/models/quote.constants.ts b/src/app/models/quote.constants.ts new file mode 100644 index 00000000..52631f8c --- /dev/null +++ b/src/app/models/quote.constants.ts @@ -0,0 +1,99 @@ +/** + * Quote status constants and messages used throughout the application + */ + +export interface StatusInfo { + explanation: string; + availableActions: string; +} + +export interface StatusMessages { + provider: StatusInfo; + buyer: StatusInfo; +} + +/** + * Quote status explanation messages for each state and role + */ +export const QUOTE_STATUS_MESSAGES: Record = { + pending: { + provider: { + explanation: 'At this stage you can decide whether to accept or reject the quote request.', + availableActions: 'Send messages to customer (chat), accept or decline the quote.' + }, + buyer: { + explanation: 'The quote is created and sent to the provider. At this stage the provider must either accept or decline the request.', + availableActions: 'Send messages to provider (chat), view quote request details or cancel the request.' + } + }, + inProgress: { + provider: { + explanation: "At this point you need to provide a proposal in the form of a PDF attachment.", + availableActions: 'Send messages to customer (chat), upload attachment or decline the quote.' + }, + buyer: { + explanation: 'Your quote request is being processed. The provider is currently building the proposal based on your specifications. You will be notified once the quote is ready for review and a PDF is uploaded.', + availableActions: 'Send messages to provider (chat), view quote details or cancel the request.' + } + }, + approved: { + provider: { + explanation: 'The quote details are now locked and cannot be modified. The quote has been sent to the customer. You should wait for the customer to review the proposal and use the chat to get in touch with them.', + availableActions: 'Send messages to customer (chat) or cancel the quote.' + }, + buyer: { + explanation: 'The provider has approved and sent you a quote proposal. Please review the details and attached documents. You can accept, reject, or request updates through the chat.', + availableActions: 'Send messages to provider (chat), accept the proposal or reject the proposal or cancel the quote.' + } + }, + accepted: { + provider: { + explanation: 'The customer has accepted your quote proposal. You should now create a customized version of the offering that will be visible only to the customer. The chat remains open for further communication.', + availableActions: 'Send messages to customer (chat), create customized offering.' + }, + buyer: { + explanation: 'You have accepted the quote proposal. The provider will now create a customized offering based on this agreement. Once available, you can proceed with the normal order process to subscribe to the service.', + availableActions: 'Send messages to provider (chat).' + } + }, + rejected: { + provider: { + explanation: 'The customer has rejected this quote proposal. The negotiation process has ended. You can reach out to the customer through the chat and consider submitting a new proposal.', + availableActions: 'View quote details and chat history.' + }, + buyer: { + explanation: 'You have rejected this quote proposal. The negotiation process has been closed. If you wish, you can submit a new quote request with updated requirements.', + availableActions: 'View quote details and chat history.' + } + }, + cancelled: { + provider: { + explanation: 'This quote has been cancelled.', + availableActions: 'View quote details and chat history. No further actions available.' + }, + buyer: { + explanation: 'This quote has been cancelled.', + availableActions: 'View quote details and chat history. No further actions available.' + } + } +}; + +/** + * Auto-generated chat messages for status changes + */ +export const QUOTE_CHAT_MESSAGES = { + STATUS_CHANGE: (status: string) => `Status changed to: ${status}`, + ATTACHMENT_UPLOADED: (filename: string) => `Attachment uploaded: ${filename}` +}; + +/** + * Action button helper texts displayed next to buttons in quote details modal + */ +export const QUOTE_ACTION_BUTTON_TEXTS = { + ACCEPT_QUOTE_PROVIDER: 'Accept the request of the customer.', + ACCEPT_PROPOSAL_CUSTOMER: 'Accept the proposal that has been sent to you by the provider.', + CANCEL_QUOTE_PROVIDER: 'Cancel the quote request.', + REJECT_PROPOSAL_CUSTOMER: 'Reject the proposal.', + CREATE_OFFER: 'Create a customized offering based on this accepted quote.', + EXPECTED_DATE_REQUIRED: 'Set an expected date for the delivery of the proposal first' +}; diff --git a/src/app/shared/chat-modal/chat-modal.component.ts b/src/app/shared/chat-modal/chat-modal.component.ts index 9cf60ee4..6740002f 100644 --- a/src/app/shared/chat-modal/chat-modal.component.ts +++ b/src/app/shared/chat-modal/chat-modal.component.ts @@ -62,16 +62,22 @@ type QuoteNote = Note;
-
-
{{ message.text }}
+
+ {{ formatMessageDate(message.date) }} +
@@ -87,7 +93,7 @@ type QuoteNote = Note; placeholder="Type a message..." required [disabled]="isSending" - (keydown.enter)="$event.preventDefault(); sendMessage()" + (keydown.enter)="onEnterKey($event)" /> @@ -220,10 +226,31 @@ export class ChatModalComponent implements OnInit, OnDestroy, OnChanges { } } + onEnterKey(event: Event) { + event.preventDefault(); + this.sendMessage(); + } + isMyMessage(message: QuoteNote): boolean { return message.author === this.currentUserId; } + formatMessageDate(dateString: string | undefined): string { + if (!dateString) return ''; + try { + const date = new Date(dateString); + // Format: DD/MM/YYYY HH:mm + const day = date.getDate().toString().padStart(2, '0'); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const year = date.getFullYear(); + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + return `${day}/${month}/${year} ${hours}:${minutes}`; + } catch { + return dateString; + } + } + getShortQuoteId(): string { if (!this.quoteId) return ''; return this.quoteId.length > 8 ? this.quoteId.slice(-8) : this.quoteId; diff --git a/src/app/shared/quote-details-modal/quote-details-modal.component.ts b/src/app/shared/quote-details-modal/quote-details-modal.component.ts index d76c3783..c17d5c37 100644 --- a/src/app/shared/quote-details-modal/quote-details-modal.component.ts +++ b/src/app/shared/quote-details-modal/quote-details-modal.component.ts @@ -1,32 +1,40 @@ -import { Component, Input, Output, EventEmitter, OnInit, OnChanges } from '@angular/core'; +import { Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { switchMap } from 'rxjs/operators'; import { QuoteService } from 'src/app/features/quotes/services/quote.service'; import { NotificationService } from '../../services/notification.service'; +import { AccountServiceService } from '../../services/account-service.service'; +import { ApiServiceService } from '../../services/product-service.service'; import { Quote } from '../../models/quote.model'; +import { QUOTE_STATUS_MESSAGES, QUOTE_CHAT_MESSAGES, QUOTE_ACTION_BUTTON_TEXTS } from '../../models/quote.constants'; import { NotificationComponent } from '../notification/notification.component'; +import { ChatModalComponent } from '../chat-modal/chat-modal.component'; +import { environment } from 'src/environments/environment'; @Component({ selector: 'app-quote-details-modal', standalone: true, - imports: [CommonModule, NotificationComponent], + imports: [CommonModule, FormsModule, NotificationComponent, ChatModalComponent], template: ` -
-
-
-

Quote Details

+
+

Quote Details

-
-
+
+
@@ -45,26 +53,226 @@ import { NotificationComponent } from '../notification/notification.component';
-
- -
-

Customer Message

-
-

{{ quote.description || 'No message provided' }}

+
+ + +
+

Buyer Information

+
+ +
+

Buyer:

+

{{ buyerName }}

+

VAT ID: {{ buyerVatId }}

+
+ +
+

Buyer Operator:

+

{{ buyerOperatorName }}

+

VAT ID: {{ buyerOperatorVatId }}

+
+
+
+ + +
+

Seller Information

+
+ +
+

Seller:

+

{{ sellerName }}

+

VAT ID: {{ sellerVatId }}

+
+ +
+

Seller Operator:

+

{{ sellerOperatorName }}

+

VAT ID: {{ sellerOperatorVatId }}

+
+
+
+ + +
+

Product:

+

{{ productName }}

+
+ + +
+

Request:

+
+

{{ quote.description || 'No message provided' }}

+
+
+ + +
+
+

Requested Date:

+

+ {{ quote.requestedQuoteCompletionDate ? (quote.requestedQuoteCompletionDate | date:'dd-MM-yyyy') : '--' }} +

+
+
+

Expected Date:

+
+

+ {{ quote.expectedQuoteCompletionDate ? (quote.expectedQuoteCompletionDate | date:'dd-MM-yyyy') : '--' }} +

+ + +
+
+
+ + +
+
+

The quote is in status:

+ + {{ getPrimaryState() }} + +
+

{{ getStatusExplanation() }}

+

{{ getAvailableActionsText() }}

+
+ + +
+

Attachments

+ + +
+ + + + {{ getAttachmentName() }} + +
+ + +
+ +

Maximum file size: 2.5MB

+

Uploading...

+
+
+ + +
+
+ +
+

{{ ACTION_TEXTS.ACCEPT_QUOTE_PROVIDER }}

+ +
+ + +
+

{{ ACTION_TEXTS.ACCEPT_PROPOSAL_CUSTOMER }}

+ +
- - -
- Created: {{ quote.quoteDate | date:'dd/MM/yyyy' }} + + +
+
+

+ {{ ACTION_TEXTS.CANCEL_QUOTE_PROVIDER }} +

+ +
+
+ + +
+
+

{{ ACTION_TEXTS.CREATE_OFFER }}

+ +
-
+
+ + @@ -72,44 +280,120 @@ import { NotificationComponent } from '../notification/notification.component';
+ + + +
+
+

Set Expected Completion Date

+

Select when you expect to complete this quote:

+ +
+ + +
+
+
` }) export class QuoteDetailsModalComponent implements OnInit, OnChanges { @Input() isOpen = false; @Input() quoteId: string | null = null; + @Input() currentUserRole: 'customer' | 'seller' = 'customer'; + @Input() currentUserId: string = ''; @Output() close = new EventEmitter(); - - - + @Output() quoteUpdated = new EventEmitter(); quote: Quote | null = null; isLoading = false; error: string | null = null; + isProcessing = false; + isUploading = false; + + // Data enrichment + buyerName = 'Loading...'; + buyerVatId = 'N/A'; + buyerOperatorName = 'Loading...'; + buyerOperatorVatId = 'N/A'; + sellerName = 'Loading...'; + sellerVatId = 'N/A'; + sellerOperatorName = 'Loading...'; + sellerOperatorVatId = 'N/A'; + productName = 'Loading...'; - constructor( - public quoteService: QuoteService, - private notificationService: NotificationService - ) {} + // Expected date picker + showExpectedDatePicker = false; + selectedExpectedDate = ''; + + // Chat modal + showChatModal = false; + + // Expose constants to template + readonly ACTION_TEXTS = QUOTE_ACTION_BUTTON_TEXTS; + + // Services + private quoteService = inject(QuoteService); + private notificationService = inject(NotificationService); + private accountService = inject(AccountServiceService); + private productService = inject(ApiServiceService); + private router = inject(Router); + + get minDate(): string { + const today = new Date(); + return today.toISOString().split('T')[0]; + } ngOnInit() { - // Load quote when modal opens and quoteId is provided if (this.isOpen && this.quoteId) { this.loadQuote(this.quoteId); } } - ngOnChanges() { - // Load quote when quoteId changes or modal opens + ngOnChanges(changes: SimpleChanges) { if (this.isOpen && this.quoteId) { this.loadQuote(this.quoteId); } else if (!this.isOpen) { - // Reset state when modal closes - this.quote = null; - this.error = null; + this.resetState(); } } + private resetState() { + this.quote = null; + this.error = null; + this.buyerName = 'Loading...'; + this.buyerVatId = 'N/A'; + this.buyerOperatorName = 'Loading...'; + this.buyerOperatorVatId = 'N/A'; + this.sellerName = 'Loading...'; + this.sellerVatId = 'N/A'; + this.sellerOperatorName = 'Loading...'; + this.sellerOperatorVatId = 'N/A'; + this.productName = 'Loading...'; + this.showExpectedDatePicker = false; + this.selectedExpectedDate = ''; + } + loadQuote(id: string) { this.isLoading = true; this.error = null; @@ -118,6 +402,7 @@ export class QuoteDetailsModalComponent implements OnInit, OnChanges { next: (quote) => { this.quote = quote; this.isLoading = false; + this.enrichQuoteData(); }, error: (error: Error) => { this.error = 'Failed to load quote. Please try again.'; @@ -127,7 +412,355 @@ export class QuoteDetailsModalComponent implements OnInit, OnChanges { }); } + /** + * Check if an ID is an organization URN that can be fetched from the API + */ + private isOrganizationId(id: string | undefined): boolean { + return !!id && id.startsWith('urn:ngsi-ld:organization:'); + } + + /** + * Fetch party name from API if it's an organization ID, otherwise use the ID as-is + */ + private fetchPartyName( + partyId: string | undefined, + onSuccess: (name: string) => void, + fallbackValue: string = 'N/A' + ) { + if (!partyId) { + onSuccess(fallbackValue); + return; + } + + if (this.isOrganizationId(partyId)) { + this.accountService.getOrgInfo(partyId).then( + (org: any) => { + onSuccess(org?.tradingName || org?.name || partyId); + }, + () => { + onSuccess(partyId); + } + ); + } else { + // Not an organization ID, use the ID value directly + onSuccess(partyId); + } + } + + private enrichQuoteData() { + if (!this.quote) return; + + // Extract all 4 parties from relatedParty + const buyer = this.quote.relatedParty?.find(p => + p.role?.toLowerCase() === 'buyer' + ); + const buyerOperator = this.quote.relatedParty?.find(p => + p.role?.toLowerCase() === 'buyeroperator' + ); + const seller = this.quote.relatedParty?.find(p => + p.role?.toLowerCase() === 'seller' + ); + const sellerOperator = this.quote.relatedParty?.find(p => + p.role?.toLowerCase() === 'selleroperator' + ); + + // Get VAT IDs from relatedParty name field + this.buyerVatId = buyer?.name || 'N/A'; + this.buyerOperatorVatId = buyerOperator?.name || 'N/A'; + this.sellerVatId = seller?.name || 'N/A'; + this.sellerOperatorVatId = sellerOperator?.name || 'N/A'; + + // Fetch party names (only calls API for organization IDs) + this.fetchPartyName(buyer?.id, (name) => this.buyerName = name); + this.fetchPartyName(buyerOperator?.id, (name) => this.buyerOperatorName = name); + this.fetchPartyName(seller?.id, (name) => this.sellerName = name); + this.fetchPartyName(sellerOperator?.id, (name) => this.sellerOperatorName = name); + + // Fetch product info + const productOfferingId = this.quote.quoteItem?.[0]?.productOffering?.id; + if (productOfferingId) { + // First check if name is already in the quote data + const existingName = this.quote.quoteItem?.[0]?.productOffering?.name; + if (existingName) { + this.productName = existingName; + } else { + this.productService.getProductById(productOfferingId).then( + (product: any) => { + this.productName = product?.name || productOfferingId; + }, + () => { + this.productName = productOfferingId; + } + ); + } + } + } + + getPrimaryState(): string { + if (!this.quote) return 'unknown'; + if (Array.isArray(this.quote.quoteItem) && this.quote.quoteItem.length > 0) { + return this.quote.quoteItem[0].state || 'unknown'; + } + return this.quote.state || 'unknown'; + } + + getStatusBadgeClass(): string { + const state = this.getPrimaryState(); + const classes: Record = { + pending: 'bg-yellow-100 text-yellow-800', + inProgress: 'bg-blue-100 text-blue-800', + approved: 'bg-green-100 text-green-800', + accepted: 'bg-emerald-100 text-emerald-800', + rejected: 'bg-red-100 text-red-800', + cancelled: 'bg-gray-100 text-gray-800' + }; + return classes[state] || 'bg-gray-100 text-gray-600'; + } + + getStatusExplanation(): string { + const state = this.getPrimaryState(); + const role = this.currentUserRole === 'customer' ? 'buyer' : 'provider'; + return QUOTE_STATUS_MESSAGES[state]?.[role]?.explanation || 'Status information unavailable.'; + } + + getAvailableActionsText(): string { + const state = this.getPrimaryState(); + const role = this.currentUserRole === 'customer' ? 'buyer' : 'provider'; + return QUOTE_STATUS_MESSAGES[state]?.[role]?.availableActions || ''; + } + + isQuoteFinalized(): boolean { + const state = this.getPrimaryState(); + return state === 'cancelled' || state === 'accepted' || state === 'rejected'; + } + + hasAttachment(): boolean { + return this.quote?.quoteItem?.some(qi => qi.attachment && qi.attachment.length > 0) || false; + } + + getAttachmentName(): string { + const attachment = this.quote?.quoteItem?.[0]?.attachment?.[0]; + return attachment?.name || 'attachment.pdf'; + } + + canUploadAttachment(): boolean { + if (this.currentUserRole !== 'seller') return false; + const state = this.getPrimaryState(); + return state === 'inProgress' || state === 'approved'; + } + + canEditExpectedDate(): boolean { + if (this.currentUserRole !== 'seller') return false; + const state = this.getPrimaryState(); + return !this.isQuoteFinalized() && (state === 'pending' || state === 'inProgress' || state === 'approved'); + } + + canRejectOrCancel(): boolean { + const state = this.getPrimaryState(); + if (this.currentUserRole === 'seller') { + // Sellers can cancel quotes in pending, inProgress, or approved states + return state === 'pending' || state === 'inProgress' || state === 'approved'; + } else { + // Customers can reject quotes only in approved state + return state === 'approved'; + } + } + + canAcceptQuote(): boolean { + // Provider can only accept if expected date is set + if (this.currentUserRole === 'seller') { + return !!this.quote?.expectedQuoteCompletionDate; + } + return true; + } + + getAcceptButtonTooltip(): string { + if (this.currentUserRole === 'seller' && !this.quote?.expectedQuoteCompletionDate) { + return this.ACTION_TEXTS.EXPECTED_DATE_REQUIRED; + } + return ''; + } + + downloadAttachment() { + if (!this.quote) return; + try { + this.quoteService.downloadAttachment(this.quote); + this.notificationService.showSuccess('Download started'); + } catch (error: any) { + this.notificationService.showError(error.message || 'Error downloading attachment'); + } + } + + onFileSelected(event: Event) { + const input = event.target as HTMLInputElement; + if (!input.files?.length || !this.quote?.id) return; + + const file = input.files[0]; + if (file.type !== 'application/pdf') { + this.notificationService.showError('Only PDF files are allowed'); + return; + } + + // Check file size (2.5MB = 2.5 * 1024 * 1024 bytes) + const maxSizeBytes = 2.5 * 1024 * 1024; + if (file.size > maxSizeBytes) { + this.notificationService.showError('File size exceeds the maximum limit of 2.5MB'); + return; + } + + this.isUploading = true; + const fileName = file.name; + const quoteId = this.quote.id; + + this.quoteService.addAttachmentToQuote(quoteId, file).pipe( + // Add note for attachment upload + switchMap((updatedQuote) => { + this.quote = updatedQuote; + return this.quoteService.addNoteToQuote(quoteId, QUOTE_CHAT_MESSAGES.ATTACHMENT_UPLOADED(fileName), this.currentUserId).pipe( + switchMap(() => { + // Auto-approve if in progress + if (this.getPrimaryState() === 'inProgress') { + return this.quoteService.updateQuoteStatus(quoteId, 'approved').pipe( + switchMap((approvedQuote) => { + this.quote = approvedQuote; + return this.quoteService.addNoteToQuote(quoteId, QUOTE_CHAT_MESSAGES.STATUS_CHANGE('approved'), this.currentUserId); + }) + ); + } + return [updatedQuote]; + }) + ); + }) + ).subscribe({ + next: () => { + this.isUploading = false; + this.notificationService.showSuccess('Attachment uploaded successfully'); + if (this.getPrimaryState() === 'approved') { + this.notificationService.showSuccess('Quote has been approved and sent to customer'); + } + this.quoteUpdated.emit(this.quote!); + }, + error: (error) => { + this.isUploading = false; + this.notificationService.showError('Failed to upload attachment'); + } + }); + } + + acceptQuote() { + if (!this.quote?.id || this.isProcessing) return; + + if (!confirm('Are you sure you want to accept this quote request?')) return; + + this.isProcessing = true; + const quoteId = this.quote.id; + + this.quoteService.updateQuoteStatus(quoteId, 'inProgress').pipe( + switchMap((updatedQuote) => { + this.quote = updatedQuote; + return this.quoteService.addNoteToQuote(quoteId, QUOTE_CHAT_MESSAGES.STATUS_CHANGE('inProgress'), this.currentUserId); + }) + ).subscribe({ + next: () => { + this.isProcessing = false; + this.notificationService.showSuccess('Quote request accepted'); + this.quoteUpdated.emit(this.quote!); + }, + error: (error) => { + this.isProcessing = false; + this.notificationService.showError('Failed to accept quote'); + } + }); + } + + acceptProposal() { + if (!this.quote?.id || this.isProcessing) return; + + if (!confirm('Are you sure you want to accept this quote proposal?')) return; + + this.isProcessing = true; + const quoteId = this.quote.id; + + this.quoteService.updateQuoteStatus(quoteId, 'accepted').pipe( + switchMap((updatedQuote) => { + this.quote = updatedQuote; + return this.quoteService.addNoteToQuote(quoteId, QUOTE_CHAT_MESSAGES.STATUS_CHANGE('accepted'), this.currentUserId); + }) + ).subscribe({ + next: () => { + this.isProcessing = false; + this.notificationService.showSuccess('Quote proposal accepted'); + this.quoteUpdated.emit(this.quote!); + }, + error: (error) => { + this.isProcessing = false; + this.notificationService.showError('Failed to accept proposal'); + } + }); + } + + rejectQuote() { + if (!this.quote?.id || this.isProcessing) return; + + if (!confirm('Are you sure you want to cancel this quote?')) return; + + this.isProcessing = true; + const quoteId = this.quote.id; + const newStatus = 'cancelled'; + + this.quoteService.updateQuoteStatus(quoteId, newStatus).pipe( + switchMap((updatedQuote) => { + this.quote = updatedQuote; + return this.quoteService.addNoteToQuote(quoteId, QUOTE_CHAT_MESSAGES.STATUS_CHANGE(newStatus), this.currentUserId); + }) + ).subscribe({ + next: () => { + this.isProcessing = false; + this.notificationService.showSuccess('Quote cancelled'); + this.quoteUpdated.emit(this.quote!); + }, + error: (error) => { + this.isProcessing = false; + this.notificationService.showError('Failed to cancel quote'); + } + }); + } + + saveExpectedDate() { + if (!this.quote?.id || !this.selectedExpectedDate) return; + + const dateObj = new Date(this.selectedExpectedDate); + const formattedDate = `${dateObj.getDate().toString().padStart(2, '0')}-${(dateObj.getMonth() + 1).toString().padStart(2, '0')}-${dateObj.getFullYear()}`; + + this.quoteService.updateQuoteDate(this.quote.id, formattedDate, 'expected').subscribe({ + next: (updatedQuote) => { + this.quote = updatedQuote; + this.showExpectedDatePicker = false; + this.selectedExpectedDate = ''; + this.notificationService.showSuccess('Expected date updated'); + this.quoteUpdated.emit(updatedQuote); + }, + error: (error) => { + this.notificationService.showError('Failed to update expected date'); + } + }); + } + + createOffer() { + if (!this.quote?.id) return; + this.closeModal(); + this.router.navigate(['/my-offerings'], { state: { quoteId: this.quote.id } }); + } + closeModal() { this.close.emit(); } -} \ No newline at end of file + + openChat() { + this.showChatModal = true; + } + + closeChatModal() { + this.showChatModal = false; + } +} diff --git a/src/app/shared/quote-request-modal/quote-request-modal.component.ts b/src/app/shared/quote-request-modal/quote-request-modal.component.ts index 57c93bfe..c0abd95e 100644 --- a/src/app/shared/quote-request-modal/quote-request-modal.component.ts +++ b/src/app/shared/quote-request-modal/quote-request-modal.component.ts @@ -1,7 +1,8 @@ import { Component, EventEmitter, Input, Output, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; -import {components} from "../../models/product-catalog"; +import { switchMap } from 'rxjs/operators'; +import { components } from "../../models/product-catalog"; type Product = components["schemas"]["ProductOffering"]; type ProductSpecification = components["schemas"]["ProductSpecification"]; import { QuoteService } from 'src/app/features/quotes/services/quote.service'; @@ -41,78 +42,144 @@ export interface QuoteRequestData {
-
- -
-

Request Quote For:

-

{{ displayProductName }}

-
- - -
- -
- - - @if (isFieldInvalid('customerMessage')) { -

Message is required

- } -

- Please provide as much detail as possible to help us prepare an accurate quote. -

-
-
-
+ + @if (showSuccessMessage) { +
+ + + +

+ The quote has been created, you can close this window +

+
+ } + + + @if (showErrorMessage) { +
+ + + +

+ {{ errorMessage || 'Error during the creation of the quote' }} +

+
+ } + + + @if (!showSuccessMessage && !showErrorMessage) { +
+ +
+

Request for Product:

+

{{ displayProductName }}

+
+ + +
+

Provider:

+

{{ displayProviderName }}

+
+ + +
+ +
+ + + @if (isFieldInvalid('customerMessage')) { +

Message is required (minimum 10 characters)

+ } +

+ Please provide as much detail as possible to help us prepare an accurate quote. +

+
+ + +
+ + + @if (isFieldInvalid('requestDate')) { +

Request date is required

+ } +
+
+
+ }
-
-
- - -
+
+ + @if (showSuccessMessage || showErrorMessage) { +
+ +
+ } + + + @if (!showSuccessMessage && !showErrorMessage) { +
+ + +
+ }
` }) export class QuoteRequestModalComponent { - //@Input() product: ProductSpecification | null = null; @Input() productOff: Product | undefined; @Input() prodSpec: ProductSpecification | {}; - @Input() orgInfo:any | undefined; + @Input() orgInfo: any | undefined; @Input() customerId: string = ''; @Input() isOpen = false; @Output() closeModal = new EventEmitter(); @@ -121,13 +188,17 @@ export class QuoteRequestModalComponent { private fb = inject(FormBuilder); private quoteService = inject(QuoteService); - + quoteForm: FormGroup; isSubmitting = false; + showSuccessMessage = false; + showErrorMessage = false; + errorMessage = ''; constructor(private eventMessage: EventMessageService) { this.quoteForm = this.fb.group({ - customerMessage: ['', [Validators.required, Validators.minLength(10)]] + customerMessage: ['', [Validators.required, Validators.minLength(10)]], + requestDate: ['', [Validators.required]] }); } @@ -135,6 +206,16 @@ export class QuoteRequestModalComponent { return this.productOff?.name || 'Unknown Product'; } + get displayProviderName(): string { + return this.orgInfo?.tradingName || this.orgInfo?.name || 'Unknown Provider'; + } + + get minDate(): string { + // Set minimum date to today + const today = new Date(); + return today.toISOString().split('T')[0]; + } + isFieldInvalid(fieldName: string): boolean { const field = this.quoteForm.get(fieldName); return !!(field && field.invalid && (field.dirty || field.touched)); @@ -148,9 +229,13 @@ export class QuoteRequestModalComponent { onClose(): void { this.quoteForm.reset({ - customerMessage: '' + customerMessage: '', + requestDate: '' }); this.isSubmitting = false; + this.showSuccessMessage = false; + this.showErrorMessage = false; + this.errorMessage = ''; this.eventMessage.emitCloseQuoteRequest(true); this.closeModal.emit(); } @@ -158,34 +243,52 @@ export class QuoteRequestModalComponent { onSubmit(): void { if (this.quoteForm.valid && this.productOff && this.customerId && !this.isSubmitting) { this.isSubmitting = true; - + const formValue = this.quoteForm.value; const requestData: QuoteRequestData = { customerMessage: formValue.customerMessage, customerIdRef: this.customerId, - providerIdRef: this.orgInfo?.id|| '', + providerIdRef: this.orgInfo?.id || '', productOfferingId: this.productOff.id || this.productOff?.productSpecification?.id || '' }; + // Format the request date as DD-MM-YYYY for the API + const dateObj = new Date(formValue.requestDate); + const formattedDate = `${dateObj.getDate().toString().padStart(2, '0')}-${(dateObj.getMonth() + 1).toString().padStart(2, '0')}-${dateObj.getFullYear()}`; + console.log('Submitting quote request with data:', requestData); - console.log('Customer ID:', this.customerId); - console.log('Product:', this.productOff); - console.log('Provider ID from product:', this.orgInfo?.id); + console.log('Request date:', formattedDate); + + // Step 1: Create the quote + this.quoteService.createQuoteFromRequest(requestData).pipe( + // Step 2: After quote is created, update the request date + switchMap((response: any) => { + const quoteId = response?.id; + console.log('Quote created successfully with ID:', quoteId); - // Call the API to create the quote - this.quoteService.createQuoteFromRequest(requestData).subscribe({ - next: (response) => { - console.log('Quote created successfully:', response); + if (quoteId) { + console.log('Updating quote with request date:', formattedDate); + return this.quoteService.updateQuoteDate(quoteId, formattedDate, 'requested'); + } else { + // If no ID returned, just return the original response + console.warn('No quote ID returned, skipping date update'); + return [response]; + } + }) + ).subscribe({ + next: (response: any) => { + console.log('Quote creation and date update completed:', response); this.quoteCreated.emit(response); - this.submitRequest.emit(requestData); // Still emit for backward compatibility + this.submitRequest.emit(requestData); this.isSubmitting = false; - this.onClose(); + this.showSuccessMessage = true; }, - error: (error) => { - console.error('Error creating quote:', error); + error: (error: any) => { + console.error('Error creating quote or updating date:', error); this.isSubmitting = false; - // You might want to show an error message to the user here - alert('Failed to create quote. Please try again.'); + this.showErrorMessage = true; + // Extract user-friendly message from backend response + this.errorMessage = error?.error?.message || error?.message || 'Error during the creation of the quote'; } }); } else { @@ -195,7 +298,7 @@ export class QuoteRequestModalComponent { console.log('Product:', this.productOff); console.log('Customer ID:', this.customerId); console.log('Is submitting:', this.isSubmitting); - + // Mark all fields as touched to show validation errors Object.keys(this.quoteForm.controls).forEach(key => { const control = this.quoteForm.get(key); @@ -203,10 +306,11 @@ export class QuoteRequestModalComponent { control.markAsTouched(); } }); - + // Show validation message if customerId is missing if (!this.customerId) { - alert('Customer ID is required. Please log in first.'); + this.showErrorMessage = true; + this.errorMessage = 'Customer ID is required. Please log in first.'; } } } From 6d54b0e56f3daeffe9ac06b9e4a9e45bdecf7c2a Mon Sep 17 00:00:00 2001 From: BazRoe Date: Fri, 23 Jan 2026 11:37:04 +0100 Subject: [PATCH 2/2] Commit to triggere E2E testing --- src/app/models/quote.constants.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/models/quote.constants.ts b/src/app/models/quote.constants.ts index 52631f8c..a9b49a90 100644 --- a/src/app/models/quote.constants.ts +++ b/src/app/models/quote.constants.ts @@ -1,5 +1,6 @@ /** * Quote status constants and messages used throughout the application + * */ export interface StatusInfo {