diff --git a/client/package.json b/client/package.json index a15ba91ee..5c9086e6a 100644 --- a/client/package.json +++ b/client/package.json @@ -43,6 +43,7 @@ "@radix-ui/react-popover": "1.0.7", "@radix-ui/react-radio-group": "1.1.3", "@radix-ui/react-select": "2.0.0", + "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "1.0.2", "@reduxjs/toolkit": "1.8.2", "@tailwindcss/forms": "0.4.0", diff --git a/client/src/components/ui/separator.tsx b/client/src/components/ui/separator.tsx new file mode 100644 index 000000000..37838d623 --- /dev/null +++ b/client/src/components/ui/separator.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import * as SeparatorPrimitive from '@radix-ui/react-separator'; + +import { cn } from '@/lib/utils'; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => ( + +)); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/client/src/containers/analysis-eudr-detail/deforestation-alerts/chart/index.tsx b/client/src/containers/analysis-eudr-detail/deforestation-alerts/chart/index.tsx new file mode 100644 index 000000000..ca04fe700 --- /dev/null +++ b/client/src/containers/analysis-eudr-detail/deforestation-alerts/chart/index.tsx @@ -0,0 +1,139 @@ +import { UTCDate } from '@date-fns/utc'; +import { format } from 'date-fns'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts'; +import { useParams } from 'next/navigation'; +import { useMemo, useState } from 'react'; + +import { EUDR_COLOR_RAMP } from '@/utils/colors'; +import { useEUDRSupplier } from '@/hooks/eudr'; +import { useAppSelector } from '@/store/hooks'; +import { eudrDetail } from '@/store/features/eudr-detail'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; + +const DeforestationAlertsChart = (): JSX.Element => { + const [selectedPlots, setSelectedPlots] = useState([]); + const { supplierId }: { supplierId: string } = useParams(); + const { + filters: { dates }, + } = useAppSelector(eudrDetail); + const { data } = useEUDRSupplier( + supplierId, + { + startAlertDate: dates.from, + endAlertDate: dates.to, + }, + { + select: (data) => data?.alerts?.values, + }, + ); + + const parsedData = data + ?.map((item) => { + return { + ...item, + ...Object.fromEntries(item.plots.map((plot) => [plot.plotName, plot.alertCount])), + alertDate: new UTCDate(item.alertDate).getTime(), + }; + }) + ?.sort((a, b) => new UTCDate(a.alertDate).getTime() - new UTCDate(b.alertDate).getTime()); + + const plotConfig = useMemo(() => { + if (!parsedData?.[0]) return []; + + return Array.from( + new Set(parsedData.map((item) => item.plots.map((plot) => plot.plotName)).flat()), + ).map((key, index) => ({ + name: key, + color: EUDR_COLOR_RAMP[index] || '#000', + })); + }, [parsedData]); + + return ( + <> +
+ {plotConfig.map(({ name, color }) => ( + { + setSelectedPlots((prev) => { + if (prev.includes(name)) { + return prev.filter((item) => item !== name); + } + return [...prev, name]; + }); + }} + > + + {name} + + ))} +
+ + + + { + if (x === 0) return format(new UTCDate(value), 'LLL yyyy'); + return format(new UTCDate(value), 'LLL'); + }} + tickLine={false} + padding={{ left: 20, right: 20 }} + axisLine={false} + className="text-xs" + tickMargin={15} + /> + + format(new UTCDate(v), 'dd/MM/yyyy')} /> + {plotConfig?.map(({ name, color }) => { + return ( + + ); + })} + + + + ); +}; + +export default DeforestationAlertsChart; diff --git a/client/src/containers/analysis-eudr-detail/deforestation-alerts/index.tsx b/client/src/containers/analysis-eudr-detail/deforestation-alerts/index.tsx new file mode 100644 index 000000000..c1e2257cc --- /dev/null +++ b/client/src/containers/analysis-eudr-detail/deforestation-alerts/index.tsx @@ -0,0 +1,49 @@ +import { useParams } from 'next/navigation'; +import { format } from 'date-fns'; +import { UTCDate } from '@date-fns/utc'; +import { BellRing } from 'lucide-react'; + +import DeforestationAlertsChart from './chart'; + +import { useEUDRSupplier } from '@/hooks/eudr'; +import { eudrDetail } from '@/store/features/eudr-detail'; +import { useAppSelector } from '@/store/hooks'; + +const dateFormatter = (date: string) => format(new UTCDate(date), "do 'of' MMMM yyyy"); + +const DeforestationAlerts = (): JSX.Element => { + const { supplierId }: { supplierId: string } = useParams(); + const { + filters: { dates }, + } = useAppSelector(eudrDetail); + const { data } = useEUDRSupplier( + supplierId, + { + startAlertDate: dates.from, + endAlertDate: dates.to, + }, + { + select: (data) => data?.alerts, + }, + ); + + return ( +
+

Deforestation alerts detected within the smallholders

+ {data?.totalAlerts && ( +
+ There were {data?.totalAlerts} deforestation alerts + reported for the supplier between the{' '} + {dateFormatter(data.startAlertDate)} and the{' '} +
+ {dateFormatter(data.endAlertDate)}. + +
+
+ )} + +
+ ); +}; + +export default DeforestationAlerts; diff --git a/client/src/containers/analysis-eudr-detail/filters/index.tsx b/client/src/containers/analysis-eudr-detail/filters/index.tsx new file mode 100644 index 000000000..4d5cfdbb4 --- /dev/null +++ b/client/src/containers/analysis-eudr-detail/filters/index.tsx @@ -0,0 +1,11 @@ +import YearsRange from './years-range'; + +const EUDRDetailFilters = () => { + return ( +
+ +
+ ); +}; + +export default EUDRDetailFilters; diff --git a/client/src/containers/analysis-eudr-detail/filters/years-range/index.tsx b/client/src/containers/analysis-eudr-detail/filters/years-range/index.tsx new file mode 100644 index 000000000..ce0f0d1fa --- /dev/null +++ b/client/src/containers/analysis-eudr-detail/filters/years-range/index.tsx @@ -0,0 +1,77 @@ +import React, { useCallback, useMemo } from 'react'; +import { UTCDate } from '@date-fns/utc'; +import { ChevronDown } from 'lucide-react'; +import { format } from 'date-fns'; + +import { useAppDispatch, useAppSelector } from 'store/hooks'; +import { eudrDetail, setFilters } from 'store/features/eudr-detail'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; + +import type { DateRange } from 'react-day-picker'; +const dateFormatter = (date: Date) => format(date, 'yyyy-MM-dd'); + +// ! the date range is hardcoded for now +export const DATES_RANGE = ['2020-12-31', dateFormatter(new Date())]; + +const DatesRange = (): JSX.Element => { + const dispatch = useAppDispatch(); + const { + filters: { dates }, + } = useAppSelector(eudrDetail); + + const handleDatesChange = useCallback( + (dates: DateRange) => { + if (dates) { + dispatch( + setFilters({ + dates: { + from: dateFormatter(dates.from), + to: dateFormatter(dates.to), + }, + }), + ); + } + }, + [dispatch], + ); + + const datesToDate = useMemo(() => { + return { + from: dates.from ? new UTCDate(dates.from) : undefined, + to: dates.to ? new UTCDate(dates.to) : undefined, + }; + }, [dates]); + + return ( + + + + + + + + + ); +}; + +export default DatesRange; diff --git a/client/src/containers/analysis-eudr-detail/map/component.tsx b/client/src/containers/analysis-eudr-detail/map/component.tsx new file mode 100644 index 000000000..8c557d6ee --- /dev/null +++ b/client/src/containers/analysis-eudr-detail/map/component.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState, useCallback } from 'react'; +import DeckGL from '@deck.gl/react/typed'; +import { GeoJsonLayer } from '@deck.gl/layers/typed'; +import Map from 'react-map-gl/maplibre'; +import { WebMercatorViewport, type MapViewState } from '@deck.gl/core/typed'; +import bbox from '@turf/bbox'; + +import ZoomControl from './zoom'; +import LegendControl from './legend'; + +import BasemapControl from '@/components/map/controls/basemap'; +import { INITIAL_VIEW_STATE, MAP_STYLES } from '@/components/map'; +import { usePlotGeometries } from '@/hooks/eudr'; + +import type { BasemapValue } from '@/components/map/controls/basemap/types'; +import type { MapStyle } from '@/components/map/types'; + +const EUDRMap = () => { + const [mapStyle, setMapStyle] = useState('terrain'); + const [viewState, setViewState] = useState(INITIAL_VIEW_STATE); + + const plotGeometries = usePlotGeometries(); + + const layer: GeoJsonLayer = new GeoJsonLayer({ + id: 'geojson-layer', + data: plotGeometries.data, + // Styles + filled: true, + getFillColor: [255, 176, 0, 84], + stroked: true, + getLineColor: [255, 176, 0, 255], + getLineWidth: 1, + lineWidthUnits: 'pixels', + // Interactive props + pickable: true, + autoHighlight: true, + highlightColor: [255, 176, 0, 255], + }); + + const layers = [layer]; + + const handleMapStyleChange = useCallback((newStyle: BasemapValue) => { + setMapStyle(newStyle); + }, []); + + const handleZoomIn = useCallback(() => { + const zoom = viewState.maxZoom === viewState.zoom ? viewState.zoom : viewState.zoom + 1; + setViewState({ ...viewState, zoom }); + }, [viewState]); + + const handleZoomOut = useCallback(() => { + const zoom = viewState.maxZoom === viewState.zoom ? viewState.zoom : viewState.zoom - 1; + setViewState({ ...viewState, zoom }); + }, [viewState]); + + const fitToPlotBounds = useCallback(() => { + if (!plotGeometries.data) return; + const [minLng, minLat, maxLng, maxLat] = bbox(plotGeometries.data); + const newViewport = new WebMercatorViewport(viewState); + const { longitude, latitude, zoom } = newViewport.fitBounds( + [ + [minLng, minLat], + [maxLng, maxLat], + ], + { + padding: 10, + }, + ); + if ( + viewState.latitude !== latitude || + viewState.longitude !== longitude || + viewState.zoom !== zoom + ) { + setViewState({ ...viewState, longitude, latitude, zoom }); + } + }, [plotGeometries.data, viewState]); + + // Fit to bounds when data is loaded or changed + useEffect(() => { + if (plotGeometries.data) { + fitToPlotBounds(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [plotGeometries.data]); + + const handleResize = useCallback(() => { + setTimeout(() => fitToPlotBounds(), 0); + }, [fitToPlotBounds]); + + return ( + <> + setViewState(viewState as MapViewState)} + controller={{ dragRotate: false }} + layers={layers} + onResize={handleResize} + > + + +
+ + + +
+ + ); +}; + +export default EUDRMap; diff --git a/client/src/containers/analysis-eudr-detail/map/index.ts b/client/src/containers/analysis-eudr-detail/map/index.ts new file mode 100644 index 000000000..b404d7fd4 --- /dev/null +++ b/client/src/containers/analysis-eudr-detail/map/index.ts @@ -0,0 +1 @@ +export { default } from './component'; diff --git a/client/src/containers/analysis-eudr-detail/map/legend/component.tsx b/client/src/containers/analysis-eudr-detail/map/legend/component.tsx new file mode 100644 index 000000000..54da03733 --- /dev/null +++ b/client/src/containers/analysis-eudr-detail/map/legend/component.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react'; +import classNames from 'classnames'; +import { MinusIcon, PlusIcon } from '@heroicons/react/outline'; + +import LegendItem from './item'; + +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import SandwichIcon from '@/components/icons/sandwich'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; + +const EURDLegend = () => { + const [isOpen, setIsOpen] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+ + + + + +
+

