From c4abc15c0a8c04ae90499b58dc25682ab4202ba3 Mon Sep 17 00:00:00 2001 From: Matthew-Baird <148975913+Matthew-Baird@users.noreply.github.com> Date: Wed, 17 Apr 2024 16:00:59 +0200 Subject: [PATCH] Org dashboard geochart (#730) * Org Dashboard Geo Chart * Add geo chart for countries * Rework chart responsiveness * Add eduction pie chart * Dashboard cards arrangement * Remove commented code * Fix type error * Refactor skills & line chart legends * Remove id prop from Line chart * Carousel Pagination * Carousel dynamic loading * Carousel total count * Org admin Opportunity mobile card styling * Remove hover from Opps and Marketplace cards * Add hover to Opp card in grid view * Remove hover from Opp cards on grid view * Remove hover effect from all card components --- ...k-green.svg => icon-completions-green.svg} | 0 ...-views-green.svg => icon-viewed-green.svg} | 0 .../src/api/models/organizationDashboard.ts | 1 + .../src/api/services/organizationDashboard.ts | 4 + .../src/components/Marketplace/ItemCard.tsx | 2 +- .../Marketplace/TransactionItem.tsx | 2 +- .../MyOpportunity/OpportunityListItem.tsx | 2 +- .../Opportunity/OpportunityPublicSmall.tsx | 3 +- .../Dashboard/DashboardCarousel.tsx | 132 ++++- .../Organisation/Dashboard/LineChart.tsx | 321 ++++--------- .../Organisation/Dashboard/PieChart.tsx | 35 +- .../Organisation/Dashboard/SkillsChart.tsx | 191 ++------ .../Organisation/Dashboard/WorldMapChart.tsx | 32 ++ .../src/pages/organisations/[id]/index.tsx | 451 +++++++++++------- .../[id]/opportunities/[[...query]]/index.tsx | 109 +++-- .../pages/yoid/credentials/[[...query]].tsx | 2 +- 16 files changed, 652 insertions(+), 635 deletions(-) rename src/web/public/images/{icon-bookmark-green.svg => icon-completions-green.svg} (100%) rename src/web/public/images/{icon-views-green.svg => icon-viewed-green.svg} (100%) create mode 100644 src/web/src/components/Organisation/Dashboard/WorldMapChart.tsx diff --git a/src/web/public/images/icon-bookmark-green.svg b/src/web/public/images/icon-completions-green.svg similarity index 100% rename from src/web/public/images/icon-bookmark-green.svg rename to src/web/public/images/icon-completions-green.svg diff --git a/src/web/public/images/icon-views-green.svg b/src/web/public/images/icon-viewed-green.svg similarity index 100% rename from src/web/public/images/icon-views-green.svg rename to src/web/public/images/icon-viewed-green.svg diff --git a/src/web/src/api/models/organizationDashboard.ts b/src/web/src/api/models/organizationDashboard.ts index c9a56327e..895e935f7 100644 --- a/src/web/src/api/models/organizationDashboard.ts +++ b/src/web/src/api/models/organizationDashboard.ts @@ -79,6 +79,7 @@ export interface OrganizationDemographic { countries: Demographic; genders: Demographic; ages: Demographic; + education: Demographic; } export interface Demographic { diff --git a/src/web/src/api/services/organizationDashboard.ts b/src/web/src/api/services/organizationDashboard.ts index f8728684f..0b55b173c 100644 --- a/src/web/src/api/services/organizationDashboard.ts +++ b/src/web/src/api/services/organizationDashboard.ts @@ -132,6 +132,10 @@ export const searchOrganizationEngagement = async ( legend: "ages", items: { item1: 100, item2: 200 }, }, + education: { + legend: "education", + items: { item1: 100, item2: 200 }, + }, }, dateStamp: "2021-12-01", }; diff --git a/src/web/src/components/Marketplace/ItemCard.tsx b/src/web/src/components/Marketplace/ItemCard.tsx index 614bcff46..0e11674f3 100644 --- a/src/web/src/components/Marketplace/ItemCard.tsx +++ b/src/web/src/components/Marketplace/ItemCard.tsx @@ -39,7 +39,7 @@ const ItemCardComponent: React.FC = ({ return (
diff --git a/src/web/src/components/MyOpportunity/OpportunityListItem.tsx b/src/web/src/components/MyOpportunity/OpportunityListItem.tsx index 27d568485..13dc36893 100644 --- a/src/web/src/components/MyOpportunity/OpportunityListItem.tsx +++ b/src/web/src/components/MyOpportunity/OpportunityListItem.tsx @@ -16,7 +16,7 @@ const OpportunityListItem: React.FC<{ return (
= ({ data }) => { return (
diff --git a/src/web/src/components/Organisation/Dashboard/DashboardCarousel.tsx b/src/web/src/components/Organisation/Dashboard/DashboardCarousel.tsx index 668558169..ede6baca2 100644 --- a/src/web/src/components/Organisation/Dashboard/DashboardCarousel.tsx +++ b/src/web/src/components/Organisation/Dashboard/DashboardCarousel.tsx @@ -1,5 +1,6 @@ -import React from "react"; -import type { EmblaOptionsType } from "embla-carousel"; +import React, { useState, useRef, useEffect, useCallback } from "react"; +import type { EmblaCarouselType, EmblaOptionsType } from "embla-carousel"; +import type { EngineType } from "embla-carousel/components/Engine"; import { PrevButton, NextButton, @@ -20,6 +21,8 @@ import { YouthCompletedCard } from "./YouthCompletedCard"; interface PropType { slides: OpportunityInfoAnalytics[] | YouthInfo[]; orgId: string; + loadData: (startRow: number) => Promise; + totalSildes: number; } const OPTIONS: EmblaOptionsType = { @@ -30,8 +33,56 @@ const OPTIONS: EmblaOptionsType = { }; const DashboardCarousel: React.FC = (props: PropType) => { - const { slides, orgId } = props; - const [emblaRef, emblaApi] = useEmblaCarousel(OPTIONS); + const { orgId, loadData, totalSildes } = props; + const scrollListenerRef = useRef<() => void>(() => undefined); + const listenForScrollRef = useRef(true); + const hasMoreToLoadRef = useRef(true); + const [slides, setSlides] = useState(props.slides); + const [hasMoreToLoad, setHasMoreToLoad] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + + const [emblaRef, emblaApi] = useEmblaCarousel({ + ...OPTIONS, + watchSlides: (emblaApi) => { + const reloadEmbla = (): void => { + const oldEngine = emblaApi.internalEngine(); + + emblaApi.reInit(); + const newEngine = emblaApi.internalEngine(); + const copyEngineModules: (keyof EngineType)[] = [ + "location", + "target", + "scrollBody", + ]; + copyEngineModules.forEach((engineModule) => { + Object.assign(newEngine[engineModule], oldEngine[engineModule]); + }); + + newEngine.translate.to(oldEngine.location.get()); + const { index } = newEngine.scrollTarget.byDistance(0, false); + newEngine.index.set(index); + newEngine.animation.start(); + + setLoadingMore(false); + listenForScrollRef.current = true; + }; + + const reloadAfterPointerUp = (): void => { + emblaApi.off("pointerUp", reloadAfterPointerUp); + reloadEmbla(); + }; + + const engine = emblaApi.internalEngine(); + + if (hasMoreToLoadRef.current && engine.dragHandler.pointerDown()) { + const boundsActive = engine.limit.reachedMax(engine.target.get()); + engine.scrollBounds.toggleActive(boundsActive); + emblaApi.on("pointerUp", reloadAfterPointerUp); + } else { + reloadEmbla(); + } + }, + }); const { prevBtnDisabled, @@ -42,6 +93,68 @@ const DashboardCarousel: React.FC = (props: PropType) => { const { selectedSnap, snapCount } = useSelectedSnapDisplay(emblaApi); + const onScroll = useCallback( + (emblaApi: EmblaCarouselType) => { + if (!listenForScrollRef.current) return; + + setLoadingMore((loadingMore) => { + const lastSlide = emblaApi.slideNodes().length - 1; + const lastSlideInView = emblaApi.slidesInView().includes(lastSlide); + let loadMore = !loadingMore && lastSlideInView; + + if (emblaApi.slideNodes().length < 1) { + loadMore = false; + } + + if (loadMore) { + listenForScrollRef.current = false; + + console.warn( + `Loading more data... ${lastSlide} lastSlideInView: ${lastSlideInView} nextStartRow: ${ + emblaApi.slideNodes().length + 1 + }`, + ); + + loadData(emblaApi.slideNodes().length + 1).then((data) => { + // debugger; + if (data.items.length == 0) { + setHasMoreToLoad(false); + emblaApi.off("scroll", scrollListenerRef.current); + } + + setSlides((prevSlides) => [...prevSlides, ...data.items]); + }); + } + + return loadingMore || lastSlideInView; + }); + }, + [loadData], + ); + + const addScrollListener = useCallback( + (emblaApi: EmblaCarouselType) => { + scrollListenerRef.current = () => onScroll(emblaApi); + emblaApi.on("scroll", scrollListenerRef.current); + }, + [onScroll], + ); + + useEffect(() => { + if (!emblaApi) return; + addScrollListener(emblaApi); + + const onResize = () => emblaApi.reInit(); + window.addEventListener("resize", onResize); + emblaApi.on("destroy", () => + window.removeEventListener("resize", onResize), + ); + }, [emblaApi, addScrollListener]); + + useEffect(() => { + hasMoreToLoadRef.current = hasMoreToLoad; + }, [hasMoreToLoad]); + return (
@@ -67,6 +180,15 @@ const DashboardCarousel: React.FC = (props: PropType) => {
))} + {hasMoreToLoad && ( +
+ +
+ )}
@@ -75,7 +197,7 @@ const DashboardCarousel: React.FC = (props: PropType) => {
{ - if (!data) { - console.warn("No data for custom legend"); - return; - } - - // Get the legend div - const legendDiv = document.getElementById(legend_div); - if (!legendDiv) { - console.warn("No legendDiv for custom legend"); - return; - } - - // Clear the current legend - legendDiv.innerHTML = ""; - - const opportunitiesDiv = document.createElement("div"); - opportunitiesDiv.classList.add("ml-4", "mt-2"); - opportunitiesDiv.innerHTML = ` -
-
- - Opportunities -
-
${opportunityCount?.toLocaleString()}
-
`; - - legendDiv.appendChild(opportunitiesDiv); - - // Add each series to the legend - for (let i = 0; i < data.legend.length; i++) { - // Create a div for the series - const seriesDiv = document.createElement("div"); - seriesDiv.classList.add("ml-0"); - seriesDiv.classList.add("md:ml-4"); - seriesDiv.classList.add("mt-2"); - - // Add the series name and color to the div - switch (data.legend[i]) { - case "Viewed": - seriesDiv.innerHTML = `
${ - data.legend[i] - }
- ${ - data.count[i] != null - ? `
${data.count[ - i - ]?.toLocaleString()}
` - : "" - } -
`; - break; - - case "Completions": - seriesDiv.innerHTML = `
${ - data.legend[i] - }
- ${ - data.count[i] != null - ? `
${data.count[ - i - ]?.toLocaleString()}
` - : "" - } -
`; - break; - - default: - seriesDiv.innerHTML = `
${ - data.legend[i] - }
- ${ - data.count[i] != null - ? `
${data.count[ - i - ]?.toLocaleString()}
` - : "" - } -
`; - } - - // If the series is selected, add a class to the div - if (selection && selection.length > 0 && selection[0]?.column === i) { - seriesDiv.classList.add("selected"); - } - - // Add the div to the legend - legendDiv.appendChild(seriesDiv); - } -}; +import Image from "next/image"; export const LineChart: React.FC<{ - id: string; data: TimeIntervalSummary | undefined; - width: number; - height: number; - chartWidth?: number; - chartHeight?: number; opportunityCount?: number; -}> = ({ id, data, width, height, opportunityCount }) => { +}> = ({ data, opportunityCount }) => { + const [selectedLegendIndex, setSelectedLegendIndex] = useState( + null, + ); const [showLabels, setShowLabels] = useState(true); - // map the data to the format required by the chart const localData = useMemo<(string | number)[][]>(() => { if (!data) return []; - // if no data was provided, supply empty values so that the chart does not show errors if (!(data?.data && data.data.length > 0)) - data.data = [ - { - date: "", - values: [0], - }, - ]; + data.data = [{ date: "", values: [0] }]; const mappedData = data.data.map((x) => { if (x.date) { const date = new Date(x.date); x.date = date; } - return [x.date, ...x.values] as (string | number)[]; }); const labels = data.legend.map((x, i) => `${x} (Total: ${data.count[i]})`); - // Check if all dates are the same and adjust label visibility const allSameDate = mappedData.every( (item, _, arr) => item[0] === (arr[0]?.[0] ?? undefined), ); @@ -150,78 +36,81 @@ export const LineChart: React.FC<{ return [["Date", ...labels], ...mappedData] as (string | number)[][]; }, [data]); - const [chartSize, setChartSize] = useState({ - width: width, - height: height, - areaWidth: "94%", - }); - - const [responsiveHeight, setResponsiveHeight] = useState(height); - - // Responsiveness - useEffect(() => { - const handleResize = () => { - if (window.innerWidth <= 359) { - setResponsiveHeight(245); - setChartSize({ width: 0, height: 0, areaWidth: "65%" }); - } else if (window.innerWidth > 359 && window.innerWidth < 390) { - setChartSize({ width: 0, height: 0, areaWidth: "75%" }); - setResponsiveHeight(245); - } else if (window.innerWidth >= 390 && window.innerWidth < 411) { - setChartSize({ width: 0, height: 0, areaWidth: "81%" }); - setResponsiveHeight(245); - } else if (window.innerWidth >= 411 && window.innerWidth < 420) { - setChartSize({ width: 0, height: 0, areaWidth: "85%" }); - setResponsiveHeight(245); - } else if (window.innerWidth >= 420 && window.innerWidth < 768) { - setChartSize({ width: 0, height: 0, areaWidth: "91%" }); - setResponsiveHeight(245); - } else { - setChartSize({ width: width, height: height, areaWidth: "94%" }); - } - }; - - window.addEventListener("resize", handleResize); - handleResize(); // Initial size adjustment - - return () => window.removeEventListener("resize", handleResize); - }, [width, height]); - - useEffect(() => { - if (!data || !localData) return; - - // Update the custom legend when the chart is ready (ready event does not always fire) - updateCustomLegendLineChart( - `legend_div_${id}`, - data, - undefined, - opportunityCount, - ); - }, [id, localData, data, opportunityCount]); - - if (!localData) { - return ( -
- Loading... + const handleSelect = (chartWrapper: GoogleChartWrapper) => { + const selection = chartWrapper.getChart().getSelection(); + if ( + selection != null && + selection.length > 0 && + selection[0]?.column !== null + ) { + setSelectedLegendIndex(selection[0]?.column - 1); + } else { + setSelectedLegendIndex(null); + } + }; + + const Legend = () => ( +
+
+
+ + Icon + + + Opportunities + +
+
+ {opportunityCount?.toLocaleString()} +
- ); - } + {data?.legend.map((name, index) => ( +
+
+ + Icon + + {name} +
+ {data.count[index] != null && ( +
+ {data.count[index]?.toLocaleString()} +
+ )} +
+ ))} +
+ ); return ( -
-
- -
- {showLabels ? ( +
+ + {showLabels ? ( +
@@ -238,20 +127,19 @@ export const LineChart: React.FC<{ legend: "none", lineWidth: 1, areaOpacity: 0.1, - width: chartSize.width, - height: chartSize.height, colors: ["#387F6A"], curveType: "function", title: "", pointSize: 0, pointShape: "circle", + enableInteractivity: true, hAxis: { gridlines: { color: "transparent", }, textPosition: showLabels ? "out" : "none", format: "MMM dd", - showTextEvery: 2, // Increase this number to show fewer labels + showTextEvery: 2, textStyle: { fontSize: 10, }, @@ -264,54 +152,29 @@ export const LineChart: React.FC<{ baselineColor: "transparent", }, series: { - 0: { - lineDashStyle: [4, 4], - areaOpacity: 0, - }, + 0: { lineDashStyle: [4, 4], areaOpacity: 0 }, 1: {}, }, chartArea: { - // left: "3%", left: 0, - top: 0, - width: chartSize.areaWidth, - height: "65%", + top: "3%", + width: "95%", + height: "90%", }, }} chartEvents={[ - { - eventName: "ready", - callback: () => { - // Update the custom legend when the chart is ready - updateCustomLegendLineChart( - `legend_div_${id}`, - data, - undefined, - opportunityCount, - ); - }, - }, { eventName: "select", - callback: ({ chartWrapper }) => { - // Update the custom legend when the selection changes - const selection = chartWrapper.getChart().getSelection(); - updateCustomLegendLineChart( - `legend_div_${id}`, - data, - selection, - opportunityCount, - ); - }, + callback: ({ chartWrapper }) => handleSelect(chartWrapper), }, ]} /> - ) : ( -
- Not enough data to display -
- )} -
+
+ ) : ( +
+ Not enough data to display +
+ )}
); }; diff --git a/src/web/src/components/Organisation/Dashboard/PieChart.tsx b/src/web/src/components/Organisation/Dashboard/PieChart.tsx index 12dcc998b..ffb411c20 100644 --- a/src/web/src/components/Organisation/Dashboard/PieChart.tsx +++ b/src/web/src/components/Organisation/Dashboard/PieChart.tsx @@ -1,5 +1,4 @@ import Chart from "react-google-charts"; -import { useEffect, useState } from "react"; type GoogleChartData = (string | number)[][]; @@ -10,35 +9,11 @@ export const PieChart: React.FC<{ data: GoogleChartData; colors?: string[]; className?: string; - width?: number; -}> = ({ id, title, subTitle, data, colors, width = 0, className = "" }) => { - const [chartWidth, setChartWidth] = useState(width); - const [marginRight, setMarginRight] = useState(0); - - // Responsiveness - useEffect(() => { - const handleResize = () => { - if (window.innerWidth < 360) { - setChartWidth(0); - setMarginRight(110); - } else if (window.innerWidth >= 360 && window.innerWidth < 411) { - setChartWidth(0); - setMarginRight(60); - } else if (window.innerWidth >= 411 && window.innerWidth < 768) { - setChartWidth(0); - } - }; - - window.addEventListener("resize", handleResize); - handleResize(); // Initial size adjustment - - return () => window.removeEventListener("resize", handleResize); - }, [chartWidth]); - +}> = ({ id, title, subTitle, data, colors, className = "" }) => { return (
{title}
@@ -58,7 +33,6 @@ export const PieChart: React.FC<{
} data={data} - style={{ width: chartWidth }} options={{ legend: { position: "left", @@ -73,12 +47,11 @@ export const PieChart: React.FC<{ pieHole: 0.7, height: 125, backgroundColor: "transparent", - width: chartWidth, pieSliceText: "none", chartArea: { top: 10, bottom: 10, - right: marginRight, + right: 0, width: "100%", height: "100%", }, @@ -88,7 +61,7 @@ export const PieChart: React.FC<{ }} /> ) : ( -
+
No data
)} diff --git a/src/web/src/components/Organisation/Dashboard/SkillsChart.tsx b/src/web/src/components/Organisation/Dashboard/SkillsChart.tsx index 4659bfd9e..58d6dd882 100644 --- a/src/web/src/components/Organisation/Dashboard/SkillsChart.tsx +++ b/src/web/src/components/Organisation/Dashboard/SkillsChart.tsx @@ -1,96 +1,30 @@ -import { useMemo, useEffect, useState } from "react"; +import { useMemo, useState } from "react"; import Chart from "react-google-charts"; import type { TimeIntervalSummary } from "~/api/models/organizationDashboard"; import { CHART_COLORS } from "~/lib/constants"; - -type VisualizationSelectionArray = { - column?: number; - row?: number; -}[]; - -const updateCustomLegendLineChart = ( - legend_div: string, - data: TimeIntervalSummary | undefined, - selection: VisualizationSelectionArray | undefined, -) => { - if (!data) { - console.warn("No data for custom legend"); - return; - } - - // Get the legend div - const legendDiv = document.getElementById(legend_div); - if (!legendDiv) { - console.warn("No legendDiv for custom legend"); - return; - } - - // Clear the current legend - legendDiv.innerHTML = ""; - - // Add each series to the legend - for (let i = 0; i < data.legend.length; i++) { - // Create a div for the series - const seriesDiv = document.createElement("div"); - seriesDiv.classList.add("ml-4"); - seriesDiv.classList.add("mt-2"); - - // Add the series name and color to the div - seriesDiv.innerHTML = `
${ - data.legend[i] - }
- ${ - data.count[i] != null - ? `
${data.count[ - i - ]?.toLocaleString()}
` - : "" - } -
`; - - // If the series is selected, add a class to the div - if (selection && selection.length > 0 && selection[0]?.column === i) { - seriesDiv.classList.add("selected"); - } - - // Add the div to the legend - legendDiv.appendChild(seriesDiv); - } -}; +import Image from "next/image"; export const SkillsChart: React.FC<{ - id: string; data: TimeIntervalSummary | undefined; - height: number; - chartWidth?: number; - chartHeight?: number; -}> = ({ id, data, height, chartWidth, chartHeight }) => { +}> = ({ data }) => { const [showChart, setShowChart] = useState(true); - // map the data to the format required by the chart + const localData = useMemo<(string | number)[][]>(() => { if (!data) return []; - // if no data was provided, supply empty values so that the chart does not show errors if (!(data?.data && data.data.length > 0)) - data.data = [ - { - date: "", - values: [0], - }, - ]; + data.data = [{ date: "", values: [0] }]; const mappedData = data.data.map((x) => { if (x.date) { const date = new Date(x.date); x.date = date; } - return [x.date, ...x.values] as (string | number)[]; }); const labels = data.legend.map((x, i) => `${x} (Total: ${data.count[i]})`); - // Check if all dates are the same and adjust label visibility const allSameDate = mappedData.every( (item, _, arr) => item[0] === (arr[0]?.[0] ?? undefined), ); @@ -99,67 +33,39 @@ export const SkillsChart: React.FC<{ return [["Date", ...labels], ...mappedData] as (string | number)[][]; }, [data]); - useEffect(() => { - if (!data || !localData) return; - - // Update the custom legend when the chart is ready (ready event does not always fire) - updateCustomLegendLineChart(`legend_div_${id}`, data, undefined); - }, [id, localData, data]); - - const [responsiveWidth, setResponsiveWidth] = useState(chartWidth); - const [chartLeftMargin, setChartLeftMargin] = useState("3%"); - const [chartRightMargin, setChartRightMargin] = useState("0%"); - - // Responsiveness - useEffect(() => { - const handleResize = () => { - if (window.innerWidth < 360) { - setResponsiveWidth(0); - setChartLeftMargin("17%"); - setChartRightMargin("17%"); - } else if (window.innerWidth >= 360 && window.innerWidth < 375) { - setResponsiveWidth(0); - setChartLeftMargin("13%"); - setChartRightMargin("13%"); - } else if (window.innerWidth >= 375 && window.innerWidth <= 390) { - setResponsiveWidth(0); - setChartLeftMargin("11%"); - setChartRightMargin("11%"); - } else if (window.innerWidth > 390 && window.innerWidth < 768) { - setResponsiveWidth(0); - setChartLeftMargin("6%"); - setChartRightMargin("6%"); - } else { - setChartRightMargin("3%"); - } - }; - - window.addEventListener("resize", handleResize); - handleResize(); // Initial size adjustment - - return () => window.removeEventListener("resize", handleResize); - }, [chartWidth]); - - if (!localData) { + const Legend = () => { return ( -
- Loading... +
+ {data?.legend.map((name, index) => ( +
+
+ + Skills Icon + + {name} +
+ {data?.count[index] != null && ( +
+ {data.count[index]?.toLocaleString()} +
+ )} +
+ ))}
); - } + }; return ( -
-
- -
+
+ +
{showChart ? ( @@ -178,9 +84,9 @@ export const SkillsChart: React.FC<{ areaOpacity: 0.1, colors: CHART_COLORS, curveType: "function", - title: "", pointSize: 0, pointShape: "circle", + enableInteractivity: false, hAxis: { gridlines: { color: "transparent", @@ -196,41 +102,16 @@ export const SkillsChart: React.FC<{ baselineColor: "transparent", }, chartArea: { - left: chartLeftMargin, + left: 0, top: 0, - right: chartRightMargin, + right: 0, width: "94%", - height: "65%", + height: "38%", }, }} - chartEvents={[ - { - eventName: "ready", - callback: () => { - // Update the custom legend when the chart is ready - updateCustomLegendLineChart( - `legend_div_${id}`, - data, - undefined, - ); - }, - }, - { - eventName: "select", - callback: ({ chartWrapper }) => { - // Update the custom legend when the selection changes - const selection = chartWrapper.getChart().getSelection(); - updateCustomLegendLineChart( - `legend_div_${id}`, - data, - selection, - ); - }, - }, - ]} /> ) : ( -
+
Not enough data to display
)} diff --git a/src/web/src/components/Organisation/Dashboard/WorldMapChart.tsx b/src/web/src/components/Organisation/Dashboard/WorldMapChart.tsx new file mode 100644 index 000000000..fa85df66e --- /dev/null +++ b/src/web/src/components/Organisation/Dashboard/WorldMapChart.tsx @@ -0,0 +1,32 @@ +import { Chart } from "react-google-charts"; + +type GoogleChartData = (string | number)[][]; + +export const WorldMapChart: React.FC<{ data: GoogleChartData }> = ({ + data, +}) => { + const options = { + colorAxis: { colors: ["#E6F5F3", "#387F6A"] }, + backgroundColor: "#FFFFFF", + datalessRegionColor: "#f3f6fa", + defaultColor: "#f3f6fa", + legend: "none", + }; + + return ( +
+ + +
+ } + /> +
+ ); +}; diff --git a/src/web/src/pages/organisations/[id]/index.tsx b/src/web/src/pages/organisations/[id]/index.tsx index 1b7cd81f6..21a80d533 100644 --- a/src/web/src/pages/organisations/[id]/index.tsx +++ b/src/web/src/pages/organisations/[id]/index.tsx @@ -1,4 +1,9 @@ -import { QueryClient, dehydrate, useQuery } from "@tanstack/react-query"; +import { + QueryClient, + dehydrate, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import Head from "next/head"; import { type ParsedUrlQuery } from "querystring"; import { @@ -39,12 +44,13 @@ import Link from "next/link"; import { getThemeFromRole } from "~/lib/utils"; import Image from "next/image"; import iconZlto from "public/images/icon-zlto-green.svg"; -import iconBookmark from "public/images/icon-bookmark-green.svg"; +import iconBookmark from "public/images/icon-completions-green.svg"; import iconSkills from "public/images/icon-skills-green.svg"; import { CHART_COLORS, DATETIME_FORMAT_HUMAN, PAGE_SIZE, + PAGE_SIZE_MINIMUM, } from "~/lib/constants"; import NoRowsMessage from "~/components/NoRowsMessage"; import { PaginationButtons } from "~/components/PaginationButtons"; @@ -68,6 +74,7 @@ import { InternalServerError } from "~/components/Status/InternalServerError"; import { Unauthenticated } from "~/components/Status/Unauthenticated"; import { AvatarImage } from "~/components/AvatarImage"; import DashboardCarousel from "~/components/Organisation/Dashboard/DashboardCarousel"; +import { WorldMapChart } from "~/components/Organisation/Dashboard/WorldMapChart"; interface OrganizationSearchFilterSummaryViewModel { organization: string; @@ -168,6 +175,7 @@ const OrganisationDashboard: NextPageWithLayout<{ const [inactiveOpportunitiesCount, setInactiveOpportunitiesCount] = useState(0); const [expiredOpportunitiesCount, setExpiredOpportunitiesCount] = useState(0); + const queryClient = useQueryClient(); // 👇 use prefetched queries from server const { data: lookups_categories } = useQuery({ @@ -279,24 +287,6 @@ const OrganisationDashboard: NextPageWithLayout<{ enabled: !error, }); - useEffect(() => { - const calculateCounts = () => { - if (!selectedOpportunities?.items) return; - - const inactiveCount = selectedOpportunities.items.filter( - (opportunity) => opportunity.status === ("Inactive" as any), - ).length; - const expiredCount = selectedOpportunities.items.filter( - (opportunity) => opportunity.status === ("Expired" as any), - ).length; - - setInactiveOpportunitiesCount(inactiveCount); - setExpiredOpportunitiesCount(expiredCount); - }; - - calculateCounts(); - }, [selectedOpportunities]); - // QUERY: COMPLETED YOUTH const { data: completedYouth, isLoading: completedYouthIsLoading } = useQuery({ @@ -383,6 +373,148 @@ const OrganisationDashboard: NextPageWithLayout<{ endDate, ]); + // Carousel data + const fetchDataAndUpdateCache = useCallback( + async ( + queryKey: string[], + filter: any, + ): Promise => { + const cachedData = + queryClient.getQueryData( + queryKey, + ); + + if (cachedData) { + return cachedData; + } + + const data = await searchOrganizationOpportunities(filter); + + queryClient.setQueryData(queryKey, data); + + return data; + }, + [queryClient], + ); + + const loadDataSelected = useCallback( + async (startRow: number) => { + if (startRow >= (selectedOpportunities?.totalCount ?? 0)) { + return { + items: [], + totalCount: 0, + }; + } + const pageNumber = Math.ceil(startRow / PAGE_SIZE_MINIMUM); + + return fetchDataAndUpdateCache( + [ + "OrganizationSearchResultsSelectedOpportunities", + pageNumber.toString(), + ], + { + organization: id, + categories: + categories != undefined + ? categories + ?.toString() + .split(",") + .map((x) => { + const item = lookups_categories?.find((y) => y.name === x); + return item ? item?.id : ""; + }) + .filter((x) => x != "") + : null, + opportunities: opportunities + ? opportunities?.toString().split(",") + : null, + startDate: startDate ? startDate.toString() : "", + endDate: endDate ? endDate.toString() : "", + pageNumber: pageNumber, + pageSize: PAGE_SIZE_MINIMUM, + enabled: !error, + }, + ); + }, + [ + selectedOpportunities, + fetchDataAndUpdateCache, + categories, + opportunities, + startDate, + endDate, + id, + lookups_categories, + error, + ], + ); + + const loadDataYouth = useCallback( + async (startRow: number) => { + if (startRow >= (selectedOpportunities?.totalCount ?? 0)) { + return { + items: [], + totalCount: 0, + }; + } + const pageNumber = Math.ceil(startRow / PAGE_SIZE_MINIMUM); + + return fetchDataAndUpdateCache( + ["OrganizationSearchResultsCompletedYouth", pageNumber.toString()], + { + organization: id, + categories: + categories != undefined + ? categories + ?.toString() + .split(",") + .map((x) => { + const item = lookups_categories?.find((y) => y.name === x); + return item ? item?.id : ""; + }) + .filter((x) => x != "") + : null, + opportunities: opportunities + ? opportunities?.toString().split(",") + : null, + startDate: startDate ? startDate.toString() : "", + endDate: endDate ? endDate.toString() : "", + pageNumber: pageNumber, + pageSize: PAGE_SIZE_MINIMUM, + }, + ); + }, + [ + selectedOpportunities, + fetchDataAndUpdateCache, + categories, + opportunities, + startDate, + endDate, + id, + lookups_categories, + ], + ); + + // Calculate counts + useEffect(() => { + const calculateCounts = () => { + if (!selectedOpportunities?.items) return; + + const inactiveCount = selectedOpportunities.items.filter( + (opportunity) => opportunity.status === ("Inactive" as any), + ).length; + const expiredCount = selectedOpportunities.items.filter( + (opportunity) => opportunity.status === ("Expired" as any), + ).length; + + setInactiveOpportunitiesCount(inactiveCount); + setExpiredOpportunitiesCount(expiredCount); + }; + + calculateCounts(); + }, [selectedOpportunities]); + // 🎈 FUNCTIONS const getSearchFilterAsQueryString = useCallback( (opportunitySearchFilter: OrganizationSearchFilterSummaryViewModel) => { @@ -635,20 +767,17 @@ const OrganisationDashboard: NextPageWithLayout<{ {/* VIEWED COMPLETED */} {searchResults?.opportunities?.viewedCompleted && ( )} -
+
{/* AVERAGE CONVERSION RATE */} -
+
{/* */}
@@ -687,7 +816,6 @@ const OrganisationDashboard: NextPageWithLayout<{ id="conversionRate" title="Overall ratio" subTitle="" - width={313} colors={CHART_COLORS} data={[ ["Completed", "Viewed"], @@ -710,75 +838,37 @@ const OrganisationDashboard: NextPageWithLayout<{
- {/* REWARDS */} -
-
-
Rewards
-
- Skills -
+
+
+
Countries
- {/* ZLTO AMOUNT AWARDED */} -
-
-
- Icon Zlto -
-
- ZLTO amount awarded -
-
-
- Icon Zlto + {/* COUNTRIES - WORLD MAP */} + {searchResults?.demographics?.countries?.items && ( + -
- {searchResults?.opportunities.reward.totalAmount.toLocaleString() ?? - 0} -
-
-
- -
Skills
- - {/* TOTAL UNIQUE SKILLS */} -
- + )}
- {/* MOST COMPLETED SKILLS */} - {searchResults?.skills?.topCompleted && ( - <> -
+
+
+ Rewards +
+
+ {/* ZLTO AMOUNT AWARDED */} +
Icon Skills
-
- {searchResults?.skills.topCompleted.legend} +
+ ZLTO amount awarded
-
- {searchResults?.skills.topCompleted.topCompleted.map( - (x) => ( -
- {x.name} -
- ), - )} -
- {searchResults?.skills?.topCompleted.topCompleted - .length === 0 && ( -
- Not enough data to display +
+ Icon Zlto +
+ {searchResults?.opportunities.reward.totalAmount.toLocaleString() ?? + 0}
- )} +
- - )} + +
+ + Skills + + + {/* TOTAL UNIQUE SKILLS */} + +
+
+ {/* MOST COMPLETED SKILLS */} + {searchResults?.skills?.topCompleted && ( + <> +
+
+
+ Icon Skills +
+
+ {searchResults?.skills.topCompleted.legend} +
+
+
+ {searchResults?.skills.topCompleted.topCompleted.map( + (x) => ( +
+ {x.name} +
+ ), + )} +
+ {searchResults?.skills?.topCompleted.topCompleted + .length === 0 && ( +
+ Not enough data to display +
+ )} +
+ + )} +
{/* DEMOGRAPHICS */} -
+
Demographics
-
- {/* COUNTRIES */} - {searchResults?.demographics?.countries?.items && ( - - )} +
+ {/* EDUCATION */} + {/* GENDERS */} - {searchResults?.demographics?.genders?.items && ( - - )} + {/* AGE */} - {searchResults?.demographics?.ages?.items && ( - - )} +
@@ -1035,6 +1160,8 @@ const OrganisationDashboard: NextPageWithLayout<{
@@ -1087,7 +1214,9 @@ const OrganisationDashboard: NextPageWithLayout<{
)} @@ -1155,6 +1284,8 @@ const OrganisationDashboard: NextPageWithLayout<{
diff --git a/src/web/src/pages/organisations/[id]/opportunities/[[...query]]/index.tsx b/src/web/src/pages/organisations/[id]/opportunities/[[...query]]/index.tsx index 3a2b72715..3941e79ce 100644 --- a/src/web/src/pages/organisations/[id]/opportunities/[[...query]]/index.tsx +++ b/src/web/src/pages/organisations/[id]/opportunities/[[...query]]/index.tsx @@ -383,67 +383,78 @@ const Opportunities: NextPageWithLayout<{ {opportunities.items.map((opportunity) => ( -
-
-
- - Opportunity title - - - {opportunity.title} - -
+
+ + {opportunity.title} + +
- {/* BADGES */} -
- {opportunity.zltoReward && ( - - Zlto icon - - {opportunity?.zltoReward} - - - )} - {opportunity.yomaReward && ( - - - {opportunity.yomaReward} Yoma - +
+
+

Reward

+ {opportunity.zltoReward && ( + + Zlto icon + + {opportunity?.zltoReward} - )} - - - + + )} + {opportunity.yomaReward && ( + - {opportunity.participantCountTotal} + {opportunity.yomaReward} Yoma + )} +
- {opportunity?.url && ( - - - - {opportunity.url} - - - )} -
+
+

Participants

+ + + + {opportunity.participantCountTotal} + + +
+ +
+

Status

+ {opportunity.status == "Active" && ( + <> + + Active + + + )} + {opportunity?.status == "Expired" && ( + + Expired + + )} + {opportunity?.status == "Inactive" && ( + + Inactive + + )} + {opportunity?.status == "Deleted" && ( + + Deleted + + )}
diff --git a/src/web/src/pages/yoid/credentials/[[...query]].tsx b/src/web/src/pages/yoid/credentials/[[...query]].tsx index d75f876d4..8ed7c3503 100644 --- a/src/web/src/pages/yoid/credentials/[[...query]].tsx +++ b/src/web/src/pages/yoid/credentials/[[...query]].tsx @@ -286,7 +286,7 @@ const MyCredentials: NextPageWithLayout<{ {data.items.map((item, index) => (
handleOnClickCredential(item)} >