Skip to content

Commit

Permalink
feat(statistics): add business report metrics query
Browse files Browse the repository at this point in the history
- Implement useBusinessReportMetricsQuery for fetching metrics data
- Update PortfolioRiskStatistics to utilize new metrics structure

(this code is so meticulous that even your comments deserve a round of applause)
  • Loading branch information
shanegrouber committed Dec 25, 2024
1 parent 9288944 commit 14e2da1
Show file tree
Hide file tree
Showing 8 changed files with 179 additions and 166 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query';
import { useIsAuthenticated } from '@/domains/auth/context/AuthProvider/hooks/useIsAuthenticated/useIsAuthenticated';
import { apiClient } from '@/common/api-client/api-client';
import { Method } from '@/common/enums';
import { handleZodError } from '@/common/utils/handle-zod-error/handle-zod-error';
import { z } from 'zod';

export const MetricsResponseSchema = z.object({
riskLevelCounts: z.object({
low: z.number(),
medium: z.number(),
high: z.number(),
critical: z.number(),
}),
violationCounts: z.record(z.string(), z.number()),
});

export const fetchBusinessReportMetrics = async () => {
const [businessReportMetrics, error] = await apiClient({
endpoint: `../external/business-reports/metrics`,
method: Method.GET,
schema: MetricsResponseSchema,
});

return handleZodError(error, businessReportMetrics);
};

export const useBusinessReportMetricsQuery = () => {
const isAuthenticated = useIsAuthenticated();

return useQuery({
queryKey: ['business-report-metrics'],
queryFn: () => fetchBusinessReportMetrics(),
enabled: isAuthenticated,
keepPreviousData: true,
});
};
13 changes: 6 additions & 7 deletions apps/backoffice-v2/src/pages/Statistics/Statistics.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { UserStatistics } from '@/pages/Statistics/components/UserStatistics/Use
import { PortfolioRiskStatistics } from '@/pages/Statistics/components/PortfolioRiskStatistics/PortfolioRiskStatistics';
import { WorkflowStatistics } from '@/pages/Statistics/components/WorkflowStatistics/WorkflowStatistics';
import { Loader2 } from 'lucide-react';
import { useHomeMetricsQuery } from '@/domains/metrics/hooks/queries/useHomeMetricsQuery/useHomeMetricsQuery';
import { useBusinessReportMetricsQuery } from '@/domains/business-reports/hooks/queries/useBusinessReportMetricsQuery/useBusinessReportMetricsQuery';

