Skip to content

Commit 7a85be8

Browse files
feat(onboarding): add individual tracking for vendors and risks with auto-expand (#1748)
- Track vendors and risks individually with dropdowns similar to policies - Extract vendors upfront to show them immediately before creation - Auto-expand current step and collapse previous steps - Update metadata tracking for vendors and risks with status indicators Co-authored-by: Mariano Fuentes <marfuen98@gmail.com>
1 parent 8372798 commit 7a85be8

3 files changed

Lines changed: 294 additions & 6 deletions

File tree

apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
3737
const [isMinimized, setIsMinimized] = useState(false);
3838
const [isDismissed, setIsDismissed] = useState(false);
3939
const [isPoliciesExpanded, setIsPoliciesExpanded] = useState(false);
40+
const [isVendorsExpanded, setIsVendorsExpanded] = useState(false);
41+
const [isRisksExpanded, setIsRisksExpanded] = useState(false);
4042

4143
// useRealtimeRun will automatically get the token from TriggerProvider context
4244
// This gives us real-time updates including metadata changes
@@ -63,6 +65,16 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
6365
risk: false,
6466
policies: false,
6567
currentStep: null,
68+
vendorsTotal: 0,
69+
vendorsCompleted: 0,
70+
vendorsRemaining: 0,
71+
vendorsInfo: [],
72+
vendorsStatus: {},
73+
risksTotal: 0,
74+
risksCompleted: 0,
75+
risksRemaining: 0,
76+
risksInfo: [],
77+
risksStatus: {},
6678
policiesTotal: 0,
6779
policiesCompleted: 0,
6880
policiesRemaining: 0,
@@ -73,6 +85,24 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
7385

7486
const meta = run.metadata as Record<string, unknown>;
7587

88+
// Build vendorsStatus object from individual vendor status keys
89+
const vendorsStatus: Record<string, 'pending' | 'processing' | 'completed'> = {};
90+
const vendorsInfo = (meta.vendorsInfo as Array<{ id: string; name: string }>) || [];
91+
92+
vendorsInfo.forEach((vendor) => {
93+
const statusKey = `vendor_${vendor.id}_status`;
94+
vendorsStatus[vendor.id] = (meta[statusKey] as 'pending' | 'processing' | 'completed') || 'pending';
95+
});
96+
97+
// Build risksStatus object from individual risk status keys
98+
const risksStatus: Record<string, 'pending' | 'processing' | 'completed'> = {};
99+
const risksInfo = (meta.risksInfo as Array<{ id: string; name: string }>) || [];
100+
101+
risksInfo.forEach((risk) => {
102+
const statusKey = `risk_${risk.id}_status`;
103+
risksStatus[risk.id] = (meta[statusKey] as 'pending' | 'processing' | 'completed') || 'pending';
104+
});
105+
76106
// Build policiesStatus object from individual policy status keys
77107
const policiesStatus: Record<string, 'pending' | 'processing' | 'completed'> = {};
78108
const policiesInfo = (meta.policiesInfo as Array<{ id: string; name: string }>) || [];
@@ -88,6 +118,16 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
88118
risk: meta.risk === true,
89119
policies: meta.policies === true,
90120
currentStep: (meta.currentStep as string) || null,
121+
vendorsTotal: (meta.vendorsTotal as number) || 0,
122+
vendorsCompleted: (meta.vendorsCompleted as number) || 0,
123+
vendorsRemaining: (meta.vendorsRemaining as number) || 0,
124+
vendorsInfo,
125+
vendorsStatus,
126+
risksTotal: (meta.risksTotal as number) || 0,
127+
risksCompleted: (meta.risksCompleted as number) || 0,
128+
risksRemaining: (meta.risksRemaining as number) || 0,
129+
risksInfo,
130+
risksStatus,
91131
policiesTotal: (meta.policiesTotal as number) || 0,
92132
policiesCompleted: (meta.policiesCompleted as number) || 0,
93133
policiesRemaining: (meta.policiesRemaining as number) || 0,
@@ -107,6 +147,28 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
107147
return ONBOARDING_STEPS.find((step) => !stepStatus[step.key as keyof typeof stepStatus]);
108148
}, [stepStatus]);
109149

