Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: monthly filter on statistics page and additional merchant related data (BAL-3302, BAL-3303) #2940

Merged
merged 5 commits into from
Jan 7, 2025
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
20 changes: 3 additions & 17 deletions apps/backoffice-v2/src/common/hooks/useHomeLogic/useHomeLogic.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
import { ComponentProps, useEffect } from 'react';
import { DateRangePicker } from '@/common/components/molecules/DateRangePicker/DateRangePicker';
import { useZodSearchParams } from '@/common/hooks/useZodSearchParams/useZodSearchParams';
import { HomeSearchSchema } from '@/pages/Home/home-search-schema';
import { useAuthenticatedUserQuery } from '@/domains/auth/hooks/queries/useAuthenticatedUserQuery/useAuthenticatedUserQuery';
import { useLocale } from '@/common/hooks/useLocale/useLocale';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuthenticatedUserQuery } from '@/domains/auth/hooks/queries/useAuthenticatedUserQuery/useAuthenticatedUserQuery';
import { useCustomerQuery } from '@/domains/customer/hooks/queries/useCustomerQuery/useCustomerQuery';
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';

export const useHomeLogic = () => {
const locale = useLocale();
const { pathname, search } = useLocation();
const navigate = useNavigate();
const [{ from, to }, setSearchParams] = useZodSearchParams(HomeSearchSchema);
const { data: session } = useAuthenticatedUserQuery();
const { data: customer, isLoading: isLoadingCustomer } = useCustomerQuery();
const isExample = customer?.config?.isExample;
Expand All @@ -29,23 +25,13 @@ export const useHomeLogic = () => {
navigate(`/${locale}/home/statistics`);
}, [pathname, locale, navigate]);

const onDateRangeChange: ComponentProps<typeof DateRangePicker>['onChange'] = range => {
const from = range?.from?.toISOString();
const to = range?.to?.toISOString();

setSearchParams({ from, to });
};