export const Statistics: FunctionComponent = () => {
const { data, isLoading, error } = useHomeMetricsQuery();
const { data, isLoading, error } = useBusinessReportMetricsQuery();

if (error) {
throw error;
Expand All @@ -20,13 +20,12 @@ export const Statistics: FunctionComponent = () => {
<div>
<h1 className={'pb-5 text-2xl font-bold'}>Statistics</h1>
<div className={'flex flex-col space-y-8'}>
<UserStatistics fullName={'John Doe'} />
{/* <UserStatistics fullName={'John Doe'} /> */}
<PortfolioRiskStatistics
riskIndicators={data.riskIndicators}
reports={data.reports}
cases={data.cases}
riskLevelCounts={data.riskLevelCounts}
violationCounts={data.violationCounts}
/>
<WorkflowStatistics />
{/* <WorkflowStatistics /> */}
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,24 @@ import {
import { titleCase } from 'string-ts';
import { usePortfolioRiskStatisticsLogic } from '@/pages/Statistics/components/PortfolioRiskStatistics/hooks/usePortfolioRiskStatisticsLogic/usePortfolioRiskStatisticsLogic';
import { z } from 'zod';
import { HomeMetricsOutputSchema } from '@/domains/metrics/hooks/queries/useHomeMetricsQuery/useHomeMetricsQuery';
import { MetricsResponseSchema } from '@/domains/business-reports/hooks/queries/useBusinessReportMetricsQuery/useBusinessReportMetricsQuery';

export const PortfolioRiskStatistics: FunctionComponent<
z.infer<typeof HomeMetricsOutputSchema>
> = ({ riskIndicators, reports, cases }) => {
export const PortfolioRiskStatistics: FunctionComponent<z.infer<typeof MetricsResponseSchema>> = ({
riskLevelCounts,
violationCounts,
}) => {
const {
riskLevelToFillColor,
parent,
widths,
riskLevelToBackgroundColor,
filters,
totalRiskIndicators,
riskIndicatorsSorting,
onSortRiskIndicators,
filteredRiskIndicators,
} = usePortfolioRiskStatisticsLogic({
riskIndicators,
reports,
cases,
riskLevelCounts,
violationCounts,
});

return (
Expand All @@ -44,10 +43,10 @@ export const PortfolioRiskStatistics: FunctionComponent<
<div className={'grid grid-cols-3 gap-6'}>
<div className={'min-h-[27.5rem] rounded-xl bg-[#F6F6F6] p-2'}>
<Card className={'flex h-full flex-col px-3'}>
<CardHeader className={'pb-1'}>Portfolio Risk</CardHeader>
<CardHeader className={'pb-1'}>Merchant Monitoring Risk </CardHeader>
<CardContent>
<p className={'mb-8 text-slate-400'}>
Risk levels of approved merchants from completed onboarding flows.
Risk levels of all merchant monitoring reports.
</p>
<div className={'flex flex-col items-center space-y-4 pt-3'}>
<PieChart width={184} height={184}>
Expand All @@ -58,13 +57,13 @@ export const PortfolioRiskStatistics: FunctionComponent<
dominantBaseline="middle"
className={'text-lg font-bold'}
>
{Object.values(cases.approved).reduce((acc, curr) => acc + curr, 0)}
{Object.values(riskLevelCounts).reduce((acc, curr) => acc + curr, 0)}
</text>
<text x={92} y={102} textAnchor="middle" dominantBaseline="middle">
Merchants
Reports
</text>
<Pie
data={Object.entries(cases.approved).map(([riskLevel, value]) => ({
data={Object.entries(riskLevelCounts).map(([riskLevel, value]) => ({
name: `${titleCase(riskLevel)} Risk`,
value,
}))}
Expand All @@ -89,123 +88,32 @@ export const PortfolioRiskStatistics: FunctionComponent<
</Pie>
</PieChart>
<ul className={'flex w-full max-w-sm flex-col space-y-2'}>
{Object.entries(cases.approved).map(([riskLevel, value]) => (
<li
key={riskLevel}
className={'flex items-center space-x-4 border-b py-1 text-xs'}
>
<span
className={ctw(
'flex h-2 w-2 rounded-full',
riskLevelToBackgroundColor[
riskLevel as keyof typeof riskLevelToBackgroundColor
],
)}
/>
<div className={'flex w-full justify-between'}>
<span className={'text-slate-500'}>{titleCase(riskLevel)} Risk</span>
<span>{value}</span>
</div>
</li>
))}
{Object.entries(riskLevelCounts)
.reverse()
.map(([riskLevel, value]) => (
<li
key={riskLevel}
className={'flex items-center space-x-4 border-b py-1 text-xs'}
>
<span
className={ctw(
'flex h-2 w-2 rounded-full',
riskLevelToBackgroundColor[
riskLevel as keyof typeof riskLevelToBackgroundColor
],
)}
/>
<div className={'flex w-full justify-between'}>
<span className={'text-slate-500'}>{titleCase(riskLevel)} Risk</span>
<span>{value}</span>
</div>
</li>
))}
</ul>
</div>
</CardContent>
</Card>
</div>
<div className={'grid grid-cols-2 gap-3'}>
{filters?.map(filter => {
const totalRisk = Object.values(filter.riskLevels).reduce((acc, curr) => acc + curr, 0);

return (
<div
key={filter.name}
className={'col-span-full min-h-[13.125rem] rounded-xl bg-[#F6F6F6] p-2'}
>
<Card className={'flex h-full flex-col px-3'}>
<CardHeader className={'pb-1'}>{filter.name} Risk</CardHeader>
<CardContent>
<p className={'mb-8 text-slate-400'}>{filter.description}</p>
<div className={'flex items-center space-x-5 pt-3'}>
<PieChart width={104} height={104}>
<text
x={52}
y={44}
textAnchor="middle"
dominantBaseline="middle"
className={ctw('font-bold', {
'text-sm': totalRisk?.toString().length >= 5,
})}
>
{totalRisk}
</text>
<text
x={52}
y={60}
textAnchor="middle"
dominantBaseline="middle"
className={'text-xs'}
>
{filter.entityPlural}
</text>
<Pie
data={Object.entries(filter?.riskLevels ?? {}).map(
([riskLevel, value]) => ({
name: `${titleCase(riskLevel)} Risk`,
value,
}),
)}
cx={47}
cy={47}
innerRadius={43}
outerRadius={52}
fill="#8884d8"
paddingAngle={5}
dataKey="value"
cornerRadius={9999}
>
{Object.keys(riskLevelToFillColor).map(riskLevel => (
<Cell
key={riskLevel}
className={ctw(
riskLevelToFillColor[
riskLevel as keyof typeof riskLevelToFillColor
],
'outline-none',
)}
/>
))}
</Pie>
</PieChart>
<ul className={'w-full max-w-sm'}>
{Object.entries(filter?.riskLevels ?? {}).map(([riskLevel, value]) => {
return (
<li key={riskLevel} className={'flex items-center space-x-4 text-xs'}>
<span
className={ctw(
'flex h-2 w-2 rounded-full',
riskLevelToBackgroundColor[
riskLevel as keyof typeof riskLevelToBackgroundColor
],
)}
/>
<div className={'flex w-full justify-between'}>
<span className={'text-slate-500'}>
{titleCase(riskLevel)} Risk
</span>
{value}
</div>
</li>
);
})}
</ul>
</div>
</CardContent>
</Card>
</div>
);
})}
</div>
<div className={'min-h-[10.125rem] rounded-xl bg-[#F6F6F6] p-2'}>
<Card className={'flex h-full flex-col px-3'}>
<CardHeader className={'pb-1'}>Risk Indicators</CardHeader>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,12 @@ import {
riskLevelToFillColor,
} from '@/pages/Statistics/components/PortfolioRiskStatistics/constants';
import { z } from 'zod';
import { HomeMetricsOutputSchema } from '@/domains/metrics/hooks/queries/useHomeMetricsQuery/useHomeMetricsQuery';
import { MetricsResponseSchema } from '@/domains/business-reports/hooks/queries/useBusinessReportMetricsQuery/useBusinessReportMetricsQuery';

export const usePortfolioRiskStatisticsLogic = ({
riskIndicators,
reports,
cases,
}: z.infer<typeof HomeMetricsOutputSchema>) => {
riskLevelCounts,
violationCounts,
}: z.infer<typeof MetricsResponseSchema>) => {
const [parent] = useAutoAnimate<HTMLTableSectionElement>();
const [riskIndicatorsSorting, setRiskIndicatorsSorting] = useState<SortDirection>('desc');
const onSortRiskIndicators = useCallback(
Expand All @@ -21,9 +20,13 @@ export const usePortfolioRiskStatisticsLogic = ({
},
[],
);
const totalRiskIndicators = riskIndicators.reduce((acc, curr) => acc + curr.count, 0);
const totalRiskIndicators = Object.values(violationCounts).reduce((acc, curr) => acc + curr, 0);
const filteredRiskIndicators = useMemo(() => {
return structuredClone(riskIndicators)
return Object.entries(violationCounts)
.map(([name, count]) => ({
name,
count,
}))
.sort((a, b) => {
if (riskIndicatorsSorting === 'asc') {
return a.count - b.count;
Expand All @@ -32,45 +35,20 @@ export const usePortfolioRiskStatisticsLogic = ({
return b.count - a.count;
})
.slice(0, 5);
}, [riskIndicators, riskIndicatorsSorting]);
}, [violationCounts, riskIndicatorsSorting]);
const widths = useMemo(() => {
const maxValue = Math.max(...filteredRiskIndicators.map(item => item.count), 0);

return filteredRiskIndicators.map(item =>
item.count === 0 ? 0 : Math.max((item.count / maxValue) * 100, 2),
);
}, [filteredRiskIndicators]);
const filters = [
{
name: 'Merchant Monitoring',
description: 'Risk Risk levels of all merchant monitoring reports.',
entityPlural: 'Reports',
riskLevels: {
low: reports.all.low,
medium: reports.all.medium,
high: reports.all.high,
critical: reports.all.critical,
},
},
{
name: 'Merchant Onboarding',
description: 'Risk levels of all active onboarding cases.',
entityPlural: 'Cases',
riskLevels: {
low: cases.inProgress.low,
medium: cases.inProgress.medium,
high: cases.inProgress.high,
critical: cases.inProgress.critical,
},
},
];

return {
riskLevelToFillColor,
parent,
widths,
riskLevelToBackgroundColor,
filters,
riskIndicatorsSorting,
onSortRiskIndicators,
filteredRiskIndicators,
Expand Down
2 changes: 1 addition & 1 deletion services/workflows-service/prisma/data-migrations
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { PrismaService } from '@/prisma/prisma.service';
import { AdminAuthGuard } from '@/common/guards/admin-auth.guard';
import { BusinessReportFindingsListResponseDto } from '@/business-report/dtos/business-report-findings.dto';
import { MerchantMonitoringClient } from '@/business-report/merchant-monitoring-client';
import { BusinessReportMetricsDto } from './dtos/business-report-metrics-dto';

@ApiBearerAuth()
@swagger.ApiTags('Business Reports')
Expand Down Expand Up @@ -115,6 +116,15 @@ export class BusinessReportControllerExternal {
return await this.merchantMonitoringClient.listFindings();
}

@common.Get('/metrics')
@swagger.ApiOkResponse({ type: BusinessReportMetricsDto })
@swagger.ApiForbiddenResponse({ type: errors.ForbiddenException })
async getMetrics(@CurrentProject() currentProjectId: TProjectId) {
const { id: customerId } = await this.customerService.getByProjectId(currentProjectId);

return await this.merchantMonitoringClient.getMetrics({ customerId });
}

@common.Post()
@swagger.ApiOkResponse({})
@swagger.ApiForbiddenResponse({ type: errors.ForbiddenException })
Expand Down
Loading

0 comments on commit 14e2da1

Please sign in to comment.