Skip to content

Commit 840374b

Browse files
feat: add risk scores in main overview page and make risk matrix selected value more clear
[dev] [Marfuen] mariano/sale-46-add-basic-risk-score
1 parent ef14a46 commit 840374b

6 files changed

Lines changed: 211 additions & 34 deletions

File tree

apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.test.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,4 +239,43 @@ describe('VendorsTable', () => {
239239
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
240240
expect(screen.getByText('Cloud')).toBeInTheDocument();
241241
});
242+
243+
it('renders the INHERENT RISK column with a numeric score for assessed vendors', () => {
244+
setMockPermissions({});
245+
246+
render(
247+
<VendorsTable
248+
vendors={mockVendors}
249+
assignees={mockAssignees}
250+
orgId="org-1"
251+
/>,
252+
);
253+
254+
// Column header
255+
expect(screen.getByText('INHERENT RISK')).toBeInTheDocument();
256+
// Acme Corp (possible × moderate) → raw 9 → score 4/10
257+
expect(screen.getByText('4/10')).toBeInTheDocument();
258+
});
259+
260+
it('shows an em-dash for vendors that have not been assessed', () => {
261+
setMockPermissions({});
262+
263+
const notAssessedVendor = {
264+
...mockVendors[0],
265+
id: 'vendor-2',
266+
name: 'Pending Inc',
267+
status: 'not_assessed',
268+
};
269+
270+
render(
271+
<VendorsTable
272+
vendors={[notAssessedVendor]}
273+
assignees={mockAssignees}
274+
orgId="org-1"
275+
/>,
276+
);
277+
278+
expect(screen.getByText('—')).toBeInTheDocument();
279+
expect(screen.queryByText('1/10')).not.toBeInTheDocument();
280+
});
242281
});

apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
'use client';
22

