diff --git a/client/src/app/(overview)/store.ts b/client/src/app/(overview)/store.ts index a87e2a5e..0d7fe945 100644 --- a/client/src/app/(overview)/store.ts +++ b/client/src/app/(overview)/store.ts @@ -12,3 +12,11 @@ export const popupAtom = atom<{ lngLat: MapMouseEvent["lngLat"]; features: MapMouseEvent["features"]; } | null>(null); + +export const projectDetailsAtom = atom<{ + isOpen: boolean; + projectName: string; +}>({ + isOpen: false, + projectName: "", +}); diff --git a/client/src/components/ui/bar-chart/index.tsx b/client/src/components/ui/bar-chart/index.tsx new file mode 100644 index 00000000..972983e6 --- /dev/null +++ b/client/src/components/ui/bar-chart/index.tsx @@ -0,0 +1,71 @@ +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/containers/overview/project-details/index.tsx b/client/src/containers/overview/project-details/index.tsx new file mode 100644 index 00000000..7c0cf318 --- /dev/null +++ b/client/src/containers/overview/project-details/index.tsx @@ -0,0 +1,489 @@ +import Link from "next/link"; + +import { useAtom } from "jotai"; +import { ChevronUp, ChevronDown, Plus, NotebookPen } from "lucide-react"; + +import { + renderCurrency, + formatCurrency, + renderAbatementCurrency, +} from "@/lib/format"; + +import { projectDetailsAtom } from "@/app/(overview)/store"; + +import ParametersProjects from "@/containers/overview/project-details/parameters"; + +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 { + Sheet, + SheetContent, + SheetHeader, + 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. + + + + + + + + + +); + +//////// ScoreIndicator component //////// +interface ScoreIndicatorProps { + score: "High" | "Medium" | "Low"; + className?: string; +} + +const ScoreIndicator = ({ score, className = "" }: ScoreIndicatorProps) => { + const bgColorClass = { + High: "bg-high", + Medium: "bg-medium", + Low: "bg-low", + }[score]; + + return ( +
+ {score} +
+ ); +}; + +//////// 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: [ + { + 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 }, + ], + }, + { + 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 }, + ], + }, + { name: "Total cost", value: 38023789 }, + ], +}; + +export default function ProjectDetails() { + const [projectDetails, setProjectDetails] = useAtom(projectDetailsAtom); + + const handleOpenDetails = (open: boolean) => + setProjectDetails({ ...projectDetails, isOpen: open }); + + return ( + + + +
+
+ + +
+
+ {projectDetails.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} + +
+
+
+ + +
+
+ +
+
+ + +
+
+
+ {projectData.scorecard.map((item, index) => ( + <> + {index === 0 &&
} +
+
{item.name}
+ +
+ {projectData.scorecard.length !== index + 1 && ( +
+ )} + + ))} +
+
+ + +
+
+ +
+
+ + +
+
+
+ {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.
+
+
+ +
+
+
+
+ ); +} diff --git a/client/src/containers/overview/project-details/parameters/index.tsx b/client/src/containers/overview/project-details/parameters/index.tsx new file mode 100644 index 00000000..afd78739 --- /dev/null +++ b/client/src/containers/overview/project-details/parameters/index.tsx @@ -0,0 +1,122 @@ +import { + PROJECT_SIZE_FILTER, + COST_TYPE_SELECTOR, + PROJECT_PRICE_TYPE, +} from "@shared/entities/projects.entity"; +import { atom, useAtom } from "jotai"; +import { z } from "zod"; + +import { FILTER_KEYS } from "@/app/(overview)/constants"; +import { filtersSchema } from "@/app/(overview)/url-store"; + +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +const INITIAL_FILTERS_STATE: Partial> = { + projectSizeFilter: PROJECT_SIZE_FILTER.MEDIUM, + priceType: PROJECT_PRICE_TYPE.OPEN_BREAK_EVEN_PRICE, + costRangeSelector: COST_TYPE_SELECTOR.NPV, +}; + +const filtersAtom = atom(INITIAL_FILTERS_STATE); + +export const PROJECT_PARAMETERS = [ + { + key: FILTER_KEYS[1], + label: "Project size", + className: "w-[125px]", + options: [ + { + label: PROJECT_SIZE_FILTER.SMALL, + value: PROJECT_SIZE_FILTER.SMALL, + }, + { + label: PROJECT_SIZE_FILTER.MEDIUM, + value: PROJECT_SIZE_FILTER.MEDIUM, + }, + { + label: PROJECT_SIZE_FILTER.LARGE, + value: PROJECT_SIZE_FILTER.LARGE, + }, + ], + }, + { + key: FILTER_KEYS[2], + label: "Carbon pricing type", + className: "w-[195px]", + options: [ + { + label: PROJECT_PRICE_TYPE.MARKET_PRICE, + value: PROJECT_PRICE_TYPE.MARKET_PRICE, + }, + { + label: PROJECT_PRICE_TYPE.OPEN_BREAK_EVEN_PRICE, + value: PROJECT_PRICE_TYPE.OPEN_BREAK_EVEN_PRICE, + }, + ], + }, + { + key: FILTER_KEYS[3], + label: "Cost", + className: "w-[85px]", + options: [ + { + label: COST_TYPE_SELECTOR.TOTAL, + value: COST_TYPE_SELECTOR.TOTAL, + }, + { + label: COST_TYPE_SELECTOR.NPV, + value: COST_TYPE_SELECTOR.NPV, + }, + ], + }, +] as const; + +function useFilters() { + return useAtom(filtersAtom); +} + +export default function ParametersProjects() { + const [filters, setFilters] = useFilters(); + + const handleParameters = ( + v: string, + parameter: keyof Omit, "keyword">, + ) => { + setFilters((prev) => ({ ...prev, [parameter]: v })); + }; + + return ( +
+ {PROJECT_PARAMETERS.map((parameter) => ( +
+ + +
+ ))} +
+ ); +} diff --git a/client/src/containers/overview/table/view/overview/index.tsx b/client/src/containers/overview/table/view/overview/index.tsx index 2207b0c2..3d41b2d6 100644 --- a/client/src/containers/overview/table/view/overview/index.tsx +++ b/client/src/containers/overview/table/view/overview/index.tsx @@ -12,6 +12,7 @@ import { SortingState, useReactTable, } from "@tanstack/react-table"; +import { useAtom } from "jotai"; import { ChevronsUpDownIcon } from "lucide-react"; import { z } from "zod"; @@ -19,12 +20,10 @@ import { client } from "@/lib/query-client"; import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; +import { projectDetailsAtom } from "@/app/(overview)/store"; import { useGlobalFilters, useTableView } from "@/app/(overview)/url-store"; -import TablePagination, { - PAGINATION_SIZE_OPTIONS, -} from "@/components/ui/table-pagination"; - +import ProjectDetails from "@/containers/overview/project-details"; import { filtersToQueryParams, NO_DATA, @@ -42,10 +41,14 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import TablePagination, { + PAGINATION_SIZE_OPTIONS, +} from "@/components/ui/table-pagination"; export function OverviewTable() { const [tableView] = useTableView(); const [filters] = useGlobalFilters(); + const [, setProjectDetails] = useAtom(projectDetailsAtom); const [sorting, setSorting] = useState([ { id: "projectName", @@ -107,6 +110,7 @@ export function OverviewTable() { return ( <> + {table.getHeaderGroups().map((headerGroup) => ( @@ -154,8 +158,15 @@ export function OverviewTable() { {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( { + setProjectDetails({ + isOpen: true, + projectName: row.original.projectName ?? "", + }); + }} > {row.getVisibleCells().map((cell) => ( diff --git a/client/src/lib/format.tsx b/client/src/lib/format.tsx index 35bee11b..ed7046eb 100644 --- a/client/src/lib/format.tsx +++ b/client/src/lib/format.tsx @@ -21,6 +21,23 @@ export const formatNumber = ( }).format(value); }; +export const renderAbatementCurrency = ( + value: number, + options: Intl.NumberFormatOptions = {}, +) => { + let formatted = formatCurrency(value, options); + formatted = formatted.replace(/\.\d+/, ""); + const [, amount] = formatted.match(/^(\D*)(.+)$/)!.slice(1); + return ( + <> + + tCO2e/yr   + + {amount} + + ); +}; + export function renderCurrency( value: number, options: Intl.NumberFormatOptions = {}, @@ -37,3 +54,17 @@ export function renderCurrency( ); } + +/** + * Converts a large numeric value into a compact format with an "M" suffix + * representing millions. + * + * @param {number} value - The numeric value to be converted. + * @returns {string} - The formatted string representing the value in millions with one decimal place. + * + * @example + * toCompactAmount(38023789); // Returns "38.0M" + */ +export const toCompactAmount = (value: number) => { + return `${(value / 1_000_000).toFixed(1)}M`; +}; diff --git a/client/tailwind.config.ts b/client/tailwind.config.ts index 198e19e5..b48f26ad 100644 --- a/client/tailwind.config.ts +++ b/client/tailwind.config.ts @@ -89,6 +89,16 @@ const config: Config = { "mint-green": { 200: "#70C69B" }, + "wheat": { + 200: "#EEE0BD" + }, + "yellow": { + 500: "#EACD3F" + }, + "high": "#B9CCA3", + "medium": "#F5EBB8", + "low": "#F7BA93", + "deep-ocean": "#132A47" }, borderRadius: { lg: "var(--radius)",