+
;
+}
diff --git a/client/src/containers/projects/form/cost-inputs-overrides/index.tsx b/client/src/containers/projects/form/cost-inputs-overrides/index.tsx
new file mode 100644
index 00000000..1b6e4d18
--- /dev/null
+++ b/client/src/containers/projects/form/cost-inputs-overrides/index.tsx
@@ -0,0 +1,3 @@
+export default function CostInputsOverridesProjectForm() {
+ return
CostInputsOverridesProjectForm
;
+}
diff --git a/client/src/containers/projects/form/index.tsx b/client/src/containers/projects/form/index.tsx
new file mode 100644
index 00000000..ec2f7583
--- /dev/null
+++ b/client/src/containers/projects/form/index.tsx
@@ -0,0 +1,21 @@
+import AssumptionsProjectForm from "@/containers/projects/form/assumptions";
+import CostInputsOverridesProjectForm from "@/containers/projects/form/cost-inputs-overrides";
+import SetupProjectForm from "@/containers/projects/form/setup";
+
+import { Card } from "@/components/ui/card";
+
+export default function ProjectForm() {
+ return (
+
+ );
+}
diff --git a/client/src/containers/projects/form/setup/conservation-project-details/index.tsx b/client/src/containers/projects/form/setup/conservation-project-details/index.tsx
new file mode 100644
index 00000000..b6b2c6d9
--- /dev/null
+++ b/client/src/containers/projects/form/setup/conservation-project-details/index.tsx
@@ -0,0 +1,68 @@
+import * as React from "react";
+
+import {
+ PROJECT_SPECIFIC_EMISSION,
+ CARBON_REVENUES_TO_COVER,
+} from "@shared/entities/custom-project.entity";
+import { useAtom } from "jotai/index";
+
+import { projectFormState } from "@/app/projects/store";
+
+import { Card } from "@/components/ui/card";
+import {
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+
+export default function ConservationProjectDetails() {
+ const [{ setup: form }] = useAtom(projectFormState);
+
+ return (
+
+ Conservation project details
+
+ This information only applies for conservation projects
+
+
+ (
+
+
+ Initial carbon price assumption in $
+
+
+
+ {
+ form?.setValue(
+ "initialCarbonPriceAssumption",
+ Number(v.target.value),
+ );
+ await form?.trigger("initialCarbonPriceAssumption");
+ }}
+ />
+
+
+
+
+ )}
+ />
+
+ );
+}
diff --git a/client/src/containers/projects/form/setup/index.tsx b/client/src/containers/projects/form/setup/index.tsx
new file mode 100644
index 00000000..34347c4c
--- /dev/null
+++ b/client/src/containers/projects/form/setup/index.tsx
@@ -0,0 +1,313 @@
+"use client";
+
+import { useEffect } from "react";
+import * as React from "react";
+
+import { useForm } from "react-hook-form";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { ACTIVITY } from "@shared/entities/activity.enum";
+import { ECOSYSTEM } from "@shared/entities/ecosystem.enum";
+import {
+ CombinedCustomProjectSchema,
+ CreateCustomProjectSchema,
+} from "@shared/schemas/custom-projects/create-custom-project.schema";
+import { useAtom } from "jotai";
+import { z } from "zod";
+
+import { client } from "@/lib/query-client";
+import { queryKeys } from "@/lib/query-keys";
+
+import { projectFormState, setupFormRef } from "@/app/projects/store";
+
+import { ACTIVITIES } from "@/containers/overview/filters/constants";
+import ConservationProjectDetails from "@/containers/projects/form/setup/conservation-project-details";
+import RestorationProjectDetails from "@/containers/projects/form/setup/restoration-project-detail";
+
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { RadioGroup, RadioGroupItemBox } from "@/components/ui/radio-group";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+
+export type CreateCustomProjectForm = z.infer<
+ typeof CombinedCustomProjectSchema
+>;
+
+export default function SetupProjectForm() {
+ const [formStore, setForm] = useAtom(projectFormState);
+
+ const [, setSetupFormRef] = useAtom(setupFormRef);
+
+ const { queryKey } = queryKeys.projects.countries;
+ const { data: countryOptions } = client.projects.getProjectCountries.useQuery(
+ queryKey,
+ {},
+ {
+ queryKey,
+ select: (data) =>
+ data.body.data.map(({ name, code }) => ({ label: name, value: code })),
+ },
+ );
+
+ const form = useForm
({
+ resolver: zodResolver(CombinedCustomProjectSchema),
+ defaultValues: {
+ name: "",
+ activity: ACTIVITY.CONSERVATION,
+ ecosystem: ECOSYSTEM.SEAGRASS,
+ countryCode: countryOptions?.[0]?.value,
+ projectSize: 20,
+ // carbonRevenuesToCover: CARBON_REVENUES_TO_COVER.CAPEX_AND_OPEX,
+ initialCarbonPriceAssumption: 1000,
+ },
+ });
+
+ useEffect(() => {
+ setForm((prev) => ({ ...prev, setup: form }));
+ }, [form, setForm]);
+
+ console.log(form.getValues());
+
+ return (
+
+
+ );
+}
diff --git a/client/src/containers/projects/form/setup/restoration-project-detail/index.tsx b/client/src/containers/projects/form/setup/restoration-project-detail/index.tsx
new file mode 100644
index 00000000..d8ba4067
--- /dev/null
+++ b/client/src/containers/projects/form/setup/restoration-project-detail/index.tsx
@@ -0,0 +1,11 @@
+import { useAtom } from "jotai/index";
+
+import { projectFormState } from "@/app/projects/store";
+
+import { Card } from "@/components/ui/card";
+
+export default function RestorationProjectDetails() {
+ const [formStore] = useAtom(projectFormState);
+
+ return RestorationProjectDetails TBD;
+}
diff --git a/client/src/containers/projects/new/header.tsx b/client/src/containers/projects/new/header.tsx
new file mode 100644
index 00000000..27c5157a
--- /dev/null
+++ b/client/src/containers/projects/new/header.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { FormProvider } from "react-hook-form";
+
+import { useAtom } from "jotai";
+
+import { projectFormState, setupFormRef } from "@/app/projects/store";
+
+import { Button } from "@/components/ui/button";
+import { SidebarTrigger } from "@/components/ui/sidebar";
+
+export default function Header() {
+ const [formStore] = useAtom(projectFormState);
+ const [setupRef] = useAtom(setupFormRef);
+
+ console.log({ setupRef });
+
+ console.log(formStore.setup?.formState.errors);
+
+ return (
+
+
+
+
Custom project
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/containers/projects/new/index.tsx b/client/src/containers/projects/new/index.tsx
new file mode 100644
index 00000000..2c28129d
--- /dev/null
+++ b/client/src/containers/projects/new/index.tsx
@@ -0,0 +1,21 @@
+import ProjectForm from "@/containers/projects/form";
+import Header from "@/containers/projects/new/header";
+import ProjectSidebar from "@/containers/projects/new/sidebar";
+
+import { ScrollArea } from "@/components/ui/scroll-area";
+
+export default function CreateCustomProject() {
+ return (
+
+ );
+}
diff --git a/client/src/containers/projects/new/sidebar.tsx b/client/src/containers/projects/new/sidebar.tsx
new file mode 100644
index 00000000..3d538179
--- /dev/null
+++ b/client/src/containers/projects/new/sidebar.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import Link from "next/link";
+import { useSearchParams } from "next/navigation";
+
+import { InfoIcon } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+
+const PROJECT_SETUP_STEPS = [
+ {
+ name: "Project setup",
+ link: "/projects/new?step=setup",
+ optional: false,
+ },
+ {
+ name: "Assumptions",
+ link: "/projects/new?step=assumptions",
+ optional: true,
+ },
+ {
+ name: "Cost inputs overrides",
+ link: "/projects/new?step=cost-inputs-overrides",
+ optional: true,
+ },
+];
+
+export default function ProjectSidebar() {
+ const searchParams = useSearchParams();
+ const currentStep = searchParams.get("step");
+
+ return (
+
+ );
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9bd51308..20bc47b7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -306,6 +306,9 @@ importers:
'@radix-ui/react-popover':
specifier: 1.1.2
version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-radio-group':
+ specifier: 1.2.1
+ version: 1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-scroll-area':
specifier: 1.2.0
version: 1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -2329,6 +2332,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-radio-group@1.2.1':
+ resolution: {integrity: sha512-kdbv54g4vfRjja9DNWPMxKvXblzqbpEC8kspEkZ6dVP7kQksGCn+iZHkcCz2nb00+lPdRvxrqy4WrvvV1cNqrQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-roving-focus@1.1.0':
resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==}
peerDependencies:
@@ -10394,6 +10410,24 @@ snapshots:
'@types/react': 18.3.5
'@types/react-dom': 18.3.0
+ '@radix-ui/react-radio-group@1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.0
+ '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.5)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.1(@types/react@18.3.5)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.0(@types/react@18.3.5)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-roving-focus': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.5)(react@18.3.1)
+ '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.5)(react@18.3.1)
+ '@radix-ui/react-use-size': 1.1.0(@types/react@18.3.5)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.5
+ '@types/react-dom': 18.3.0
+
'@radix-ui/react-roving-focus@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.0
diff --git a/shared/entities/custom-project.entity.ts b/shared/entities/custom-project.entity.ts
index 9cc131a7..8a2c32a3 100644
--- a/shared/entities/custom-project.entity.ts
+++ b/shared/entities/custom-project.entity.ts
@@ -7,6 +7,22 @@ import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
* The shape defined here is probably wrong, it's only based on the output of the prototype in the notebooks, and it will only serve as a learning resource.
*/
+export enum CARBON_REVENUES_TO_COVER {
+ OPEX = 'Opex',
+ CAPEX_AND_OPEX = 'Capex and Opex',
+}
+
+export enum PROJECT_SPECIFIC_EMISSION {
+ ONE_EMISSION_FACTOR = 'One emission factor',
+ TWO_EMISSION_FACTORS = 'Two emission factors',
+}
+export enum PROJECT_EMISSION_FACTORS {
+ TIER_3 = 'Tier 3 - Project specific emission factor',
+ TIER_2 = 'Tier 2 - Country-specific emission factor',
+ TIER_1 = 'Tier 1 - Global emission factor',
+}
+
+
@Entity({ name: "custom_projects" })
export class CustomProject {
@PrimaryGeneratedColumn()
diff --git a/shared/schemas/custom-projects/create-custom-project.schema.ts b/shared/schemas/custom-projects/create-custom-project.schema.ts
index 9785bcb3..6a51481f 100644
--- a/shared/schemas/custom-projects/create-custom-project.schema.ts
+++ b/shared/schemas/custom-projects/create-custom-project.schema.ts
@@ -1,6 +1,7 @@
import { z } from "zod";
import { ECOSYSTEM } from "@shared/entities/ecosystem.enum";
import { ACTIVITY } from "@shared/entities/activity.enum";
+import { CARBON_REVENUES_TO_COVER} from "@shared/entities/custom-project.entity";
/**
* @description: WIP: Prototype for creating a custom project. This should include optional overrides for default assumptions, cost inputs etc
@@ -11,8 +12,11 @@ export const CreateCustomProjectSchema = z.object({
name: z.string().min(3).max(255),
ecosystem: z.nativeEnum(ECOSYSTEM),
activity: z.nativeEnum(ACTIVITY),
+ projectSize: z.number().positive(),
+ carbonRevenuesToCover: z.nativeEnum(CARBON_REVENUES_TO_COVER),
+ initialCarbonPriceAssumption: z.number().positive(),
// We need to include activity subtype here
-});
+})
export enum LOSS_RATE_USED {
NATIONAL_AVERAGE = "National average",
@@ -29,6 +33,43 @@ export const ConservationCustomProjectSchema = z
.number({ message: "Project Specific Loss Rate should be a message" })
.negative({ message: "Project Specific Loss Rate should be negative" })
.optional(),
+ // todo: delete me
+ exclusiveConservationProp: z.number().optional(),
+ })
+ .superRefine((data, ctx) => {
+ console.log("DATA", data);
+ if (
+ data.lossRateUsed === LOSS_RATE_USED.PROJECT_SPECIFIC &&
+ !data.projectSpecificLossRate
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Project Specific Loss Rate is required when lossRateUsed is ${LOSS_RATE_USED.PROJECT_SPECIFIC}`,
+ });
+ }
+ if (
+ data.lossRateUsed === LOSS_RATE_USED.NATIONAL_AVERAGE &&
+ data.projectSpecificLossRate
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Project Specific Loss Rate should not be provided when lossRateUsed is ${LOSS_RATE_USED.NATIONAL_AVERAGE}`,
+ });
+ }
+ });
+
+export const RestorationCustomProjectSchema = z
+ .object({
+ activity: z.literal(ACTIVITY.RESTORATION),
+ countryCode: z.string().min(3).max(3),
+ ecosystem: z.nativeEnum(ECOSYSTEM),
+ lossRateUsed: z.nativeEnum(LOSS_RATE_USED),
+ projectSpecificLossRate: z
+ .number({ message: "Project Specific Loss Rate should be a message" })
+ .negative({ message: "Project Specific Loss Rate should be negative" })
+ .optional(),
+ // todo: delete me
+ exclusiveRestorationProp: z.number().optional(),
})
.superRefine((data, ctx) => {
console.log("DATA", data);
@@ -54,11 +95,28 @@ export const ConservationCustomProjectSchema = z
// TODO: Work on having a conditionally validated schema based on multiple conditions
-export const CombinedCustomProjectSchema = z.union([
- CreateCustomProjectSchema.extend({
- activity: z.literal(ACTIVITY.CONSERVATION),
- }).and(ConservationCustomProjectSchema),
- CreateCustomProjectSchema.extend({
- activity: z.literal(ACTIVITY.RESTORATION),
- }),
-]);
+// export const CombinedCustomProjectSchema = z.union([
+// CreateCustomProjectSchema.extend({
+// activity: z.literal(ACTIVITY.CONSERVATION),
+// }).and(ConservationCustomProjectSchema),
+// CreateCustomProjectSchema.extend({
+// activity: z.literal(ACTIVITY.RESTORATION),
+// }).and(RestorationCustomProjectSchema),
+// ]);
+
+
+export const CombinedCustomProjectSchema = CreateCustomProjectSchema.extend({
+ activity: z.union([
+ z.literal(ACTIVITY.CONSERVATION),
+ z.literal(ACTIVITY.RESTORATION),
+ ]),
+}).superRefine((data, ctx) => {
+ console.log(data, ctx)
+ if (data.activity === ACTIVITY.CONSERVATION) {
+ return CreateCustomProjectSchema.and(ConservationCustomProjectSchema).parse(data);
+ // ConservationCustomProjectSchema.parse(data);
+ } else if (data.activity === ACTIVITY.RESTORATION) {
+ // RestorationCustomProjectSchema.parse(data);
+ return CreateCustomProjectSchema.and(RestorationCustomProjectSchema).parse(data);
+ }
+});
\ No newline at end of file