150+
// Auto-expand current step and collapse others
151+
useEffect(() => {
152+
if (!currentStep) return;
153+
154+
const stepKey = currentStep.key;
155+
156+
// Expand current step if it has items to show
157+
if (stepKey === 'vendors' && stepStatus.vendorsTotal > 0) {
158+
setIsVendorsExpanded(true);
159+
setIsRisksExpanded(false);
160+
setIsPoliciesExpanded(false);
161+
} else if (stepKey === 'risk' && stepStatus.risksTotal > 0) {
162+
setIsVendorsExpanded(false);
163+
setIsRisksExpanded(true);
164+
setIsPoliciesExpanded(false);
165+
} else if (stepKey === 'policies' && stepStatus.policiesTotal > 0) {
166+
setIsVendorsExpanded(false);
167+
setIsRisksExpanded(false);
168+
setIsPoliciesExpanded(true);
169+
}
170+
}, [currentStep?.key, stepStatus.vendorsTotal, stepStatus.risksTotal, stepStatus.policiesTotal]);
171+
110172
// Build dynamic current step message with progress
111173
const currentStepMessage = useMemo(() => {
112174
if (stepStatus.currentStep) {
@@ -267,8 +329,180 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
267329
{ONBOARDING_STEPS.map((step) => {
268330
const isCompleted = stepStatus[step.key as keyof typeof stepStatus];
269331
const isCurrent = currentStep?.key === step.key;
332+
const isVendorsStep = step.key === 'vendors';
333+
const isRisksStep = step.key === 'risk';
270334
const isPoliciesStep = step.key === 'policies';
271335

336+
// Vendors step with expandable dropdown
337+
if (isVendorsStep && stepStatus.vendorsTotal > 0) {
338+
return (
339+
<div key={step.key} className="flex flex-col gap-2">
340+
<button
341+
onClick={() => setIsVendorsExpanded(!isVendorsExpanded)}
342+
className="flex items-center gap-2 w-full text-left"
343+
>
344+
{isCompleted ? (
345+
<CheckCircle2 className="text-chart-positive h-5 w-5 flex-shrink-0" />
346+
) : isCurrent ? (
347+
<Loader2 className="h-5 w-5 flex-shrink-0 animate-spin text-primary" />
348+
) : (
349+
<div className="h-5 w-5 flex-shrink-0 rounded-full border-2 border-muted" />
350+
)}
351+
<div className="flex flex-1 items-center justify-between gap-2 min-w-0">
352+
<span
353+
className={`text-sm ${
354+
isCompleted
355+
? 'text-chart-positive'
356+
: isCurrent
357+
? 'text-primary font-medium'
358+
: 'text-muted-foreground'
359+
}`}
360+
>
361+
{step.label}
362+
</span>
363+
<div className="flex items-center gap-2 flex-shrink-0">
364+
<span className="text-muted-foreground text-sm">
365+
{stepStatus.vendorsCompleted}/{stepStatus.vendorsTotal}
366+
</span>
367+
{isVendorsExpanded ? (
368+
<ChevronUp className="h-4 w-4 text-muted-foreground" />
369+
) : (
370+
<ChevronDown className="h-4 w-4 text-muted-foreground" />
371+
)}
372+
</div>
373+
</div>
374+
</button>
375+
376+
{/* Expanded vendor list */}
377+
{isVendorsExpanded && stepStatus.vendorsInfo.length > 0 && (
378+
<motion.div
379+
initial={{ opacity: 0, height: 0 }}
380+
animate={{ opacity: 1, height: 'auto' }}
381+
exit={{ opacity: 0, height: 0 }}
382+
transition={{ duration: 0.2 }}
383+
className="overflow-hidden"
384+
>
385+
<div className="flex flex-col gap-1.5 pl-7">
386+
{stepStatus.vendorsInfo.map((vendor) => {
387+
const vendorStatus = stepStatus.vendorsStatus[vendor.id] || 'pending';
388+
const isVendorCompleted = vendorStatus === 'completed';
389+
const isVendorProcessing = vendorStatus === 'processing';
390+
391+
return (
392+
<div key={vendor.id} className="flex items-center gap-2">
393+
{isVendorCompleted ? (
394+
<CheckCircle2 className="text-chart-positive h-4 w-4 flex-shrink-0" />
395+
) : isVendorProcessing ? (
396+
<Loader2 className="h-4 w-4 flex-shrink-0 animate-spin text-primary" />
397+
) : (
398+
<div className="h-4 w-4 flex-shrink-0 rounded-full border-2 border-muted" />
399+
)}
400+
<span
401+
className={`text-sm truncate ${
402+
isVendorCompleted
403+
? 'text-chart-positive'
404+
: isVendorProcessing
405+
? 'text-primary'
406+
: 'text-muted-foreground'
407+
}`}
408+
>
409+
{vendor.name}
410+
</span>
411+
</div>
412+
);
413+
})}
414+
</div>
415+
</motion.div>
416+
)}
417+
</div>
418+
);
419+
}
420+
421+
// Risks step with expandable dropdown
422+
if (isRisksStep && stepStatus.risksTotal > 0) {
423+
return (
424+
<div key={step.key} className="flex flex-col gap-2">
425+
<button
426+
onClick={() => setIsRisksExpanded(!isRisksExpanded)}
427+
className="flex items-center gap-2 w-full text-left"
428+
>
429+
{isCompleted ? (
430+
<CheckCircle2 className="text-chart-positive h-5 w-5 flex-shrink-0" />
431+
) : isCurrent ? (
432+
<Loader2 className="h-5 w-5 flex-shrink-0 animate-spin text-primary" />
433+
) : (
434+
<div className="h-5 w-5 flex-shrink-0 rounded-full border-2 border-muted" />
435+
)}
436+
<div className="flex flex-1 items-center justify-between gap-2 min-w-0">
437+
<span
438+
className={`text-sm ${
439+
isCompleted
440+
? 'text-chart-positive'
441+
: isCurrent
442+
? 'text-primary font-medium'
443+
: 'text-muted-foreground'
444+
}`}
445+
>
446+
{step.label}
447+
</span>
448+
<div className="flex items-center gap-2 flex-shrink-0">
449+
<span className="text-muted-foreground text-sm">
450+
{stepStatus.risksCompleted}/{stepStatus.risksTotal}
451+
</span>
452+
{isRisksExpanded ? (
453+
<ChevronUp className="h-4 w-4 text-muted-foreground" />
454+
) : (
455+
<ChevronDown className="h-4 w-4 text-muted-foreground" />
456+
)}
457+
</div>
458+
</div>
459+
</button>
460+
461+
{/* Expanded risk list */}
462+
{isRisksExpanded && stepStatus.risksInfo.length > 0 && (
463+
<motion.div
464+
initial={{ opacity: 0, height: 0 }}
465+
animate={{ opacity: 1, height: 'auto' }}
466+
exit={{ opacity: 0, height: 0 }}
467+
transition={{ duration: 0.2 }}
468+
className="overflow-hidden"
469+
>
470+
<div className="flex flex-col gap-1.5 pl-7">
471+
{stepStatus.risksInfo.map((risk) => {
472+
const riskStatus = stepStatus.risksStatus[risk.id] || 'pending';
473+
const isRiskCompleted = riskStatus === 'completed';
474+
const isRiskProcessing = riskStatus === 'processing';
475+
476+
return (
477+
<div key={risk.id} className="flex items-center gap-2">
478+
{isRiskCompleted ? (
479+
<CheckCircle2 className="text-chart-positive h-4 w-4 flex-shrink-0" />
480+
) : isRiskProcessing ? (
481+
<Loader2 className="h-4 w-4 flex-shrink-0 animate-spin text-primary" />
482+
) : (
483+
<div className="h-4 w-4 flex-shrink-0 rounded-full border-2 border-muted" />
484+
)}
485+
<span
486+
className={`text-sm truncate ${
487+
isRiskCompleted
488+
? 'text-chart-positive'
489+
: isRiskProcessing
490+
? 'text-primary'
491+
: 'text-muted-foreground'
492+
}`}
493+
>
494+
{risk.name}
495+
</span>
496+
</div>
497+
);
498+
})}
499+
</div>
500+
</motion.div>
501+
)}
502+
</div>
503+
);
504+
}
505+
272506
if (isPoliciesStep && stepStatus.policiesTotal > 0) {
273507
// Policies step with expandable dropdown
274508
return (

apps/app/src/jobs/tasks/onboarding/onboard-organization-helpers.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -554,12 +554,13 @@ export async function triggerPolicyUpdates(
554554
export async function createVendors(
555555
questionsAndAnswers: ContextItem[],
556556
organizationId: string,
557+
vendorData?: VendorData[],
557558
): Promise<any[]> {
558-
// Extract vendors using AI
559-
const vendorData = await extractVendorsFromContext(questionsAndAnswers);
559+
// Extract vendors using AI if not provided
560+
const vendorsToCreate = vendorData || await extractVendorsFromContext(questionsAndAnswers);
560561

561562
// Create vendor records in database
562-
const createdVendors = await createVendorsFromData(vendorData, organizationId);
563+
const createdVendors = await createVendorsFromData(vendorsToCreate, organizationId);
563564

564565
// Trigger background research for each vendor
565566
await triggerVendorResearch(createdVendors);

apps/app/src/jobs/tasks/onboarding/onboard-organization.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { generateVendorMitigationsForOrg } from './generate-vendor-mitigation';
66
import {
77
createRisks,
88
createVendors,
9+
extractVendorsFromContext,
910
getOrganizationContext,
1011
updateOrganizationPolicies,
1112
} from './onboard-organization-helpers';
@@ -93,8 +94,41 @@ export const onboardOrganization = task({
9394
},
9495
});
9596

96-
// Create vendors
97-
const vendors = await createVendors(questionsAndAnswers, payload.organizationId);
97+
// Extract vendors first so we can show them immediately
98+
const vendorData = await extractVendorsFromContext(questionsAndAnswers);
99+
100+
// Track vendors immediately as "pending" before creation
101+
if (vendorData.length > 0) {
102+
metadata.set('vendorsTotal', vendorData.length);
103+
metadata.set('vendorsCompleted', 0);
104+
metadata.set('vendorsRemaining', vendorData.length);
105+
// Use temporary IDs based on index until we have real IDs
106+
metadata.set(
107+
'vendorsInfo',
108+
vendorData.map((v, index) => ({ id: `temp_${index}`, name: v.vendor_name })),
109+
);
110+
// Mark all as pending initially
111+
vendorData.forEach((_, index) => {
112+
metadata.set(`vendor_temp_${index}_status`, 'pending');
113+
});
114+
}
115+
116+
// Create vendors (pass extracted data to avoid re-extraction)
117+
const vendors = await createVendors(questionsAndAnswers, payload.organizationId, vendorData);
118+
119+
// Update tracking with real vendor IDs and mark as completed
120+
if (vendors.length > 0) {
121+
metadata.set('vendorsCompleted', vendors.length);
122+
metadata.set('vendorsRemaining', 0);
123+
metadata.set(
124+
'vendorsInfo',
125+
vendors.map((v) => ({ id: v.id, name: v.name })),
126+
);
127+
// Mark all as completed
128+
vendors.forEach((vendor) => {
129+
metadata.set(`vendor_${vendor.id}_status`, 'completed');
130+
});
131+
}
98132

99133
// Mark vendors step as complete in metadata (real-time)
100134
metadata.set('vendors', true);
@@ -109,7 +143,26 @@ export const onboardOrganization = task({
109143
);
110144

111145
// Create risks
112-
await createRisks(questionsAndAnswers, payload.organizationId, organization.name);
146+
const risks = await createRisks(
147+
questionsAndAnswers,
148+
payload.organizationId,
149+
organization.name,
150+
);
151+
152+
// Track risks with metadata for real-time tracking
153+
if (risks.length > 0) {
154+
metadata.set('risksTotal', risks.length);
155+
metadata.set('risksCompleted', risks.length);
156+
metadata.set('risksRemaining', 0);
157+
metadata.set(
158+
'risksInfo',
159+
risks.map((r) => ({ id: r.id, name: r.title })),
160+
);
161+
// All risks are created immediately, so mark them all as completed
162+
risks.forEach((risk) => {
163+
metadata.set(`risk_${risk.id}_status`, 'completed');
164+
});
165+
}
113166

114167
// Mark risks step as complete in metadata (real-time)
115168
metadata.set('risk', true);

0 commit comments

Comments
 (0)