diff --git a/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx b/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx index 835dcf5f2..e78174280 100644 --- a/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx +++ b/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx @@ -218,6 +218,86 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) => return 'Initializing...'; }, [stepStatus.currentStep, stepStatus.policiesTotal, stepStatus.policiesCompleted, currentStep]); + // Normalize vendor name for deduplication - strips parenthetical suffixes + // e.g., "Fanta (cool)" and "Fanta" are treated as the same vendor + const normalizeVendorName = useCallback((name: string): string => { + return name + .toLowerCase() + .replace(/\s*\([^)]*\)\s*$/, '') // Remove trailing parenthetical suffixes + .trim(); + }, []); + + const uniqueVendorsInfo = useMemo(() => { + const statusRank = (status: 'pending' | 'processing' | 'assessing' | 'completed') => { + switch (status) { + case 'completed': + return 3; + case 'assessing': + case 'processing': + return 2; + case 'pending': + default: + return 1; + } + }; + + const map = new Map< + string, + { vendor: { id: string; name: string }; rank: number; status: 'pending' | 'processing' | 'assessing' | 'completed' } + >(); + + stepStatus.vendorsInfo.forEach((vendor) => { + const status = stepStatus.vendorsStatus[vendor.id] || 'pending'; + const nameKey = normalizeVendorName(vendor.name); + const rank = statusRank(status); + const existing = map.get(nameKey); + + if (!existing || rank > existing.rank) { + map.set(nameKey, { vendor, rank, status }); + } + }); + + return Array.from(map.values()).map(({ vendor }) => vendor); + }, [stepStatus.vendorsInfo, stepStatus.vendorsStatus, normalizeVendorName]); + + // Calculate unique completed count for the counter (to match deduplicated list) + const uniqueVendorsCounts = useMemo(() => { + const statusRank = (status: 'pending' | 'processing' | 'assessing' | 'completed') => { + switch (status) { + case 'completed': + return 3; + case 'assessing': + case 'processing': + return 2; + case 'pending': + default: + return 1; + } + }; + + const map = new Map< + string, + { status: 'pending' | 'processing' | 'assessing' | 'completed'; rank: number } + >(); + + stepStatus.vendorsInfo.forEach((vendor) => { + const status = stepStatus.vendorsStatus[vendor.id] || 'pending'; + const nameKey = normalizeVendorName(vendor.name); + const rank = statusRank(status); + const existing = map.get(nameKey); + + if (!existing || rank > existing.rank) { + map.set(nameKey, { status, rank }); + } + }); + + const entries = Array.from(map.values()); + return { + total: entries.length, + completed: entries.filter((e) => e.status === 'completed').length, + }; + }, [stepStatus.vendorsInfo, stepStatus.vendorsStatus, normalizeVendorName]); + if (!triggerJobId || !mounted) { return null; } @@ -361,10 +441,10 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) => const isRisksStep = step.key === 'risk'; const isPoliciesStep = step.key === 'policies'; - // Determine completion based on actual counts, not boolean flags + // Determine completion based on unique counts, not raw metadata totals const vendorsCompleted = - stepStatus.vendorsTotal > 0 && - stepStatus.vendorsCompleted >= stepStatus.vendorsTotal; + uniqueVendorsCounts.total > 0 && + uniqueVendorsCounts.completed >= uniqueVendorsCounts.total; const risksCompleted = stepStatus.risksTotal > 0 && stepStatus.risksCompleted >= stepStatus.risksTotal; const policiesCompleted = @@ -437,7 +517,7 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
- {stepStatus.vendorsCompleted}/{stepStatus.vendorsTotal} + {uniqueVendorsCounts.completed}/{uniqueVendorsCounts.total} {isVendorsExpanded ? ( @@ -449,7 +529,7 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) => {/* Expanded vendor list */} - {isVendorsExpanded && stepStatus.vendorsInfo.length > 0 && ( + {isVendorsExpanded && uniqueVendorsInfo.length > 0 && ( className="overflow-hidden" >
- {stepStatus.vendorsInfo.map((vendor) => { + {uniqueVendorsInfo.map((vendor) => { const vendorStatus = stepStatus.vendorsStatus[vendor.id] || 'pending'; const isVendorCompleted = vendorStatus === 'completed'; const isVendorProcessing = vendorStatus === 'processing'; diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx index 89127ebe3..dcadbd67f 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx @@ -244,10 +244,65 @@ export function VendorsTable({ return [...vendorsWithStatus, ...pendingVendors, ...tempVendors]; }, [vendors, itemsInfo, itemStatuses, orgId, isActive, onboardingRunId]); + const dedupedVendors = useMemo(() => { + // Normalize vendor name for deduplication - strips parenthetical suffixes + // e.g., "Fanta (cool)" and "Fanta" are treated as the same vendor + const normalizeVendorName = (name: string): string => { + return name + .toLowerCase() + .replace(/\s*\([^)]*\)\s*$/, '') // Remove trailing parenthetical suffixes + .trim(); + }; + + // Rank vendors for deduplication - higher rank wins + // Rank 3: assessed (completed) + // Rank 2: actively being assessed (via isAssessing flag OR metadata status) + // Rank 1: pending placeholder + // Rank 0: not assessed and not processing + const getRank = (vendor: VendorRow) => { + if (vendor.status === 'assessed') return 3; + // Check both isAssessing flag and metadata status for active assessment + const metadataStatus = itemStatuses[vendor.id]; + if (vendor.isAssessing || metadataStatus === 'assessing' || metadataStatus === 'processing') { + return 2; + } + if (vendor.isPending) return 1; + return 0; + }; + + const map = new Map(); + mergedVendors.forEach((vendor) => { + const nameKey = normalizeVendorName(vendor.name); + const existing = map.get(nameKey); + if (!existing) { + map.set(nameKey, vendor); + return; + } + + const currentRank = getRank(vendor); + const existingRank = getRank(existing); + + if (currentRank > existingRank) { + map.set(nameKey, vendor); + return; + } + + if (currentRank === existingRank) { + const existingUpdatedAt = new Date(existing.updatedAt).getTime(); + const currentUpdatedAt = new Date(vendor.updatedAt).getTime(); + if (currentUpdatedAt > existingUpdatedAt) { + map.set(nameKey, vendor); + } + } + }); + + return Array.from(map.values()); + }, [mergedVendors, itemStatuses]); + const columns = useMemo[]>(() => getColumns(orgId), [orgId]); const { table } = useDataTable({ - data: mergedVendors, + data: dedupedVendors, columns, pageCount, getRowId: (row) => row.id, @@ -285,33 +340,55 @@ export function VendorsTable({ [itemStatuses], ); - // Calculate actual assessment progress + // Calculate actual assessment progress (using deduplicated counts to match the table) const assessmentProgress = useMemo(() => { if (!progress || !itemsInfo.length) { return null; } - // Count vendors that are completed (either 'completed' in metadata or 'assessed' in DB) - const completedCount = vendors.filter((vendor) => { - const metadataStatus = itemStatuses[vendor.id]; - return metadataStatus === 'completed' || vendor.status === 'assessed'; - }).length; + // Normalize vendor name for deduplication (same as dedupedVendors) + const normalizeVendorName = (name: string): string => { + return name + .toLowerCase() + .replace(/\s*\([^)]*\)\s*$/, '') + .trim(); + }; - // Also count vendors in metadata that are completed but not yet in DB - const completedInMetadata = Object.values(itemStatuses).filter( - (status) => status === 'completed', - ).length; + // Build a map of unique vendor names with their best status + // This mirrors the deduplication logic used for the table + const uniqueVendorStatuses = new Map(); + + // First, add all vendors from metadata + itemsInfo.forEach((item) => { + const nameKey = normalizeVendorName(item.name); + const metadataStatus = itemStatuses[item.id]; + const isCompleted = metadataStatus === 'completed'; + + const existing = uniqueVendorStatuses.get(nameKey); + if (!existing || (isCompleted && !existing.isCompleted)) { + uniqueVendorStatuses.set(nameKey, { isCompleted }); + } + }); - // Total is the max of progress.total, itemsInfo.length, or actual vendors created - const total = Math.max(progress.total, itemsInfo.length, vendors.length); + // Then, update with DB vendor statuses (which may be more accurate) + vendors.forEach((vendor) => { + const nameKey = normalizeVendorName(vendor.name); + const metadataStatus = itemStatuses[vendor.id]; + const isCompleted = metadataStatus === 'completed' || vendor.status === 'assessed'; + + const existing = uniqueVendorStatuses.get(nameKey); + if (!existing || (isCompleted && !existing.isCompleted)) { + uniqueVendorStatuses.set(nameKey, { isCompleted }); + } + }); - // Completed is the max of DB assessed vendors or metadata completed - const completed = Math.max(completedCount, completedInMetadata); + const total = uniqueVendorStatuses.size; + const completed = Array.from(uniqueVendorStatuses.values()).filter((v) => v.isCompleted).length; return { total, completed }; }, [progress, itemsInfo, vendors, itemStatuses]); - const isEmpty = mergedVendors.length === 0; + const isEmpty = dedupedVendors.length === 0; // Show empty state if onboarding is active (even if progress metadata isn't set yet) const showEmptyState = isEmpty && onboardingRunId && isActive; diff --git a/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx b/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx index 6912b039f..41134eac9 100644 --- a/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx +++ b/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx @@ -525,6 +525,15 @@ const getVendorDisplayName = (vendor: GlobalVendors): string => { return vendor.company_name ?? vendor.legal_name ?? vendor.website ?? ''; }; +// Helper to normalize vendor name for deduplication +// Strips parenthetical suffixes like "(cool)", trims whitespace, and lowercases +const normalizeVendorName = (name: string): string => { + return name + .toLowerCase() + .replace(/\s*\([^)]*\)\s*$/, '') // Remove trailing parenthetical suffixes + .trim(); +}; + // Helper to validate domain/URL format const isValidDomain = (domain: string): boolean => { if (!domain || domain.trim() === '') return true; // Empty is valid (optional field) @@ -640,12 +649,16 @@ function SoftwareVendorInput({ const handleSelectGlobalVendor = (vendor: GlobalVendors) => { const name = getVendorDisplayName(vendor); + const normalizedName = normalizeVendorName(name); - // Check if already selected + // Check if already selected (using normalized names) const alreadyInPredefined = selectedPredefined.some( - (v) => v.toLowerCase() === name.toLowerCase(), + (v) => normalizeVendorName(v) === normalizedName, ); - if (alreadyInPredefined) { + const alreadyInCustom = customVendors.some( + (v) => normalizeVendorName(v.name) === normalizedName, + ); + if (alreadyInPredefined || alreadyInCustom) { setCustomValue(''); setShowSuggestions(false); setSearchResults([]); @@ -665,29 +678,31 @@ function SoftwareVendorInput({ const trimmedValue = customValue.trim(); if (!trimmedValue) return; - // Check if already exists in selected predefined or custom + const normalizedInput = normalizeVendorName(trimmedValue); + + // Check if already exists in selected predefined or custom (using normalized names) const alreadyInPredefined = selectedPredefined.some( - (v) => v.toLowerCase() === trimmedValue.toLowerCase(), + (v) => normalizeVendorName(v) === normalizedInput, ); if (alreadyInPredefined) { setCustomValue(''); setShowSuggestions(false); return; } - if (customVendors.some((v) => v.name.toLowerCase() === trimmedValue.toLowerCase())) { + if (customVendors.some((v) => normalizeVendorName(v.name) === normalizedInput)) { setCustomValue(''); setShowSuggestions(false); return; } - // Check if the typed value matches a predefined option (case-insensitive) + // Check if the typed value matches a predefined option (using normalized names) const matchedPredefined = predefinedOptions.find( - (option) => option.toLowerCase() === trimmedValue.toLowerCase(), + (option) => normalizeVendorName(option) === normalizedInput, ); - // Check if there's a matching GlobalVendor in search results + // Check if there's a matching GlobalVendor in search results (using normalized names) const matchedGlobal = searchResults.find( - (v) => getVendorDisplayName(v).toLowerCase() === trimmedValue.toLowerCase(), + (v) => normalizeVendorName(getVendorDisplayName(v)) === normalizedInput, ); if (matchedPredefined) { @@ -746,23 +761,24 @@ function SoftwareVendorInput({ } }; - // Deduplicate search results by display name (case-insensitive) + // Deduplicate search results by normalized name (strips parenthetical suffixes) + // e.g., "Fanta (cool)" and "Fanta" are treated as the same vendor const uniqueSearchResults = Array.from( searchResults.reduce((map, vendor) => { - const name = getVendorDisplayName(vendor).toLowerCase(); - if (!map.has(name)) { - map.set(name, vendor); + const normalizedName = normalizeVendorName(getVendorDisplayName(vendor)); + if (!map.has(normalizedName)) { + map.set(normalizedName, vendor); } return map; }, new Map()), ).map(([, vendor]) => vendor); - // Filter out already selected vendors from search results + // Filter out already selected vendors from search results (using normalized names) const filteredSearchResults = uniqueSearchResults.filter((vendor) => { - const name = getVendorDisplayName(vendor).toLowerCase(); + const normalizedName = normalizeVendorName(getVendorDisplayName(vendor)); return ( - !selectedPredefined.some((v) => v.toLowerCase() === name) && - !customVendors.some((v) => v.name.toLowerCase() === name) + !selectedPredefined.some((v) => normalizeVendorName(v) === normalizedName) && + !customVendors.some((v) => normalizeVendorName(v.name) === normalizedName) ); });