diff --git a/api/src/modules/calculations/cost.calculator.ts b/api/src/modules/calculations/cost.calculator.ts index a25e17a9..b32e16da 100644 --- a/api/src/modules/calculations/cost.calculator.ts +++ b/api/src/modules/calculations/cost.calculator.ts @@ -17,8 +17,8 @@ import { CostPlanMap, CustomProjectCostDetails, CustomProjectSummary, - OutputCostNames, YearlyBreakdown, + YearlyBreakdownCostName, } from '@shared/dtos/custom-projects/custom-project-output.dto'; export type CostPlans = Record< @@ -410,7 +410,7 @@ export class CostCalculator { const totalCost = sum(Object.values(costValues)); const totalNPV = this.calculateNpv(costValues, discountRate); yearlyBreakdown.push({ - costName: costName as keyof OverridableCostInputs & OutputCostNames, + costName: costName as YearlyBreakdownCostName, totalCost, totalNPV, costValues, diff --git a/client/package.json b/client/package.json index b2e4fbdf..b98ba92f 100644 --- a/client/package.json +++ b/client/package.json @@ -37,6 +37,7 @@ "@ts-rest/react-query": "3.51.0", "class-variance-authority": "0.7.0", "clsx": "2.1.1", + "country-iso-3-to-2": "^1.1.1", "d3": "7.9.0", "framer-motion": "11.11.9", "jotai": "2.10.1", @@ -52,6 +53,7 @@ "react-dropzone": "^14.3.5", "react-map-gl": "7.1.7", "react-resizable-panels": "2.1.6", + "recharts": "^2.13.3", "rooks": "7.14.1", "tailwind-merge": "2.5.3", "tailwindcss-animate": "1.0.7", diff --git a/client/src/app/globals.css b/client/src/app/globals.css index bae7e2b4..90ac670b 100644 --- a/client/src/app/globals.css +++ b/client/src/app/globals.css @@ -4,7 +4,6 @@ @tailwind components; @tailwind utilities; - body { font-family: Arial, Helvetica, sans-serif; } @@ -50,6 +49,11 @@ body { --sidebar-accent-foreground: 192, 86%, 69%; --sidebar-border: 220 13% 91%; --sidebar-ring: 212, 57%, 24%; + --chart-1: 150 43% 61%; + --chart-2: 191 87% 82%; + --chart-3: 194 88% 43%; + --chart-4: 213 73% 97%; + --chart-5: 50 80% 58%; } } @@ -65,31 +69,52 @@ body { /* mapbox styles */ .mapboxgl-popup-anchor-top { .mapboxgl-popup-tip { - @apply !border-b-popover !z-10 relative; + @apply relative !z-10 !border-b-popover; } } .mapboxgl-popup-anchor-bottom { .mapboxgl-popup-tip { - @apply !border-t-popover !z-10 relative; + @apply relative !z-10 !border-t-popover; } } .mapboxgl-popup-anchor-left { .mapboxgl-popup-tip { - @apply !border-r-popover !z-10 relative; + @apply relative !z-10 !border-r-popover; } } .mapboxgl-popup-anchor-right { .mapboxgl-popup-tip { - @apply !border-l-popover !z-10 relative; + @apply relative !z-10 !border-l-popover; } } .mapboxgl-popup-content { - @apply !bg-popover !rounded-md border border-border text-big-stone-50 shadow-md; + @apply !rounded-md border border-border !bg-popover text-big-stone-50 shadow-md; } .mapboxgl-popup-close-button { @apply hidden; -} \ No newline at end of file +} + +/* cashflow chart */ +.cashflow-chart { + .recharts-cartesian-grid-vertical line { + stroke-dasharray: 3, 3; + + &:nth-last-child(1), + &:nth-last-child(2) { + stroke: transparent; + } + } + + .recharts-cartesian-grid-horizontal line { + stroke-dasharray: 0; + + &:first-of-type, + &:last-of-type { + stroke: transparent; + } + } +} diff --git a/client/src/app/projects/[id]/page.tsx b/client/src/app/projects/[id]/page.tsx index c85b17fe..fd1cdb7f 100644 --- a/client/src/app/projects/[id]/page.tsx +++ b/client/src/app/projects/[id]/page.tsx @@ -1,5 +1,65 @@ +import { notFound } from "next/navigation"; + +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from "@tanstack/react-query"; + +import { client } from "@/lib/query-client"; +import { queryKeys } from "@/lib/query-keys"; +import { getAuthHeader } from "@/lib/utils"; + +import { auth } from "@/app/auth/api/[...nextauth]/config"; + import CustomProject from "@/containers/projects/custom-project"; -export default function CustomProjectPage() { - return ; +async function getCustomProject(id: string, accessToken: string) { + const response = await client.customProjects.getCustomProject.query({ + params: { id }, + query: { + include: ["country"], + }, + extraHeaders: { + ...getAuthHeader(accessToken), + }, + }); + + if (response.status !== 200) { + throw new Error(`Failed to fetch project: ${response.status}`); + } + + return response; +} + +export default async function CustomProjectPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const queryClient = new QueryClient(); + + try { + const id = (await params).id; + const session = await auth(); + + if (!session?.accessToken) { + throw new Error("Unauthorized"); + } + + // Using fetchQuery because prefetchQuery will not throw or return any data + // https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientprefetchquery + await queryClient.fetchQuery({ + queryKey: queryKeys.customProjects.one(id).queryKey, + queryFn: async () => getCustomProject(id, session.accessToken), + }); + + return ( + + + + ); + } catch (e) { + notFound(); + } } diff --git a/client/src/app/projects/preview/page.tsx b/client/src/app/projects/preview/page.tsx new file mode 100644 index 00000000..69fc3a1d --- /dev/null +++ b/client/src/app/projects/preview/page.tsx @@ -0,0 +1,17 @@ +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from "@tanstack/react-query"; + +import CustomProject from "@/containers/projects/custom-project"; + +export default function CustomProjectPreviewPage() { + const queryClient = new QueryClient(); + + return ( + + + + ); +} diff --git a/client/src/app/projects/store.ts b/client/src/app/projects/store.ts new file mode 100644 index 00000000..3624fb32 --- /dev/null +++ b/client/src/app/projects/store.ts @@ -0,0 +1,12 @@ +import { COST_TYPE_SELECTOR } from "@shared/entities/projects.entity"; +import { atom } from "jotai"; + +export const projectsUIState = atom<{ + projectSummaryOpen: boolean; +}>({ + projectSummaryOpen: false, +}); +export const showCostDetailsAtom = atom(false); +export const costDetailsFilterAtom = atom( + COST_TYPE_SELECTOR.TOTAL, +); diff --git a/client/src/app/projects/[id]/store.ts b/client/src/app/projects/url-store.ts similarity index 51% rename from client/src/app/projects/[id]/store.ts rename to client/src/app/projects/url-store.ts index 9eb5bf4e..e421675e 100644 --- a/client/src/app/projects/[id]/store.ts +++ b/client/src/app/projects/url-store.ts @@ -1,18 +1,10 @@ -import { atom } from "jotai"; import { parseAsStringLiteral, useQueryState } from "nuqs"; import { CASH_FLOW_VIEWS } from "@/containers/projects/custom-project/annual-project-cash-flow/header/tabs"; -export const projectsUIState = atom<{ - projectSummaryOpen: boolean; -}>({ - projectSummaryOpen: false, -}); -export const showCostDetailsAtom = atom(false); - -export function useProjectCashFlowView() { +export function useProjectCashFlowTab() { return useQueryState( - "cashflow", + "cashflowTab", parseAsStringLiteral(CASH_FLOW_VIEWS).withDefault("chart"), ); } diff --git a/client/src/components/ui/chart.tsx b/client/src/components/ui/chart.tsx new file mode 100644 index 00000000..f0f6dd73 --- /dev/null +++ b/client/src/components/ui/chart.tsx @@ -0,0 +1,368 @@ +"use client"; + +import * as React from "react"; + +import * as RechartsPrimitive from "recharts"; + +import { cn } from "@/lib/utils"; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const; + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error("useChart must be used within a "); + } + + return context; +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"]; + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; + + return ( + +
+ + + {children} + +
+
+ ); +}); +ChartContainer.displayName = "Chart"; + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color, + ); + + if (!colorConfig.length) { + return null; + } + + return ( +