diff --git a/client/src/app/(overview)/store.ts b/client/src/app/(overview)/store.ts index 0d7fe945..1d0108e5 100644 --- a/client/src/app/(overview)/store.ts +++ b/client/src/app/(overview)/store.ts @@ -1,6 +1,14 @@ import { MapMouseEvent } from "react-map-gl"; +import { + COST_TYPE_SELECTOR, + PROJECT_PRICE_TYPE, + PROJECT_SIZE_FILTER, +} from "@shared/entities/projects.entity"; import { atom } from "jotai"; +import { z } from "zod"; + +import { filtersSchema } from "@/app/(overview)/url-store"; export const projectsUIState = atom<{ filtersOpen: boolean; @@ -15,8 +23,23 @@ export const popupAtom = atom<{ export const projectDetailsAtom = atom<{ isOpen: boolean; - projectName: string; + id: string; + visibleProjectIds: string[]; }>({ isOpen: false, - projectName: "", + id: "", + visibleProjectIds: [], }); + +export type ProjectDetailsFilters = Pick< + z.infer, + "projectSizeFilter" | "priceType" | "costRangeSelector" +>; + +const INITIAL_FILTERS_STATE: ProjectDetailsFilters = { + projectSizeFilter: PROJECT_SIZE_FILTER.MEDIUM, + priceType: PROJECT_PRICE_TYPE.MARKET_PRICE, + costRangeSelector: COST_TYPE_SELECTOR.NPV, +}; + +export const projectDetailsFiltersAtom = atom(INITIAL_FILTERS_STATE); diff --git a/client/src/app/(overview)/url-store.ts b/client/src/app/(overview)/url-store.ts index 93570745..d69baa20 100644 --- a/client/src/app/(overview)/url-store.ts +++ b/client/src/app/(overview)/url-store.ts @@ -1,5 +1,3 @@ -import { ReactNode } from "react"; - import { ACTIVITY, RESTORATION_ACTIVITY_SUBTYPE, @@ -25,18 +23,15 @@ import { TABLE_VIEWS } from "@/containers/overview/table/toolbar/table-selector" const SUB_ACTIVITIES = RESTORATION_ACTIVITY_SUBTYPE; -interface ParameterOption { - label: string; - value: string; - disabled?: boolean; -} - -export interface Parameter { - key: keyof Omit, "keyword">; +export interface Parameter { + key: T; label: string; className: string; - options: ParameterOption[]; - tooltipContent?: ReactNode; + options: { + label: string; + value: string; + disabled?: boolean; + }[]; } export const filtersSchema = z.object({ @@ -65,7 +60,7 @@ export const INITIAL_FILTERS_STATE: z.infer = { abatementPotentialRange: INITIAL_ABATEMENT_POTENTIAL_RANGE, }; -export function useGlobalFilters() { +export function useProjectOverviewFilters() { const [popup, setPopup] = useAtom(popupAtom); const [filters, setFilters] = useQueryState( "filters", diff --git a/client/src/components/ui/bar-chart/index.tsx b/client/src/components/ui/bar-chart/index.tsx deleted file mode 100644 index 972983e6..00000000 --- a/client/src/components/ui/bar-chart/index.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { toCompactAmount } from "@/lib/format"; - -interface BarChartProps { - total: number; - segments: { - value: number; - label: string; - colorClass: string; - }[]; - orientation?: "horizontal" | "vertical"; -} - -const BarChart = ({ - total, - segments, - orientation = "horizontal", -}: BarChartProps) => { - const getSize = (value: number) => { - const percentage = (value / total) * 100; - return `${Math.max(percentage, 0)}%`; - }; - - if (orientation === "horizontal") { - return ( -
-
- {segments.map((segment, index) => ( -
-
-
- ${toCompactAmount(segment.value)} -
-
-
- ))} -
-
- ); - } - - return ( -
-
- {segments.map((segment, index) => ( -
-
-
- ${toCompactAmount(segment.value)} -
-
-
- ))} -
-
- ); -}; - -export default BarChart; diff --git a/client/src/components/ui/graph.tsx b/client/src/components/ui/graph.tsx index 4a910ad4..7f5a38a6 100644 --- a/client/src/components/ui/graph.tsx +++ b/client/src/components/ui/graph.tsx @@ -30,7 +30,7 @@ const getSize = (value: number, total: number) => { * 2. Split mode (when leftover is provided): Shows total on left and segments with leftover on right */ const Graph: FC = ({ total, leftover, segments }) => { - if (leftover) { + if (typeof leftover === "number") { return (
@@ -149,4 +149,62 @@ const GraphLegend: FC = ({ items }) => { ); }; -export { Graph, GraphLegend }; +interface GraphWithLegendProps { + /** The total value to be displayed */ + total: number; + /** Optional value that, when provided, shows a split view with total on left and segments with leftover on right */ + leftover?: number; + /** Array of segments with their corresponding legend items */ + items: Array<{ + value: number; + label: string; + textColor: string; + bgColor: string; + }>; +} + +const GraphWithLegend: FC = ({ + total, + leftover, + items, +}) => { + return ( +
+
+
+ + {renderCurrency(leftover || total)} + +
+ ({ + label, + textColor, + bgColor, + })), + ]} + /> +
+ ({ + value, + colorClass: bgColor, + }))} + leftover={leftover} + /> +
+ ); +}; + +export { Graph, GraphLegend, GraphWithLegend }; diff --git a/client/src/containers/overview/filters/index.tsx b/client/src/containers/overview/filters/index.tsx index afcce826..bd6b173d 100644 --- a/client/src/containers/overview/filters/index.tsx +++ b/client/src/containers/overview/filters/index.tsx @@ -15,7 +15,7 @@ import { cn } from "@/lib/utils"; import { projectsUIState } from "@/app/(overview)/store"; import { INITIAL_FILTERS_STATE, - useGlobalFilters, + useProjectOverviewFilters, } from "@/app/(overview)/url-store"; import { Button } from "@/components/ui/button"; @@ -39,7 +39,7 @@ import { export const FILTERS_SIDEBAR_WIDTH = 320; export default function ProjectsFilters() { - const [filters, setFilters] = useGlobalFilters(); + const [filters, setFilters] = useProjectOverviewFilters(); const setFiltersOpen = useSetAtom(projectsUIState); const [costValuesState, setCostValuesState] = useState([ filters.costRange[0] || INITIAL_COST_RANGE[filters.costRangeSelector][0], diff --git a/client/src/containers/overview/header/index.tsx b/client/src/containers/overview/header/index.tsx index 42c3b542..edb6a62f 100644 --- a/client/src/containers/overview/header/index.tsx +++ b/client/src/containers/overview/header/index.tsx @@ -7,7 +7,7 @@ import { Settings2Icon } from "lucide-react"; import { cn } from "@/lib/utils"; import { projectsUIState } from "@/app/(overview)/store"; -import { useGlobalFilters } from "@/app/(overview)/url-store"; +import { useProjectOverviewFilters } from "@/app/(overview)/url-store"; import { INITIAL_FILTERS_STATE } from "@/app/(overview)/url-store"; import ParametersProjects from "@/containers/overview/header/parameters"; @@ -17,7 +17,7 @@ import { SidebarTrigger } from "@/components/ui/sidebar"; export default function ProjectsHeader() { const setFiltersOpen = useSetAtom(projectsUIState); - const [filters] = useGlobalFilters(); + const [filters] = useProjectOverviewFilters(); /* eslint-disable @typescript-eslint/no-unused-vars */ const { diff --git a/client/src/containers/overview/header/parameters/index.tsx b/client/src/containers/overview/header/parameters/index.tsx index 525dc726..7f1497d9 100644 --- a/client/src/containers/overview/header/parameters/index.tsx +++ b/client/src/containers/overview/header/parameters/index.tsx @@ -6,7 +6,10 @@ import { import { z } from "zod"; import { FILTER_KEYS } from "@/app/(overview)/constants"; -import { Parameter, useGlobalFilters } from "@/app/(overview)/url-store"; +import { + Parameter, + useProjectOverviewFilters, +} from "@/app/(overview)/url-store"; import { filtersSchema } from "@/app/(overview)/url-store"; import { FILTERS } from "@/constants/tooltip"; @@ -80,7 +83,8 @@ export const PROJECT_PARAMETERS: Parameter[] = [ ] as const; export default function ParametersProjects() { - const [filters, setFilters] = useGlobalFilters(); + const [filters, setFilters] = useProjectOverviewFilters(); + const handleParameters = async ( v: string, parameter: keyof Omit, "keyword">, diff --git a/client/src/containers/overview/map/layers/projects/index.tsx b/client/src/containers/overview/map/layers/projects/index.tsx index fdf4ddcd..79898dba 100644 --- a/client/src/containers/overview/map/layers/projects/index.tsx +++ b/client/src/containers/overview/map/layers/projects/index.tsx @@ -6,14 +6,14 @@ import { FillLayerSpecification } from "mapbox-gl"; import { client } from "@/lib/query-client"; import { geometriesKeys } from "@/lib/query-keys"; -import { useGlobalFilters } from "@/app/(overview)/url-store"; +import { useProjectOverviewFilters } from "@/app/(overview)/url-store"; import { generateColorRamp } from "@/containers/overview/map/layers/projects/utils"; export const LAYER_ID = "cost-abatement-layer"; export default function ProjectsLayer() { - const [filters] = useGlobalFilters(); + const [filters] = useProjectOverviewFilters(); const queryKey = geometriesKeys.all(filters).queryKey; diff --git a/client/src/containers/overview/project-details/abatement-potential/index.tsx b/client/src/containers/overview/project-details/abatement-potential/index.tsx new file mode 100644 index 00000000..c782060b --- /dev/null +++ b/client/src/containers/overview/project-details/abatement-potential/index.tsx @@ -0,0 +1,44 @@ +import { FC } from "react"; + +import { formatNumber } from "@/lib/format"; + +import { PROJECT_DETAILS } from "@/constants/tooltip"; + +import { Label } from "@/components/ui/label"; + +interface AbatementPotentialProps { + value?: number; +} + +const AbatementPotential: FC = ({ value }) => { + return ( + <> +
+
+ +
+ + + tCO2e/yr  + + {formatNumber(value || 0)} + +
+
+

Estimation of total CO2 expected during the project.

+
+ + ); +}; + +export default AbatementPotential; diff --git a/client/src/containers/overview/project-details/compare-button/index.tsx b/client/src/containers/overview/project-details/compare-button/index.tsx new file mode 100644 index 00000000..b38bb88e --- /dev/null +++ b/client/src/containers/overview/project-details/compare-button/index.tsx @@ -0,0 +1,19 @@ +import { Plus } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; + +const CompareButton = () => { + return ( +
+ + +
+ ); +}; + +export default CompareButton; diff --git a/client/src/containers/overview/project-details/cost-estimates/index.tsx b/client/src/containers/overview/project-details/cost-estimates/index.tsx new file mode 100644 index 00000000..c59ab36c --- /dev/null +++ b/client/src/containers/overview/project-details/cost-estimates/index.tsx @@ -0,0 +1,59 @@ +import { FC } from "react"; + +import { cn } from "@/lib/utils"; + +import { useFeatureFlags } from "@/hooks/use-feature-flags"; + +import CompareButton from "@/containers/overview/project-details/compare-button"; +import { CostItem } from "@/containers/projects/custom-project/cost-details/table"; + +import { Label } from "@/components/ui/label"; +import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; + +interface CostEstimatesProps { + items: CostItem[]; +} +const CostEstimates: FC = ({ items }) => { + const { "compare-with-other-project": compareWithOtherProject } = + useFeatureFlags(); + return ( + <> +
+
+ +
+ {compareWithOtherProject && } +
+ + + {items.map(({ costName, label, value }) => ( + + {label} + {value} + + ))} + +
+ + ); +}; + +export default CostEstimates; diff --git a/client/src/containers/overview/project-details/cost/index.tsx b/client/src/containers/overview/project-details/cost/index.tsx new file mode 100644 index 00000000..23b21981 --- /dev/null +++ b/client/src/containers/overview/project-details/cost/index.tsx @@ -0,0 +1,66 @@ +import { FC } from "react"; + +import { ProjectScorecardDto } from "@shared/dtos/projects/project-scorecard.dto"; +import { COST_TYPE_SELECTOR } from "@shared/entities/projects.entity"; +import { useAtomValue } from "jotai"; + +import { projectDetailsFiltersAtom } from "@/app/(overview)/store"; + +import { PROJECT_DETAILS } from "@/constants/tooltip"; + +import { GraphWithLegend } from "@/components/ui/graph"; +import { Label } from "@/components/ui/label"; + +interface ProjectDetailsCostProps { + data?: ProjectScorecardDto["projectCost"]; +} +const ProjectDetailsCost: FC = ({ data }) => { + const { costRangeSelector } = useAtomValue(projectDetailsFiltersAtom); + const tooltipContent = + costRangeSelector === COST_TYPE_SELECTOR.NPV + ? PROJECT_DETAILS.TOTAL_PROJECT_COST_NPV + : PROJECT_DETAILS.TOTAL_PROJECT_COST; + + return ( + <> +
+
+
+ +
+
+ Refers to the summary of Capital Expenditure and Operating + Expenditure +
+
+
+ + + ); +}; + +export default ProjectDetailsCost; diff --git a/client/src/containers/overview/project-details/footer/index.tsx b/client/src/containers/overview/project-details/footer/index.tsx new file mode 100644 index 00000000..761c185f --- /dev/null +++ b/client/src/containers/overview/project-details/footer/index.tsx @@ -0,0 +1,60 @@ +import { Link, NotebookPen } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +const Footer = () => { + return ( +
+
+
+ Values considered for a{" "} + small project (40 ha). +
+
For more detailed analysis, create a custom project.
+
+
+ + + + + + + + + Create a Custom Project + + + + By creating a custom project you will generate a customizable + version where you can edit all parameters to fit your specific + needs. + + + + + + + + + +
+
+ ); +}; + +export default Footer; diff --git a/client/src/containers/overview/project-details/index.tsx b/client/src/containers/overview/project-details/index.tsx index b3d01232..3cfe3fdf 100644 --- a/client/src/containers/overview/project-details/index.tsx +++ b/client/src/containers/overview/project-details/index.tsx @@ -1,37 +1,29 @@ -import Link from "next/link"; +import { useMemo } from "react"; -import { PROJECT_SCORE } from "@shared/entities/project-score.enum"; -import { useAtom } from "jotai"; -import { ChevronUp, ChevronDown, NotebookPen, Plus } from "lucide-react"; +import { keepPreviousData } from "@tanstack/react-query"; +import { useAtom, useAtomValue } from "jotai"; -import { - renderCurrency, - formatCurrency, - renderAbatementCurrency, -} from "@/lib/format"; -import { cn } from "@/lib/utils"; - -import { projectDetailsAtom } from "@/app/(overview)/store"; - -import { useFeatureFlags } from "@/hooks/use-feature-flags"; +import { client } from "@/lib/query-client"; +import { queryKeys } from "@/lib/query-keys"; +import { + projectDetailsAtom, + projectDetailsFiltersAtom, +} from "@/app/(overview)/store"; + +import AbatementPotential from "@/containers/overview/project-details/abatement-potential"; +import ProjectDetailsCost from "@/containers/overview/project-details/cost"; +import CostEstimates from "@/containers/overview/project-details/cost-estimates"; +import Footer from "@/containers/overview/project-details/footer"; +import ProjectDetailsLeftover from "@/containers/overview/project-details/left-over"; +import Navigation from "@/containers/overview/project-details/navigation"; import ParametersProjects from "@/containers/overview/project-details/parameters"; +import ScoreCardRating from "@/containers/overview/project-details/score-card-rating"; +import ScoreCardRatings from "@/containers/overview/project-details/score-card-ratings"; +import { parseCostEstimatesForTable } from "@/containers/overview/project-details/utils"; -import BarChart from "@/components/ui/bar-chart"; -import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import { - Dialog, - DialogContent, - DialogClose, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { Label } from "@/components/ui/label"; -import { ScoreIndicator, DEFAULT_BG_CLASSES } from "@/components/ui/score-card"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { Sheet, SheetContent, @@ -39,442 +31,86 @@ import { SheetTitle, } from "@/components/ui/sheet"; -const CreateProjectDetails = () => ( - - - - - - - - Create a Custom Project - - - By creating a custom project you will generate a customizable version - where you can edit all parameters to fit your specific needs. - - - - - - - - - -); - -//////// Legend component //////// -const Legend = ({ - name, - textColor, - bgColor, -}: { - name: string; - textColor: string; - bgColor: string; -}) => { - return ( -
-
-
- {name} -
-
- ); -}; - -// Mock data - to be replaced with API data later -const projectData = { - name: "Australian Mangrove Conservation", - size: "Small", - carbonPricingType: "Market (30$)", - cost: "NPV", - totalCost: 38023789, - capEx: 1500000, - opEx: 36500000, - leftover: 4106132, - totalRevenue: 40600000, - opExRevenue: 36500000, - abatement: 962991, - overallScore: "medium", - scorecard: [ - { name: "Economic feasibility", rating: "high" }, - { name: "Legal feasibility", rating: "low" }, - { name: "Implementation risk", rating: "high" }, - { name: "Social feasibility", rating: "medium" }, - { name: "Security risk", rating: "medium" }, - { name: "Value for money", rating: "low" }, - { name: "Overall", rating: "medium" }, - ], - costEstimates: [ +export default function ProjectDetails() { + const filters = useAtomValue(projectDetailsFiltersAtom); + const [projectDetails, setProjectDetails] = useAtom(projectDetailsAtom); + const { costRangeSelector } = filters; + const queryKey = queryKeys.projects.one(projectDetails.id, filters).queryKey; + const { data: projectData } = client.projects.getProject.useQuery( + queryKey, { - name: "Capital expenditure", - value: 1514218, - items: [ - { name: "Feasibility analysis", value: 70000 }, - { name: "Conservation planning and admin", value: 629559 }, - { name: "Data collection and field costs", value: 76963 }, - { name: "Community representation", value: 286112 }, - { name: "Blue carbon project planning", value: 111125 }, - { name: "Establishing carbon rights", value: 296010 }, - { name: "Validation", value: 44450 }, - { name: "Implementation labor", value: 0 }, - ], + query: { + filter: filters, + }, + params: { id: projectDetails.id }, }, { - name: "Operating expenditure", - value: 36509571, - items: [ - { name: "Monitoring and Maintenance", value: 402322 }, - { name: "Community benefit sharing fund", value: 34523347 }, - { name: "Carbon standard fees", value: 227875 }, - { name: "Baseline reassessment", value: 75812 }, - { name: "MRV", value: 223062 }, - ], + queryKey, + select: (data) => data.body.data, + enabled: projectDetails.isOpen && !!projectDetails.id, + placeholderData: keepPreviousData, }, - { name: "Total cost", value: 38023789 }, - ], -}; - -export default function ProjectDetails() { - const [projectDetails, setProjectDetails] = useAtom(projectDetailsAtom); - const { "project-comparison": projectComparison } = useFeatureFlags(); + ); + const costItems = useMemo( + () => + projectData?.projectCost[costRangeSelector] + ? parseCostEstimatesForTable( + projectData?.projectCost[costRangeSelector], + ) + : [], + [projectData?.projectCost, costRangeSelector], + ); const handleOpenDetails = (open: boolean) => setProjectDetails({ ...projectDetails, isOpen: open }); return ( - - + +
-
- - -
+
- {projectDetails.projectName} + {projectData?.projectName}
-
-
- -
-
-
- -
-
- Refers to the summary of Capital Expenditure and Operating - Expenditure -
-
-
-
-
-
- - {renderCurrency(projectData.totalCost)} - -
-
- - -
-
-
- -
-
-
- - -
-
-
- -
-
- Refers to the summary of Capital Expenditure and Operating - Expenditure -
-
-
-
-
-
- - {renderCurrency(projectData.totalCost)} - -
-
- - -
-
-
- -
-
-
-
- -
- -
-
- -
- - {renderAbatementCurrency(projectData.abatement)} - -
-
-

Estimation of total CO2 expected during the project.

-
-
- - -
-
- -
-
-
- - {projectData.overallScore} - -
-
-
- - -
-
- -
- {projectComparison && ( -
- - -
- )} -
-
- {projectData.scorecard.map((item, index) => ( - <> - {index === 0 &&
} -
-
{item.name}
- - {item.rating} - -
- {projectData.scorecard.length !== index + 1 && ( -
- )} - - ))} + +
+
+ + + + + + +
- - -
-
- -
- {projectComparison && ( -
- - -
- )} -
-
- {projectData.costEstimates.map((estimate) => ( -
-
-
- {estimate.name} -
-
- - {formatCurrency(estimate.value)} - -
-
- {estimate.items?.map((item) => ( -
-
- {item.name} -
-
- - {formatCurrency(item.value)} - -
-
- ))} -
- ))} -
-
-
+
+ + + -
-
-
- Values considered for a{" "} - small project (40 ha). + + +
-
For more detailed analysis, create a custom project.
-
-
- + + + + + + +
-
+ +