Skip to content
This repository has been archived by the owner on Dec 10, 2024. It is now read-only.

Commit

Permalink
feat(analytics): responses table and color consistency
Browse files Browse the repository at this point in the history
  • Loading branch information
jhdcruz committed Nov 16, 2024
1 parent cc5bd05 commit cd4ee59
Show file tree
Hide file tree
Showing 8 changed files with 455 additions and 18 deletions.
40 changes: 40 additions & 0 deletions src/app/api/activities/export/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { NextRequest } from 'next/server';
import { getActivitiesInRange } from '@/libs/supabase/api/activity';
import { activitiesToFc } from '@/utils/json-restructure';
import { createServerClient } from '@/libs/supabase/server';

/**
* Export activities feedback responses as CSV
*
* @param req - The activity ID
*/
export async function GET(req: NextRequest) {
// get search params from request
const params = req.nextUrl.searchParams;
const id = params.get('id') as string;

if (!id) {
return new Response('Invalid request', {
status: 400,
});
}

const cookies = req.cookies;
const supabase = await createServerClient(cookies);

const { data, error } = await supabase
.from('activity_feedback')
.select()
.csv();

if (error) {
return new Response(error.message, {
status: 400,
});
}

return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'text/csv' },
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

import { memo } from 'react';
import dynamic from 'next/dynamic';
import { Grid, Paper, ScrollArea, rem } from '@mantine/core';
import { Divider, Grid, Group, Paper, ScrollArea, rem } from '@mantine/core';
import { PageLoader } from '@/components/Loader/PageLoader';
import { StatRatings } from './StatRatings';
import { IconFileSpreadsheet } from '@tabler/icons-react';

const EmotionsRadar = dynamic(
() =>
Expand All @@ -28,12 +29,45 @@ const SentimentSegments = dynamic(
},
);

const EvaluationsTable = dynamic(
() =>
import('./EvaluationsTable').then((mod) => ({
default: mod.EvaluationsTable,
})),
{
ssr: false,
loading: () => <PageLoader label={false} />,
},
);

function ActivityAnalyticsShell({ id }: { id: string }) {
return (
<Grid align="flex-start" columns={3} gutter="xs" justify="space-between">
<Grid align="flex-start" columns={3} gutter={0} justify="space-between">
<Grid.Col span="auto">
<ScrollArea.Autosize offsetScrollbars type="auto">
<StatRatings id={id} />
<Divider
label={
<Group gap={0} wrap="nowrap">
<IconFileSpreadsheet className="mr-2" size={16} />
Evaluation Responses
</Group>
}
labelPosition="left"
my="md"
/>

{/* Respondents Table */}
<Paper
bg="light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-7)
)"
p="md"
withBorder
>
<EvaluationsTable id={id} />
</Paper>
</ScrollArea.Autosize>
</Grid.Col>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
aggregateEmotions,
} from '@/utils/json-restructure';
import classes from '@/styles/Utilties.module.css';
import { getEvaluatorColor } from '@/utils/colors';

function EmotionsRadarComponent({ id }: { id: string }) {
const [data, setData] = useState<CategorizedEmotions[]>([]);
Expand Down Expand Up @@ -55,19 +56,19 @@ function EmotionsRadarComponent({ id }: { id: string }) {
{
label: 'Partners',
name: 'partners',
color: 'red.6',
color: getEvaluatorColor('partners'),
opacity: 0.2,
},
{
label: 'Implementers',
name: 'implementers',
color: 'green.6',
color: getEvaluatorColor('implementers'),
opacity: 0.2,
},
{
label: 'Beneficiaries',
name: 'beneficiaries',
color: 'blue.6',
color: getEvaluatorColor('beneficiaries'),
opacity: 0.2,
},
]}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
'use client';

import { memo, useDeferredValue, useEffect, useState } from 'react';
import {
Anchor,
Badge,
Box,
Button,
Group,
NumberFormatter,
Pill,
Progress,
rem,
Table,
Text,
TextInput,
} from '@mantine/core';
import { createBrowserClient } from '@/libs/supabase/client';
import { IconDownload, IconSearch } from '@tabler/icons-react';
import type { Tables } from '@/libs/supabase/_database';
import type { BeneficiariesFeedbackProps } from '@/app/eval/_components/Forms/BeneficiariesForm';
import type { PartnersFeedbackProps } from '@/app/eval/_components/Forms/PartnersForm';
import type { ImplementerFeedbackProps } from '@/app/eval/_components/Forms/ImplementersForm';
import type {
Emotions,
EmotionsResponse,
SentimentResponse,
} from '@/libs/huggingface/types';
import classes from '@/styles/Table.module.css';
import { notifications } from '@mantine/notifications';
import { getEmotionColor, getEvaluatorColor } from '@/utils/colors';

