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": { diff --git a/prisma/migrations/20240531045514_uniq_certificate_on_share/migration.sql b/prisma/migrations/20240531045514_uniq_certificate_on_share/migration.sql new file mode 100644 index 000000000..39746aa40 --- /dev/null +++ b/prisma/migrations/20240531045514_uniq_certificate_on_share/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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 011a8c46a..6c9cb627e 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]) diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/securities/options/page.tsx b/src/app/(authenticated)/(dashboard)/[publicId]/securities/options/page.tsx index b86612e22..976ff3e92 100644 --- a/src/app/(authenticated)/(dashboard)/[publicId]/securities/options/page.tsx +++ b/src/app/(authenticated)/(dashboard)/[publicId]/securities/options/page.tsx @@ -24,15 +24,7 @@ const OptionsPage = async () => { > - } + subtitle="Please fill in the details to create an option." trigger={ + } + /> + + ); + } + return ( - } - title="Work in progress." - subtitle="This page is not yet available." - > - - +
+
+
+

Shares

+

+ Issue shares to stakeholders +

+
+
+ + + Create a share + + } + /> +
+
+ + + +
); }; 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/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 + + + ); +}; 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/components/securities/options/steps/vesting-details.tsx b/src/components/securities/options/steps/vesting-details.tsx index 76dfe4dd3..e77e270e5 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, @@ -31,6 +30,7 @@ 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"; const formSchema = z.object({ equityPlanId: z.string(), @@ -46,20 +46,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; 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} + + ); +} 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-modal.tsx b/src/components/securities/shares/share-modal.tsx new file mode 100644 index 000000000..cfebd90d0 --- /dev/null +++ b/src/components/securities/shares/share-modal.tsx @@ -0,0 +1,51 @@ +import { + 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"; + +async function ContributionDetailsStep() { + const stakeholders = await api.stakeholder.getStakeholders.query(); + return ; +} + +async function GeneralDetailsStep() { + const shareClasses = await api.shareClass.get.query(); + return ; +} + +export const ShareModal = (props: Omit) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; 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()} + /> + )} +
+
+ +
+ ); +} diff --git a/src/components/securities/shares/share-table.tsx b/src/components/securities/shares/share-table.tsx new file mode 100644 index 000000000..a55def1ef --- /dev/null +++ b/src/components/securities/shares/share-table.tsx @@ -0,0 +1,394 @@ +"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 { 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 { toast } from "sonner"; +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-600 ring-green-600/20"; + case "DRAFT": + return "bg-yellow-50 text-yellow-600 ring-yellow-600/20"; + case "SIGNED": + return "bg-blue-50 text-blue-600 ring-blue-600/20"; + case "PENDING": + return "bg-gray-50 text-gray-600 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 share = row.original; + + const deleteShareMutation = api.securities.deleteShare.useMutation({ + onSuccess: () => { + toast.success("🎉 Successfully deleted the stakeholder"); + router.refresh(); + }, + onError: () => { + toast.error("Failed deleting the share"); + }, + }); + + 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; 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..4177447e2 --- /dev/null +++ b/src/components/securities/shares/steps/contribution-details.tsx @@ -0,0 +1,226 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Form, + 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 { + 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 { NumericFormat } from "react-number-format"; +import { z } from "zod"; +import { EmptySelect } from "../../shared/EmptySelect"; + +interface ContributionDetailsProps { + stakeholders: RouterOutputs["stakeholder"]["getStakeholders"]; +} + +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), +}); + +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 */} + + + + )} + /> +
+
+ { + const { onChange, ...rest } = field; + return ( + + Contributed capital amount + + { + const { floatValue } = values; + onChange(floatValue); + }} + /> + + + + ); + }} + /> +
+
+ { + const { onChange, ...rest } = field; + return ( + + Value of intellectual property + + { + const { floatValue } = values; + onChange(floatValue); + }} + /> + + + + ); + }} + /> +
+
+
+
+ { + const { onChange, ...rest } = field; + return ( + + Debt cancelled amount + + { + const { floatValue } = values; + onChange(floatValue); + }} + /> + + + + ); + }} + /> +
+
+ { + const { onChange, ...rest } = field; + return ( + + Other contributed amount + + { + const { floatValue } = values; + onChange(floatValue); + }} + /> + + + + ); + }} + /> +
+
+
+ + Back + + +
+ + ); +}; diff --git a/src/components/securities/shares/steps/documents.tsx b/src/components/securities/shares/steps/documents.tsx new file mode 100644 index 000000000..706f8e404 --- /dev/null +++ b/src/components/securities/shares/steps/documents.tsx @@ -0,0 +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 { 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"; + +export const 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 ( +
+
+ { + 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 new file mode 100644 index 000000000..d65caeeda --- /dev/null +++ b/src/components/securities/shares/steps/general-details.tsx @@ -0,0 +1,308 @@ +"use client"; +import { EmptySelect } from "@/components/securities/shared/EmptySelect"; +import { Button } from "@/components/ui/button"; +import { + Form, + 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 { + StepperModalFooter, + StepperPrev, + useStepper, +} from "@/components/ui/stepper"; +import { VestingSchedule } from "@/lib/vesting"; +import { + SecuritiesStatusEnum, + ShareLegendsEnum, + VestingScheduleEnum, +} from "@/prisma/enums"; +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"; + +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 ""; + } +}; + +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"]; + +interface GeneralDetailsProps { + shareClasses: ShareClasses; +} + +export const GeneralDetails = ({ shareClasses }: GeneralDetailsProps) => { + const form = useForm({ + resolver: zodResolver(formSchema), + }); + const { next } = useStepper(); + const { setValue } = useAddShareFormValues(); + + const status = Object.values(SecuritiesStatusEnum); + const vestingSchedule = Object.values(VestingScheduleEnum); + const companyLegends = Object.values(ShareLegendsEnum); + + const handleSubmit = (data: TFormSchema) => { + setValue(data); + next(); + }; + + return ( +
+ +
+
+ ( + + Certificate ID + + + + + + )} + /> +
+
+
+ ( + + Share class + + + + )} + /> +
+
+ ( + + Status + + + + )} + /> +
+
+ +
+
+ { + const { onChange, ...rest } = field; + return ( + + Quantity + + { + const { floatValue } = values; + onChange(floatValue); + }} + /> + + + + ); + }} + /> +
+
+ { + const { onChange, ...rest } = field; + return ( + + Price per share + + { + const { floatValue } = values; + onChange(floatValue); + }} + /> + + + + ); + }} + /> +
+
+ + ( + + Vesting schedule + + + + )} + /> + + ( + + Company legends + + + + + + + {companyLegends.map((cl) => ( + + {humanizeCompanyLegends(cl)} + + ))} + + + + + )} + /> +
+ + Back + + +
+ + ); +}; 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..e1553f3d4 --- /dev/null +++ b/src/components/securities/shares/steps/relevant-dates.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +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(), +}); + +type TFormSchema = z.infer; + +export const RelevantDates = () => { + 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 + + + + + + )} + /> +
+
+ +
+
+ ( + + Board approval date + + + + + + )} + /> +
+
+ ( + + Rule 144 date + + + + + + )} + /> +
+
+
+ + Back + + +
+ + ); +}; 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, +}; 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); +} 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; +}; diff --git a/src/server/audit/schema.ts b/src/server/audit/schema.ts index 7b0e9ac20..43768caad 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", @@ -49,7 +53,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(), }), ), 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(), }), }); 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.", + }; + } + }); 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.", + }; + } +} 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 }; + }, +); 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, }); diff --git a/src/trpc/routers/securities-router/schema.ts b/src/trpc/routers/securities-router/schema.ts index 140fa6b19..03a8554af 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,42 @@ export const ZodDeleteOptionMutationSchema = z.object({ export type TypeZodDeleteOptionMutationSchema = z.infer< typeof ZodDeleteOptionMutationSchema >; + +// SHARES +export const ZodAddShareMutationSchema = z.object({ + 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(), + }), + ), +}); + +export type TypeZodAddShareMutationSchema = z.infer< + typeof ZodAddShareMutationSchema +>; + +export const ZodDeleteShareMutationSchema = z.object({ + shareId: z.string(), +}); + +export type TypeZodDeleteShareMutationSchema = z.infer< + typeof ZodDeleteShareMutationSchema +>; 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; + }), }); 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", },