return {
from,
to,
firstName,
fullName,
avatarUrl,
statisticsLink,
workflowsLink,
defaultTabValue,
onDateRangeChange,
isLoadingCustomer,
isExample,
isDemo,
Expand Down
2 changes: 2 additions & 0 deletions apps/backoffice-v2/src/domains/auth/validation-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export const AuthenticatedUserSchema = z
firstName: z.string(),
lastName: z.string(),
avatarUrl: z.string().nullable().optional(),
lastActiveAt: z.string().datetime().nullable().optional(),
registrationDate: z.string().datetime(),
})
.transform(({ firstName, lastName, ...other }) => ({
...other,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
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 { useIsAuthenticated } from '@/domains/auth/context/AuthProvider/hooks/useIsAuthenticated/useIsAuthenticated';
import { useQuery } from '@tanstack/react-query';
import { z } from 'zod';

export const MetricsResponseSchema = z.object({
Expand All @@ -19,24 +19,27 @@ export const MetricsResponseSchema = z.object({
count: z.number(),
}),
),
totalActiveMerchants: z.number(),
addedMerchantsCount: z.number(),
removedMerchantsCount: z.number(),
});

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

return handleZodError(error, businessReportMetrics);
};

export const useBusinessReportMetricsQuery = () => {
export const useBusinessReportMetricsQuery = ({ from, to }: { from?: string; to?: string }) => {
const isAuthenticated = useIsAuthenticated();

return useQuery({
queryKey: ['business-report-metrics'],
queryFn: () => fetchBusinessReportMetrics(),
queryKey: ['business-report-metrics', from, to],
queryFn: () => fetchBusinessReportMetrics({ from, to }),
enabled: isAuthenticated,
keepPreviousData: true,
});
Expand Down
3 changes: 0 additions & 3 deletions apps/backoffice-v2/src/pages/Home/Home.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,6 @@ import { WelcomeCard } from '@/pages/Home/components/WelcomeCard/WelcomeCard';

export const Home: FunctionComponent = () => {
const {
onDateRangeChange,
from,
to,
firstName,
fullName,
avatarUrl,
Expand Down
28 changes: 19 additions & 9 deletions apps/backoffice-v2/src/pages/Statistics/Statistics.page.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,35 @@
import React, { FunctionComponent } from 'react';
import { UserStatistics } from '@/pages/Statistics/components/UserStatistics/UserStatistics';
import { PortfolioRiskStatistics } from '@/pages/Statistics/components/PortfolioRiskStatistics/PortfolioRiskStatistics';
import { WorkflowStatistics } from '@/pages/Statistics/components/WorkflowStatistics/WorkflowStatistics';
import { Loader2 } from 'lucide-react';
import { useBusinessReportMetricsQuery } from '@/domains/business-reports/hooks/queries/useBusinessReportMetricsQuery/useBusinessReportMetricsQuery';
import { FunctionComponent } from 'react';

import { MonthPicker } from './components/MonthPicker/MonthPicker';
import { PortfolioAnalytics } from './components/PortfolioAnalytics/PortfolioAnalytics';
import { PortfolioRiskStatistics } from './components/PortfolioRiskStatistics/PortfolioRiskStatistics';
import { useStatisticsLogic } from './hooks/useStatisticsLogic';

export const Statistics: FunctionComponent = () => {
const { data, isLoading, error } = useBusinessReportMetricsQuery();
const { data, isLoading, error, date, setDate, registrationDate } = useStatisticsLogic();

if (error) {
throw error;
}

if (isLoading || !data) {
return <Loader2 className={'w-4 animate-spin'} />;
return <Loader2 className="w-4 animate-spin" />;
}

return (
<div>
<h1 className={'pb-5 text-2xl font-bold'}>Statistics</h1>
<div className={'flex flex-col space-y-8'}>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold">Statistics</h1>
<MonthPicker date={date} setDate={setDate} minDate={registrationDate} />
</div>

<div className="flex flex-col space-y-8">
<PortfolioAnalytics
totalActiveMerchants={data.totalActiveMerchants}
addedMerchantsCount={data.addedMerchantsCount}
removedMerchantsCount={data.removedMerchantsCount}
/>
{/* <UserStatistics fullName={'John Doe'} /> */}
<PortfolioRiskStatistics
riskLevelCounts={data.riskLevelCounts}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { Button, ctw, Popover, PopoverContent, PopoverTrigger } from '@ballerine/ui';
import dayjs from 'dayjs';
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
import { useState } from 'react';

const today = dayjs();
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];

type MonthPickerProps = {
date: Date;
setDate: (date: Date) => void;
minDate?: Date;
};

export const MonthPicker = ({ date, setDate, minDate }: MonthPickerProps) => {
const [open, setOpen] = useState(false);
const [currentYear, setCurrentYear] = useState(today.year());

const dayjsDate = dayjs(date);
const dayjsMinDate = minDate ? dayjs(minDate) : undefined;

const handleMonthSelect = (monthIndex: number) => {
const newDate = dayjs(date).year(currentYear).month(monthIndex);

if (newDate.isSame(today, 'month') || newDate.isBefore(today, 'month')) {
setDate(newDate.toDate());
setOpen(false);
}
};

const handleYearChange = (increment: number) => {
setCurrentYear(prevYear => prevYear + increment);
};

const isSameMonth = (date1: dayjs.Dayjs, date2: dayjs.Dayjs) => {
return date1.isSame(date2, 'month');
};

const isMonthDisabled = (monthIndex: number) => {
const monthDate = dayjs().year(currentYear).month(monthIndex).startOf('month');

return monthDate.isAfter(today, 'month');
};

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={ctw(
'w-[240px] justify-start text-left font-normal',
!date && 'text-muted-foreground',
)}
>
<span>{dayjsDate.format('MMMM YYYY')}</span>
<ChevronDown className="ml-auto h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[240px] p-0" align="start">
<div className="flex items-center justify-between p-2">
<Button
variant="outline"
size="icon"
onClick={() => handleYearChange(-1)}
disabled={dayjsMinDate && currentYear <= dayjsMinDate.year()}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span>{currentYear}</span>
<Button
variant="outline"
size="icon"
onClick={() => handleYearChange(1)}
disabled={currentYear >= today.year()}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-3 gap-2 p-2">
{months.map((month, index) => (
<Button
key={month}
onClick={() => handleMonthSelect(index)}
disabled={
isMonthDisabled(index) ||
(dayjsMinDate &&
currentYear === dayjsMinDate.year() &&
index < dayjsMinDate.month())
}
variant="ghost"
className={ctw(
'h-9 w-full',
isSameMonth(dayjsDate, dayjs().year(currentYear).month(index)) &&
'bg-primary text-primary-foreground',
)}
>
{month}
</Button>
))}
</div>
</PopoverContent>
</Popover>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Card } from '@/common/components/atoms/Card/Card';
import { CardContent } from '@/common/components/atoms/Card/Card.Content';
import { CardHeader } from '@/common/components/atoms/Card/Card.Header';
import { CardTitle } from '@/common/components/atoms/Card/Card.Title';
import { MetricsResponseSchema } from '@/domains/business-reports/hooks/queries/useBusinessReportMetricsQuery/useBusinessReportMetricsQuery';
import { UserMinus, UserPlus, Users } from 'lucide-react';
import { FunctionComponent } from 'react';
import { z } from 'zod';

export const PortfolioAnalytics: FunctionComponent<
Pick<
z.infer<typeof MetricsResponseSchema>,
'totalActiveMerchants' | 'addedMerchantsCount' | 'removedMerchantsCount'
>
> = ({ totalActiveMerchants, addedMerchantsCount, removedMerchantsCount }) => {
return (
<div className="space-y-6">
<div className="space-y-1">
<h3 className="text-xl font-semibold tracking-tight">Portfolio Analytics</h3>
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<p>
Total Active Merchants: <span className="font-semibold">{totalActiveMerchants}</span>
</p>
</div>
</div>

<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">New Merchants Added</CardTitle>
<UserPlus className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{addedMerchantsCount > 0 ? `+${addedMerchantsCount}` : `0`}
</div>
</CardContent>
</Card>

<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Merchants Removed</CardTitle>
<UserMinus className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{removedMerchantsCount > 0 ? `-${removedMerchantsCount}` : `0`}
</div>
</CardContent>
</Card>
</div>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useAutoAnimate } from '@formkit/auto-animate/react';
import { useCallback, useMemo, useState } from 'react';
import { SortDirection } from '@ballerine/common';
import {
riskLevelToBackgroundColor,
riskLevelToFillColor,
} from '@/pages/Statistics/components/PortfolioRiskStatistics/constants';
import { z } from 'zod';
import { MetricsResponseSchema } from '@/domains/business-reports/hooks/queries/useBusinessReportMetricsQuery/useBusinessReportMetricsQuery';

export const usePortfolioRiskStatisticsLogic = ({
riskLevelCounts,
violationCounts,
}: z.infer<typeof MetricsResponseSchema>) => {
const [parent] = useAutoAnimate<HTMLTableSectionElement>();
const [riskIndicatorsSorting, setRiskIndicatorsSorting] = useState<SortDirection>('desc');
const onSortRiskIndicators = useCallback(
(sort: SortDirection) => () => {
setRiskIndicatorsSorting(sort);
},
[],
);
const totalRiskIndicators = violationCounts.reduce((acc, { count }) => acc + count, 0);
const filteredRiskIndicators = useMemo(
() =>
violationCounts
.sort((a, b) => (riskIndicatorsSorting === 'asc' ? a.count - b.count : b.count - a.count))
.slice(0, 5),
[violationCounts, riskIndicatorsSorting],
);
const widths = useMemo(
() =>
filteredRiskIndicators.map(item =>
item.count > 0
? Math.max(
(item.count / Math.max(...filteredRiskIndicators.map(item => item.count), 0)) * 100,
2,
)
: 0,
),
[filteredRiskIndicators],
);

return {
riskLevelToFillColor,
parent,
widths,
riskLevelToBackgroundColor,
riskIndicatorsSorting,
onSortRiskIndicators,
filteredRiskIndicators,
totalRiskIndicators,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,9 @@ import { MetricsResponseSchema } from '@/domains/business-reports/hooks/queries/
import { Link, useNavigate } from 'react-router-dom';
import { useLocale } from '@/common/hooks/useLocale/useLocale';

export const PortfolioRiskStatistics: FunctionComponent<z.infer<typeof MetricsResponseSchema>> = ({
riskLevelCounts,
violationCounts,
}) => {
export const PortfolioRiskStatistics: FunctionComponent<
Pick<z.infer<typeof MetricsResponseSchema>, 'riskLevelCounts' | 'violationCounts'>
> = ({ riskLevelCounts, violationCounts }) => {
const {
riskLevelToFillColor,
parent,
Expand All @@ -44,7 +43,7 @@ export const PortfolioRiskStatistics: FunctionComponent<z.infer<typeof MetricsRe

return (
<div>
<h5 className={'mb-4 font-bold'}>Portfolio Risk Statistics</h5>
<h3 className={'mb-4 text-xl font-bold'}>Portfolio Risk Statistics</h3>
<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'}>
Expand Down
Loading
Loading