From 6fb9542f838c0a8e945cf58079c329ca78c7a5dc Mon Sep 17 00:00:00 2001 From: Matthew Smawfield Date: Thu, 9 Jan 2025 11:10:11 +0000 Subject: [PATCH] Implement PER dashboard --- .../PERPerformanceDashboard/dataHandler.ts | 695 +++++++++++++++++ .../PERPerformanceDashboard/i18n.json | 15 + .../PERPerformanceDashboard/index.tsx | 206 +++++ .../PERPerformanceDashboard/styles.module.css | 77 ++ .../PERPerformanceDashboard/types.ts | 92 +++ .../PERSummaryDashboard/dataHandler.ts | 636 +++++++++++++++ .../PERSummaryDashboard/i18n.json | 20 + .../PERSummaryDashboard/index.tsx | 523 +++++++++++++ .../PERSummaryDashboard/styles.module.css | 97 +++ .../PERDashboard/PERSummaryDashboard/types.ts | 65 ++ app/src/views/PERDashboard/styles.module.css | 28 + .../PreparednessGlobalPerformance/i18n.json | 6 - .../PreparednessGlobalPerformance/index.tsx | 12 +- .../styles.module.css | 3 +- .../views/PreparednessGlobalSummary/i18n.json | 6 - .../views/PreparednessGlobalSummary/index.tsx | 12 +- packages/go-ui-storybook/public/analysis.svg | 5 + .../src/stories/PERAnalysis.stories.tsx | 75 ++ .../src/stories/PERAnalysis.tsx | 12 + .../src/stories/PERChartLegend.stories.tsx | 123 +++ .../src/stories/PERChartLegend.tsx | 12 + .../src/stories/PERConsiderations.stories.tsx | 113 +++ .../src/stories/PERConsiderations.tsx | 47 ++ .../src/stories/PERDonutChart.stories.tsx | 167 ++++ .../src/stories/PERDonutChart.tsx | 12 + .../src/stories/PERExportButton.stories.tsx | 72 ++ .../src/stories/PERExportButton.tsx | 12 + .../src/stories/PERGaugeChart.stories.tsx | 80 ++ .../src/stories/PERGaugeChart.tsx | 12 + .../src/stories/PERKPITabs.stories.tsx | 118 +++ .../src/stories/PERKPITabs.tsx | 12 + .../src/stories/PERMap.stories.tsx | 125 +++ .../go-ui-storybook/src/stories/PERMap.tsx | 15 + .../src/stories/PERRatingAnalysis.stories.tsx | 214 ++++++ .../src/stories/PERRatingAnalysis.tsx | 13 + .../src/stories/PERRegionToggle.stories.tsx | 112 +++ .../src/stories/PERRegionToggle.tsx | 12 + .../stories/PERStackedBarChart.stories.tsx | 148 ++++ .../src/stories/PERStackedBarChart.tsx | 12 + .../PERStackedHorizontalBarChart.stories.tsx | 79 ++ .../stories/PERStackedHorizontalBarChart.tsx | 15 + .../src/stories/PERTreemapChart.stories.tsx | 110 +++ .../src/stories/PERTreemapChart.tsx | 12 + packages/ui/package.json | 12 +- .../ui/src/components/PERAnalysis/i18n.json | 19 + .../ui/src/components/PERAnalysis/index.tsx | 395 ++++++++++ .../components/PERAnalysis/styles.module.css | 376 +++++++++ .../src/components/PERChartLegend/i18n.json | 10 + .../src/components/PERChartLegend/index.tsx | 94 +++ .../PERChartLegend/styles.module.css | 67 ++ .../PERConsiderations/assets/environment.png | Bin 0 -> 9020 bytes .../PERConsiderations/assets/epidemic.png | Bin 0 -> 8845 bytes .../PERConsiderations/assets/goicon.svg | 1 + .../PERConsiderations/assets/migration.png | Bin 0 -> 11450 bytes .../PERConsiderations/assets/urban.png | Bin 0 -> 7428 bytes .../components/PERConsiderations/i18n.json | 18 + .../components/PERConsiderations/images.d.ts | 4 + .../components/PERConsiderations/index.tsx | 212 +++++ .../PERConsiderations/styles.module.css | 65 ++ .../ui/src/components/PERDonutChart/i18n.json | 8 + .../ui/src/components/PERDonutChart/index.tsx | 252 ++++++ .../PERDonutChart/styles.module.css | 78 ++ .../src/components/PERExportButton/i18n.json | 7 + .../src/components/PERExportButton/index.tsx | 83 ++ .../PERExportButton/styles.module.css | 63 ++ .../ui/src/components/PERGaugeChart/i18n.json | 9 + .../ui/src/components/PERGaugeChart/index.tsx | 235 ++++++ .../PERGaugeChart/styles.module.css | 46 ++ .../ui/src/components/PERKPITabs/i18n.json | 9 + .../ui/src/components/PERKPITabs/index.tsx | 160 ++++ .../components/PERKPITabs/styles.module.css | 103 +++ packages/ui/src/components/PERMap/i18n.json | 15 + packages/ui/src/components/PERMap/index.tsx | 454 +++++++++++ .../src/components/PERMap/styles.module.css | 159 ++++ .../PERRatingAnalysis/RatingBar.tsx | 166 ++++ .../PERRatingAnalysis/RatingChange.tsx | 28 + .../PERRatingAnalysis/RatingScale.tsx | 142 ++++ .../PERRatingAnalysis/RatingStatusBadge.tsx | 60 ++ .../PERRatingAnalysis/Sparkline.tsx | 33 + .../components/PERRatingAnalysis/i18n.json | 19 + .../components/PERRatingAnalysis/index.tsx | 254 ++++++ .../PERRatingAnalysis/styles.module.css | 320 ++++++++ .../src/components/PERRatingAnalysis/types.ts | 54 ++ .../src/components/PERRegionToggle/i18n.json | 9 + .../src/components/PERRegionToggle/index.tsx | 109 +++ .../PERRegionToggle/styles.module.css | 234 ++++++ .../components/PERStackedBarChart/i18n.json | 12 + .../components/PERStackedBarChart/index.tsx | 279 +++++++ .../PERStackedBarChart/styles.module.css | 30 + .../components/PERStackedBarChart/utils.ts | 10 + .../PERStackedHorizontalBarChart/i18n.json | 9 + .../PERStackedHorizontalBarChart/index.tsx | 241 ++++++ .../styles.module.css | 5 + .../PERStackedHorizontalBarChart/types.ts | 16 + .../src/components/PERTreemapChart/i18n.json | 13 + .../src/components/PERTreemapChart/index.tsx | 723 ++++++++++++++++++ .../PERTreemapChart/styles.module.css | 53 ++ .../src/components/PERTreemapChart/types.ts | 28 + packages/ui/src/index.tsx | 28 + packages/ui/src/utils/common.ts | 9 + pnpm-lock.yaml | 712 ++++++++++++++++- .../000061-1764324133370.json | 717 +++++++++++++++++ 102 files changed, 11462 insertions(+), 37 deletions(-) create mode 100644 app/src/views/PERDashboard/PERPerformanceDashboard/dataHandler.ts create mode 100644 app/src/views/PERDashboard/PERPerformanceDashboard/i18n.json create mode 100644 app/src/views/PERDashboard/PERPerformanceDashboard/index.tsx create mode 100644 app/src/views/PERDashboard/PERPerformanceDashboard/styles.module.css create mode 100644 app/src/views/PERDashboard/PERPerformanceDashboard/types.ts create mode 100644 app/src/views/PERDashboard/PERSummaryDashboard/dataHandler.ts create mode 100644 app/src/views/PERDashboard/PERSummaryDashboard/i18n.json create mode 100644 app/src/views/PERDashboard/PERSummaryDashboard/index.tsx create mode 100644 app/src/views/PERDashboard/PERSummaryDashboard/styles.module.css create mode 100644 app/src/views/PERDashboard/PERSummaryDashboard/types.ts create mode 100644 app/src/views/PERDashboard/styles.module.css delete mode 100644 app/src/views/PreparednessGlobalPerformance/i18n.json delete mode 100644 app/src/views/PreparednessGlobalSummary/i18n.json create mode 100644 packages/go-ui-storybook/public/analysis.svg create mode 100644 packages/go-ui-storybook/src/stories/PERAnalysis.stories.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERAnalysis.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERChartLegend.stories.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERChartLegend.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERConsiderations.stories.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERConsiderations.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERDonutChart.stories.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERDonutChart.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERExportButton.stories.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERExportButton.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERGaugeChart.stories.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERGaugeChart.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERKPITabs.stories.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERKPITabs.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERMap.stories.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERMap.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERRatingAnalysis.stories.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERRatingAnalysis.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERRegionToggle.stories.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERRegionToggle.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERStackedBarChart.stories.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERStackedBarChart.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERStackedHorizontalBarChart.stories.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERStackedHorizontalBarChart.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERTreemapChart.stories.tsx create mode 100644 packages/go-ui-storybook/src/stories/PERTreemapChart.tsx create mode 100644 packages/ui/src/components/PERAnalysis/i18n.json create mode 100644 packages/ui/src/components/PERAnalysis/index.tsx create mode 100644 packages/ui/src/components/PERAnalysis/styles.module.css create mode 100644 packages/ui/src/components/PERChartLegend/i18n.json create mode 100644 packages/ui/src/components/PERChartLegend/index.tsx create mode 100644 packages/ui/src/components/PERChartLegend/styles.module.css create mode 100644 packages/ui/src/components/PERConsiderations/assets/environment.png create mode 100644 packages/ui/src/components/PERConsiderations/assets/epidemic.png create mode 100644 packages/ui/src/components/PERConsiderations/assets/goicon.svg create mode 100644 packages/ui/src/components/PERConsiderations/assets/migration.png create mode 100644 packages/ui/src/components/PERConsiderations/assets/urban.png create mode 100644 packages/ui/src/components/PERConsiderations/i18n.json create mode 100644 packages/ui/src/components/PERConsiderations/images.d.ts create mode 100644 packages/ui/src/components/PERConsiderations/index.tsx create mode 100644 packages/ui/src/components/PERConsiderations/styles.module.css create mode 100644 packages/ui/src/components/PERDonutChart/i18n.json create mode 100644 packages/ui/src/components/PERDonutChart/index.tsx create mode 100644 packages/ui/src/components/PERDonutChart/styles.module.css create mode 100644 packages/ui/src/components/PERExportButton/i18n.json create mode 100644 packages/ui/src/components/PERExportButton/index.tsx create mode 100644 packages/ui/src/components/PERExportButton/styles.module.css create mode 100644 packages/ui/src/components/PERGaugeChart/i18n.json create mode 100644 packages/ui/src/components/PERGaugeChart/index.tsx create mode 100644 packages/ui/src/components/PERGaugeChart/styles.module.css create mode 100644 packages/ui/src/components/PERKPITabs/i18n.json create mode 100644 packages/ui/src/components/PERKPITabs/index.tsx create mode 100644 packages/ui/src/components/PERKPITabs/styles.module.css create mode 100644 packages/ui/src/components/PERMap/i18n.json create mode 100644 packages/ui/src/components/PERMap/index.tsx create mode 100644 packages/ui/src/components/PERMap/styles.module.css create mode 100644 packages/ui/src/components/PERRatingAnalysis/RatingBar.tsx create mode 100644 packages/ui/src/components/PERRatingAnalysis/RatingChange.tsx create mode 100644 packages/ui/src/components/PERRatingAnalysis/RatingScale.tsx create mode 100644 packages/ui/src/components/PERRatingAnalysis/RatingStatusBadge.tsx create mode 100644 packages/ui/src/components/PERRatingAnalysis/Sparkline.tsx create mode 100644 packages/ui/src/components/PERRatingAnalysis/i18n.json create mode 100644 packages/ui/src/components/PERRatingAnalysis/index.tsx create mode 100644 packages/ui/src/components/PERRatingAnalysis/styles.module.css create mode 100644 packages/ui/src/components/PERRatingAnalysis/types.ts create mode 100644 packages/ui/src/components/PERRegionToggle/i18n.json create mode 100644 packages/ui/src/components/PERRegionToggle/index.tsx create mode 100644 packages/ui/src/components/PERRegionToggle/styles.module.css create mode 100644 packages/ui/src/components/PERStackedBarChart/i18n.json create mode 100644 packages/ui/src/components/PERStackedBarChart/index.tsx create mode 100644 packages/ui/src/components/PERStackedBarChart/styles.module.css create mode 100644 packages/ui/src/components/PERStackedBarChart/utils.ts create mode 100644 packages/ui/src/components/PERStackedHorizontalBarChart/i18n.json create mode 100644 packages/ui/src/components/PERStackedHorizontalBarChart/index.tsx create mode 100644 packages/ui/src/components/PERStackedHorizontalBarChart/styles.module.css create mode 100644 packages/ui/src/components/PERStackedHorizontalBarChart/types.ts create mode 100644 packages/ui/src/components/PERTreemapChart/i18n.json create mode 100644 packages/ui/src/components/PERTreemapChart/index.tsx create mode 100644 packages/ui/src/components/PERTreemapChart/styles.module.css create mode 100644 packages/ui/src/components/PERTreemapChart/types.ts create mode 100644 translationMigrations/000061-1764324133370.json diff --git a/app/src/views/PERDashboard/PERPerformanceDashboard/dataHandler.ts b/app/src/views/PERDashboard/PERPerformanceDashboard/dataHandler.ts new file mode 100644 index 0000000000..6888c4fd0a --- /dev/null +++ b/app/src/views/PERDashboard/PERPerformanceDashboard/dataHandler.ts @@ -0,0 +1,695 @@ +import type { + AreaSummary, + Assessment, + ComponentRating, + ComponentRatingsResult, + Filters, + RegionData, +} from './types'; + +const AREA_COLORS: Record = { + 'Policy Strategy and Standards': '#8748b3', + 'Analysis and planning': '#ff8655', + 'Operations support': '#da283d', + 'Operational capacity': '#3478ec', + Coordination: '#00B2A2', +} as const; + +// Rating scale colors +const RATING_SCALE_COLORS = { + "Doesn't exist": '#E0E3E7', + 'Partially exists': '#99A5B3', + 'Needs improvement': '#7D8B9D', + 'Good performing': '#4D617A', + 'High performing': '#011E41', +} as const; + +// Define interfaces for API response data +interface APIAssessmentData { + assessment_id: number; + assessment_number: number; + country_id: number; + country_name: string; + region_id: number; + region_name: string; + date_of_assessment: string; + rating_value: number; + rating_title: string; +} + +interface APIComponentData { + component_num: number; + component_name: string; + area_id: number; + area_name: string; + assessments: APIAssessmentData[]; +} + +interface APIResponse { + assessments: Record; +} + +interface UpdateData { + last_update: string; +} + +let perDashboardData: Assessment[] = []; +let lastUpdateData: UpdateData | null = null; + +function initializeData(data: APIResponse, updateData: UpdateData) { + // Transform the data from the API format to our internal format + const assessments: Assessment[] = []; + Object.entries(data.assessments).forEach(([, component]: [string, APIComponentData]) => { + component.assessments.forEach((assessment: APIAssessmentData) => { + assessments.push({ + ...assessment, + component_num: component.component_num, + component_name: component.component_name, + area_id: component.area_id, + area_name: component.area_name, + }); + }); + }); + + perDashboardData = assessments; + lastUpdateData = updateData; +} + +// Helper function to check if an assessment is newer +function isNewerAssessment(current: Assessment, existing: Assessment): boolean { + return current.assessment_number > existing.assessment_number + || (current.assessment_number === existing.assessment_number + && new Date(current.date_of_assessment) > new Date(existing.date_of_assessment)); +} + +// Helper function to filter duplicate assessments +const filterDuplicateAssessments = (assessments: Assessment[]): Assessment[] => { + const groups = new Map(); + + assessments.forEach((assessment) => { + const key = [ + assessment.assessment_id, + assessment.assessment_number, + assessment.component_num, + ].join('_'); + if (!groups.has(key)) { + groups.set(key, []); + } + groups.get(key)?.push(assessment); + }); + + const filteredAssessments: Assessment[] = []; + groups.forEach((group) => { + const sorted = group.sort((a, b) => b.rating_value - a.rating_value); + if (sorted[0]!.rating_value > 0 || group.length === 1) { + filteredAssessments.push(sorted[0]!); + } + }); + + return filteredAssessments; +}; + +// Helper function to apply filters +const applyFilters = (filters: Filters | null = null): Assessment[] => { + const assessments = filterDuplicateAssessments(perDashboardData); + + // Apply filtering logic if filters are provided + if (filters) { + let filteredAssessments = assessments; + + if (filters.region) { + filteredAssessments = filteredAssessments.filter( + (assessment) => assessment.region_name === filters.region, + ); + } + + if (filters.year) { + const targetYear = filters.year; + filteredAssessments = filteredAssessments.filter((assessment) => { + const assessmentYear = new Date(assessment.date_of_assessment).getFullYear(); + return assessmentYear === targetYear; + }); + } + + if (filters.cycle) { + filteredAssessments = filteredAssessments.filter( + (assessment) => assessment.assessment_number === filters.cycle, + ); + } + + return filteredAssessments; + } + + return assessments; +}; + +// Helper function to get rating status +function getRatingStatus(rating: number): keyof typeof RATING_SCALE_COLORS { + if (rating >= 4) return 'High performing'; + if (rating >= 3) return 'Good performing'; + if (rating >= 2) return 'Needs improvement'; + if (rating >= 1) return 'Partially exists'; + return "Doesn't exist"; +} + +// Helper function to get rounded rating +function getRoundedRating(rating: number): number { + return Math.round(rating * 10) / 10; +} + +function groupDataByRegion(): RegionData[] { + const assessments = applyFilters(); + const regionComponentAverages: Record< + string, + Map + > = {}; + + // First pass: identify latest assessments for each country-component combination + const latestAssessments = new Map(); + + assessments.forEach((assessment) => { + const key = `${assessment.country_id}_${assessment.component_num}`; + const existing = latestAssessments.get(key); + + if (!existing || isNewerAssessment(assessment, existing)) { + latestAssessments.set(key, assessment); + } + }); + + // Second pass: calculate component averages by region + latestAssessments.forEach((assessment) => { + const region = assessment.region_name; + if (!regionComponentAverages[region]) { + regionComponentAverages[region] = new Map(); + } + + const componentNum = assessment.component_num; + const componentData = regionComponentAverages[region].get(componentNum) + ?? { total: 0, count: 0 }; + + componentData.total += assessment.rating_value; + componentData.count += 1; + regionComponentAverages[region].set(componentNum, componentData); + }); + + // Calculate final regional averages + return Object.entries(regionComponentAverages).map(([region, components]) => { + let totalComponentRating = 0; + let componentCount = 0; + + components.forEach((comp) => { + if (comp.count > 0) { + totalComponentRating += comp.total / comp.count; + componentCount += 1; + } + }); + + return { + name: region, + count: componentCount > 0 + ? getRoundedRating(totalComponentRating / componentCount) + : 0, + totalComponents: componentCount, + }; + }); +} + +function getComponentRatings( + filters: Filters | null = null, +): ComponentRatingsResult { + const assessments = applyFilters(filters); + const componentGroups = new Map(); + assessments.forEach((assessment) => { + if (!componentGroups.has(assessment.component_num)) { + componentGroups.set(assessment.component_num, []); + } + componentGroups.get(assessment.component_num)?.push(assessment); + }); + + const componentMap = new Map(); + + componentGroups.forEach((componentAssessments, componentId) => { + // Filter out duplicate zero ratings when better ratings exist + const filteredComponentAssessments = filterDuplicateAssessments(componentAssessments); + const sample = filteredComponentAssessments[0]!; + + // Skip if no sample or if component name is missing + if (!sample || !sample.component_name || !sample.area_name) { + return; + } + + // Current Rating Calculation (latest assessment per country) + const latestAssessmentsByCountry = new Map(); + filteredComponentAssessments.forEach((a) => { + const existing = latestAssessmentsByCountry.get(a.country_id); + + if ( + !existing + || a.assessment_number > existing.assessment_number + || ( + a.assessment_number === existing.assessment_number + && new Date(a.date_of_assessment) > new Date(existing.date_of_assessment) + ) + ) { + latestAssessmentsByCountry.set(a.country_id, a); + } + }); + + const latestAssessments = Array.from(latestAssessmentsByCountry.values()); + const sum = latestAssessments.reduce((s, a) => s + a.rating_value, 0); + const average = sum / latestAssessments.length; + const currentRating = latestAssessments.length > 0 + ? parseFloat(average.toFixed(2)) + : 0; + + // Cycle Ratings Calculation + const cycles = [...new Set( + filteredComponentAssessments.map((a) => a.assessment_number), + )].sort((a, b) => a - b); + + const cycleRatings = cycles.map((cycle) => { + const cycleAssessments = filteredComponentAssessments + .filter((a) => a.assessment_number === cycle); + const averageRating = cycleAssessments.length > 0 + ? parseFloat( + ( + cycleAssessments.reduce( + (sm, a) => sm + a.rating_value, + 0, + ) / cycleAssessments.length + ).toFixed(2), + ) + : 0; + const roundedRating = getRoundedRating(averageRating); + const status = getRatingStatus(roundedRating); + + return { + cycle, + rating: averageRating, + rating_display: roundedRating.toString(), + rating_color: RATING_SCALE_COLORS[status] || '#000000', + }; + }); + + componentMap.set(componentId, { + component_num: componentId, + component_name: sample.component_name, + area_id: sample.area_id, + area_name: sample.area_name, + cycleRatings, + total: currentRating, + count: latestAssessments.length, + }); + }); + + // Calculate area ratings by aggregating component data + const areaMap = new Map(); + + componentGroups.forEach((_, componentId) => { + const component = componentMap.get(componentId); + // Skip if component is missing or has no area name + if (!component || !component.area_name) { + return; + } + if (!areaMap.has(component.area_name)) { + areaMap.set(component.area_name, { + name: component.area_name, + rating: 0, + status: '', + change: 0, + changeDirection: '', + cycleRatings: [], + components: [component], + areaColor: AREA_COLORS[component.area_name] || '#000000', + }); + } else { + const area = areaMap.get(component.area_name); + if (area) { + area.components.push(component); + } + } + }); + + const areas = Array.from(areaMap.values()) + .filter((area) => area.name && area.components.length > 0) + .map((area) => { + // Current area rating + const componentSum = area.components.reduce((sum, c) => sum + c.total, 0); + + const currentRating = area.components.length > 0 + ? parseFloat((componentSum / area.components.length).toFixed(2)) + : 0; + + // Cycle ratings for area + const allCycles = [...new Set( + area.components.flatMap((c) => c.cycleRatings.map((r) => r.cycle)), + )].sort((a, b) => a - b); + const areaCycleRatings = allCycles.map((cycle) => { + let cycleTotal = 0; + let validCount = 0; + area.components.forEach((comp) => { + const cycleRating = comp.cycleRatings.find((r) => r.cycle === cycle); + if (cycleRating) { + cycleTotal += cycleRating.rating; + validCount += 1; + } + }); + const cycleAverage = validCount > 0 + ? parseFloat((cycleTotal / validCount).toFixed(2)) + : 0; + const roundedRating = getRoundedRating(cycleAverage); + const status = getRatingStatus(roundedRating); + return { + cycle, + rating: cycleAverage, + rating_display: roundedRating.toString(), + rating_color: RATING_SCALE_COLORS[status] || '#000000', + }; + }); + + return { + ...area, + rating: currentRating, + status: getRatingStatus(currentRating), + cycleRatings: areaCycleRatings, + }; + }); + + // Overall ratings + const componentValues = Array.from(componentMap.values()); + const componentTotal = componentValues.reduce((sum, c) => sum + c.total, 0); + const currentOverallRating = componentValues.length > 0 + ? parseFloat((componentTotal / componentValues.length).toFixed(2)) + : 0; + + const allComponentCycles = [...new Set( + componentValues.flatMap((c) => c.cycleRatings.map((r) => r.cycle)), + )].sort((a, b) => a - b); + + const overallCycleRatings = allComponentCycles.map((cycle) => { + let cycleTotal = 0; + let validCount = 0; + componentValues.forEach((comp) => { + const cycleRating = comp.cycleRatings.find((r) => r.cycle === cycle); + if (cycleRating) { + cycleTotal += cycleRating.rating; + validCount += 1; + } + }); + const cycleAverage = validCount > 0 + ? parseFloat((cycleTotal / validCount).toFixed(2)) + : 0; + const roundedRating = getRoundedRating(cycleAverage); + const status = getRatingStatus(roundedRating); + return { + cycle, + rating: cycleAverage, + rating_display: roundedRating.toString(), + rating_color: RATING_SCALE_COLORS[status] || '#000000', + }; + }); + + // Calculate last two cycles change + const lastTwoOverallCycles = overallCycleRatings.slice(-2); + const overallChange = lastTwoOverallCycles.length > 1 + ? parseFloat((lastTwoOverallCycles[1]!.rating - lastTwoOverallCycles[0]!.rating).toFixed(2)) + : 0; + + return { + overallRating: { + rating: currentOverallRating, + status: getRatingStatus(currentOverallRating), + change: overallChange, + changeDirection: overallChange >= 0 ? 'up' : 'down', + cycleRatings: overallCycleRatings.map((cycle) => ({ + ...cycle, + color: RATING_SCALE_COLORS[getRatingStatus(cycle.rating)] || '#000000', + })), + color: RATING_SCALE_COLORS[getRatingStatus(currentOverallRating)] || '#000000', + } as unknown as ComponentRatingsResult['overallRating'], + areaData: areas.map((area) => { + const lastTwoCycles = area.cycleRatings.slice(-2); + const change = lastTwoCycles.length > 1 + ? parseFloat((lastTwoCycles[1]!.rating - lastTwoCycles[0]!.rating).toFixed(2)) + : 0; + return { + ...area, + change, + changeDirection: change >= 0 ? 'up' : 'down', + cycleRatings: area.cycleRatings.map((cycle) => ({ + ...cycle, + color: RATING_SCALE_COLORS[getRatingStatus(cycle.rating)] || '#000000', + })), + }; + }) as unknown as ComponentRatingsResult['areaData'], + componentData: Array.from(componentMap.values()).map((comp) => { + const lastTwoCycles = comp.cycleRatings.slice(-2); + const change = lastTwoCycles.length > 1 + ? parseFloat((lastTwoCycles[1]!.rating - lastTwoCycles[0]!.rating).toFixed(2)) + : 0; + return { + id: comp.component_num, + name: comp.component_name, + rating: comp.total, + status: getRatingStatus(comp.total), + change, + changeDirection: change >= 0 ? 'up' : 'down', + cycleRatings: comp.cycleRatings.map((cycle) => ({ + ...cycle, + color: RATING_SCALE_COLORS[getRatingStatus(cycle.rating)] || '#000000', + })), + areaColor: AREA_COLORS[comp.area_name] || '#000000', + type: 'component' as const, + }; + }) as unknown as ComponentRatingsResult['componentData'], + }; +} + +function summarizeData(filters: Filters | null = null, includeLatest: boolean = false) { + let assessments = applyFilters(filters); + + if (includeLatest) { + const latestAssessmentsMap = new Map(); + assessments.forEach((assessment) => { + const key = `${assessment.component_num}_${assessment.country_id}`; + const existing = latestAssessmentsMap.get(key); + + if (!existing || assessment.assessment_number > existing.assessment_number) { + latestAssessmentsMap.set(key, assessment); + } + }); + assessments = Array.from(latestAssessmentsMap.values()); + } + + // Calculate component averages + const componentAverages = new Map(); + assessments.forEach((assessment) => { + if (!componentAverages.has(assessment.component_num)) { + componentAverages.set(assessment.component_num, { + total: 0, + count: 0, + }); + } + const comp = componentAverages.get(assessment.component_num)!; + comp.total += assessment.rating_value; + comp.count += 1; + }); + + // Calculate total average + let totalComponentAverage = 0; + let componentCount = 0; + componentAverages.forEach((comp) => { + if (comp.count > 0) { + totalComponentAverage += (comp.total / comp.count); + componentCount += 1; + } + }); + + const averageRating = componentCount > 0 + ? parseFloat((totalComponentAverage / componentCount).toFixed(2)) + : 0; + + type AreaRatingStats = { + total: number; + count: number; + components?: Set; + }; + + // Calculate area averages + const areaRatings: Record = {}; + assessments.forEach((assessment) => { + const area = assessment.area_name; + if (!areaRatings[area]) { + areaRatings[area] = { total: 0, count: 0, components: new Set() }; + } + + const compAvg = componentAverages.get(assessment.component_num)!; + if (!areaRatings[area].components!.has(assessment.component_num)) { + areaRatings[area].total += (compAvg.total / compAvg.count); + areaRatings[area].count += 1; + areaRatings[area].components!.add(assessment.component_num); + } + }); + + const averageRatingByArea: { [area: string]: number } = {}; + Object.keys(areaRatings).forEach((area) => { + averageRatingByArea[area] = areaRatings[area]!.count > 0 + ? parseFloat((areaRatings[area]!.total / areaRatings[area]!.count).toFixed(2)) + : 0; + }); + + return { + totalComponents: componentCount, + averageRating, + averageRatingByArea, + }; +} + +function getCycles(filters: Filters | null = null) { + // Get all assessments without cycle filter first + const allAssessments = applyFilters({ + ...filters, + cycle: null, // Remove cycle filter temporarily + }); + + // Then get filtered assessments for rating calculations + const filteredAssessments = applyFilters(filters); + + // Group assessments by cycle first + const cycleData: { + [cycle: number]: { + completed: number; + in_progress: number; + componentRatings: { [componentId: number]: { total: number; count: number } }; + countries: Set; + }; + } = {}; + + // First pass: organize data using ALL assessments (unfiltered) + allAssessments.forEach((assessment) => { + const cycle = assessment.assessment_number; + if (!cycleData[cycle]) { + cycleData[cycle] = { + completed: 0, + in_progress: 0, + componentRatings: {}, + countries: new Set(), + }; + } + cycleData[cycle].countries.add(assessment.country_id); + }); + + // Second pass: determine completed vs in-progress using ALL assessments + Object.keys(cycleData).forEach((cycle) => { + const currentCycle = parseInt(cycle, 10); + const countriesInLaterCycles = new Set(); + + // Check if countries appear in later cycles + Object.keys(cycleData).forEach((laterCycle) => { + if (parseInt(laterCycle, 10) > currentCycle) { + cycleData[parseInt(laterCycle, 10)]!.countries.forEach((countryId) => { + countriesInLaterCycles.add(countryId); + }); + } + }); + + // Count completed (countries that appear in later cycles) + cycleData[currentCycle]!.completed = Array.from(cycleData[currentCycle]!.countries) + .filter((countryId) => countriesInLaterCycles.has(countryId)).length; + + // Count in progress (countries that don't appear in later cycles) + cycleData[currentCycle]!.in_progress = cycleData[currentCycle]!.countries.size + - cycleData[currentCycle]!.completed; + }); + + // Third pass: calculate ratings using filtered assessments + filteredAssessments.forEach((assessment) => { + const cycle = assessment.assessment_number; + if (cycleData[cycle]) { + if (!cycleData[cycle].componentRatings[assessment.component_num]) { + cycleData[cycle].componentRatings[assessment.component_num] = { + total: 0, + count: 0, + }; + } + const currentComponent = cycleData[cycle].componentRatings[assessment.component_num]; + currentComponent!.total += assessment.rating_value; + currentComponent!.count += 1; + } + }); + + // Calculate cycle statistics using component averages + const cycles = Object.entries(cycleData).map(([cycleNum, data]) => { + const cycle = parseInt(cycleNum, 10); + + // Calculate average rating from component averages + let totalComponentRating = 0; + let componentCount = 0; + + Object.values(data.componentRatings).forEach((comp) => { + if (comp.count > 0) { + totalComponentRating += (comp.total / comp.count); + componentCount += 1; + } + }); + + const averageRating = componentCount > 0 + ? parseFloat((totalComponentRating / componentCount).toFixed(2)) + : 0; + + // Calculate previous cycle rating + const previousCycle = cycleData[cycle - 1]; + let previousRating = 0; + + if (previousCycle) { + let prevTotalRating = 0; + let prevComponentCount = 0; + + Object.values(previousCycle.componentRatings).forEach((comp) => { + if (comp.count > 0) { + prevTotalRating += (comp.total / comp.count); + prevComponentCount += 1; + } + }); + + previousRating = prevComponentCount > 0 + ? prevTotalRating / prevComponentCount + : 0; + } + + return { + cycle: `Cycle ${cycle}`, + cycleNumber: cycle, + completed: data.completed, + inProgress: data.in_progress, + rating: averageRating, + totalNS: data.countries.size, + ratingChange: parseFloat((averageRating - previousRating).toFixed(2)), + }; + }) + .sort((a, b) => parseInt(a.cycle.split(' ')[1]!, 10) - parseInt(b.cycle.split(' ')[1]!, 10)); + + // Filter cycles at the end based on original filters + let filteredCycles = cycles; + if (filters?.cycle) { + filteredCycles = cycles.filter((cycle) => cycle.cycleNumber === filters.cycle); + } + + return { + total_cycles: filteredCycles.reduce((sum, cycle) => sum + cycle.totalNS, 0), + cycles: filteredCycles, + }; +} + +function getLastUpdateDate(): string { + return lastUpdateData?.last_update ?? 'N/A'; +} + +export { + getComponentRatings, + getCycles, + getLastUpdateDate, + groupDataByRegion, + initializeData, + summarizeData, +}; diff --git a/app/src/views/PERDashboard/PERPerformanceDashboard/i18n.json b/app/src/views/PERDashboard/PERPerformanceDashboard/i18n.json new file mode 100644 index 0000000000..1682dc5962 --- /dev/null +++ b/app/src/views/PERDashboard/PERPerformanceDashboard/i18n.json @@ -0,0 +1,15 @@ +{ + "namespace": "PERDashboard", + "strings": { + "performanceLastUpdate": "Last updated:", + "performanceResetFilter": "Clear Filter", + "performanceResetFilterAriaLabel": "Reset all active filters", + "performanceContainerAriaLabel": "PER Performance Dashboard", + "performanceHeaderDescription": "This dashboard contains a summary of the overall preparedness and response capacity among National Societies engaged in the PER Approach. The values presented represent the ratings for each component within National Societies' PER Mechanism aggregated at global and regional levels. The visualisations show average rating, the changes in capacity over time, as well as top and bottom-rated components. You can filter the components by region.", + "overviewHeading": "Performance Overview", + "overviewDescription": "Click on a PER assessment cycle to filter", + "globalRatingsHeading": "Global Performance Ratings", + "globalRatingsDescription": "Overview of PER ratings and performance metrics", + "performanceFetchFailedError": "Failed to load dashboard data" + } +} diff --git a/app/src/views/PERDashboard/PERPerformanceDashboard/index.tsx b/app/src/views/PERDashboard/PERPerformanceDashboard/index.tsx new file mode 100644 index 0000000000..8b4fd3aa71 --- /dev/null +++ b/app/src/views/PERDashboard/PERPerformanceDashboard/index.tsx @@ -0,0 +1,206 @@ +import { + type Component, + useEffect, + useState, +} from 'react'; +import { + BlockLoading, + Button, + Container, + PERAnalysis, + PERRatingAnalysis, + PERRegionToggle, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { _cs } from '@togglecorp/fujs'; + +import { + getComponentRatings, + getCycles, + getLastUpdateDate, + groupDataByRegion, + initializeData, + summarizeData, +} from './dataHandler'; +import { type AssessmentRecord } from './types'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +const PER_DASHBOARD_DATA_URL = 'https://raw.githubusercontent.com/IFRCGo/ifrc-per-data-fetcher/refs/heads/main/data/per-dashboard-data.json'; +const LAST_UPDATE_DATA_URL = 'https://raw.githubusercontent.com/IFRCGo/ifrc-per-data-fetcher/refs/heads/main/data/last-update.json'; + +interface ActiveFilters { + id: number | null; + region: string | null; + year: number | null; + cycle: number | undefined; +} + +function PERPerformanceDashboard() { + const strings = useTranslation(i18n); + const [activeFilters, setActiveFilters] = useState({ + id: null, + region: null, + year: null, + cycle: undefined, + }); + + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + interface DashboardData { + assessments: Record; + } + const [dashboardData, setDashboardData] = useState(null); + const [lastUpdateData, setLastUpdateData] = useState(null); + + useEffect(() => { + async function fetchData() { + setIsLoading(true); + setError(null); + try { + const [dashboardResponse, lastUpdateResponse] = await Promise.all([ + fetch(PER_DASHBOARD_DATA_URL, { + headers: { + Accept: 'application/vnd.github.v3.raw', + }, + }), + fetch(LAST_UPDATE_DATA_URL, { + headers: { + Accept: 'application/vnd.github.v3.raw', + }, + }), + ]); + + if (!dashboardResponse.ok || !lastUpdateResponse.ok) { + throw new Error(strings.performanceFetchFailedError); + } + + const [dashboardResponseData, lastUpdateResponseData] = await Promise.all([ + dashboardResponse.json(), + lastUpdateResponse.json(), + ]); + + setDashboardData(dashboardResponseData); + setLastUpdateData(lastUpdateResponseData); + initializeData(dashboardResponseData, lastUpdateResponseData); + } catch { + setError(strings.performanceFetchFailedError); + } finally { + setIsLoading(false); + } + } + + fetchData(); + }, [strings.performanceFetchFailedError]); + + if (isLoading) { + return ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (!dashboardData || !lastUpdateData) { + return null; + } + + const updateFilter = ( + filterType: keyof ActiveFilters, + value: ActiveFilters[keyof ActiveFilters], + ): void => { + setActiveFilters((prev) => ({ + ...prev, + [filterType]: prev[filterType] === value ? null : value, + })); + }; + + const handleCycleClick = (cycle: number | null): void => { + updateFilter('cycle', cycle); + }; + + const handleRegionClick = (region: string | null): void => { + updateFilter('region', region); + }; + + const ratings = getComponentRatings(activeFilters); + + return ( + <> +
+ {strings.performanceLastUpdate} + {' '} + {new Date(getLastUpdateDate()).toLocaleString()} +
+
+ {strings.performanceHeaderDescription} +
+
+ + updateFilter('cycle', undefined)} + aria-label={ + strings.performanceResetFilterAriaLabel + } + > + {strings.performanceResetFilter} + + ) : undefined} + > + + + + + +
+ + ); +} + +export default PERPerformanceDashboard; diff --git a/app/src/views/PERDashboard/PERPerformanceDashboard/styles.module.css b/app/src/views/PERDashboard/PERPerformanceDashboard/styles.module.css new file mode 100644 index 0000000000..353d5bd0ac --- /dev/null +++ b/app/src/views/PERDashboard/PERPerformanceDashboard/styles.module.css @@ -0,0 +1,77 @@ +.per-performance-dashboard { + display: flex; + flex-direction: column; + gap: var(--go-ui-spacing-md); + margin: 0; + /* background-color: var(--go-ui-color-background); */ + padding: var(--go-ui-spacing-2xl) 0; + overflow-x: hidden; +} + +.loading-container { + display: flex; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + min-height: 500px; +} + +.lastUpdate { + position: absolute; + top: 20px; + margin-bottom: var(--go-ui-spacing-md); + padding: 0 var(--go-ui-spacing-md); + color: var(--go-ui-color-gray-60); + font-size: var(--go-ui-font-size-sm); +} + +.header-body { + line-height: var(--go-ui-line-height-md); + color: var(--go-ui-color-text); + font-size: var(--go-ui-font-size-md); +} + +.headerDescription { + margin-bottom: var(--go-ui-spacing-md); + background-color: var(--go-ui-color-background); + padding: var(--go-ui-spacing-xl); + width: auto; + text-align: center; + font-size: var(--go-ui-font-size-md); + +} + +.content { + display: flex; + flex-direction: column; + gap: var(--go-ui-spacing-md); + margin: 0; + background-color: var(--go-ui-color-white); + padding: var(--go-ui-spacing-md); +} + +.container { + width: 100%; + max-width: 100%; +} + +.perAnalysis { + margin-bottom: var(--go-ui-spacing-md); +} + +.ratingAnalysis { + width: 100%; + max-width: 100%; +} + +.ratingAnalysis > div { + padding: 0px !important; + max-width: 100% !important; + --max-width: 100% !important; +} + +.ratingAnalysis :global(.content) { + width: 100%; + max-width: 100%; +} diff --git a/app/src/views/PERDashboard/PERPerformanceDashboard/types.ts b/app/src/views/PERDashboard/PERPerformanceDashboard/types.ts new file mode 100644 index 0000000000..9a948415f7 --- /dev/null +++ b/app/src/views/PERDashboard/PERPerformanceDashboard/types.ts @@ -0,0 +1,92 @@ +import { type PERRatingAnalysisProps } from '@ifrc-go/ui'; + +// Base interfaces +export interface Assessment { + component_num: number; + component_name: string; + area_id: number; + area_name: string; + + assessment_id: number; + assessment_number: number; + country_id: number; + country_name: string; + region_id: number; + region_name: string; + date_of_assessment: string; + rating_value: number; + rating_title: string; +} + +export interface AssessmentRecord { + id: number; + country_id: number; + country_name: string; + region_name: string; + date_of_assessment: string; + phase: number; + phase_display: string; + assessment_number: number; + type_of_assessment_name: string; + prioritized_components: { + areaTitle: string; + componentTitle: string; + }[]; + epi_considerations: boolean; + climate_environmental_considerations: boolean; + urban_considerations: boolean; + migration_considerations: boolean; +} + +export interface Filters { + region?: string | null; + year?: number | null; + phase?: number | null; + cycle?: number | null; + id?: number | null; + perConsiderations?: string | null; + completedAssessment?: boolean | null; + highPriorityComponent?: string | null; + assessmentType?: string | null; + numberOfCycles?: number | null; +} + +interface CycleRating { + cycle: number; + rating: number; + rating_display: string; + rating_color: string; +} + +export interface AreaSummary { + name: string; + rating: number; + status: string; + change: number; + changeDirection: string; + cycleRatings: CycleRating[]; + components: ComponentRating[]; + areaColor: string; +} + +export interface ComponentRating { + component_num: number, + component_name: string, + area_id: number, + area_name: string, + cycleRatings: CycleRating[], + total: number, + count: number, +} + +export interface ComponentRatingsResult { + overallRating: PERRatingAnalysisProps['overallRating'], + areaData: PERRatingAnalysisProps['areaData'], + componentData: PERRatingAnalysisProps['componentData'], +} + +export interface RegionData { + name: string; + count: number; + totalComponents: number; +} diff --git a/app/src/views/PERDashboard/PERSummaryDashboard/dataHandler.ts b/app/src/views/PERDashboard/PERSummaryDashboard/dataHandler.ts new file mode 100644 index 0000000000..27ef3a67e7 --- /dev/null +++ b/app/src/views/PERDashboard/PERSummaryDashboard/dataHandler.ts @@ -0,0 +1,636 @@ +import { + type AssessmentRecord, + type ChartDataItem, + type ComponentSummary, + type Filters, + type MapAssessmentRecord, +} from './types'; + +let mapData: MapAssessmentRecord[] = []; +interface LastUpdateData { + lastUpdate: string; +} + +let lastUpdateData: LastUpdateData | null = null; + +function initializeData(data: MapAssessmentRecord[], updateData: LastUpdateData) { + mapData = data; + lastUpdateData = updateData; +} + +const AREA_COLORS = { + 'Policy Strategy and Standards': '#8748b3', + 'Analysis and planning': '#ff8655', + 'Operations support': '#da283d', + 'Operational capacity': '#3478ec', + Coordination: '#00B2A2', +} as const; + +const PHASE_COLORS = [ + { + phase: 'Orientation', + label: 'Orientation', + phaseNumber: 1, + color: '#00B2A2', + }, + { + phase: 'Assessment', + label: 'Assessment', + phaseNumber: 2, + color: '#DA283D', + }, + { + phase: 'Prioritisation', + label: 'Prioritisation & analysis', + phaseNumber: 3, + color: '#3377EB', + }, + { + phase: 'Workplan', + label: 'Workplan', + phaseNumber: 4, + color: '#8648B3', + }, + { + phase: 'Action & accountability', + label: 'Action & accountability', + phaseNumber: 5, + color: '#FF8654', + }, +]; + +function groupByAndFilter( + data: Array, + groupKey: keyof MapAssessmentRecord, + compareKey: keyof MapAssessmentRecord, +): Array { + const groupedDataMap = data.reduce( + (acc, record) => { + const existingRecord = acc[record[groupKey] as string]; + if ( + !existingRecord + || (existingRecord + && record[compareKey] !== undefined + && existingRecord[compareKey] !== undefined + && record[compareKey] > existingRecord[compareKey]) + ) { + acc[record[groupKey] as string] = record; + } + return acc; + }, + {} as Record, + ); + + return Object.values(groupedDataMap); +} + +function assignFillColors( + data: Array, +): Array { + return data.map((record) => { + const phaseMatch = PHASE_COLORS.find( + (phase) => phase.phase === record.phase_display + && phase.phaseNumber === record.phase, + ); + return { + ...record, + color: phaseMatch ? phaseMatch.color : '#CCCCCC', + }; + }); +} + +function applyFilters( + data: Array, + filters: Filters | null = null, +): Array { + let filteredData = [...data]; + + if (!filters) { + return filteredData; + } + + if (filters.region) { + filteredData = filteredData.filter( + (record) => record.region_name === filters.region, + ); + } + + if (filters.year) { + const yearStr = filters.year.toString(); + filteredData = filteredData.filter( + (record) => new Date(record.date_of_assessment).getFullYear().toString() + === yearStr, + ); + } + + if (filters.phase) { + filteredData = filteredData.filter( + (record) => record.phase === filters.phase, + ); + } + + if (filters.id) { + filteredData = filteredData.filter((record) => record.id === filters.id); + } + + if (filters.perConsiderations) { + filteredData = filteredData.filter( + (record) => record[filters.perConsiderations as keyof MapAssessmentRecord], + ); + } + + if (filters.completedAssessment) { + filteredData = filteredData.filter((record) => record.phase >= 2); + } + + if (filters.highPriorityComponent) { + filteredData = filteredData.filter((record) => record.prioritized_components.some( + (component) => component.componentTitle === filters.highPriorityComponent, + )); + } + + if (filters.assessmentType) { + filteredData = filteredData.filter( + (record) => record.type_of_assessment_name === filters.assessmentType, + ); + } + + if (filters.numberOfCycles !== undefined && filters.numberOfCycles !== null) { + const cyclesCount = filters.numberOfCycles; + filteredData = filteredData.filter( + (record) => record.assessment_number >= cyclesCount, + ); + } + + return filteredData; +} + +function processFilteredMapData( + filters: Filters | null = null, +): Array { + const filteredData = applyFilters(mapData, filters); + const groupedData = groupByAndFilter( + filteredData, + 'country_id', + 'assessment_number', + ); + return assignFillColors(groupedData); +} + +function getFilteredMapData( + filters: Filters | null = null, +): Array { + return processFilteredMapData(filters); +} + +function getRecordsByRegion( + filters: Filters | null = null, +): Array<{ name: string; count: number }> { + const regionNames = ['Africa', 'Americas', 'Asia Pacific', 'Europe', 'MENA']; + const filters2 = { ...filters }; + filters2.region = null; + + const filteredData = applyFilters(mapData, filters2); + const regionCounts = regionNames.reduce( + (acc, region) => { + acc[region] = { name: region, count: 0 }; + return acc; + }, + {} as Record, + ); + + filteredData.forEach((record) => { + const regionName = record.region_name || 'Unknown'; + if (regionCounts[regionName]) { + regionCounts[regionName].count += 1; + } + }); + + return Object.values(regionCounts); +} + +function getRecordsByAssessmentType( + filters: Filters | null, +): Array<{ label: string; count: number }> { + const filteredData = applyFilters(mapData, filters); + + // Initialize all assessment types with 0 + const assessmentTypeCounts: Record = { + 'Self assessment': 0, + Simulation: 0, + Operational: 0, + 'Post operational': 0, + }; + + // Count only if we match the filter + filteredData.forEach((record) => { + const assessmentType = record.type_of_assessment_name; + if (assessmentType && assessmentType in assessmentTypeCounts) { + assessmentTypeCounts[assessmentType]! += 1; + } + }); + + // Always return all assessment types, even if count is 0 + return [ + { label: 'Self assessment', count: assessmentTypeCounts['Self assessment']! }, + { label: 'Simulation', count: assessmentTypeCounts.Simulation! }, + { label: 'Operational', count: assessmentTypeCounts.Operational! }, + { label: 'Post operational', count: assessmentTypeCounts['Post operational']! }, + ]; +} + +function getStackedBarDataByYearAndRegion( + filters: Filters | null, +): Array<{ year: string; values: Record; label: string }> { + const regionNames = ['Africa', 'Americas', 'Asia Pacific', 'Europe', 'MENA']; + const filteredData = applyFilters(mapData, filters); + + // Get all possible years from the data + const allYears = [ + ...new Set( + mapData.map((record) => new Date(record.date_of_assessment).getFullYear().toString()), + ), + ].sort(); + + // Initialize yearRegionCounts with all years and regions set to 0 + const yearRegionCounts: Record> = {}; + allYears.forEach((year) => { + yearRegionCounts[year] = regionNames.reduce( + (acc, region) => { + acc[region] = 0; + return acc; + }, + {} as Record, + ); + }); + + // Count records for each year and region + filteredData.forEach((record) => { + const year = new Date(record.date_of_assessment).getFullYear().toString(); + const region = record.region_name; + if (year && region && yearRegionCounts[year] && regionNames.includes(region)) { + yearRegionCounts[year]![region]! += 1; + } + }); + + // Convert to array format + return Object.entries(yearRegionCounts) + .map(([year, values]) => ({ + year, + values, + label: year, + })) + .sort((a, b) => a.year.localeCompare(b.year)); +} + +function getComponentSummaryForTreemap( + filters: Filters | null, +): ComponentSummary { + const filteredData = applyFilters(mapData, filters); + + const componentFrequency: Record; + }> = {}; + + // Process each record's prioritized components + filteredData.forEach((record) => { + record.prioritized_components.forEach((component) => { + const { areaTitle, componentTitle } = component; + + // Initialize area if not exists + if (!componentFrequency[areaTitle]) { + componentFrequency[areaTitle] = { + name: areaTitle, + id: areaTitle, + color: AREA_COLORS[areaTitle as keyof typeof AREA_COLORS] || '#CCCCCC', + children: [], + }; + } + + // Find or create component in children array + const existingComponent = componentFrequency[areaTitle].children.find( + (child) => child.name === componentTitle, + ); + + if (existingComponent) { + existingComponent.value += 1; + } else { + componentFrequency[areaTitle].children.push({ + name: componentTitle, + id: `${areaTitle}-${componentTitle}`, + value: 1, + color: AREA_COLORS[areaTitle as keyof typeof AREA_COLORS] || '#CCCCCC', + }); + } + }); + }); + + // Sort children by value in descending order + Object.values(componentFrequency).forEach((area) => { + area.children.sort((a, b) => b.value - a.value); + }); + + return { + name: 'Root', + id: 'root', + color: '#CCCCCC', + children: Object.values(componentFrequency) + .filter((area) => area.children.length > 0) + .sort((a, b) => b.children.reduce((sum, child) => sum + child.value, 0) + - a.children.reduce((sum, child) => sum + child.value, 0)), + }; +} + +function getPERConsiderations( + filters: Filters | null, +): { + data: ChartDataItem[][]; + totals: { + totalAssessments: number; + totalEpiConsiderations: number; + totalClimateConsiderations: number; + totalUrbanConsiderations: number; + totalMigrationConsiderations: number; + }; + percentages: { + epiPercentage: number; + climatePercentage: number; + urbanPercentage: number; + migrationPercentage: number; + }; +} { + const filteredData = applyFilters(mapData, filters); + + // Define assessment types and normalize names for consistency + const assessmentTypeMapping: Record = { + 'Self assessment': 'SelfAssessment', + SelfAssessment: 'SelfAssessment', + Simulation: 'Simulation', + 'Post operational': 'PostOperational', + PostOperational: 'PostOperational', + Operational: 'Operational', + }; + + // Define regions + const regions: Array = [ + 'Africa', + 'Americas', + 'Europe', + 'Asia Pacific', + 'MENA', + ]; + + // Initialize summary data structures + const considerations: Record< + string, + Record + > = { + epi_considerations: {}, + climate_environmental_considerations: {}, + urban_considerations: {}, + migration_considerations: {}, + }; + + // Initialize counts per region and assessment type for each consideration + regions.forEach((region) => { + considerations.epi_considerations![region] = { + name: region, + SelfAssessment: 0, + Simulation: 0, + PostOperational: 0, + Operational: 0, + } as ChartDataItem; + + considerations.climate_environmental_considerations![region] = { + name: region, + SelfAssessment: 0, + Simulation: 0, + PostOperational: 0, + Operational: 0, + } as ChartDataItem; + + considerations.urban_considerations![region] = { + name: region, + SelfAssessment: 0, + Simulation: 0, + PostOperational: 0, + Operational: 0, + } as ChartDataItem; + + considerations.migration_considerations![region] = { + name: region, + SelfAssessment: 0, + Simulation: 0, + PostOperational: 0, + Operational: 0, + } as ChartDataItem; + }); + + // Initialize total counts + let totalAssessments = 0; + let totalEpiConsiderations = 0; + let totalClimateConsiderations = 0; + let totalUrbanConsiderations = 0; + let totalMigrationConsiderations = 0; + + // Process the filtered data + filteredData.forEach((record) => { + const regionName = record.region_name; + const assessmentType = assessmentTypeMapping[record.type_of_assessment_name]; + + if (!assessmentType || !regions.includes(regionName)) { + return; // Skip if assessment type or region is not recognized + } + + totalAssessments += 1; + + // EPI Considerations + if (record.epi_considerations) { + considerations.epi_considerations![regionName]![ + assessmentType as + 'SelfAssessment' | + 'Simulation' | + 'PostOperational' | + 'Operational' + ] += 1; + totalEpiConsiderations += 1; + } + + // Climate Environmental Considerations + if (record.climate_environmental_considerations) { + const normalizedAssessmentType = assessmentTypeMapping[ + record.type_of_assessment_name + ] as + 'SelfAssessment' | + 'Simulation' | + 'PostOperational' | + 'Operational'; + considerations.climate_environmental_considerations![regionName]![ + normalizedAssessmentType + ] += 1; + totalClimateConsiderations += 1; + } + + // Urban Considerations + if (record.urban_considerations) { + const normalizedAssessmentType = assessmentTypeMapping[ + record.type_of_assessment_name + ] as + 'SelfAssessment' | + 'Simulation' | + 'PostOperational' | + 'Operational'; + considerations.urban_considerations![regionName]![ + normalizedAssessmentType + ] += 1; + totalUrbanConsiderations += 1; + } + + // Migration Considerations + if (record.migration_considerations) { + considerations.migration_considerations![regionName]![ + assessmentType as + 'SelfAssessment' | + 'Simulation' | + 'PostOperational' | + 'Operational' + ] += 1; + totalMigrationConsiderations += 1; + } + }); + + // Convert the considerations data into arrays + const epiConsiderationsArray: ChartDataItem[] = regions.map( + (region) => considerations.epi_considerations![region]!, + ); + + const climateConsiderationsArray: ChartDataItem[] = regions.map( + (region) => considerations.climate_environmental_considerations![region]!, + ); + + const urbanConsiderationsArray: ChartDataItem[] = regions.map( + (region) => considerations.urban_considerations![region]!, + ); + + const migrationConsiderationsArray: ChartDataItem[] = regions.map( + (region) => considerations.migration_considerations![region]!, + ); + + // Calculate percentages + const epiPercentage = totalAssessments > 0 + ? (totalEpiConsiderations / totalAssessments) * 100 + : 0; + + const climatePercentage = totalAssessments > 0 + ? (totalClimateConsiderations / totalAssessments) * 100 + : 0; + + const urbanPercentage = totalAssessments > 0 + ? (totalUrbanConsiderations / totalAssessments) * 100 + : 0; + + const migrationPercentage = totalAssessments > 0 + ? (totalMigrationConsiderations / totalAssessments) * 100 + : 0; + + // Return the summarized data + return { + data: [ + epiConsiderationsArray, + climateConsiderationsArray, + urbanConsiderationsArray, + migrationConsiderationsArray, + ], + totals: { + totalAssessments, + totalEpiConsiderations, + totalClimateConsiderations, + totalUrbanConsiderations, + totalMigrationConsiderations, + }, + percentages: { + epiPercentage: Math.floor(epiPercentage), + climatePercentage: Math.floor(climatePercentage), + urbanPercentage: Math.floor(urbanPercentage), + migrationPercentage: Math.floor(migrationPercentage), + }, + }; +} + +function getKPIData( + filters: Filters | null = null, +): Array<{ key: string; value: number; color?: string; description: string }> { + // Apply all filters + const data = applyFilters(mapData, filters); + + const totalEngaged = data.length; + let orientation = 0; + let assessment = 0; + let action = 0; + let completed = 0; + + data.forEach((record) => { + if (record.phase === 1) { + orientation += 1; + } + if (record.phase >= 2) { + assessment += 1; + } + if (record.phase === 5) { + action += 1; + } + if (record.assessment_number >= 2) { + completed += 1; + } + }); + + return [ + { + key: 'total-engaged', + value: totalEngaged, + description: 'Total number of NS engaged in PER process', + }, + { + key: 'orientation', + value: orientation, + color: '#00B2A2', + description: 'Number of NS currently in initial Orientation phase', + }, + { + key: 'assessment', + value: assessment, + color: '#DA283D', + description: 'Number of NS who completed or are in assessment phase', + }, + { + key: 'action', + value: action, + color: '#FF8654', + description: 'Number of NS at Action & Accountability phase', + }, + { + key: 'completed', + value: completed, + description: 'Number of NS completed 2+ cycles of PER process', + }, + ]; +} + +function getLastUpdateDate(): string { + return lastUpdateData?.lastUpdate ?? 'N/A'; +} + +export { + getComponentSummaryForTreemap, + getFilteredMapData, + getKPIData, + getLastUpdateDate, + getPERConsiderations, + getRecordsByAssessmentType, + getRecordsByRegion, + getStackedBarDataByYearAndRegion, + initializeData, +}; diff --git a/app/src/views/PERDashboard/PERSummaryDashboard/i18n.json b/app/src/views/PERDashboard/PERSummaryDashboard/i18n.json new file mode 100644 index 0000000000..4da9b81b40 --- /dev/null +++ b/app/src/views/PERDashboard/PERSummaryDashboard/i18n.json @@ -0,0 +1,20 @@ +{ + "namespace": "PERSummaryDashboard", + "strings": { + "summaryLastUpdate": "Last updated:", + "summaryResetFilter": "Clear Filter", + "summaryResetFilterAriaLabel": "Reset all active filters", + "summaryContainerAriaLabel": "PER Summary Dashboard", + "summaryHeaderDescription": "This dashboard contains a summary of National Societies around the world engaged in the Preparedness for Effective Response (PER) Approach. The visuals below show regional and country-level information on the number of National Societies engaged in the PER Approach, as well as the current phase of the PER Process the NS is in. It also includes information on the PER Components which have been identified as 'High Priority' indicating it requires improvement. Finally, this dashboard includes the types of PER assessments conducted and the year of the assessment by region. Several National Societies have gone through multiple cycles of the PER process and evidence indicates that there have been improvements in NS preparedness and response capacity which has been supported by the PER Approach.", + "mapHeading": "PER Global Distribution", + "mapDescription": "Click on a NS to filter", + "assessmentTypeHeading": "PER Process by Assessment Type", + "assessmentTypeDescription": "Click on an assessment type to filter", + "yearAndRegionHeading": "PER Process by Year and Region", + "yearAndRegionDescription": "Click on a year or region to filter", + "highPriorityComponentsHeading": "High Priority Components", + "highPriorityComponentsDescription": "Click on a component to filter", + "perConsiderationsHeading": "PER Considerations", + "perConsiderationsDescription": "Click on a PER consideration type to filter" + } +} diff --git a/app/src/views/PERDashboard/PERSummaryDashboard/index.tsx b/app/src/views/PERDashboard/PERSummaryDashboard/index.tsx new file mode 100644 index 0000000000..076d88af05 --- /dev/null +++ b/app/src/views/PERDashboard/PERSummaryDashboard/index.tsx @@ -0,0 +1,523 @@ +import { + useEffect, + useState, +} from 'react'; +import { + BlockLoading, + Button, + Container, + PERChartLegend, + PERConsiderations, + PERDonutChart, + PERKPITabs, + PERMap, + PERRegionToggle, + PERStackedBarChart, + PERTreemapChart, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import { mbtoken } from '#config'; +import { defaultMapStyle } from '#utils/map'; + +import { + getComponentSummaryForTreemap, + getFilteredMapData, + getKPIData, + getLastUpdateDate, + getPERConsiderations, + getRecordsByAssessmentType, + getRecordsByRegion, + getStackedBarDataByYearAndRegion, + initializeData, +} from './dataHandler'; +import type { + AssessmentRecord, + MapAssessmentRecord, +} from './types'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +const MAP_DATA_URL = 'https://raw.githubusercontent.com/IFRCGo/ifrc-per-data-fetcher/refs/heads/main/data/map-data.json'; +const LAST_UPDATE_DATA_URL = 'https://raw.githubusercontent.com/IFRCGo/ifrc-per-data-fetcher/refs/heads/main/data/last-update.json'; + +const PHASE_COLORS = [ + { + phase: 'Orientation', + label: 'Orientation', + phaseNumber: 1, + color: '#00B2A2', + }, + { + phase: 'Assessment', + label: 'Assessment', + phaseNumber: 2, + color: '#DA283D', + }, + { + phase: 'Prioritisation', + label: 'Prioritisation & analysis', + phaseNumber: 3, + color: '#3377EB', + }, + { + phase: 'Workplan', + label: 'Workplan', + phaseNumber: 4, + color: '#8648B3', + }, + { + phase: 'Action & accountability', + label: 'Action & accountability', + phaseNumber: 5, + color: '#FF8654', + }, +]; + +interface ActiveFilters { + id: number | null; + region: string | null; + assessmentType: string | null; + year: number | null; + phase: number | null; + highPriorityComponent: string | null; + perConsiderations: string | null; + numberOfCycles: number | null; + completedAssessment: boolean | null; + highPriorityArea: string | null; +} + +function PERSummaryDashboard() { + const strings = useTranslation(i18n); + const [activeFilters, setActiveFilters] = useState({ + id: null, + region: null, + assessmentType: null, + year: null, + phase: null, + highPriorityComponent: null, + perConsiderations: null, + numberOfCycles: null, + completedAssessment: null, + highPriorityArea: null, + }); + const [activeTab, setActiveTab] = useState(0); + const [activePhase, setActivePhase] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [mapData, setMapData] = useState(null); + interface LastUpdateData { + lastUpdate: string; + } + const [lastUpdateData, setLastUpdateData] = useState(null); + + useEffect(() => { + async function fetchData() { + setIsLoading(true); + setError(null); + try { + const [mapResponse, lastUpdateResponse] = await Promise.all([ + fetch(MAP_DATA_URL, { + headers: { + Accept: 'application/vnd.github.v3.raw', + }, + }), + fetch(LAST_UPDATE_DATA_URL, { + headers: { + Accept: 'application/vnd.github.v3.raw', + }, + }), + ]); + + if (!mapResponse.ok || !lastUpdateResponse.ok) { + throw new Error('Failed to fetch data'); + } + + const [mapDataResponse, lastUpdateDataResponse] = await Promise.all([ + mapResponse.json(), + lastUpdateResponse.json(), + ]); + + setMapData(mapDataResponse); + setLastUpdateData(lastUpdateDataResponse); + initializeData(mapDataResponse, lastUpdateDataResponse); + } catch { + setError('Failed to load dashboard data'); + } finally { + setIsLoading(false); + } + } + + fetchData(); + }, []); + + if (isLoading) { + return ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (!mapData || !lastUpdateData) { + return null; + } + + const updateFilter = (key: keyof ActiveFilters, value: ActiveFilters[keyof ActiveFilters]) => { + setActiveFilters((prev) => ({ + ...prev, + [key]: prev[key] === value ? null : value, + })); + }; + + const handleRegionStackedClick = (item: { label: string; year?: string | number | null }) => { + if (item.year !== undefined && item.year !== null) { + updateFilter('year', Number(item.year)); + } else if (item.label) { + updateFilter('region', item.label); + } + }; + + const handleTabClick = (key: string): void => { + if (key === 'total-engaged') { + updateFilter('phase', null); + updateFilter('numberOfCycles', null); + updateFilter('completedAssessment', null); + setActivePhase(null); + } + if (key === 'orientation') { + updateFilter('phase', 1); + updateFilter('numberOfCycles', null); + updateFilter('completedAssessment', null); + setActivePhase('Orientation'); + } + if (key === 'assessment') { + updateFilter('phase', null); + updateFilter('numberOfCycles', null); + updateFilter('completedAssessment', true); + setActivePhase(null); + } + if (key === 'action') { + setActivePhase('Action & accountability'); + updateFilter('completedAssessment', null); + updateFilter('phase', 5); + updateFilter('numberOfCycles', null); + } + if (key === 'completed') { + updateFilter('completedAssessment', null); + updateFilter('phase', null); + updateFilter('numberOfCycles', 2); + setActivePhase(null); + } + }; + + const handleAssessmentTypeClick = (item: { label: string }) => { + updateFilter('assessmentType', item.label); + }; + + const handleHighPriorityComponentClick = (component: { + area: string; + component: string | null; + }) => { + if (component) { + updateFilter('highPriorityArea', component.area); + updateFilter('highPriorityComponent', component.component); + } + }; + + const handlePhaseClick = (item: { label: string; color: string; phaseNumber: number }) => { + if (activeTab !== 4) { + updateFilter('numberOfCycles', null); + updateFilter('completedAssessment', null); + setActiveTab(0); + setActivePhase(null); + } + + if (item.label === 'assessment') { + updateFilter('numberOfCycles', null); + updateFilter('completedAssessment', null); + setActiveTab(1); + } + + if (item.label === 'Action & accountability') { + updateFilter('numberOfCycles', null); + updateFilter('completedAssessment', null); + } + + updateFilter('phase', item.phaseNumber); + + if (!activeFilters.phase) { + setActivePhase(item.label); + } else { + setActivePhase(null); + } + }; + + const regionColors = { + Africa: '#1B365D', + Americas: '#236192', + 'Asia Pacific': '#418FDE', + Europe: '#009CDD', + MENA: '#C6C6C6', + }; + + const regionCategories = ['Africa', 'Americas', 'Asia Pacific', 'Europe', 'MENA'].map((region) => ({ + label: region, + fillColor: regionColors[region as keyof typeof regionColors], + })); + + const regionLegendCategories = ['Africa', 'Americas', 'Asia Pacific', 'Europe', 'MENA'].map( + (region) => ({ + label: region, + color: regionColors[region as keyof typeof regionColors], + }), + ); + + return ( + <> +
+ {strings.summaryLastUpdate} + {' '} + {new Date(getLastUpdateDate()).toLocaleString()} +
+ {/* */} +
+ {strings.summaryHeaderDescription} +
+
+ { + const keys = ['total-engaged', 'orientation', 'assessment', 'action', 'completed']; + setActiveTab(index); + handleTabClick(keys[index]!); + }} + disableTabs={false} + /> + + updateFilter('region', region)} + regions={getRecordsByRegion(activeFilters)} + precision={0} + showCount + /> + + { + updateFilter('phase', null); + updateFilter('id', null); + updateFilter('region', null); + updateFilter('completedAssessment', null); + updateFilter('numberOfCycles', null); + setActiveTab(0); + setActivePhase(null); + }} + aria-label={strings.summaryResetFilterAriaLabel} + > + {strings.summaryResetFilter} + + ) : null + )} + > +
+ updateFilter('id', record.id)} + valueField="assessment_number" + tooltipTrigger="click" + enableClickToFilter + accessToken={mbtoken} + mapboxStyle={defaultMapStyle} + minRadius={4} + maxRadius={7} + /> +
+ { + const phaseItem = item as { + label: string; + color: string; + phaseNumber: number; + }; + handlePhaseClick(phaseItem); + }} + activeIndex={activePhase} + layout="horizontal" + /> +
+ +
+ updateFilter('assessmentType', null)} + aria-label={strings.summaryResetFilterAriaLabel} + > + {strings.summaryResetFilter} + + )} + > + + + + + { + updateFilter('region', null); + updateFilter('year', null); + }} + aria-label={strings.summaryResetFilterAriaLabel} + > + {strings.summaryResetFilter} + + )} + > + + + +
+ +
+ { + updateFilter('highPriorityComponent', null); + updateFilter('highPriorityArea', null); + }} + aria-label={strings.summaryResetFilterAriaLabel} + > + {strings.summaryResetFilter} + + )} + > + + +
+ + { + updateFilter('perConsiderations', null); + updateFilter('assessmentType', null); + }} + aria-label={strings.summaryResetFilterAriaLabel} + > + {strings.summaryResetFilter} + + )} + > + updateFilter('perConsiderations', index)} + activeIndex={activeFilters?.assessmentType} + activePERFilter={activeFilters?.perConsiderations ?? undefined} + /> + +
+ + ); +} + +export default PERSummaryDashboard; diff --git a/app/src/views/PERDashboard/PERSummaryDashboard/styles.module.css b/app/src/views/PERDashboard/PERSummaryDashboard/styles.module.css new file mode 100644 index 0000000000..d16065169c --- /dev/null +++ b/app/src/views/PERDashboard/PERSummaryDashboard/styles.module.css @@ -0,0 +1,97 @@ +.per-summary-dashboard { + display: flex; + flex-direction: column; + gap: var(--go-ui-spacing-md); + margin: 0; + /* background-color: var(--go-ui-color-background); */ + padding: var(--go-ui-spacing-2xl) 0; + overflow-x: hidden; +} + +.loading-container { + margin: 0; + padding: 0; + /* display: flex; */ + /* justify-content: center; */ + /* align-items: center; */ + min-height: 300px; +} + +.title { + text-align: center; + line-height: normal; + color: var(--go-ui-color-text); + font-size: var(--go-ui-font-size-lg); + font-weight: var(--go-ui-font-weight-medium); +} + +.lastUpdate { + display: inline-block; + position: absolute; + top: 20px; + margin-bottom: var(--go-ui-spacing-md); + padding: 0 var(--go-ui-spacing-md); + color: var(--go-ui-color-gray-60); + font-size: var(--go-ui-font-size-sm); +} + +.header-body { + line-height: var(--go-ui-line-height-md); + color: var(--go-ui-color-text); + font-size: var(--go-ui-font-size-md); +} + +.headerDescription { + margin-bottom: var(--go-ui-spacing-md); + background-color: var(--go-ui-color-background); + padding: var(--go-ui-spacing-xl); + width: auto; + text-align: center; + font-size: var(--go-ui-font-size-md); +} + +.content { + display: flex; + flex-direction: column; + gap: var(--go-ui-spacing-md); + margin: 0; + background-color: var(--go-ui-color-white); + padding: var(--go-ui-spacing-md); + +} + +.charts { + display: grid; + gap: var(--go-ui-spacing-md); + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + margin-top: var(--go-ui-spacing-md); + margin-bottom: var(--go-ui-spacing-md); + + +} + +.charts > div { + display: flex; + height: 100%; +} + +.charts > div > div:last-child { + display: flex; + flex-direction: column; + height: calc(100% - 80px); /* Subtract header height */ +} + +.treemap { + margin-bottom: var(--go-ui-spacing-md); +} +.assessmentTypesContent { + display: flex; + flex: 1; + gap: var(--go-ui-spacing-md); +} + +.regionsContent { + display: flex; + flex: 1; + gap: var(--go-ui-spacing-md); +} diff --git a/app/src/views/PERDashboard/PERSummaryDashboard/types.ts b/app/src/views/PERDashboard/PERSummaryDashboard/types.ts new file mode 100644 index 0000000000..4f4a433eaf --- /dev/null +++ b/app/src/views/PERDashboard/PERSummaryDashboard/types.ts @@ -0,0 +1,65 @@ +export interface ComponentSummary { + id: string; + color: string; + name: string; + value?: number; + children?: ComponentSummary[]; +} + +interface PrioritizedComponent { + areaTitle: string; + componentTitle: string; +} + +export interface AssessmentRecord { + id: number; + country_id: number; + country_name: string; + region_name: string; + date_of_assessment: string; + type_of_assessment: string; + country_iso3: string; + assessment_date: string; + created_at: string; + updated_at: string; + phase: number; + phase_display: string; + assessment_number: number; + type_of_assessment_name: string; + prioritized_components: PrioritizedComponent[]; + epi_considerations: boolean; + climate_environmental_considerations: boolean; + urban_considerations: boolean; + migration_considerations: boolean; + lat: number; + lon: number; + latitude: number; + longitude: number; + color?: string; +} +// Chart data interfaces +export interface ChartDataItem { + name: string; + SelfAssessment: number; + Simulation: number; + PostOperational: number; + Operational: number; + [key: string]: string | number; +} + +export interface Filters { + region?: string | null; + year?: number | null; + phase?: number | null; + id?: number | null; + perConsiderations?: string | null; + completedAssessment?: boolean | null; + highPriorityComponent?: string | null; + assessmentType?: string | null; + numberOfCycles?: number | null; +} + +// Map specific types +export interface MapAssessmentRecord extends AssessmentRecord { + color: string; +} diff --git a/app/src/views/PERDashboard/styles.module.css b/app/src/views/PERDashboard/styles.module.css new file mode 100644 index 0000000000..6c001ae472 --- /dev/null +++ b/app/src/views/PERDashboard/styles.module.css @@ -0,0 +1,28 @@ +.perDashboard { + display: flex; + flex-direction: column; + gap: var(--go-ui-spacing-md); + font-family: var(--go-ui-font-family-sans-serif); +} + +button { + font-size: var(--go-ui-font-size-sm) !important; +} + +.perDashboard > :first-child { + padding: 0px !important; +} + +.header > div { + width: auto !important; + /* padding: 0px !important; */ +} + + +.header { + display: flex; + flex-direction: column; + gap: var(--go-ui-spacing-sm); + background-color: var(--go-ui-color-background); + font-size: var(--go-ui-font-size-sm); +} diff --git a/app/src/views/PreparednessGlobalPerformance/i18n.json b/app/src/views/PreparednessGlobalPerformance/i18n.json deleted file mode 100644 index 65fb450ccc..0000000000 --- a/app/src/views/PreparednessGlobalPerformance/i18n.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "namespace": "preparednessGlobalPerformance", - "strings": { - "globalPerformanceTitle": "Preparedness - Global performance" - } -} diff --git a/app/src/views/PreparednessGlobalPerformance/index.tsx b/app/src/views/PreparednessGlobalPerformance/index.tsx index 545a3a55e0..6c6ace8d8a 100644 --- a/app/src/views/PreparednessGlobalPerformance/index.tsx +++ b/app/src/views/PreparednessGlobalPerformance/index.tsx @@ -1,21 +1,13 @@ -import { useTranslation } from '@ifrc-go/ui/hooks'; +import PERPerformanceDashboard from '../PERDashboard/PERPerformanceDashboard'; -import i18n from './i18n.json'; import styles from './styles.module.css'; /** @knipignore */ // eslint-disable-next-line import/prefer-default-export export function Component() { - const strings = useTranslation(i18n); - const url = 'https://app.powerbi.com/view?r=eyJrIjoiMDQ5YzBlODItOTQ3Yy00Y2Q2LWFjZmEtZWIxMTAwZjkxZGU2IiwidCI6ImEyYjUzYmU1LTczNGUtNGU2Yy1hYjBkLWQxODRmNjBmZDkxNyIsImMiOjh9'; - return (
-