From 07393da7850fbf172ea9c5c8d28efa8a323f8c8e Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 13:53:16 +0545 Subject: [PATCH 01/35] feat: prisma migration --- .../migration.sql | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 prisma/migrations/20240518062928_unique_company_id_certificate_id_in_share_model/migration.sql diff --git a/prisma/migrations/20240518062928_unique_company_id_certificate_id_in_share_model/migration.sql b/prisma/migrations/20240518062928_unique_company_id_certificate_id_in_share_model/migration.sql new file mode 100644 index 000000000..39746aa40 --- /dev/null +++ b/prisma/migrations/20240518062928_unique_company_id_certificate_id_in_share_model/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[companyId,certificateId]` on the table `Share` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "Share_companyId_certificateId_key" ON "Share"("companyId", "certificateId"); From b298dfa2b4a03a51eebc609192d5424bb1aaedd6 Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 13:54:11 +0545 Subject: [PATCH 02/35] feat: add unique property in share table --- prisma/schema.prisma | 1 + 1 file changed, 1 insertion(+) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 952074821..94842da10 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -659,6 +659,7 @@ model Share { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + @@unique([companyId,certificateId]) @@index([companyId]) @@index([shareClassId]) @@index([stakeholderId]) From 7aa5a389cdc4102146fd71084d8ae6bbb3620cd2 Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 13:54:45 +0545 Subject: [PATCH 03/35] feat: add share procedure --- .../securities-router/procedures/add-share.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/trpc/routers/securities-router/procedures/add-share.ts diff --git a/src/trpc/routers/securities-router/procedures/add-share.ts b/src/trpc/routers/securities-router/procedures/add-share.ts new file mode 100644 index 000000000..644528250 --- /dev/null +++ b/src/trpc/routers/securities-router/procedures/add-share.ts @@ -0,0 +1,86 @@ +import { generatePublicId } from "@/common/id"; +import { Audit } from "@/server/audit"; +import { checkMembership } from "@/server/auth"; +import { withAuth } from "@/trpc/api/trpc"; +import { ZodAddShareMutationSchema } from "../schema"; + +export const addShareProcedure = withAuth + .input(ZodAddShareMutationSchema) + .mutation(async ({ ctx, input }) => { + console.log({ input }, "#############"); + + const { userAgent, requestIp } = ctx; + + try { + const user = ctx.session.user; + const documents = input.documents; + + await ctx.db.$transaction(async (tx) => { + const { companyId } = await checkMembership({ + session: ctx.session, + tx, + }); + + const data = { + companyId, + stakeholderId: input.stakeholderId, + shareClassId: input.shareClassId, + status: input.status, + certificateId: input.certificateId, + quantity: input.quantity, + pricePerShare: input.pricePerShare, + capitalContribution: input.capitalContribution, + ipContribution: input.ipContribution, + debtCancelled: input.debtCancelled, + otherContributions: input.otherContributions, + vestingSchedule: input.vestingSchedule, + companyLegends: input.companyLegends, + issueDate: new Date(input.issueDate), + rule144Date: new Date(input.rule144Date), + vestingStartDate: new Date(input.vestingStartDate), + boardApprovalDate: new Date(input.boardApprovalDate), + }; + const share = await tx.share.create({ data }); + + const bulkDocuments = documents.map((doc) => ({ + companyId, + uploaderId: user.memberId, + publicId: generatePublicId(), + name: doc.name, + bucketId: doc.bucketId, + shareId: share.id, + })); + + await tx.document.createMany({ + data: bulkDocuments, + skipDuplicates: true, + }); + + await Audit.create( + { + action: "share.created", + companyId: user.companyId, + actor: { type: "user", id: user.id }, + context: { + userAgent, + requestIp, + }, + target: [{ type: "share", id: share.id }], + summary: `${user.name} added share for stakeholder ${input.stakeholderId}`, + }, + tx, + ); + }); + + return { + success: true, + message: "🎉 Successfully added a share", + }; + } catch (error) { + console.error("Error adding shares: ", error); + return { + success: false, + message: "Please use unique Certificate Id.", + }; + } + }); From 0ee88e02d832441a94f4271b69f0576913905ccb Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 13:55:51 +0545 Subject: [PATCH 04/35] feat: get shares procedure --- .../procedures/get-shares.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/trpc/routers/securities-router/procedures/get-shares.ts diff --git a/src/trpc/routers/securities-router/procedures/get-shares.ts b/src/trpc/routers/securities-router/procedures/get-shares.ts new file mode 100644 index 000000000..ead84143e --- /dev/null +++ b/src/trpc/routers/securities-router/procedures/get-shares.ts @@ -0,0 +1,71 @@ +import { checkMembership } from "@/server/auth"; +import { withAuth } from "@/trpc/api/trpc"; + +export const getSharesProcedure = withAuth.query( + async ({ ctx: { db, session } }) => { + const data = await db.$transaction(async (tx) => { + const { companyId } = await checkMembership({ session, tx }); + + const shares = await tx.share.findMany({ + where: { + companyId, + }, + select: { + id: true, + certificateId: true, + quantity: true, + pricePerShare: true, + capitalContribution: true, + ipContribution: true, + debtCancelled: true, + otherContributions: true, + vestingSchedule: true, + companyLegends: true, + status: true, + + issueDate: true, + rule144Date: true, + vestingStartDate: true, + boardApprovalDate: true, + stakeholder: { + select: { + name: true, + }, + }, + shareClass: { + select: { + classType: true, + }, + }, + documents: { + select: { + id: true, + name: true, + uploader: { + select: { + user: { + select: { + name: true, + image: true, + }, + }, + }, + }, + bucket: { + select: { + key: true, + mimeType: true, + size: true, + }, + }, + }, + }, + }, + }); + + return shares; + }); + + return { data }; + }, +); From 32c93d90a6cfd034d148e06de3a6a2975e4ce41b Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 13:56:18 +0545 Subject: [PATCH 05/35] feat: delete share procedure --- .../procedures/delete-share.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/trpc/routers/securities-router/procedures/delete-share.ts diff --git a/src/trpc/routers/securities-router/procedures/delete-share.ts b/src/trpc/routers/securities-router/procedures/delete-share.ts new file mode 100644 index 000000000..e1f2154a7 --- /dev/null +++ b/src/trpc/routers/securities-router/procedures/delete-share.ts @@ -0,0 +1,75 @@ +import { Audit } from "@/server/audit"; +import { checkMembership } from "@/server/auth"; +import { withAuth, type withAuthTrpcContextType } from "@/trpc/api/trpc"; +import { + type TypeZodDeleteShareMutationSchema, + ZodDeleteShareMutationSchema, +} from "../schema"; + +export const deleteShareProcedure = withAuth + .input(ZodDeleteShareMutationSchema) + .mutation(async (args) => { + return await deleteShareHandler(args); + }); + +interface deleteShareHandlerOptions { + input: TypeZodDeleteShareMutationSchema; + ctx: withAuthTrpcContextType; +} + +export async function deleteShareHandler({ + ctx: { db, session, requestIp, userAgent }, + input, +}: deleteShareHandlerOptions) { + const user = session.user; + const { shareId } = input; + try { + await db.$transaction(async (tx) => { + const { companyId } = await checkMembership({ session, tx }); + + const share = await tx.share.delete({ + where: { + id: shareId, + companyId, + }, + select: { + id: true, + stakeholder: { + select: { + id: true, + name: true, + }, + }, + company: { + select: { + name: true, + }, + }, + }, + }); + + await Audit.create( + { + action: "share.deleted", + companyId: user.companyId, + actor: { type: "user", id: session.user.id }, + context: { + requestIp, + userAgent, + }, + target: [{ type: "share", id: share.id }], + summary: `${user.name} deleted share of stakholder ${share.stakeholder.name}`, + }, + tx, + ); + }); + + return { success: true }; + } catch (err) { + console.error(err); + return { + success: false, + message: "Oops, something went wrong while deleting option.", + }; + } +} From 450c2b706fdee6b746896fbc5d4d3c16fe1aa7fe Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 14:10:13 +0545 Subject: [PATCH 06/35] fix: conflict in option-modal --- .../securities/options/option-modal.tsx | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/src/components/securities/options/option-modal.tsx b/src/components/securities/options/option-modal.tsx index 1be7db67a..15baf1a78 100644 --- a/src/components/securities/options/option-modal.tsx +++ b/src/components/securities/options/option-modal.tsx @@ -1,4 +1,5 @@ import { +<<<<<<< HEAD StepperModal, StepperModalContent, type StepperModalProps, @@ -47,5 +48,107 @@ export const OptionModal = (props: Omit) => { +======= + type TypeZodAddOptionMutationSchema, + ZodAddOptionMutationSchema, +} from "@/trpc/routers/securities-router/schema"; +import { useRouter } from "next/navigation"; +import type React from "react"; +import { useState } from "react"; +import { + Documents, + GeneralDetails, + GeneralDetailsField, + RelevantDates, + RelevantDatesFields, + VestingDetails, + VestingDetailsFields, +} from "./steps"; + +type OptionModalProps = { + title: string; + subtitle: string | React.ReactNode; + trigger: string | React.ReactNode; +}; + +const OptionModal = ({ title, subtitle, trigger }: OptionModalProps) => { + const [open, setOpen] = useState(false); + const { toast } = useToast(); + const router = useRouter(); + + const addOptionMutation = api.securities.addOptions.useMutation({ + onSuccess: ({ message, success }) => { + toast({ + variant: success ? "default" : "destructive", + title: message, + description: success + ? "A new stakeholder option has been created." + : "Failed adding an option. Please try again.", + }); + setOpen(false); + if (success) { + router.refresh(); + } + }, + }); + + const steps = [ + { + id: 1, + title: "General details", + component: GeneralDetails, + fields: GeneralDetailsField, + }, + { + id: 2, + title: "Vesting details", + component: VestingDetails, + fields: VestingDetailsFields, + }, + { + id: 3, + title: "Relevant dates", + component: RelevantDates, + fields: RelevantDatesFields, + }, + { + id: 4, + title: "Documents", + component: Documents, + fields: ["documents"], + }, + ]; + + const onSubmit = async (data: TypeZodAddOptionMutationSchema) => { + if (data?.documents.length === 0) { + toast({ + variant: "destructive", + title: "Uh ohh! Documents not found", + description: "Please upload necessary documents", + }); + return; + } + await addOptionMutation.mutateAsync(data); + }; + + return ( +
+ { + setOpen(val); + }, + }} + schema={ZodAddOptionMutationSchema} + onSubmit={onSubmit} + /> +
+>>>>>>> 66bc51c (chore: humanize vesting schedule) ); }; From 2a232f5680e557e4bc365fb4ce65afc5e7c83ad0 Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 14:11:40 +0545 Subject: [PATCH 07/35] feat: schema updation for share feature --- src/trpc/routers/securities-router/schema.ts | 71 ++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/trpc/routers/securities-router/schema.ts b/src/trpc/routers/securities-router/schema.ts index 140fa6b19..fdac47531 100644 --- a/src/trpc/routers/securities-router/schema.ts +++ b/src/trpc/routers/securities-router/schema.ts @@ -1,10 +1,13 @@ import { OptionStatusEnum, OptionTypeEnum, + ShareLegendsEnum, VestingScheduleEnum, } from "@/prisma/enums"; +import { SecuritiesStatusEnum } from "@prisma/client"; import { z } from "zod"; +// OPTIONS export const ZodAddOptionMutationSchema = z.object({ id: z.string().optional(), grantId: z.string(), @@ -40,3 +43,71 @@ export const ZodDeleteOptionMutationSchema = z.object({ export type TypeZodDeleteOptionMutationSchema = z.infer< typeof ZodDeleteOptionMutationSchema >; + +// SHARES +export const ZodAddShareMutationSchema = z.object({ + id: z.string().optional(), + status: z.nativeEnum(SecuritiesStatusEnum, { + errorMap: () => ({ message: "Invalid value for status type" }), + }), + certificateId: z.string().min(1, { + message: "Certificate ID is required", + }), + quantity: z.coerce.number().min(0, { + message: "Quantity is required", + }), + pricePerShare: z.coerce.number().optional(), + capitalContribution: z.coerce.number().min(0, { + message: "Capital contribution is required", + }), + ipContribution: z.coerce.number().optional(), + debtCancelled: z.coerce.number().optional(), + otherContributions: z.coerce.number().optional(), + vestingSchedule: z.nativeEnum(VestingScheduleEnum, { + errorMap: () => ({ message: "Invalid value for vesting schedule" }), + }), + // companyLegends: z.array(z.string()), + companyLegends: z + .nativeEnum(ShareLegendsEnum, { + errorMap: () => ({ message: "Invalid value for compnay legends" }), + }) + .array(), + issueDate: z.coerce.date({ + required_error: "Issue date is required", + invalid_type_error: "This is not valid date", + }), + rule144Date: z.coerce.date({ + invalid_type_error: "This is not a valid date", + }), + vestingStartDate: z.coerce.date({ + invalid_type_error: "This is not a valid date", + }), + boardApprovalDate: z.coerce.date({ + required_error: "Board approval date is required", + invalid_type_error: "This is not valid date", + }), + documents: z.array( + z.object({ + bucketId: z.string(), + name: z.string(), + }), + ), + stakeholderId: z.string().min(1, { + message: "Stakeholder is required", + }), + shareClassId: z.string().min(1, { + message: "Share Class is required", + }), +}); + +export type TypeZodAddShareMutationSchema = z.infer< + typeof ZodAddShareMutationSchema +>; + +export const ZodDeleteShareMutationSchema = z.object({ + shareId: z.string(), +}); + +export type TypeZodDeleteShareMutationSchema = z.infer< + typeof ZodDeleteShareMutationSchema +>; From 5a31a4a7d7f2cf71dedaae3933a89fc34d24d5c0 Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 14:13:43 +0545 Subject: [PATCH 08/35] feat: add share events in audit schema --- src/server/audit/schema.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/server/audit/schema.ts b/src/server/audit/schema.ts index 4e2e66aed..4f08b04bb 100644 --- a/src/server/audit/schema.ts +++ b/src/server/audit/schema.ts @@ -31,6 +31,10 @@ export const AuditSchema = z.object({ "option.created", "option.deleted", + "share.created", + "share.updated", + "share.deleted", + "safe.created", "safe.imported", "safe.sent", @@ -47,7 +51,14 @@ export const AuditSchema = z.object({ target: z.array( z.object({ - type: z.enum(["user", "company", "document", "option", "documentShare"]), + type: z.enum([ + "user", + "company", + "document", + "option", + "documentShare", + "share", + ]), id: z.string().optional().nullable(), }), ), From 6a1a260a43bb7b68cfd902c9fb347d472aefee4e Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 14:42:59 +0545 Subject: [PATCH 09/35] chore: also get company-name with stakeholder info --- .../stakeholder-router/procedures/get-stakeholders.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/trpc/routers/stakeholder-router/procedures/get-stakeholders.ts b/src/trpc/routers/stakeholder-router/procedures/get-stakeholders.ts index c1768827a..247a260c8 100644 --- a/src/trpc/routers/stakeholder-router/procedures/get-stakeholders.ts +++ b/src/trpc/routers/stakeholder-router/procedures/get-stakeholders.ts @@ -11,7 +11,13 @@ export const getStakeholdersProcedure = withAuth.query(async ({ ctx }) => { where: { companyId, }, - + include: { + company: { + select: { + name: true, + }, + }, + }, orderBy: { createdAt: "desc", }, From 20744fad7efb41fc38834b2ad0c6cee29bafc1bf Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 14:47:33 +0545 Subject: [PATCH 10/35] feat: share-table toolbar --- src/components/securities/shares/data.tsx | 7 +++ .../securities/shares/share-table-toolbar.tsx | 48 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 src/components/securities/shares/data.tsx create mode 100644 src/components/securities/shares/share-table-toolbar.tsx diff --git a/src/components/securities/shares/data.tsx b/src/components/securities/shares/data.tsx new file mode 100644 index 000000000..31b00dba6 --- /dev/null +++ b/src/components/securities/shares/data.tsx @@ -0,0 +1,7 @@ +import { SecuritiesStatusEnum } from "@prisma/client"; +import { capitalize } from "lodash-es"; + +export const statusValues = Object.keys(SecuritiesStatusEnum).map((item) => ({ + label: capitalize(item), + value: item, +})); diff --git a/src/components/securities/shares/share-table-toolbar.tsx b/src/components/securities/shares/share-table-toolbar.tsx new file mode 100644 index 000000000..7836bd58c --- /dev/null +++ b/src/components/securities/shares/share-table-toolbar.tsx @@ -0,0 +1,48 @@ +import { useDataTable } from "@/components/ui/data-table/data-table"; +import { ResetButton } from "@/components/ui/data-table/data-table-buttons"; +import { DataTableFacetedFilter } from "@/components/ui/data-table/data-table-faceted-filter"; +import { DataTableViewOptions } from "@/components/ui/data-table/data-table-view-options"; +import { Input } from "@/components/ui/input"; +import { statusValues } from "./data"; + +export function ShareTableToolbar() { + const { table } = useDataTable(); + const isFiltered = table.getState().columnFilters.length > 0; + + return ( +
+
+ + table + .getColumn("stakeholderName") + ?.setFilterValue(event.target.value) + } + className="h-8 w-64" + /> +
+ {table.getColumn("status") && ( + + )} + + {isFiltered && ( + table.resetColumnFilters()} + /> + )} +
+
+ +
+ ); +} From ca643a7d02175bdd476b2996609c9d3727e76626 Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 14:48:58 +0545 Subject: [PATCH 11/35] feat: register share procedures in router --- src/trpc/routers/securities-router/router.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/trpc/routers/securities-router/router.ts b/src/trpc/routers/securities-router/router.ts index c352cc7a2..cf6d1ab1b 100644 --- a/src/trpc/routers/securities-router/router.ts +++ b/src/trpc/routers/securities-router/router.ts @@ -1,10 +1,16 @@ import { createTRPCRouter } from "@/trpc/api/trpc"; import { addOptionProcedure } from "./procedures/add-option"; +import { addShareProcedure } from "./procedures/add-share"; import { deleteOptionProcedure } from "./procedures/delete-option"; +import { deleteShareProcedure } from "./procedures/delete-share"; import { getOptionsProcedure } from "./procedures/get-options"; +import { getSharesProcedure } from "./procedures/get-shares"; export const securitiesRouter = createTRPCRouter({ getOptions: getOptionsProcedure, addOptions: addOptionProcedure, deleteOption: deleteOptionProcedure, + getShares: getSharesProcedure, + addShares: addShareProcedure, + deleteShare: deleteShareProcedure, }); From 8d82433671122f486abdbce6ba14cc98acdd0a23 Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 14:51:14 +0545 Subject: [PATCH 12/35] feat: share-modal --- .../securities/shares/share-modal.tsx | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/components/securities/shares/share-modal.tsx diff --git a/src/components/securities/shares/share-modal.tsx b/src/components/securities/shares/share-modal.tsx new file mode 100644 index 000000000..a9f69b596 --- /dev/null +++ b/src/components/securities/shares/share-modal.tsx @@ -0,0 +1,94 @@ +"use client"; + +import MultiStepModal from "@/components/common/multistep-modal"; +import { useToast } from "@/components/ui/use-toast"; +import { api } from "@/trpc/react"; +import { + type TypeZodAddShareMutationSchema, + ZodAddShareMutationSchema, +} from "@/trpc/routers/securities-router/schema"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { + ContributionDetails, + ContributionDetailsField, + DocumentFields, + Documents, + GeneralDetails, + GeneralDetailsField, + RelevantDates, + RelevantDatesFields, +} from "./steps"; + +type ShareModalProps = { + title: string; + subtitle: string | React.ReactNode; + trigger: string | React.ReactNode; +}; + +const ShareModal = ({ title, subtitle, trigger }: ShareModalProps) => { + const [open, setOpen] = useState(false); + const { toast } = useToast(); + const router = useRouter(); + const addShareMutation = api.securities.addShares.useMutation({ + onSuccess: ({ message, success }) => { + toast({ + variant: success ? "default" : "destructive", + title: message, + description: success + ? "A new share has been created." + : "Failed adding a share. Please try again.", + }); + if (success) { + router.refresh(); + } + }, + }); + + const steps = [ + { + id: 1, + title: "General details", + component: GeneralDetails, + fields: GeneralDetailsField, + }, + { + id: 2, + title: "Contribution details", + component: ContributionDetails, + fields: ContributionDetailsField, + }, + { + id: 3, + title: "Relevant dates", + component: RelevantDates, + fields: RelevantDatesFields, + }, + { + id: 4, + title: "Upload Documents", + component: Documents, + fields: DocumentFields, + }, + ]; + + const onSubmit = async (data: TypeZodAddShareMutationSchema) => { + await addShareMutation.mutateAsync(data); + }; + + return ( +
+ setOpen(val) }} + /> +
+ ); +}; + +export default ShareModal; From cbea43c261fa334208543b1df90b111d30a175a7 Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 14:56:48 +0545 Subject: [PATCH 13/35] feat: index file exporting all steps --- src/components/securities/shares/steps/index.ts | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/components/securities/shares/steps/index.ts diff --git a/src/components/securities/shares/steps/index.ts b/src/components/securities/shares/steps/index.ts new file mode 100644 index 000000000..ed7941e04 --- /dev/null +++ b/src/components/securities/shares/steps/index.ts @@ -0,0 +1,4 @@ +export * from "./contribution-details"; +export * from "./documents"; +export * from "./general-details"; +export * from "./relevant-dates"; From 052066ff1113b03afa4a56b9b8cbf99f6dd0c354 Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 15:02:33 +0545 Subject: [PATCH 14/35] feat: general-details step --- .../shares/steps/general-details.tsx | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 src/components/securities/shares/steps/general-details.tsx diff --git a/src/components/securities/shares/steps/general-details.tsx b/src/components/securities/shares/steps/general-details.tsx new file mode 100644 index 000000000..5e2790aae --- /dev/null +++ b/src/components/securities/shares/steps/general-details.tsx @@ -0,0 +1,275 @@ +import { LoadingSpinner } from "@/components/common/LoadingSpinner"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorInput, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +} from "@/components/ui/simple-multi-select"; +import { + SecuritiesStatusEnum, + ShareLegendsEnum, + VestingScheduleEnum, +} from "@/prisma/enums"; +import { api } from "@/trpc/react"; +import { useMemo } from "react"; +import { useFormContext } from "react-hook-form"; + +export const humanizeCompanyLegends = (type: string): string => { + switch (type) { + case ShareLegendsEnum.US_SECURITIES_ACT: + return "Us securities act"; + case ShareLegendsEnum.TRANSFER_RESTRICTIONS: + return "Transfer restrictions"; + case ShareLegendsEnum.SALE_AND_ROFR: + return "Sale and Rofr"; + default: + return ""; + } +}; + +export const humanizeVestingSchedule = (type: string): string => { + switch (type) { + case VestingScheduleEnum.VESTING_0_0_0: + return "Immediate vesting"; + case VestingScheduleEnum.VESTING_0_0_1: + return "1 year cliff with no vesting"; + case VestingScheduleEnum.VESTING_4_1_0: + return "4 years vesting every month with no cliff"; + case VestingScheduleEnum.VESTING_4_1_1: + return "4 years vesting every month with 1 year cliff"; + case VestingScheduleEnum.VESTING_4_3_1: + return "4 years vesting every 3 months with 1 year cliff"; + case VestingScheduleEnum.VESTING_4_6_1: + return "4 years vesting every 6 months with 1 year cliff"; + case VestingScheduleEnum.VESTING_4_12_1: + return "4 years vesting every year with 1 year cliff"; + default: + return ""; + } +}; + +export const GeneralDetailsField = [ + "certificateId", + "status", + "quantity", + "pricePerShare", + "vestingSchedule", + "shareClassId", + "companyLegends", +]; + +export const GeneralDetails = () => { + const form = useFormContext(); + const shareClasses = api.shareClass.get.useQuery(); + + const status = useMemo( + () => + Object.values(SecuritiesStatusEnum).filter( + (value) => typeof value === "string", + ), + [], + ) as string[]; + + const vestingSchedule = useMemo( + () => + Object.values(VestingScheduleEnum).filter( + (value) => typeof value === "string", + ), + [], + ) as string[]; + + const companyLegends = useMemo( + () => + Object.values(ShareLegendsEnum).filter( + (value) => typeof value === "string", + ), + [], + ) as string[]; + + return ( +
+
+ ( + + Certificate ID + + + + + + )} + /> +
+
+
+ ( + + Share class + {/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment */} + + + + )} + /> +
+
+ ( + + Status + + + + )} + /> +
+
+ +
+
+ ( + + Quantity + + + + + + )} + /> +
+
+ ( + + Price per share + + + + + + )} + /> +
+
+ + ( + + Vesting schedule + + + + )} + /> + + ( + + Company Legends + + + + + + + {companyLegends.map((cl) => ( + + {humanizeCompanyLegends(cl)} + + ))} + + + + + )} + /> +
+ ); +}; From 9f0ae86a861a8be60ea91d90e911f692d192f71f Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 15:04:45 +0545 Subject: [PATCH 15/35] feat: relevant-dates step --- .../shares/steps/relevant-dates.tsx | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/components/securities/shares/steps/relevant-dates.tsx diff --git a/src/components/securities/shares/steps/relevant-dates.tsx b/src/components/securities/shares/steps/relevant-dates.tsx new file mode 100644 index 000000000..140d6317d --- /dev/null +++ b/src/components/securities/shares/steps/relevant-dates.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useFormContext } from "react-hook-form"; + +export const RelevantDatesFields = [ + "issueDate", + "vestingStartDate", + "boardApprovalDate", + "rule144Date", +]; + +export const RelevantDates = () => { + const form = useFormContext(); + + return ( + <> +
+
+
+ ( + + Issue date + + + + + + )} + /> +
+
+ ( + + Vesting start date + + + + + + )} + /> +
+
+ +
+
+ ( + + Board approval date + + + + + + )} + /> +
+
+ ( + + Rule 144 date + + + + + + )} + /> +
+
+
+ + ); +}; From 8f31ad80a40dfe7793caf586755c614e8a3c8643 Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 15:05:25 +0545 Subject: [PATCH 16/35] feat: contribution-details step --- .../shares/steps/contribution-details.tsx | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 src/components/securities/shares/steps/contribution-details.tsx diff --git a/src/components/securities/shares/steps/contribution-details.tsx b/src/components/securities/shares/steps/contribution-details.tsx new file mode 100644 index 000000000..cf5fdcb47 --- /dev/null +++ b/src/components/securities/shares/steps/contribution-details.tsx @@ -0,0 +1,129 @@ +import { LoadingSpinner } from "@/components/common/LoadingSpinner"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/trpc/react"; +import { useFormContext } from "react-hook-form"; + +export const ContributionDetailsField = [ + "capitalContribution", + "ipContribution", + "debtCancelled", + "otherContributions", + "stakeholderId", +]; + +export const ContributionDetails = () => { + const form = useFormContext(); + const stakeholders = api.stakeholder.getStakeholders.useQuery(); + + return ( +
+ ( + + Stakeholder + {/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment */} + + + + )} + /> +
+
+ ( + + Capital contribution + + + + + + )} + /> +
+
+ ( + + Intellectual property + + + + + + )} + /> +
+
+
+
+ ( + + Debt cancelled + + + + + + )} + /> +
+
+ ( + + Other contributions + + + + + + )} + /> +
+
+
+ ); +}; From 8450a1691dba6942e1ffa9a7e3ffbeb55b443f19 Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 15:10:11 +0545 Subject: [PATCH 17/35] feat: documents step --- .../securities/shares/steps/documents.tsx | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/components/securities/shares/steps/documents.tsx diff --git a/src/components/securities/shares/steps/documents.tsx b/src/components/securities/shares/steps/documents.tsx new file mode 100644 index 000000000..d1c784b3c --- /dev/null +++ b/src/components/securities/shares/steps/documents.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import Uploader from "@/components/ui/uploader"; +import { useFormContext } from "react-hook-form"; + +type Documents = { + bucketId: string; + name: string; +}; +export const DocumentFields = ["documents"]; +export const Documents = () => { + const form = useFormContext(); + //eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const documents: [Documents] = form.watch("documents"); + + return ( + <> + { + form.setValue("documents", [ + //eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + ...(form.getValues("documents") || []), + { + bucketId: bucketData.id, + name: bucketData.name, + }, + ]); + }} + accept={{ + "application/pdf": [".pdf"], + }} + /> + {documents?.length ? ( + + + {documents.length > 1 + ? `${documents.length} documents uploaded` + : `${documents.length} document uploaded`} + + + You can submit the form to proceed. + + + ) : ( + + 0 document uploaded + + Please upload necessary documents to continue. + + + )} + + ); +}; From b3c7712aca12fcdd273cc68263d3de3c78181e6d Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 15:29:53 +0545 Subject: [PATCH 18/35] feat: share-table rendering all share information --- .../securities/shares/share-table.tsx | 405 ++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 src/components/securities/shares/share-table.tsx diff --git a/src/components/securities/shares/share-table.tsx b/src/components/securities/shares/share-table.tsx new file mode 100644 index 000000000..b7da074b0 --- /dev/null +++ b/src/components/securities/shares/share-table.tsx @@ -0,0 +1,405 @@ +"use client"; + +import { + type ColumnDef, + type ColumnFiltersState, + type SortingState, + type VisibilityState, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import * as React from "react"; + +import { dayjsExt } from "@/common/dayjs"; +import { Checkbox } from "@/components/ui/checkbox"; + +import { Avatar, AvatarImage } from "@/components/ui/avatar"; +import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTableBody } from "@/components/ui/data-table/data-table-body"; +import { DataTableContent } from "@/components/ui/data-table/data-table-content"; +import { DataTableHeader } from "@/components/ui/data-table/data-table-header"; +import { DataTablePagination } from "@/components/ui/data-table/data-table-pagination"; +import type { RouterOutputs } from "@/trpc/shared"; + +import { Button } from "@/components/ui/button"; +import { SortButton } from "@/components/ui/data-table/data-table-buttons"; +import { + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { useToast } from "@/components/ui/use-toast"; +import { formatCurrency, formatNumber } from "@/lib/utils"; +import { getPresignedGetUrl } from "@/server/file-uploads"; +import { api } from "@/trpc/react"; +import { + DropdownMenu, + DropdownMenuTrigger, +} from "@radix-ui/react-dropdown-menu"; +import { RiFileDownloadLine, RiMoreLine } from "@remixicon/react"; +import { useRouter } from "next/navigation"; +import { ShareTableToolbar } from "./share-table-toolbar"; + +type Share = RouterOutputs["securities"]["getShares"]["data"]; + +type SharesType = { + shares: Share; +}; + +const humanizeShareStatus = (type: string) => { + switch (type) { + case "ACTIVE": + return "Active"; + case "DRAFT": + return "Draft"; + case "SIGNED": + return "Signed"; + case "PENDING": + return "Pending"; + default: + return ""; + } +}; + +const StatusColorProvider = (type: string) => { + switch (type) { + case "ACTIVE": + return "bg-green-50 text-green-400 ring-green-600/20"; + case "DRAFT": + return "bg-yellow-50 text-yellow-400 ring-yellow-600/20"; + case "SIGNED": + return "bg-blue-50 text-blue-400 ring-blue-600/20"; + case "PENDING": + return "bg-gray-50 text-gray-400 ring-gray-600/20"; + default: + return ""; + } +}; + +export const columns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + id: "stakeholderName", + accessorKey: "stakeholder.name", + header: ({ column }) => { + return ( + column.toggleSorting(column.getIsSorted() === "asc")} + /> + ); + }, + cell: ({ row }) => ( +
+ + + +
+

{row?.original?.stakeholder?.name}

+
+
+ ), + }, + { + id: "status", + accessorKey: "status", + header: ({ column }) => ( + column.toggleSorting(column?.getIsSorted() === "asc")} + /> + ), + cell: ({ row }) => { + const status = row.original?.status; + return ( + + {humanizeShareStatus(status)} + + ); + }, + }, + { + id: "shareClass", + accessorKey: "shareClass.classType", + + header: ({ column }) => ( + column.toggleSorting(column.getIsSorted() === "asc")} + /> + ), + cell: ({ row }) => ( +
{row.original.shareClass.classType}
+ ), + }, + { + id: "quantity", + accessorKey: "quantity", + header: ({ column }) => ( +
+ column.toggleSorting(column.getIsSorted() === "asc")} + /> +
+ ), + cell: ({ row }) => { + const quantity = row.original.quantity; + return ( +
+ {quantity ? formatNumber(quantity) : null} +
+ ); + }, + }, + { + id: "pricePerShare", + accessorKey: "pricePerShare", + header: ({ column }) => ( +
+ column.toggleSorting(column.getIsSorted() === "asc")} + /> +
+ ), + cell: ({ row }) => { + const price = row.original.pricePerShare; + return ( +
+ {price ? formatCurrency(price, "USD") : null} +
+ ); + }, + }, + { + id: "issueDate", + accessorKey: "issueDate", + header: ({ column }) => ( + column.toggleSorting(column.getIsSorted() === "asc")} + /> + ), + cell: ({ row }) => ( +
+ {dayjsExt(row.original.issueDate).format("DD/MM/YYYY")} +
+ ), + }, + { + id: "boardApprovalDate", + accessorKey: "boardApprovalDate", + header: ({ column }) => ( +
+ column.toggleSorting(column.getIsSorted() === "asc")} + /> +
+ ), + cell: ({ row }) => ( +
+ {dayjsExt(row.original.boardApprovalDate).format("DD/MM/YYYY")} +
+ ), + }, + { + id: "Documents", + enableHiding: false, + header: ({ column }) => { + return ( + column.toggleSorting(column.getIsSorted() === "asc")} + /> + ); + }, + cell: ({ row }) => { + const documents = row?.original?.documents; + + const openFileOnTab = async (key: string) => { + const fileUrl = await getPresignedGetUrl(key); + window.open(fileUrl.url, "_blank"); + }; + + return ( + +
+ + + +
+ + Documents + {documents?.map((doc) => ( + { + await openFileOnTab(doc.bucket.key); + }} + > + + {doc.name.slice(0, 12)} +

+ {doc?.uploader?.user?.name} +

+
+ ))} +
+
+ ); + }, + }, + { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const router = useRouter(); + // eslint-disable-next-line react-hooks/rules-of-hooks + const { toast } = useToast(); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const share = row.original; + + const deleteShareMutation = api.securities.deleteShare.useMutation({ + onSuccess: () => { + toast({ + variant: "default", + title: "🎉 Successfully deleted", + description: "Share deleted for stakeholder", + }); + router.refresh(); + }, + onError: () => { + toast({ + variant: "destructive", + title: "Failed deletion", + description: "Stakeholder share couldn't be deleted", + }); + }, + }); + + const updateAction = "Update Share"; + const deleteAction = "Delete Share"; + + const handleDeleteShare = async () => { + await deleteShareMutation.mutateAsync({ shareId: share.id }); + }; + + return ( + +
+ + + +
+ + Actions + + {updateAction} + + {deleteAction} + + +
+ ); + }, + }, +]; + +const ShareTable = ({ shares }: SharesType) => { + const [sorting, setSorting] = React.useState([]); + const [columnFilters, setColumnFilters] = React.useState( + [], + ); + const [columnVisibility, setColumnVisibility] = + React.useState({}); + const [rowSelection, setRowSelection] = React.useState({}); + + const table = useReactTable({ + data: shares ?? [], + columns: columns, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + }, + }); + + return ( +
+ + + + + + + + +
+ ); +}; + +export default ShareTable; From b360a5dd894deac5550db0ee80662996dede2433 Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 15:32:42 +0545 Subject: [PATCH 19/35] feat: shares page --- .../[publicId]/securities/shares/page.tsx | 80 ++++++++++++++++--- 1 file changed, 69 insertions(+), 11 deletions(-) diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/securities/shares/page.tsx b/src/app/(authenticated)/(dashboard)/[publicId]/securities/shares/page.tsx index 1a1e0c000..38ece1457 100644 --- a/src/app/(authenticated)/(dashboard)/[publicId]/securities/shares/page.tsx +++ b/src/app/(authenticated)/(dashboard)/[publicId]/securities/shares/page.tsx @@ -1,21 +1,79 @@ import EmptyState from "@/components/common/empty-state"; +import Tldr from "@/components/common/tldr"; +import ShareModal from "@/components/securities/shares/share-modal"; +import ShareTable from "@/components/securities/shares/share-table"; import { Button } from "@/components/ui/button"; -import { RiPieChartFill } from "@remixicon/react"; -import { type Metadata } from "next"; +import { Card } from "@/components/ui/card"; +import { api } from "@/trpc/server"; +import { RiAddFill, RiPieChartFill } from "@remixicon/react"; +import type { Metadata } from "next"; export const metadata: Metadata = { - title: "Cap table", + title: "Captable | Shares", }; -const SharesPage = () => { +const SharesPage = async () => { + const shares = await api.securities.getShares.query(); + + if (shares?.data?.length === 0) { + return ( + } + title="You do not have any shares yet." + subtitle="Please click the button for adding new shares." + > + + } + trigger={ + + } + /> + + ); + } + return ( - } - title="Work in progress." - subtitle="This page is not yet available." - > - - +
+
+
+

Shares

+

+ Add shares for stakeholders +

+
+
+ + } + trigger={} + /> +
+
+ + + +
); }; From 03acb916ee715e47378ac553c4eef27edc175c1b Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 15:34:49 +0545 Subject: [PATCH 20/35] feat: get shareclass procedure --- src/trpc/routers/share-class/router.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/trpc/routers/share-class/router.ts b/src/trpc/routers/share-class/router.ts index 4e0690c1a..080895550 100644 --- a/src/trpc/routers/share-class/router.ts +++ b/src/trpc/routers/share-class/router.ts @@ -48,6 +48,7 @@ export const shareClassRouter = createTRPCRouter({ }; await tx.shareClass.create({ data }); + await Audit.create( { action: "shareClass.created", @@ -137,4 +138,26 @@ export const shareClassRouter = createTRPCRouter({ }; } }), + + get: withAuth.query(async ({ ctx: { db, session } }) => { + const shareClass = await db.$transaction(async (tx) => { + const { companyId } = await checkMembership({ session, tx }); + + return await tx.shareClass.findMany({ + where: { + companyId, + }, + select: { + id: true, + name: true, + company: { + select: { + name: true, + }, + }, + }, + }); + }); + return shareClass; + }), }); From 09993904f8732f187bbabc84527ec30bdd997f89 Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 16:05:05 +0545 Subject: [PATCH 21/35] feat: currency formatter utils --- src/lib/utils.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index d2a5a5e86..d7e74c439 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -9,7 +9,7 @@ export function cn(...inputs: ClassValue[]) { export function getFileSizeSuffix(bytes: number): string { const suffixes = ["", "K", "M", "G", "T"]; const magnitude = Math.floor(Math.log2(bytes) / 10); - const suffix = suffixes[magnitude] + "B"; + const suffix = `${suffixes[magnitude]}B`; return suffix; } @@ -71,3 +71,14 @@ export function compareFormDataWithInitial>( return isChanged; } + +export function formatNumber(value: number): string { + return new Intl.NumberFormat("en-US").format(value); +} + +export function formatCurrency(value: number, currency: "USD") { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currency, + }).format(value); +} From 322ed07abf88cb3ec5f0a526ebab7ecc0a4c8209 Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 16:19:50 +0545 Subject: [PATCH 22/35] feat: loading spinner --- src/components/common/LoadingSpinner.tsx | 31 ++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/components/common/LoadingSpinner.tsx diff --git a/src/components/common/LoadingSpinner.tsx b/src/components/common/LoadingSpinner.tsx new file mode 100644 index 000000000..e28313e08 --- /dev/null +++ b/src/components/common/LoadingSpinner.tsx @@ -0,0 +1,31 @@ +import { cn } from "@/lib/utils"; + +export interface ISVGProps extends React.SVGProps { + size?: number; + className?: string; +} + +export const LoadingSpinner = ({ + size = 24, + className, + ...props +}: ISVGProps) => { + return ( + + Loading + + + ); +}; From fd12a6df70b05d740329e2873c350b9548df5352 Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 16:46:04 +0545 Subject: [PATCH 23/35] feat: simple-multi-select shadcn ui --- src/components/ui/simple-multi-select.tsx | 315 ++++++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 src/components/ui/simple-multi-select.tsx diff --git a/src/components/ui/simple-multi-select.tsx b/src/components/ui/simple-multi-select.tsx new file mode 100644 index 000000000..4af827845 --- /dev/null +++ b/src/components/ui/simple-multi-select.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { + Command, + CommandEmpty, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { cn } from "@/lib/utils"; +import { RiCheckLine, RiCloseLine } from "@remixicon/react"; +import { Command as CommandPrimitive } from "cmdk"; +// import { X as RemoveIcon, Ri } from "lucide-react"; +import React, { + KeyboardEvent, + createContext, + forwardRef, + useCallback, + useContext, + useState, +} from "react"; + +type MultiSelectorProps = { + values: string[]; + onValuesChange: (value: string[]) => void; + loop?: boolean; +} & React.ComponentPropsWithoutRef; + +interface MultiSelectContextProps { + value: string[]; + onValueChange: (value: any) => void; + open: boolean; + setOpen: (value: boolean) => void; + inputValue: string; + setInputValue: React.Dispatch>; + activeIndex: number; + setActiveIndex: React.Dispatch>; +} + +const MultiSelectContext = createContext(null); + +const useMultiSelect = () => { + const context = useContext(MultiSelectContext); + if (!context) { + throw new Error("useMultiSelect must be used within MultiSelectProvider"); + } + return context; +}; + +const MultiSelector = ({ + values: value, + onValuesChange: onValueChange, + loop = false, + className, + children, + dir, + ...props +}: MultiSelectorProps) => { + const [inputValue, setInputValue] = useState(""); + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + + const onValueChangeHandler = useCallback( + (val: string) => { + if (value?.includes(val)) { + onValueChange(value?.filter((item) => item !== val)); + } else { + if (value?.length) { + onValueChange([...value, val]); + } else { + onValueChange([val]); + } + } + }, + [value], + ); + + // TODO : change from else if use to switch case statement + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + const moveNext = () => { + const nextIndex = activeIndex + 1; + setActiveIndex( + nextIndex > value.length - 1 ? (loop ? 0 : -1) : nextIndex, + ); + }; + + const movePrev = () => { + const prevIndex = activeIndex - 1; + setActiveIndex(prevIndex < 0 ? value.length - 1 : prevIndex); + }; + + if ((e.key === "Backspace" || e.key === "Delete") && value.length > 0) { + if (inputValue.length === 0) { + if (activeIndex !== -1 && activeIndex < value.length) { + onValueChange(value.filter((item) => item !== value[activeIndex])); + const newIndex = activeIndex - 1 < 0 ? 0 : activeIndex - 1; + setActiveIndex(newIndex); + } else { + onValueChange( + value.filter((item) => item !== value[value.length - 1]), + ); + } + } + } else if (e.key === "Enter") { + setOpen(true); + } else if (e.key === "Escape") { + if (activeIndex !== -1) { + setActiveIndex(-1); + } else { + setOpen(false); + } + } else if (dir === "rtl") { + if (e.key === "ArrowRight") { + movePrev(); + } else if (e.key === "ArrowLeft" && (activeIndex !== -1 || loop)) { + moveNext(); + } + } else { + if (e.key === "ArrowLeft") { + movePrev(); + } else if (e.key === "ArrowRight" && (activeIndex !== -1 || loop)) { + moveNext(); + } + } + }, + [value, inputValue, activeIndex, loop], + ); + + return ( + + + {children} + + + ); +}; + +const MultiSelectorTrigger = forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, children, ...props }, ref) => { + const { value, onValueChange, activeIndex } = useMultiSelect(); + + const mousePreventDefault = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + return ( +
+ {value?.map((item, index) => ( + + {item} + + + ))} + {children} +
+ ); +}); + +MultiSelectorTrigger.displayName = "MultiSelectorTrigger"; + +const MultiSelectorInput = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { setOpen, inputValue, setInputValue, activeIndex, setActiveIndex } = + useMultiSelect(); + return ( + setOpen(false)} + onFocus={() => setOpen(true)} + onClick={() => setActiveIndex(-1)} + className={cn( + "ml-2 bg-transparent outline-none placeholder:text-muted-foreground flex-1", + className, + activeIndex !== -1 && "caret-transparent", + )} + /> + ); +}); + +MultiSelectorInput.displayName = "MultiSelectorInput"; + +const MultiSelectorContent = forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ children }, ref) => { + const { open } = useMultiSelect(); + return ( +
+ {open && children} +
+ ); +}); + +MultiSelectorContent.displayName = "MultiSelectorContent"; + +const MultiSelectorList = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children }, ref) => { + return ( + + {children} + + No results found + + + ); +}); + +MultiSelectorList.displayName = "MultiSelectorList"; + +const MultiSelectorItem = forwardRef< + React.ElementRef, + { value: string } & React.ComponentPropsWithoutRef< + typeof CommandPrimitive.Item + > +>(({ className, value, children, ...props }, ref) => { + const { value: Options, onValueChange, setInputValue } = useMultiSelect(); + + const mousePreventDefault = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const isIncluded = Options?.includes(value); + return ( + { + onValueChange(value); + setInputValue(""); + }} + className={cn( + "rounded-md cursor-pointer px-2 py-1 transition-colors flex justify-between ", + className, + isIncluded && "opacity-50 cursor-default", + props.disabled && "opacity-50 cursor-not-allowed", + )} + onMouseDown={mousePreventDefault} + > + {children} + {isIncluded && } + + ); +}); + +MultiSelectorItem.displayName = "MultiSelectorItem"; + +export { + MultiSelector, + MultiSelectorContent, + MultiSelectorInput, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +}; From a705e5fae3df7c92e5f0617c3b8d3b86eb7780f6 Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 16:47:50 +0545 Subject: [PATCH 24/35] fix: ignore lint checking in shadcn simple-select-ui --- biome.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/biome.json b/biome.json index edab1ee11..292fd169c 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,8 @@ ".next", "dist", "public/pdf.worker.min.js", - "./prisma/enums.ts" + "./prisma/enums.ts", + "./src/components/ui/simple-multi-select.tsx" ] }, "linter": { @@ -25,7 +26,8 @@ ".next", "dist", "public/pdf.worker.min.js", - "./prisma/enums.ts" + "./prisma/enums.ts", + "./src/components/ui/simple-multi-select.tsx" ] }, "formatter": { From 113526532e555a68a7f10f1cc09cde94129204db Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Sat, 18 May 2024 17:20:43 +0545 Subject: [PATCH 25/35] chore: minor refactoring and improvements --- src/components/securities/shares/share-modal.tsx | 1 + src/components/securities/shares/share-table.tsx | 8 ++++---- .../securities/shares/steps/contribution-details.tsx | 2 +- .../securities/shares/steps/general-details.tsx | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/securities/shares/share-modal.tsx b/src/components/securities/shares/share-modal.tsx index a9f69b596..adbe9ef48 100644 --- a/src/components/securities/shares/share-modal.tsx +++ b/src/components/securities/shares/share-modal.tsx @@ -40,6 +40,7 @@ const ShareModal = ({ title, subtitle, trigger }: ShareModalProps) => { : "Failed adding a share. Please try again.", }); if (success) { + setOpen(false); router.refresh(); } }, diff --git a/src/components/securities/shares/share-table.tsx b/src/components/securities/shares/share-table.tsx index b7da074b0..7c998ebb5 100644 --- a/src/components/securities/shares/share-table.tsx +++ b/src/components/securities/shares/share-table.tsx @@ -70,13 +70,13 @@ const humanizeShareStatus = (type: string) => { const StatusColorProvider = (type: string) => { switch (type) { case "ACTIVE": - return "bg-green-50 text-green-400 ring-green-600/20"; + return "bg-green-50 text-green-600 ring-green-600/20"; case "DRAFT": - return "bg-yellow-50 text-yellow-400 ring-yellow-600/20"; + return "bg-yellow-50 text-yellow-600 ring-yellow-600/20"; case "SIGNED": - return "bg-blue-50 text-blue-400 ring-blue-600/20"; + return "bg-blue-50 text-blue-600 ring-blue-600/20"; case "PENDING": - return "bg-gray-50 text-gray-400 ring-gray-600/20"; + return "bg-gray-50 text-gray-600 ring-gray-600/20"; default: return ""; } diff --git a/src/components/securities/shares/steps/contribution-details.tsx b/src/components/securities/shares/steps/contribution-details.tsx index cf5fdcb47..600f2fd9e 100644 --- a/src/components/securities/shares/steps/contribution-details.tsx +++ b/src/components/securities/shares/steps/contribution-details.tsx @@ -52,7 +52,7 @@ export const ContributionDetails = () => { )) ) : ( - + )} diff --git a/src/components/securities/shares/steps/general-details.tsx b/src/components/securities/shares/steps/general-details.tsx index 5e2790aae..4ad675911 100644 --- a/src/components/securities/shares/steps/general-details.tsx +++ b/src/components/securities/shares/steps/general-details.tsx @@ -143,7 +143,7 @@ export const GeneralDetails = () => { )) ) : ( - + )} From a768f76cec4782d2266a72de6bf9ead970be3857 Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Fri, 24 May 2024 16:15:32 +0545 Subject: [PATCH 26/35] feat: form-provider for adding shares --- src/providers/add-share-form-provider.tsx | 45 +++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/providers/add-share-form-provider.tsx diff --git a/src/providers/add-share-form-provider.tsx b/src/providers/add-share-form-provider.tsx new file mode 100644 index 000000000..ad15b585d --- /dev/null +++ b/src/providers/add-share-form-provider.tsx @@ -0,0 +1,45 @@ +"use client"; + +import type { TypeZodAddShareMutationSchema } from "@/trpc/routers/securities-router/schema"; +import { + type Dispatch, + type ReactNode, + createContext, + useContext, + useReducer, +} from "react"; + +type TFormValue = TypeZodAddShareMutationSchema; + +interface AddShareFormProviderProps { + children: ReactNode; +} + +const AddShareFormProviderContext = createContext<{ + value: TFormValue; + setValue: Dispatch>; +} | null>(null); + +export function AddShareFormProvider({ children }: AddShareFormProviderProps) { + const [value, setValue] = useReducer( + (data: TFormValue, partialData: Partial) => ({ + ...data, + ...partialData, + }), + {} as TFormValue, + ); + + return ( + + {children} + + ); +} + +export const useAddShareFormValues = () => { + const data = useContext(AddShareFormProviderContext); + if (!data) { + throw new Error("useAddShareFormValues shouldn't be null"); + } + return data; +}; From 1399cd3674b194532a957f91053ffc03fa175050 Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Fri, 24 May 2024 16:20:13 +0545 Subject: [PATCH 27/35] chore: refactor steps components with new stepper modal --- .../shares/steps/contribution-details.tsx | 238 ++++++----- .../securities/shares/steps/documents.tsx | 148 ++++--- .../shares/steps/general-details.tsx | 374 ++++++++++-------- .../securities/shares/steps/index.ts | 4 - .../shares/steps/relevant-dates.tsx | 173 ++++---- 5 files changed, 537 insertions(+), 400 deletions(-) delete mode 100644 src/components/securities/shares/steps/index.ts diff --git a/src/components/securities/shares/steps/contribution-details.tsx b/src/components/securities/shares/steps/contribution-details.tsx index 600f2fd9e..866915722 100644 --- a/src/components/securities/shares/steps/contribution-details.tsx +++ b/src/components/securities/shares/steps/contribution-details.tsx @@ -1,5 +1,8 @@ -import { LoadingSpinner } from "@/components/common/LoadingSpinner"; +"use client"; + +import { Button } from "@/components/ui/button"; import { + Form, FormControl, FormField, FormItem, @@ -14,116 +17,153 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { api } from "@/trpc/react"; -import { useFormContext } from "react-hook-form"; +import { + StepperModalFooter, + StepperPrev, + useStepper, +} from "@/components/ui/stepper"; +import { useAddShareFormValues } from "@/providers/add-share-form-provider"; +import type { RouterOutputs } from "@/trpc/shared"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { EmptySelect } from "../../shared/EmptySelect"; + +interface ContributionDetailsProps { + stakeholders: RouterOutputs["stakeholder"]["getStakeholders"]; +} -export const ContributionDetailsField = [ - "capitalContribution", - "ipContribution", - "debtCancelled", - "otherContributions", - "stakeholderId", -]; +const formSchema = z.object({ + stakeholderId: z.string(), + capitalContribution: z.coerce.number().min(0), + ipContribution: z.coerce.number().min(0), + debtCancelled: z.coerce.number().min(0), + otherContributions: z.coerce.number().min(0), +}); -export const ContributionDetails = () => { - const form = useFormContext(); - const stakeholders = api.stakeholder.getStakeholders.useQuery(); +type TFormSchema = z.infer; +export const ContributionDetails = ({ + stakeholders, +}: ContributionDetailsProps) => { + const form = useForm({ resolver: zodResolver(formSchema) }); + const { next } = useStepper(); + const { setValue } = useAddShareFormValues(); + + const handleSubmit = (data: TFormSchema) => { + console.log({ data }); + setValue(data); + next(); + }; return ( -
- ( - - Stakeholder - {/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment */} - - - - )} - /> -
-
- ( - - Capital contribution - - - - - - )} - /> -
-
- ( - - Intellectual property - - - - - - )} - /> -
-
-
-
- ( - - Debt cancelled - - - - - - )} - /> -
-
+
+ +
( - Other contributions - - - + Stakeholder + {/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment */} + )} /> +
+
+ ( + + Capital contribution + + + + + + )} + /> +
+
+ ( + + Intellectual property + + + + + + )} + /> +
+
+
+
+ ( + + Debt cancelled + + + + + + )} + /> +
+
+ ( + + Other contributions + + + + + + )} + /> +
+
-
-
+ + Back + + + + ); }; diff --git a/src/components/securities/shares/steps/documents.tsx b/src/components/securities/shares/steps/documents.tsx index d1c784b3c..706f8e404 100644 --- a/src/components/securities/shares/steps/documents.tsx +++ b/src/components/securities/shares/steps/documents.tsx @@ -1,58 +1,108 @@ "use client"; +import { uploadFile } from "@/common/uploads"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { DialogClose } from "@/components/ui/dialog"; +import { + StepperModalFooter, + StepperPrev, + useStepper, +} from "@/components/ui/stepper"; import Uploader from "@/components/ui/uploader"; -import { useFormContext } from "react-hook-form"; +import { invariant } from "@/lib/error"; +import { useAddShareFormValues } from "@/providers/add-share-form-provider"; +import { api } from "@/trpc/react"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import type { FileWithPath } from "react-dropzone"; +import { toast } from "sonner"; -type Documents = { - bucketId: string; - name: string; -}; -export const DocumentFields = ["documents"]; export const Documents = () => { - const form = useFormContext(); - //eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const documents: [Documents] = form.watch("documents"); - + const router = useRouter(); + const { data: session } = useSession(); + const { value } = useAddShareFormValues(); + const { reset } = useStepper(); + const [documentsList, setDocumentsList] = useState([]); + const { mutateAsync: handleBucketUpload } = api.bucket.create.useMutation(); + const { mutateAsync: addShareMutation } = + api.securities.addShares.useMutation({ + onSuccess: ({ success }) => { + invariant(session, "session not found"); + if (success) { + toast.success("Successfully issued the share."); + router.refresh(); + reset(); + } else { + toast.error("Failed issuing the share. Please try again."); + } + }, + }); + const handleComplete = async () => { + invariant(session, "session not found"); + const uploadedDocuments: { name: string; bucketId: string }[] = []; + for (const document of documentsList) { + const { key, mimeType, name, size } = await uploadFile(document, { + identifier: session.user.companyPublicId, + keyPrefix: "sharesDocs", + }); + const { id: bucketId, name: docName } = await handleBucketUpload({ + key, + mimeType, + name, + size, + }); + uploadedDocuments.push({ bucketId, name: docName }); + } + await addShareMutation({ ...value, documents: uploadedDocuments }); + }; return ( - <> - { - form.setValue("documents", [ - //eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - ...(form.getValues("documents") || []), - { - bucketId: bucketData.id, - name: bucketData.name, - }, - ]); - }} - accept={{ - "application/pdf": [".pdf"], - }} - /> - {documents?.length ? ( - - - {documents.length > 1 - ? `${documents.length} documents uploaded` - : `${documents.length} document uploaded`} - - - You can submit the form to proceed. - - - ) : ( - - 0 document uploaded - - Please upload necessary documents to continue. - - - )} - +
+
+ { + setDocumentsList(bucketData); + }} + accept={{ + "application/pdf": [".pdf"], + }} + /> + {documentsList?.length ? ( + + + {documentsList.length > 1 + ? `${documentsList.length} documents uploaded` + : `${documentsList.length} document uploaded`} + + + You can submit the form to proceed. + + + ) : ( + + 0 document uploaded + + Please upload necessary documents to continue. + + + )} +
+ + Back + + + + +
); }; diff --git a/src/components/securities/shares/steps/general-details.tsx b/src/components/securities/shares/steps/general-details.tsx index 4ad675911..47a061a85 100644 --- a/src/components/securities/shares/steps/general-details.tsx +++ b/src/components/securities/shares/steps/general-details.tsx @@ -1,5 +1,7 @@ -import { LoadingSpinner } from "@/components/common/LoadingSpinner"; +"use client"; +import { Button } from "@/components/ui/button"; import { + Form, FormControl, FormField, FormItem, @@ -22,14 +24,22 @@ import { MultiSelectorList, MultiSelectorTrigger, } from "@/components/ui/simple-multi-select"; +import { + StepperModalFooter, + StepperPrev, + useStepper, +} from "@/components/ui/stepper"; import { SecuritiesStatusEnum, ShareLegendsEnum, VestingScheduleEnum, } from "@/prisma/enums"; -import { api } from "@/trpc/react"; -import { useMemo } from "react"; -import { useFormContext } from "react-hook-form"; +import { useAddShareFormValues } from "@/providers/add-share-form-provider"; +import type { RouterOutputs } from "@/trpc/shared"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { EmptySelect } from "../../shared/EmptySelect"; export const humanizeCompanyLegends = (type: string): string => { switch (type) { @@ -65,211 +75,225 @@ export const humanizeVestingSchedule = (type: string): string => { } }; -export const GeneralDetailsField = [ - "certificateId", - "status", - "quantity", - "pricePerShare", - "vestingSchedule", - "shareClassId", - "companyLegends", -]; +const formSchema = z.object({ + shareClassId: z.string(), + certificateId: z.string(), + status: z.nativeEnum(SecuritiesStatusEnum), + quantity: z.coerce.number().min(0), + vestingSchedule: z.nativeEnum(VestingScheduleEnum), + companyLegends: z.nativeEnum(ShareLegendsEnum).array(), + pricePerShare: z.coerce.number().min(0), +}); + +type TFormSchema = z.infer; + +type ShareClasses = RouterOutputs["shareClass"]["get"]; -export const GeneralDetails = () => { - const form = useFormContext(); - const shareClasses = api.shareClass.get.useQuery(); +interface GeneralDetailsProps { + shareClasses: ShareClasses; +} - const status = useMemo( - () => - Object.values(SecuritiesStatusEnum).filter( - (value) => typeof value === "string", - ), - [], - ) as string[]; +export const GeneralDetails = ({ shareClasses }: GeneralDetailsProps) => { + const form = useForm({ + resolver: zodResolver(formSchema), + }); + const { next } = useStepper(); + const { setValue } = useAddShareFormValues(); - const vestingSchedule = useMemo( - () => - Object.values(VestingScheduleEnum).filter( - (value) => typeof value === "string", - ), - [], - ) as string[]; + const status = Object.values(SecuritiesStatusEnum); + const vestingSchedule = Object.values(VestingScheduleEnum); + const companyLegends = Object.values(ShareLegendsEnum); - const companyLegends = useMemo( - () => - Object.values(ShareLegendsEnum).filter( - (value) => typeof value === "string", - ), - [], - ) as string[]; + const handleSubmit = (data: TFormSchema) => { + setValue(data); + next(); + }; return ( -
-
- ( - - Certificate ID - - - - - - )} - /> -
-
-
- ( - - Share class - {/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment */} - - - {shareClasses?.data?.length ? ( - shareClasses?.data?.map((sc) => ( - - {sc.name} - - )) - ) : ( - - )} - - - - - )} - /> -
-
+ + + )} + /> +
+
+
+ ( + + Share class + {/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment */} + + + + )} + /> +
+
+ ( + + Status + + + + )} + /> +
+
+ +
+
+ ( + + Quantity + + + + + + )} + /> +
+
+ ( + + Price per share + + + + + + )} + /> +
+
+ ( - Status + Vesting schedule )} /> -
-
-
-
( - Quantity - - - - - - )} - /> -
-
- ( - - Price per share - - - - + Company Legends + + + + + + + {companyLegends.map((cl) => ( + + {humanizeCompanyLegends(cl)} + + ))} + + + )} />
-
- - ( - - Vesting schedule - - - - )} - /> - - ( - - Company Legends - - - - - - - {companyLegends.map((cl) => ( - - {humanizeCompanyLegends(cl)} - - ))} - - - - - )} - /> -
+ + Back + + + + ); }; diff --git a/src/components/securities/shares/steps/index.ts b/src/components/securities/shares/steps/index.ts deleted file mode 100644 index ed7941e04..000000000 --- a/src/components/securities/shares/steps/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./contribution-details"; -export * from "./documents"; -export * from "./general-details"; -export * from "./relevant-dates"; diff --git a/src/components/securities/shares/steps/relevant-dates.tsx b/src/components/securities/shares/steps/relevant-dates.tsx index 140d6317d..e1553f3d4 100644 --- a/src/components/securities/shares/steps/relevant-dates.tsx +++ b/src/components/securities/shares/steps/relevant-dates.tsx @@ -1,6 +1,8 @@ "use client"; +import { Button } from "@/components/ui/button"; import { + Form, FormControl, FormField, FormItem, @@ -8,87 +10,112 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { useFormContext } from "react-hook-form"; +import { + StepperModalFooter, + StepperPrev, + useStepper, +} from "@/components/ui/stepper"; +import { useAddShareFormValues } from "@/providers/add-share-form-provider"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const formSchema = z.object({ + boardApprovalDate: z.string().date(), + rule144Date: z.string().date(), + issueDate: z.string().date(), + vestingStartDate: z.string().date(), +}); -export const RelevantDatesFields = [ - "issueDate", - "vestingStartDate", - "boardApprovalDate", - "rule144Date", -]; +type TFormSchema = z.infer; export const RelevantDates = () => { - const form = useFormContext(); + const form = useForm({ resolver: zodResolver(formSchema) }); + const { next } = useStepper(); + const { setValue } = useAddShareFormValues(); + const handleSubmit = (data: TFormSchema) => { + setValue(data); + next(); + }; return ( - <> -
-
-
- ( - - Issue date - - - - - - )} - /> -
-
- ( - - Vesting start date - - - - - - )} - /> +
+ +
+
+
+ ( + + Issue date + + + + + + )} + /> +
+
+ ( + + Vesting start date + + + + + + )} + /> +
-
-
-
- ( - - Board approval date - - - - - - )} - /> -
-
- ( - - Rule 144 date - - - - - - )} - /> +
+
+ ( + + Board approval date + + + + + + )} + /> +
+
+ ( + + Rule 144 date + + + + + + )} + /> +
-
- + + Back + + +
+ ); }; From 89dd1f8feef4e34c427959bb90ad986afa00691d Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Fri, 24 May 2024 16:21:49 +0545 Subject: [PATCH 28/35] chore: schema refactoring of securities router --- src/trpc/routers/securities-router/schema.ts | 63 ++++++-------------- 1 file changed, 17 insertions(+), 46 deletions(-) diff --git a/src/trpc/routers/securities-router/schema.ts b/src/trpc/routers/securities-router/schema.ts index fdac47531..03a8554af 100644 --- a/src/trpc/routers/securities-router/schema.ts +++ b/src/trpc/routers/securities-router/schema.ts @@ -46,58 +46,29 @@ export type TypeZodDeleteOptionMutationSchema = z.infer< // SHARES export const ZodAddShareMutationSchema = z.object({ - id: z.string().optional(), - status: z.nativeEnum(SecuritiesStatusEnum, { - errorMap: () => ({ message: "Invalid value for status type" }), - }), - certificateId: z.string().min(1, { - message: "Certificate ID is required", - }), - quantity: z.coerce.number().min(0, { - message: "Quantity is required", - }), - pricePerShare: z.coerce.number().optional(), - capitalContribution: z.coerce.number().min(0, { - message: "Capital contribution is required", - }), - ipContribution: z.coerce.number().optional(), - debtCancelled: z.coerce.number().optional(), - otherContributions: z.coerce.number().optional(), - vestingSchedule: z.nativeEnum(VestingScheduleEnum, { - errorMap: () => ({ message: "Invalid value for vesting schedule" }), - }), - // companyLegends: z.array(z.string()), - companyLegends: z - .nativeEnum(ShareLegendsEnum, { - errorMap: () => ({ message: "Invalid value for compnay legends" }), - }) - .array(), - issueDate: z.coerce.date({ - required_error: "Issue date is required", - invalid_type_error: "This is not valid date", - }), - rule144Date: z.coerce.date({ - invalid_type_error: "This is not a valid date", - }), - vestingStartDate: z.coerce.date({ - invalid_type_error: "This is not a valid date", - }), - boardApprovalDate: z.coerce.date({ - required_error: "Board approval date is required", - invalid_type_error: "This is not valid date", - }), + id: z.string().optional().nullable(), + stakeholderId: z.string(), + shareClassId: z.string(), + certificateId: z.string(), + quantity: z.coerce.number().min(0), + pricePerShare: z.coerce.number().min(0), + capitalContribution: z.coerce.number().min(0), + ipContribution: z.coerce.number().min(0), + debtCancelled: z.coerce.number().min(0), + otherContributions: z.coerce.number().min(0), + status: z.nativeEnum(SecuritiesStatusEnum), + vestingSchedule: z.nativeEnum(VestingScheduleEnum), + companyLegends: z.nativeEnum(ShareLegendsEnum).array(), + issueDate: z.string().date(), + rule144Date: z.string().date(), + vestingStartDate: z.string().date(), + boardApprovalDate: z.string().date(), documents: z.array( z.object({ bucketId: z.string(), name: z.string(), }), ), - stakeholderId: z.string().min(1, { - message: "Stakeholder is required", - }), - shareClassId: z.string().min(1, { - message: "Share Class is required", - }), }); export type TypeZodAddShareMutationSchema = z.infer< From 1cfc8c695348883f55dea8af9da0d07c13439500 Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Fri, 24 May 2024 16:27:46 +0545 Subject: [PATCH 29/35] feat: shared empty-alert component --- src/components/securities/shared/EmptySelect.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/components/securities/shared/EmptySelect.tsx diff --git a/src/components/securities/shared/EmptySelect.tsx b/src/components/securities/shared/EmptySelect.tsx new file mode 100644 index 000000000..23c87c452 --- /dev/null +++ b/src/components/securities/shared/EmptySelect.tsx @@ -0,0 +1,15 @@ +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; + +interface EmptySelectProps { + title: string; + description: string; +} + +export function EmptySelect({ title, description }: EmptySelectProps) { + return ( + + {title} + {description} + + ); +} From 7a5ce124b05f3887c0f375e7455c5fe953e63798 Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Fri, 24 May 2024 16:33:05 +0545 Subject: [PATCH 30/35] chore: refactoring share-table and share-modal --- .../securities/shares/share-modal.tsx | 132 ++++++------------ .../securities/shares/share-table.tsx | 17 +-- 2 files changed, 47 insertions(+), 102 deletions(-) diff --git a/src/components/securities/shares/share-modal.tsx b/src/components/securities/shares/share-modal.tsx index adbe9ef48..cfebd90d0 100644 --- a/src/components/securities/shares/share-modal.tsx +++ b/src/components/securities/shares/share-modal.tsx @@ -1,95 +1,51 @@ -"use client"; - -import MultiStepModal from "@/components/common/multistep-modal"; -import { useToast } from "@/components/ui/use-toast"; -import { api } from "@/trpc/react"; -import { - type TypeZodAddShareMutationSchema, - ZodAddShareMutationSchema, -} from "@/trpc/routers/securities-router/schema"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; import { - ContributionDetails, - ContributionDetailsField, - DocumentFields, - Documents, - GeneralDetails, - GeneralDetailsField, - RelevantDates, - RelevantDatesFields, -} from "./steps"; - -type ShareModalProps = { - title: string; - subtitle: string | React.ReactNode; - trigger: string | React.ReactNode; -}; - -const ShareModal = ({ title, subtitle, trigger }: ShareModalProps) => { - const [open, setOpen] = useState(false); - const { toast } = useToast(); - const router = useRouter(); - const addShareMutation = api.securities.addShares.useMutation({ - onSuccess: ({ message, success }) => { - toast({ - variant: success ? "default" : "destructive", - title: message, - description: success - ? "A new share has been created." - : "Failed adding a share. Please try again.", - }); - if (success) { - setOpen(false); - router.refresh(); - } - }, - }); + StepperModal, + StepperModalContent, + type StepperModalProps, + StepperStep, +} from "@/components/ui/stepper"; +import { AddShareFormProvider } from "@/providers/add-share-form-provider"; +import { api } from "@/trpc/server"; +import { ContributionDetails } from "./steps/contribution-details"; +import { Documents } from "./steps/documents"; +import { GeneralDetails } from "./steps/general-details"; +import { RelevantDates } from "./steps/relevant-dates"; - const steps = [ - { - id: 1, - title: "General details", - component: GeneralDetails, - fields: GeneralDetailsField, - }, - { - id: 2, - title: "Contribution details", - component: ContributionDetails, - fields: ContributionDetailsField, - }, - { - id: 3, - title: "Relevant dates", - component: RelevantDates, - fields: RelevantDatesFields, - }, - { - id: 4, - title: "Upload Documents", - component: Documents, - fields: DocumentFields, - }, - ]; +async function ContributionDetailsStep() { + const stakeholders = await api.stakeholder.getStakeholders.query(); + return ; +} - const onSubmit = async (data: TypeZodAddShareMutationSchema) => { - await addShareMutation.mutateAsync(data); - }; +async function GeneralDetailsStep() { + const shareClasses = await api.shareClass.get.query(); + return ; +} +export const ShareModal = (props: Omit) => { return ( -
- setOpen(val) }} - /> -
+ + + + + + + + + + + + + + + + + + + + + + + + ); }; - -export default ShareModal; diff --git a/src/components/securities/shares/share-table.tsx b/src/components/securities/shares/share-table.tsx index 7c998ebb5..a55def1ef 100644 --- a/src/components/securities/shares/share-table.tsx +++ b/src/components/securities/shares/share-table.tsx @@ -34,7 +34,6 @@ import { DropdownMenuLabel, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; -import { useToast } from "@/components/ui/use-toast"; import { formatCurrency, formatNumber } from "@/lib/utils"; import { getPresignedGetUrl } from "@/server/file-uploads"; import { api } from "@/trpc/react"; @@ -44,6 +43,7 @@ import { } from "@radix-ui/react-dropdown-menu"; import { RiFileDownloadLine, RiMoreLine } from "@remixicon/react"; import { useRouter } from "next/navigation"; +import { toast } from "sonner"; import { ShareTableToolbar } from "./share-table-toolbar"; type Share = RouterOutputs["securities"]["getShares"]["data"]; @@ -294,27 +294,16 @@ export const columns: ColumnDef[] = [ cell: ({ row }) => { // eslint-disable-next-line react-hooks/rules-of-hooks const router = useRouter(); - // eslint-disable-next-line react-hooks/rules-of-hooks - const { toast } = useToast(); - // eslint-disable-next-line react-hooks/rules-of-hooks const share = row.original; const deleteShareMutation = api.securities.deleteShare.useMutation({ onSuccess: () => { - toast({ - variant: "default", - title: "🎉 Successfully deleted", - description: "Share deleted for stakeholder", - }); + toast.success("🎉 Successfully deleted the stakeholder"); router.refresh(); }, onError: () => { - toast({ - variant: "destructive", - title: "Failed deletion", - description: "Stakeholder share couldn't be deleted", - }); + toast.error("Failed deleting the share"); }, }); From 6fc206ba975e25b5c713c4fe2e27b63d7fcd28ca Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Fri, 24 May 2024 16:37:25 +0545 Subject: [PATCH 31/35] chore: make empty-select component as shared compoennt --- .../securities/options/steps/vesting-details.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/components/securities/options/steps/vesting-details.tsx b/src/components/securities/options/steps/vesting-details.tsx index 3be8fc8e8..41968a6c3 100644 --- a/src/components/securities/options/steps/vesting-details.tsx +++ b/src/components/securities/options/steps/vesting-details.tsx @@ -1,6 +1,5 @@ "use client"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Form, @@ -30,6 +29,7 @@ import type { RouterOutputs } from "@/trpc/shared"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { EmptySelect } from "../../shared/EmptySelect"; const vestingSchedule = Object.values(VestingScheduleEnum).map((val) => ({ label: toTitleCase(val).replace("Vesting_", "").replaceAll("_", "-"), @@ -50,20 +50,6 @@ interface VestingDetailsProps { equityPlans: RouterOutputs["equityPlan"]["getPlans"]; } -interface EmptySelectProps { - title: string; - description: string; -} - -function EmptySelect({ title, description }: EmptySelectProps) { - return ( - - {title} - {description} - - ); -} - export const VestingDetails = (props: VestingDetailsProps) => { const { stakeholders, equityPlans } = props; From 16ea1a270a4c822b378dec97b006d81ac1a93ec3 Mon Sep 17 00:00:00 2001 From: Raju-github-profile Date: Fri, 24 May 2024 16:39:18 +0545 Subject: [PATCH 32/35] chore: refactoring shares page and general details --- .../(dashboard)/[publicId]/securities/shares/page.tsx | 2 +- src/components/securities/shares/steps/general-details.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/securities/shares/page.tsx b/src/app/(authenticated)/(dashboard)/[publicId]/securities/shares/page.tsx index 38ece1457..23c7f34ab 100644 --- a/src/app/(authenticated)/(dashboard)/[publicId]/securities/shares/page.tsx +++ b/src/app/(authenticated)/(dashboard)/[publicId]/securities/shares/page.tsx @@ -1,6 +1,6 @@ import EmptyState from "@/components/common/empty-state"; import Tldr from "@/components/common/tldr"; -import ShareModal from "@/components/securities/shares/share-modal"; +import { ShareModal } from "@/components/securities/shares/share-modal"; import ShareTable from "@/components/securities/shares/share-table"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; diff --git a/src/components/securities/shares/steps/general-details.tsx b/src/components/securities/shares/steps/general-details.tsx index 47a061a85..637dab32e 100644 --- a/src/components/securities/shares/steps/general-details.tsx +++ b/src/components/securities/shares/steps/general-details.tsx @@ -143,7 +143,7 @@ export const GeneralDetails = ({ shareClasses }: GeneralDetailsProps) => { - - - - )} + render={({ field }) => { + const { onChange, ...rest } = field; + return ( + + Contributed capital amount + + { + const { floatValue } = values; + onChange(floatValue); + }} + /> + + + + ); + }} />
( - - Intellectual property - - - - - - )} + render={({ field }) => { + const { onChange, ...rest } = field; + return ( + + Value of intellectual property + + { + const { floatValue } = values; + onChange(floatValue); + }} + /> + + + + ); + }} />
@@ -131,30 +160,58 @@ export const ContributionDetails = ({ ( - - Debt cancelled - - - - - - )} + render={({ field }) => { + const { onChange, ...rest } = field; + return ( + + Debt cancelled amount + + { + const { floatValue } = values; + onChange(floatValue); + }} + /> + + + + ); + }} />
( - - Other contributions - - - - - - )} + render={({ field }) => { + const { onChange, ...rest } = field; + return ( + + Other contributed amount + + { + const { floatValue } = values; + onChange(floatValue); + }} + /> + + + + ); + }} />
diff --git a/src/components/securities/shares/steps/general-details.tsx b/src/components/securities/shares/steps/general-details.tsx index 637dab32e..d65caeeda 100644 --- a/src/components/securities/shares/steps/general-details.tsx +++ b/src/components/securities/shares/steps/general-details.tsx @@ -1,4 +1,5 @@ "use client"; +import { EmptySelect } from "@/components/securities/shared/EmptySelect"; import { Button } from "@/components/ui/button"; import { Form, @@ -29,6 +30,7 @@ import { StepperPrev, useStepper, } from "@/components/ui/stepper"; +import { VestingSchedule } from "@/lib/vesting"; import { SecuritiesStatusEnum, ShareLegendsEnum, @@ -38,38 +40,17 @@ import { useAddShareFormValues } from "@/providers/add-share-form-provider"; import type { RouterOutputs } from "@/trpc/shared"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; +import { NumericFormat } from "react-number-format"; import { z } from "zod"; -import { EmptySelect } from "../../shared/EmptySelect"; export const humanizeCompanyLegends = (type: string): string => { switch (type) { case ShareLegendsEnum.US_SECURITIES_ACT: - return "Us securities act"; + return "US Securities Act"; case ShareLegendsEnum.TRANSFER_RESTRICTIONS: - return "Transfer restrictions"; + return "Transfer Restrictions"; case ShareLegendsEnum.SALE_AND_ROFR: - return "Sale and Rofr"; - default: - return ""; - } -}; - -export const humanizeVestingSchedule = (type: string): string => { - switch (type) { - case VestingScheduleEnum.VESTING_0_0_0: - return "Immediate vesting"; - case VestingScheduleEnum.VESTING_0_0_1: - return "1 year cliff with no vesting"; - case VestingScheduleEnum.VESTING_4_1_0: - return "4 years vesting every month with no cliff"; - case VestingScheduleEnum.VESTING_4_1_1: - return "4 years vesting every month with 1 year cliff"; - case VestingScheduleEnum.VESTING_4_3_1: - return "4 years vesting every 3 months with 1 year cliff"; - case VestingScheduleEnum.VESTING_4_6_1: - return "4 years vesting every 6 months with 1 year cliff"; - case VestingScheduleEnum.VESTING_4_12_1: - return "4 years vesting every year with 1 year cliff"; + return "Sale and ROFR"; default: return ""; } @@ -139,11 +120,10 @@ export const GeneralDetails = ({ shareClasses }: GeneralDetailsProps) => { render={({ field }) => ( Share class - {/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment */} - - - - )} + render={({ field }) => { + const { onChange, ...rest } = field; + return ( + + Quantity + + { + const { floatValue } = values; + onChange(floatValue); + }} + /> + + + + ); + }} />
( - - Price per share - - - - - - )} + render={({ field }) => { + const { onChange, ...rest } = field; + return ( + + Price per share + + { + const { floatValue } = values; + onChange(floatValue); + }} + /> + + + + ); + }} />
@@ -244,14 +251,14 @@ export const GeneralDetails = ({ shareClasses }: GeneralDetailsProps) => { > - + {vestingSchedule?.length && vestingSchedule.map((vs) => ( - {humanizeVestingSchedule(vs)} + {VestingSchedule[vs]} ))} @@ -266,14 +273,16 @@ export const GeneralDetails = ({ shareClasses }: GeneralDetailsProps) => { name="companyLegends" render={({ field }) => ( - Company Legends + Company legends - + From 21427cf7c472cae02011872154f5a836b1351377 Mon Sep 17 00:00:00 2001 From: Puru D Date: Fri, 31 May 2024 02:48:06 -0500 Subject: [PATCH 35/35] chore: some minor updates --- .../[publicId]/securities/shares/page.tsx | 25 ++++++++----------- .../[publicId]/settings/company/page.tsx | 2 +- src/components/onboarding/company-form.tsx | 1 + src/trpc/routers/company-router/router.ts | 1 + src/trpc/routers/onboarding-router/schema.ts | 9 ++++--- 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/securities/shares/page.tsx b/src/app/(authenticated)/(dashboard)/[publicId]/securities/shares/page.tsx index a96fdb192..e0b3c3147 100644 --- a/src/app/(authenticated)/(dashboard)/[publicId]/securities/shares/page.tsx +++ b/src/app/(authenticated)/(dashboard)/[publicId]/securities/shares/page.tsx @@ -19,8 +19,8 @@ const SharesPage = async () => { return ( } - title="You do not have any shares yet." - subtitle="Please click the button for adding new shares." + title="You have not issued any shares" + subtitle="Please click the button below to start issuing shares." > {

Shares

- Add shares for stakeholders + Issue shares to stakeholders

+ size="4xl" + title="Create a share" + subtitle="Please fill in the details to create and issue a share." + trigger={ + } - trigger={} />
diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/settings/company/page.tsx b/src/app/(authenticated)/(dashboard)/[publicId]/settings/company/page.tsx index a11f4dee3..28cd82951 100644 --- a/src/app/(authenticated)/(dashboard)/[publicId]/settings/company/page.tsx +++ b/src/app/(authenticated)/(dashboard)/[publicId]/settings/company/page.tsx @@ -1,6 +1,6 @@ import { CompanyForm } from "@/components/onboarding/company-form"; import { api } from "@/trpc/server"; -import { type Metadata } from "next"; +import type { Metadata } from "next"; export const metadata: Metadata = { title: "Company", diff --git a/src/components/onboarding/company-form.tsx b/src/components/onboarding/company-form.tsx index 3731f29fe..a1932bf1c 100644 --- a/src/components/onboarding/company-form.tsx +++ b/src/components/onboarding/company-form.tsx @@ -82,6 +82,7 @@ export const CompanyForm = ({ type, data }: CompanyFormProps) => { state: data?.company.state ?? "", streetAddress: data?.company.streetAddress ?? "", zipcode: data?.company.zipcode ?? "", + country: data?.company.country ?? "", }, }, }); diff --git a/src/trpc/routers/company-router/router.ts b/src/trpc/routers/company-router/router.ts index abcb847b1..10654dedc 100644 --- a/src/trpc/routers/company-router/router.ts +++ b/src/trpc/routers/company-router/router.ts @@ -30,6 +30,7 @@ export const companyRouter = createTRPCRouter({ city: true, zipcode: true, streetAddress: true, + country: true, logo: true, }, }, diff --git a/src/trpc/routers/onboarding-router/schema.ts b/src/trpc/routers/onboarding-router/schema.ts index 5198c6d6d..e2def1311 100644 --- a/src/trpc/routers/onboarding-router/schema.ts +++ b/src/trpc/routers/onboarding-router/schema.ts @@ -47,9 +47,12 @@ export const ZodCompanyMutationSchema = z.object({ zipcode: z.string().min(1, { message: "Zipcode is required", }), - country: z.string().min(1, { - message: "Country is required", - }), + country: z + .string() + .min(1, { + message: "Country is required", + }) + .default("US"), logo: z.string().min(1).optional(), }), });