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 (
+