Legend

+
+ +
+ + +
+ +
+
+ + + + +
+
+
+
+
+ ); +}; + +export default EURDLegend; diff --git a/client/src/containers/analysis-eudr-detail/map/legend/index.ts b/client/src/containers/analysis-eudr-detail/map/legend/index.ts new file mode 100644 index 000000000..b404d7fd4 --- /dev/null +++ b/client/src/containers/analysis-eudr-detail/map/legend/index.ts @@ -0,0 +1 @@ +export { default } from './component'; diff --git a/client/src/containers/analysis-eudr-detail/map/legend/item.tsx b/client/src/containers/analysis-eudr-detail/map/legend/item.tsx new file mode 100644 index 000000000..3946119f2 --- /dev/null +++ b/client/src/containers/analysis-eudr-detail/map/legend/item.tsx @@ -0,0 +1,33 @@ +import classNames from 'classnames'; + +import type { FC, PropsWithChildren } from 'react'; + +type LegendItemProps = { title: string; description: string; iconClassName?: string }; + +const LegendItem: FC> = ({ + title, + description, + children, + iconClassName, +}) => { + return ( +
+
+
+
+

{title}

+
+
+

{description}

+ {children} +
+
+ ); +}; + +export default LegendItem; diff --git a/client/src/containers/analysis-eudr-detail/map/zoom/component.tsx b/client/src/containers/analysis-eudr-detail/map/zoom/component.tsx new file mode 100644 index 000000000..8b2eeeba1 --- /dev/null +++ b/client/src/containers/analysis-eudr-detail/map/zoom/component.tsx @@ -0,0 +1,51 @@ +import { MinusIcon, PlusIcon } from '@heroicons/react/solid'; +import cx from 'classnames'; + +import type { MapViewState } from '@deck.gl/core/typed'; +import type { FC } from 'react'; + +const COMMON_CLASSES = + 'p-2 transition-colors bg-white cursor-pointer hover:bg-gray-100 active:bg-navy-50 disabled:bg-gray-100 disabled:opacity-75 disabled:cursor-default'; + +const ZoomControl: FC<{ + viewState: MapViewState; + className?: string; + onZoomIn: () => void; + onZoomOut: () => void; +}> = ({ viewState, className = null, onZoomIn, onZoomOut }) => { + const { zoom, minZoom, maxZoom } = viewState; + + return ( +
+ + +
+ ); +}; + +export default ZoomControl; diff --git a/client/src/containers/analysis-eudr-detail/map/zoom/index.ts b/client/src/containers/analysis-eudr-detail/map/zoom/index.ts new file mode 100644 index 000000000..b404d7fd4 --- /dev/null +++ b/client/src/containers/analysis-eudr-detail/map/zoom/index.ts @@ -0,0 +1 @@ +export { default } from './component'; diff --git a/client/src/containers/analysis-eudr-detail/sourcing-info/chart/index.tsx b/client/src/containers/analysis-eudr-detail/sourcing-info/chart/index.tsx new file mode 100644 index 000000000..7ab89f74a --- /dev/null +++ b/client/src/containers/analysis-eudr-detail/sourcing-info/chart/index.tsx @@ -0,0 +1,223 @@ +import { useParams } from 'next/navigation'; +import { useMemo, useState } from 'react'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Label, +} from 'recharts'; +import { format } from 'date-fns'; +import { groupBy } from 'lodash-es'; + +import { useEUDRSupplier } from '@/hooks/eudr'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { useAppSelector } from '@/store/hooks'; +import { eudrDetail } from '@/store/features/eudr-detail'; +import { EUDR_COLOR_RAMP } from '@/utils/colors'; +import { Badge } from '@/components/ui/badge'; + +const SupplierSourcingInfoChart = (): JSX.Element => { + const [showBy, setShowBy] = useState<'byVolume' | 'byArea'>('byVolume'); + const [selectedPlots, setSelectedPlots] = useState([]); + const { supplierId }: { supplierId: string } = useParams(); + const { + filters: { dates }, + } = useAppSelector(eudrDetail); + + const { data } = useEUDRSupplier( + supplierId, + { + startAlertDate: dates.from, + endAlertDate: dates.to, + }, + { + select: (data) => data?.sourcingInformation, + }, + ); + + const parsedData = useMemo(() => { + if (showBy === 'byVolume') { + const plotsByYear = groupBy(data?.[showBy], 'year'); + + return Object.keys(groupBy(data?.[showBy], 'year')).map((year) => ({ + year: `${year}-01-01`, + ...Object.fromEntries( + plotsByYear[year].map(({ plotName, percentage }) => [plotName, percentage]), + ), + })); + } + + if (showBy === 'byArea') { + const plots = data?.[showBy]?.map(({ plotName, percentage }) => ({ + plotName, + percentage, + })); + + // ! for now, we are hardcoding the date and showing just the baseline (2020) + return [ + { + year: '2020-01-01', + ...Object.fromEntries(plots?.map(({ plotName, percentage }) => [plotName, percentage])), + }, + ]; + } + }, [data, showBy]); + + const plotConfig = useMemo(() => { + if (!parsedData?.[0]) return []; + + return Object.keys(parsedData[0]) + .filter((key) => key !== 'year') + .map((key, index) => ({ + name: key, + color: EUDR_COLOR_RAMP[index], + })); + }, [parsedData]); + + return ( +
+
+

Individual plot contributions to volume accumulation

+
+ Show by +
+ + +
+
+
+
+ {plotConfig.map(({ name, color }) => ( + { + setSelectedPlots((prev) => { + if (prev.includes(name)) { + return prev.filter((item) => item !== name); + } + return [...prev, name]; + }); + }} + > + + {name} + + ))} +
+ +
+ + + + + } + /> + ( + + + {format(new Date(payload.value), 'yyyy')} + + + )} + tickLine={false} + type="category" + width={200} + /> + format(new Date(value), 'yyyy')} + formatter={(value: number, name) => [`${value.toFixed(2)}%`, name]} + /> + {plotConfig.map(({ name, color }) => ( + + ))} + + +
+
+ ); +}; + +export default SupplierSourcingInfoChart; diff --git a/client/src/containers/analysis-eudr-detail/sourcing-info/index.tsx b/client/src/containers/analysis-eudr-detail/sourcing-info/index.tsx new file mode 100644 index 000000000..87db82d68 --- /dev/null +++ b/client/src/containers/analysis-eudr-detail/sourcing-info/index.tsx @@ -0,0 +1,72 @@ +import { useParams } from 'next/navigation'; +import Flag from 'react-world-flags'; + +import SupplierSourcingInfoChart from './chart'; + +import { useEUDRSupplier } from '@/hooks/eudr'; +import { useAppSelector } from '@/store/hooks'; +import { eudrDetail } from '@/store/features/eudr-detail'; +import { Separator } from '@/components/ui/separator'; + +const SupplierSourcingInfo = (): JSX.Element => { + const { supplierId }: { supplierId: string } = useParams(); + const { + filters: { dates }, + } = useAppSelector(eudrDetail); + const { data } = useEUDRSupplier( + supplierId, + { + startAlertDate: dates.from, + endAlertDate: dates.to, + }, + { + select: (data) => data?.sourcingInformation, + }, + ); + + return ( +
+

Sourcing information

+
+
+

HS code

+ {data?.hsCode || '-'} +
+
+

Commodity sourced from plot

+ {data?.materialName || '-'} +
+
+

Country prod.

+ {data?.country?.name || '-'} + +
+
+
+
+
+

Sourcing volume

+ + {data?.totalVolume + ? `${Intl.NumberFormat(undefined, { style: 'unit', unit: 'kilogram' }).format( + data.totalVolume, + )}` + : '-'} + +
+ +
+

Sourcing area

+ + {data?.totalArea ? `${Intl.NumberFormat(undefined).format(data.totalArea)} Kha` : '-'} + +
+
+ + +
+
+ ); +}; + +export default SupplierSourcingInfo; diff --git a/client/src/containers/analysis-eudr-detail/supplier-info/index.tsx b/client/src/containers/analysis-eudr-detail/supplier-info/index.tsx new file mode 100644 index 000000000..3576e424e --- /dev/null +++ b/client/src/containers/analysis-eudr-detail/supplier-info/index.tsx @@ -0,0 +1,34 @@ +import { useParams } from 'next/navigation'; + +import { useEUDRSupplier } from '@/hooks/eudr'; +import { useAppSelector } from '@/store/hooks'; +import { eudrDetail } from '@/store/features/eudr-detail'; + +const SupplierInfo = (): JSX.Element => { + const { supplierId }: { supplierId: string } = useParams(); + const { + filters: { dates }, + } = useAppSelector(eudrDetail); + const { data } = useEUDRSupplier(supplierId, { + startAlertDate: dates.from, + endAlertDate: dates.to, + }); + + return ( +
+

Supplier information

+
+
+

Supplier ID

+ {data?.companyId || '-'} +
+
+

Address

+ {data?.address || '-'} +
+
+
+ ); +}; + +export default SupplierInfo; diff --git a/client/src/containers/analysis-eudr/category-list/breakdown/breakdown-item/index.tsx b/client/src/containers/analysis-eudr/category-list/breakdown/breakdown-item/index.tsx index 40b46d5ff..cdb501801 100644 --- a/client/src/containers/analysis-eudr/category-list/breakdown/breakdown-item/index.tsx +++ b/client/src/containers/analysis-eudr/category-list/breakdown/breakdown-item/index.tsx @@ -19,7 +19,7 @@ const BreakdownItem = ({
- {`${value}%`} of suppliers + {`${value.toFixed(2)}%`} of suppliers
diff --git a/client/src/containers/analysis-eudr/category-list/index.tsx b/client/src/containers/analysis-eudr/category-list/index.tsx index c8198d2d5..7fce86af5 100644 --- a/client/src/containers/analysis-eudr/category-list/index.tsx +++ b/client/src/containers/analysis-eudr/category-list/index.tsx @@ -97,7 +97,8 @@ export const CategoryList = (): JSX.Element => {
- {`${category.totalPercentage}%`} of suppliers + {`${category.totalPercentage.toFixed(2)}%`}{' '} + of suppliers
{ return ( -
+
All commodities

Suppliers List

diff --git a/client/src/containers/analysis-eudr/supplier-list-table/table/columns.tsx b/client/src/containers/analysis-eudr/supplier-list-table/table/columns.tsx index d863384ba..5209a5eb3 100644 --- a/client/src/containers/analysis-eudr/supplier-list-table/table/columns.tsx +++ b/client/src/containers/analysis-eudr/supplier-list-table/table/columns.tsx @@ -48,24 +48,24 @@ export const columns: ColumnDef[] = [ accessorKey: 'dfs', header: ({ column }) => , cell: ({ row }) => { - const dfs = row.getValue('dfs'); - return {`${Number.isNaN(dfs) ? '-' : `${dfs}%`}`}; + const dfs: number = row.getValue('dfs'); + return {`${Number.isNaN(dfs) ? '-' : `${dfs.toFixed(2)}%`}`}; }, }, { accessorKey: 'sda', header: ({ column }) => , cell: ({ row }) => { - const sda = row.getValue('sda'); - return {`${Number.isNaN(sda) ? '-' : `${sda}%`}`}; + const sda: number = row.getValue('sda'); + return {`${Number.isNaN(sda) ? '-' : `${sda.toFixed(2)}%`}`}; }, }, { accessorKey: 'tpl', header: ({ column }) => , cell: ({ row }) => { - const tpl = row.getValue('tpl'); - return {`${Number.isNaN(tpl) ? '-' : `${tpl}%`}`}; + const tpl: number = row.getValue('tpl'); + return {`${Number.isNaN(tpl) ? '-' : `${tpl.toFixed(2)}%`}`}; }, }, { diff --git a/client/src/containers/analysis-eudr/supplier-list-table/table/pagination.tsx b/client/src/containers/analysis-eudr/supplier-list-table/table/pagination.tsx index 48b335b05..a49eda84e 100644 --- a/client/src/containers/analysis-eudr/supplier-list-table/table/pagination.tsx +++ b/client/src/containers/analysis-eudr/supplier-list-table/table/pagination.tsx @@ -41,7 +41,7 @@ export function DataTablePagination({ table }: DataTablePaginationProps
-
+
Page {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}- {(table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize > table.getRowCount() diff --git a/client/src/containers/analysis-eudr/suppliers-stacked-bar/component.tsx b/client/src/containers/analysis-eudr/suppliers-stacked-bar/component.tsx index 297bf6480..d9204243d 100644 --- a/client/src/containers/analysis-eudr/suppliers-stacked-bar/component.tsx +++ b/client/src/containers/analysis-eudr/suppliers-stacked-bar/component.tsx @@ -167,7 +167,7 @@ const SuppliersStackedBar = () => { cursor={{ fill: 'transparent' }} labelFormatter={(value: string) => value} formatter={(value: number, name: keyof typeof TOOLTIP_LABELS) => [ - `${value}%`, + `${value.toFixed(2)}%`, TOOLTIP_LABELS[name], ]} /> diff --git a/client/src/hooks/eudr/index.ts b/client/src/hooks/eudr/index.ts index d2e543491..10e216a82 100644 --- a/client/src/hooks/eudr/index.ts +++ b/client/src/hooks/eudr/index.ts @@ -173,3 +173,72 @@ export const useEUDRData = ( }, ); }; + +export interface SupplierDetail { + name: string; + address: string; + companyId: string; + sourcingInformation: { + materialName: string; + hsCode: string; + country: { + name: string; + isoA3: string; + }; + totalArea: number; + totalVolume: number; + byVolume: [ + { + plotName: string; + year: number; + percentage: number; + volume: number; + }, + ]; + byArea: [ + { + plotName: string; + percentage: number; + area: number; + geoRegionId: string; + }, + ]; + }; + alerts: { + startAlertDate: string; + endAlertDate: string; + totalAlerts: number; + values: [ + { + alertDate: string; + plots: [ + { + plotName: string; + alertCount: number; + }, + ]; + }, + ]; + }; +} + +export const useEUDRSupplier = ( + supplierId: string, + params?: { startAlertDate: string; endAlertDate: string }, + options: UseQueryOptions = {}, +) => { + return useQuery( + ['eudr-supplier', supplierId, params], + () => + apiService + .request({ + method: 'GET', + url: `/eudr/dashboard/detail/${supplierId}`, + params, + }) + .then(({ data: responseData }) => responseData), + { + ...options, + }, + ); +}; diff --git a/client/src/pages/eudr/suppliers/[supplierId].tsx b/client/src/pages/eudr/suppliers/[supplierId].tsx new file mode 100644 index 000000000..4888f4e26 --- /dev/null +++ b/client/src/pages/eudr/suppliers/[supplierId].tsx @@ -0,0 +1,117 @@ +import { useRef, useState } from 'react'; +import { Transition } from '@headlessui/react'; +import { MapProvider } from 'react-map-gl/maplibre'; +import Link from 'next/link'; +import { ArrowLeft } from 'lucide-react'; +import { useParams } from 'next/navigation'; + +import { tasksSSR } from 'services/ssr'; +import ApplicationLayout from 'layouts/application'; +import CollapseButton from 'containers/collapse-button/component'; +import TitleTemplate from 'utils/titleTemplate'; +import Map from 'containers/analysis-eudr/map'; +import EUDRFilters from '@/containers/analysis-eudr-detail/filters'; +import { Button } from '@/components/ui/button'; +import { useEUDRSupplier } from '@/hooks/eudr'; +import SupplierInfo from '@/containers/analysis-eudr-detail/supplier-info'; +import SupplierSourcingInfo from '@/containers/analysis-eudr-detail/sourcing-info'; +import { Separator } from '@/components/ui/separator'; +import DeforestationAlerts from '@/containers/analysis-eudr-detail/deforestation-alerts'; + +import type { NextPageWithLayout } from 'pages/_app'; +import type { ReactElement } from 'react'; +import type { GetServerSideProps } from 'next'; + +const MapPage: NextPageWithLayout = () => { + const scrollRef = useRef(null); + const [isCollapsed, setIsCollapsed] = useState(false); + + const { supplierId }: { supplierId: string } = useParams(); + const { data } = useEUDRSupplier(supplierId); + + return ( + + +
+ + +
+ +
+
+
+ ); +}; + +MapPage.Layout = function getLayout(page: ReactElement) { + return {page}; +}; + +export const getServerSideProps: GetServerSideProps = async ({ req, res, query }) => { + try { + const tasks = await tasksSSR({ req, res }); + if (tasks && tasks[0]?.attributes.status === 'processing') { + return { + redirect: { + permanent: false, + destination: '/data', + }, + }; + } + return { props: { query } }; + } catch (error) { + if (error.code === '401' || error.response.status === 401) { + return { + redirect: { + permanent: false, + destination: '/auth/signin', + }, + }; + } + } +}; + +export default MapPage; diff --git a/client/src/store/features/eudr-detail/index.ts b/client/src/store/features/eudr-detail/index.ts new file mode 100644 index 000000000..d5f5ad75d --- /dev/null +++ b/client/src/store/features/eudr-detail/index.ts @@ -0,0 +1,44 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { DATES_RANGE } from 'containers/analysis-eudr-detail/filters/years-range'; + +import type { PayloadAction } from '@reduxjs/toolkit'; +import type { RootState } from 'store'; + +export type EUDRDetailState = { + filters: { + dates: { + from: string; + to: string; + }; + }; +}; + +export const initialState: EUDRDetailState = { + filters: { + dates: { + from: DATES_RANGE[0], + to: DATES_RANGE[1], + }, + }, +}; + +export const EUDRSlice = createSlice({ + name: 'eudrDetail', + initialState, + reducers: { + setFilters: (state, action: PayloadAction>) => ({ + ...state, + filters: { + ...state.filters, + ...action.payload, + }, + }), + }, +}); + +export const { setFilters } = EUDRSlice.actions; + +export const eudrDetail = (state: RootState) => state['eudrDetail']; + +export default EUDRSlice.reducer; diff --git a/client/src/store/index.ts b/client/src/store/index.ts index d77c384b5..48f714987 100644 --- a/client/src/store/index.ts +++ b/client/src/store/index.ts @@ -17,6 +17,7 @@ import analysisScenarios, { setScenarioToCompare, } from 'store/features/analysis/scenarios'; import eudr from 'store/features/eudr'; +import eudrDetail from 'store/features/eudr-detail'; import type { Action, ReducersMapObject, Middleware } from '@reduxjs/toolkit'; import type { AnalysisState } from './features/analysis'; @@ -28,6 +29,7 @@ const staticReducers = { 'analysis/map': analysisMap, 'analysis/scenarios': analysisScenarios, eudr, + eudrDetail, }; const asyncReducers = {}; diff --git a/client/src/utils/colors.ts b/client/src/utils/colors.ts index 3298c47d7..a89a2b2c9 100644 --- a/client/src/utils/colors.ts +++ b/client/src/utils/colors.ts @@ -23,3 +23,14 @@ export function useColors(layerName: string, colorScale): RGBColor[] { } export const themeColors = resolveConfig(tailwindConfig).theme.colors; + +export const EUDR_COLOR_RAMP = [ + themeColors.navy['600'], + '#50B1F6', + '#E2564F', + '#FAE26C', + '#ED7542', + '#ED75CC', + '#78C679', + '#AB93FF', +]; diff --git a/client/yarn.lock b/client/yarn.lock index f24bb8aa1..971fad216 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -2314,6 +2314,26 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-separator@npm:^1.0.3": + version: 1.0.3 + resolution: "@radix-ui/react-separator@npm:1.0.3" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-primitive": 1.0.3 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 42f8c95e404de2ce9387040d78049808a48d423cd4c3bad8cca92c4b0bcbdcb3566b5b52a920d4e939a74b51188697f20a012221f0e630fc7f56de64096c15d2 + languageName: node + linkType: hard + "@radix-ui/react-slot@npm:1.0.2": version: 1.0.2 resolution: "@radix-ui/react-slot@npm:1.0.2" @@ -7813,6 +7833,7 @@ __metadata: "@radix-ui/react-popover": 1.0.7 "@radix-ui/react-radio-group": 1.1.3 "@radix-ui/react-select": 2.0.0 + "@radix-ui/react-separator": ^1.0.3 "@radix-ui/react-slot": 1.0.2 "@reduxjs/toolkit": 1.8.2 "@tailwindcss/forms": 0.4.0