Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 86 additions & 6 deletions apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -437,7 +517,7 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
</span>
<div className="flex items-center gap-2 shrink-0">
<span className="text-muted-foreground text-sm">
{stepStatus.vendorsCompleted}/{stepStatus.vendorsTotal}
{uniqueVendorsCounts.completed}/{uniqueVendorsCounts.total}
</span>
{isVendorsExpanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
Expand All @@ -449,7 +529,7 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
</button>

{/* Expanded vendor list */}
{isVendorsExpanded && stepStatus.vendorsInfo.length > 0 && (
{isVendorsExpanded && uniqueVendorsInfo.length > 0 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
Expand All @@ -458,7 +538,7 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
className="overflow-hidden"
>
<div className="flex flex-col gap-1.5 pl-7">
{stepStatus.vendorsInfo.map((vendor) => {
{uniqueVendorsInfo.map((vendor) => {
const vendorStatus = stepStatus.vendorsStatus[vendor.id] || 'pending';
const isVendorCompleted = vendorStatus === 'completed';
const isVendorProcessing = vendorStatus === 'processing';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,10 +244,65 @@ export function VendorsTable({
return [...vendorsWithStatus, ...pendingVendors, ...tempVendors];
}, [vendors, itemsInfo, itemStatuses, orgId, isActive, onboardingRunId]);

const dedupedVendors = useMemo<VendorRow[]>(() => {
// 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<string, VendorRow>();
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<ColumnDef<VendorRow>[]>(() => getColumns(orgId), [orgId]);

const { table } = useDataTable({
data: mergedVendors,
data: dedupedVendors,
columns,
pageCount,
getRowId: (row) => row.id,
Expand Down Expand Up @@ -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<string, { isCompleted: boolean }>();

// 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;

Expand Down
52 changes: 34 additions & 18 deletions apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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([]);
Expand All @@ -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) {
Expand Down Expand Up @@ -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<string, GlobalVendors>()),
).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)
);
});

Expand Down