33
import { OnboardingLoadingAnimation } from '@/components/onboarding-loading-animation';
4+
import { RiskScoreBadge } from '@/components/risks/RiskScoreBadge';
5+
import { VendorStatus } from '@/components/vendor-status';
46
import { usePermissions } from '@/hooks/use-permissions';
57
import { useVendors, useVendorActions, type Vendor } from '@/hooks/use-vendors';
6-
import { VendorStatus } from '@/components/vendor-status';
8+
import { getRiskScore } from '@/lib/risk-score';
79
import {
810
AlertDialog,
911
AlertDialogAction,
@@ -168,7 +170,10 @@ export function VendorsTable({
168170

169171
// Local state for search, sorting, and pagination
170172
const [searchQuery, setSearchQuery] = useState('');
171-
const [sort, setSort] = useState<{ id: 'name' | 'updatedAt'; desc: boolean }>({
173+
const [sort, setSort] = useState<{
174+
id: 'name' | 'updatedAt' | 'inherentRisk';
175+
desc: boolean;
176+
}>({
172177
id: 'name',
173178
desc: false,
174179
});
@@ -319,15 +324,17 @@ export function VendorsTable({
319324

320325
// Sort
321326
result.sort((a, b) => {
322-
const aValue = sort.id === 'name' ? a.name : a.updatedAt;
323-
const bValue = sort.id === 'name' ? b.name : b.updatedAt;
324-
325327
if (sort.id === 'name') {
326-
const comparison = (aValue as string).localeCompare(bValue as string);
328+
const comparison = a.name.localeCompare(b.name);
329+
return sort.desc ? -comparison : comparison;
330+
}
331+
if (sort.id === 'inherentRisk') {
332+
const aScore = getRiskScore(a.inherentProbability, a.inherentImpact).raw;
333+
const bScore = getRiskScore(b.inherentProbability, b.inherentImpact).raw;
334+
const comparison = aScore - bScore;
327335
return sort.desc ? -comparison : comparison;
328336
}
329-
const comparison =
330-
new Date(aValue as string).getTime() - new Date(bValue as string).getTime();
337+
const comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
331338
return sort.desc ? -comparison : comparison;
332339
});
333340

@@ -385,15 +392,15 @@ export function VendorsTable({
385392
router.push(`/${orgId}/vendors/${vendorId}`);
386393
};
387394

388-
const handleSort = (columnId: 'name' | 'updatedAt') => {
395+
const handleSort = (columnId: 'name' | 'updatedAt' | 'inherentRisk') => {
389396
if (sort.id === columnId) {
390397
setSort({ id: columnId, desc: !sort.desc });
391398
} else {
392399
setSort({ id: columnId, desc: false });
393400
}
394401
};
395402

396-
const getSortIcon = (columnId: 'name' | 'updatedAt') => {
403+
const getSortIcon = (columnId: 'name' | 'updatedAt' | 'inherentRisk') => {
397404
if (sort.id !== columnId) {
398405
return <ArrowUpDown className="ml-1 h-3 w-3 text-muted-foreground" />;
399406
}
@@ -528,6 +535,16 @@ export function VendorsTable({
528535
</button>
529536
</TableHead>
530537
<TableHead>STATUS</TableHead>
538+
<TableHead>
539+
<button
540+
type="button"
541+
onClick={() => handleSort('inherentRisk')}
542+
className="flex items-center hover:text-foreground"
543+
>
544+
INHERENT RISK
545+
{getSortIcon('inherentRisk')}
546+
</button>
547+
</TableHead>
531548
<TableHead>CATEGORY</TableHead>
532549
<TableHead>OWNER</TableHead>
533550
{hasPermission('vendor', 'delete') && <TableHead>ACTIONS</TableHead>}
@@ -549,6 +566,16 @@ export function VendorsTable({
549566
<TableCell>
550567
<VendorStatusCell vendor={vendor} />
551568
</TableCell>
569+
<TableCell>
570+
{vendor.status === 'not_assessed' ? (
571+
<Text variant="muted" size="sm"></Text>
572+
) : (
573+
<RiskScoreBadge
574+
likelihood={vendor.inherentProbability}
575+
impact={vendor.inherentImpact}
576+
/>
577+
)}
578+
</TableCell>
552579
<TableCell>
553580
<Badge variant="secondary">
554581
{CATEGORY_MAP[vendor.category] || vendor.category}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { cn } from '@/lib/utils';
2+
import type { Impact, Likelihood } from '@db';
3+
import { getRiskScore, type RiskLevel } from '@/lib/risk-score';
4+
5+
const LEVEL_CLASSES: Record<RiskLevel, string> = {
6+
'very-low':
7+
'bg-emerald-500/15 border-emerald-500/40 text-emerald-700 dark:text-emerald-300',
8+
low: 'bg-green-500/15 border-green-500/40 text-green-700 dark:text-green-300',
9+
medium: 'bg-yellow-500/15 border-yellow-600/40 text-yellow-700 dark:text-yellow-300',
10+
high: 'bg-orange-500/15 border-orange-500/40 text-orange-700 dark:text-orange-300',
11+
'very-high': 'bg-red-500/15 border-red-500/40 text-red-700 dark:text-red-300',
12+
};
13+
14+
const LEVEL_LABEL: Record<RiskLevel, string> = {
15+
'very-low': 'Very low',
16+
low: 'Low',
17+
medium: 'Medium',
18+
high: 'High',
19+
'very-high': 'Very high',
20+
};
21+
22+
export interface RiskScoreBadgeProps {
23+
likelihood: Likelihood;
24+
impact: Impact;
25+
className?: string;
26+
}
27+
28+
export function RiskScoreBadge({ likelihood, impact, className }: RiskScoreBadgeProps) {
29+
const { score, level } = getRiskScore(likelihood, impact);
30+
return (
31+
<span
32+
className={cn(
33+
'inline-flex w-fit items-center rounded-md border px-2 py-0.5 text-xs font-medium tabular-nums',
34+
LEVEL_CLASSES[level],
35+
className,
36+
)}
37+
title={`${LEVEL_LABEL[level]} risk`}
38+
>
39+
{score}/10
40+
</span>
41+
);
42+
}

apps/app/src/components/risks/charts/RiskMatrixChart.tsx

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,10 @@
11
'use client';
22

3+
import { IMPACT_SCORES, LIKELIHOOD_SCORES, getRiskLevel } from '@/lib/risk-score';
34
import { Impact, Likelihood } from '@db';
45
import { Button, HStack, Section } from '@trycompai/design-system';
56
import { useEffect, useState } from 'react';
67

7-
const LIKELIHOOD_SCORES: Record<Likelihood, number> = {
8-
very_unlikely: 1,
9-
unlikely: 2,
10-
possible: 3,
11-
likely: 4,
12-
very_likely: 5,
13-
};
14-
15-
const IMPACT_SCORES: Record<Impact, number> = {
16-
insignificant: 1,
17-
minor: 2,
18-
moderate: 3,
19-
major: 4,
20-
severe: 5,
21-
};
22-
238
const VISUAL_LIKELIHOOD_ORDER: Likelihood[] = [
249
Likelihood.very_likely,
2510
Likelihood.likely,
@@ -114,13 +99,7 @@ export function RiskMatrixChart({
11499
const likelihoodScore =
115100
LIKELIHOOD_SCORES[VISUAL_LIKELIHOOD_ORDER[probabilityLevels.indexOf(probability)]];
116101
const impactScore = IMPACT_SCORES[VISUAL_IMPACT_ORDER[impactLevels.indexOf(impact)]];
117-
const score = likelihoodScore * impactScore;
118-
119-
let level: RiskCell['level'] = 'very-low';
120-
if (score > 16) level = 'very-high';
121-
else if (score > 9) level = 'high';
122-
else if (score > 4) level = 'medium';
123-
else if (score > 1) level = 'low';
102+
const level = getRiskLevel(likelihoodScore * impactScore);
124103

125104
return {
126105
probability,
@@ -207,7 +186,7 @@ export function RiskMatrixChart({
207186
onClick={() => handleCellClick(probability, impact)}
208187
>
209188
{cell?.value && (
210-
<div className="h-3 w-3 animate-pulse rounded-full bg-white shadow-lg" />
189+
<div className="h-4 w-4 animate-pulse rounded-full bg-white shadow-lg ring-2 ring-slate-900/70 dark:ring-slate-100/80" />
211190
)}
212191
</div>
213192
);
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { getRiskLevel, getRiskScore } from './risk-score';
3+
4+
describe('getRiskScore', () => {
5+
it('returns 1/10 very-low at the minimum corner (1×1)', () => {
6+
expect(getRiskScore('very_unlikely', 'insignificant')).toEqual({
7+
raw: 1,
8+
score: 1,
9+
level: 'very-low',
10+
});
11+
});
12+
13+
it('returns 10/10 very-high at the maximum corner (5×5)', () => {
14+
expect(getRiskScore('very_likely', 'severe')).toEqual({
15+
raw: 25,
16+
score: 10,
17+
level: 'very-high',
18+
});
19+
});
20+
21+
it('computes raw as likelihood × impact', () => {
22+
expect(getRiskScore('possible', 'moderate').raw).toBe(9);
23+
expect(getRiskScore('likely', 'major').raw).toBe(16);
24+
expect(getRiskScore('very_likely', 'major').raw).toBe(20);
25+
});
26+
27+
it('normalizes raw 1–25 into a 1–10 integer score', () => {
28+
for (const l of ['very_unlikely', 'unlikely', 'possible', 'likely', 'very_likely'] as const) {
29+
for (const i of ['insignificant', 'minor', 'moderate', 'major', 'severe'] as const) {
30+
const { score } = getRiskScore(l, i);
31+
expect(score).toBeGreaterThanOrEqual(1);
32+
expect(score).toBeLessThanOrEqual(10);
33+
expect(Number.isInteger(score)).toBe(true);
34+
}
35+
}
36+
});
37+
});
38+
39+
describe('getRiskLevel', () => {
40+
it('matches the risk-matrix thresholds used elsewhere', () => {
41+
expect(getRiskLevel(1)).toBe('very-low');
42+
expect(getRiskLevel(2)).toBe('low');
43+
expect(getRiskLevel(4)).toBe('low');
44+
expect(getRiskLevel(5)).toBe('medium');
45+
expect(getRiskLevel(9)).toBe('medium');
46+
expect(getRiskLevel(10)).toBe('high');
47+
expect(getRiskLevel(16)).toBe('high');
48+
expect(getRiskLevel(17)).toBe('very-high');
49+
expect(getRiskLevel(25)).toBe('very-high');
50+
});
51+
});

apps/app/src/lib/risk-score.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Impact, Likelihood } from '@db';
2+
3+
export const LIKELIHOOD_SCORES: Record<Likelihood, number> = {
4+
very_unlikely: 1,
5+
unlikely: 2,
6+
possible: 3,
7+
likely: 4,
8+
very_likely: 5,
9+
};
10+
11+
export const IMPACT_SCORES: Record<Impact, number> = {
12+
insignificant: 1,
13+
minor: 2,
14+
moderate: 3,
15+
major: 4,
16+
severe: 5,
17+
};
18+
19+
export type RiskLevel = 'very-low' | 'low' | 'medium' | 'high' | 'very-high';
20+
21+
export interface RiskScore {
22+
raw: number;
23+
score: number;
24+
level: RiskLevel;
25+
}
26+
27+
export function getRiskLevel(raw: number): RiskLevel {
28+
if (raw > 16) return 'very-high';
29+
if (raw > 9) return 'high';
30+
if (raw > 4) return 'medium';
31+
if (raw > 1) return 'low';
32+
return 'very-low';
33+
}
34+
35+
export function getRiskScore(likelihood: Likelihood, impact: Impact): RiskScore {
36+
const raw = LIKELIHOOD_SCORES[likelihood] * IMPACT_SCORES[impact];
37+
const score = Math.max(1, Math.ceil(raw / 2.5));
38+
return { raw, score, level: getRiskLevel(raw) };
39+
}

0 commit comments

Comments
 (0)