interface EvaluationProps
extends Omit<
Tables<'activity_feedback'>,
'score_emotions' | 'score_sentiment' | 'response'
> {
response:
| BeneficiariesFeedbackProps
| PartnersFeedbackProps
| ImplementerFeedbackProps;
score_emotions: EmotionsResponse;
score_sentiment: SentimentResponse;
}

export const EvaluationsTable = memo(({ id }: { id: string }) => {
const [data, setData] = useState<EvaluationProps[]>([]);

const [search, setSearch] = useState<string>('');
const query = useDeferredValue(search);

const handleExport = async () => {
notifications.show({
id: 'export',
loading: true,
message: 'Processing export request...',
color: 'brand',
withBorder: true,
});

const response = await fetch(`/api/activities/export?id=${id}`, {
headers: {
'Content-Type': 'text/csv',
},
});

if (response.ok) {
notifications.show({
id: 'export',
loading: false,
title: 'Processing completed',
message: 'Downloading file...',
color: 'brand',
withBorder: true,
withCloseButton: true,
autoClose: 4000,
});

const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `evaluations-${id}.csv`;
a.click();
} else {
notifications.show({
id: 'export',
loading: false,
title: 'Export request failed',
message: response.statusText,
color: 'red',
withBorder: true,
withCloseButton: true,
autoClose: 4000,
});
}
};

useEffect(() => {
const fetchEvals = async () => {
const supabase = createBrowserClient();

let db = supabase
.from('activity_feedback')
.select()
.eq('activity_id', id)
.order('submitted_at', { ascending: false });

if (query) {
// allow search on respondent->name, respondent->email, or id
db = db.or(
`response->respondent(name,email).ilike.%${query}%,id.ilike.%${query}%`,
);
}

const { data: results } = await db.returns<EvaluationProps[]>();

if (results) setData(results);
};

void fetchEvals();
}, [id, query]);

const rows = data.map((row) => {
const {
response,
score_emotions: emotions,
score_sentiment: sentiments,
} = row;

const totalSentiment =
sentiments?.negative + sentiments?.neutral + sentiments?.positive;
const sentimentData = {
negative: (sentiments?.negative / totalSentiment) * 100,
neutral: (sentiments?.neutral / totalSentiment) * 100,
positive: (sentiments?.positive / totalSentiment) * 100,
};

const emotionTags: string[] = emotions.emotions.map(
(emotion) => emotion.label,
);

return (
<Table.Tr key={row.id}>
<Table.Td>
<Badge
color={getEvaluatorColor(row.type)}
size="sm"
tt="capitalize"
variant="dot"
>
{row.type}
</Badge>
</Table.Td>

<Table.Td>
<Anchor component="button" fz="sm" truncate>
{response?.respondent.name ?? response?.respondent.email ?? row.id}
</Anchor>
</Table.Td>
<Table.Td>
{emotionTags.map((tag) => (
<Badge
autoContrast
color={getEmotionColor(tag as keyof Emotions)}
key={tag}
size="sm"
tt="capitalize"
variant="light"
>
{tag}
</Badge>
))}
</Table.Td>

{/* Sentiment Bar */}
<Table.Td>
<Group justify="space-between">
<Text c="red" fw={700} fz="xs">
<NumberFormatter
decimalScale={2}
suffix="%"
value={sentimentData.negative}
/>
</Text>
<Text c="gray" fw={700} fz="xs">
<NumberFormatter
decimalScale={2}
suffix="%"
value={sentimentData.neutral}
/>
</Text>
<Text c="teal" fw={700} fz="xs">
<NumberFormatter
decimalScale={2}
suffix="%"
value={sentimentData.positive}
/>
</Text>
</Group>
<Progress.Root>
<Progress.Section
className={classes.progressSection}
color="red"
value={sentimentData.negative}
/>
<Progress.Section
className={classes.progressSection}
color="gray"
value={sentimentData.neutral}
/>
<Progress.Section
className={classes.progressSection}
color="teal"
value={sentimentData.positive}
/>
</Progress.Root>
</Table.Td>
</Table.Tr>
);
});

return (
<Box>
<Group mb="xs">
<TextInput
bg="light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-7)
)"
leftSection={<IconSearch size={16} />}
miw={rem(400)}
onChange={(event) => setSearch(event.currentTarget.value)}
placeholder="Search for name, email or uuid"
value={search}
/>

<Button
onClick={handleExport}
rightSection={<IconDownload size={16} stroke={1.5} />}
variant="default"
>
Export
</Button>
</Group>

<Table.ScrollContainer minWidth={600}>
<Table verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Respondent</Table.Th>
<Table.Th>Emotions</Table.Th>
<Table.Th>Sentiment</Table.Th>
</Table.Tr>
</Table.Thead>

<Table.Tbody>{rows}</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Box>
);
});
EvaluationsTable.displayName = 'EvaluationsTable';
Loading

0 comments on commit cd4ee59

Please sign in to comment.