mirror of
https://github.com/hanzoai/captable.git
synced 2026-10-16 14:21:29 +00:00
feat: ability to import csv and invite team invitation in bulk (#237)
* feat: ability to import csv and invite team invitation in bulk * feat: bulk imports * feat: some minor fixes * fix: component import --------- Co-authored-by: Puru D <[email protected]>
This commit is contained in:
committed by
GitHub
co-authored by
Puru D
parent
0ba19a712f
commit
964beb55ba
@@ -0,0 +1,3 @@
|
||||
Full name,Work email,Job title
|
||||
John Doe,[email protected],Co-Founder & CTO
|
||||
Jane Doe,[email protected],Lawyer at Law Firm LLP
|
||||
|
@@ -1,10 +1,18 @@
|
||||
import MemberBulkImportModal from "@/components/member/member-bulk-import-modal";
|
||||
import MemberModal from "@/components/member/member-modal";
|
||||
import MemberTable from "@/components/member/member-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { api } from "@/trpc/server";
|
||||
import { RiAddLine } from "@remixicon/react";
|
||||
import { RiAddLine, RiUserLine } from "@remixicon/react";
|
||||
import { type Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -13,7 +21,6 @@ export const metadata: Metadata = {
|
||||
|
||||
const TeamMembersPage = async () => {
|
||||
const members = await api.member.getMembers.query();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<div className="flex items-center justify-between gap-y-3 ">
|
||||
@@ -25,20 +32,36 @@ const TeamMembersPage = async () => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MemberModal
|
||||
title="Invite a team member"
|
||||
subtitle="Invite a team member to your company."
|
||||
member={{
|
||||
name: "",
|
||||
email: "",
|
||||
title: "",
|
||||
}}
|
||||
>
|
||||
<Button className="w-full md:w-auto" size="sm">
|
||||
<RiAddLine className="inline-block h-5 w-5" />
|
||||
Team member
|
||||
</Button>
|
||||
</MemberModal>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button className="w-full md:w-auto" size="sm">
|
||||
<RiAddLine className="inline-block h-5 w-5" />
|
||||
Invite member
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem asChild>
|
||||
<MemberModal
|
||||
title="Invite a team member"
|
||||
subtitle="Invite a team member to your company."
|
||||
member={{
|
||||
name: "",
|
||||
email: "",
|
||||
title: "",
|
||||
}}
|
||||
>
|
||||
<div className="flex cursor-default items-center rounded-sm py-1.5 pr-2 text-sm">
|
||||
<RiUserLine className="mr-2 h-5 w-5" />
|
||||
Invite a team member
|
||||
</div>
|
||||
</MemberModal>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<MemberBulkImportModal />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -168,21 +168,29 @@ const navigation = [
|
||||
const company = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Team",
|
||||
rootPath: "/settings/team",
|
||||
href: "/settings/team",
|
||||
icon: RiGroup2Line,
|
||||
activeIcon: RiGroup2Fill,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Settings",
|
||||
rootPath: "/settings/",
|
||||
rootPath: "/settings/company",
|
||||
href: "/settings/company",
|
||||
icon: RiEqualizer2Line,
|
||||
activeIcon: RiEqualizer2Fill,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
id: 3,
|
||||
name: "Form 3921",
|
||||
href: "/3921",
|
||||
icon: RiFileTextLine,
|
||||
activeIcon: RiFileTextFill,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
id: 4,
|
||||
name: "409A Valuation",
|
||||
href: "/409a",
|
||||
icon: RiFileTextLine,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
import Modal from "@/components/common/modal";
|
||||
import TeamMemberUploader from "@/components/member/member-uploader";
|
||||
import { RiGroupLine } from "@remixicon/react";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function MemberBulkImportModal() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Modal
|
||||
size="xl"
|
||||
title="Import multiple team members"
|
||||
subtitle="Import and invite multiple team members to join your company."
|
||||
dialogProps={{
|
||||
open,
|
||||
onOpenChange: (val) => {
|
||||
setOpen(val);
|
||||
},
|
||||
}}
|
||||
trigger={
|
||||
<div className="flex cursor-default items-center rounded-sm py-1.5 pr-2 text-sm">
|
||||
<RiGroupLine className="mr-2 h-5 w-5" />
|
||||
Invite multiple team members
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TeamMemberUploader setOpen={setOpen} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -178,7 +178,9 @@ const MemberModal = ({
|
||||
|
||||
<Button
|
||||
loading={isSubmitting}
|
||||
loadingText="Sending invite..."
|
||||
loadingText={
|
||||
rest.isEditMode === true ? "Updating..." : "Sending invite..."
|
||||
}
|
||||
className="mt-5"
|
||||
>
|
||||
{rest.isEditMode === true ? "Update team member" : "Send an invite"}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { parseInviteMembersCSV } from "@/lib/invite-team-members-csv-parser";
|
||||
import { api } from "@/trpc/react";
|
||||
import { type TypeZodInviteMemberArrayMutationSchema } from "@/trpc/routers/member-router/schema";
|
||||
import { RiUploadLine } from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
type TeamMemberUploaderType = {
|
||||
setOpen: (val: boolean) => void;
|
||||
};
|
||||
|
||||
const TeamMemberUploader = ({ setOpen }: TeamMemberUploaderType) => {
|
||||
const [csvFile, setCSVFile] = useState<File[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const inviteMember = api.member.inviteMember.useMutation({
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "🎉 Invited!",
|
||||
description: "You have successfully invited the stakeholder.",
|
||||
});
|
||||
router.refresh();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: error.message,
|
||||
description: "",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.currentTarget.files ?? []);
|
||||
setCSVFile(files);
|
||||
};
|
||||
|
||||
const onImport = async () => {
|
||||
try {
|
||||
if (!csvFile[0]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedData = (await parseInviteMembersCSV(
|
||||
csvFile[0],
|
||||
)) as TypeZodInviteMemberArrayMutationSchema;
|
||||
await Promise.all(
|
||||
parsedData.map(async (data) => {
|
||||
await inviteMember.mutateAsync(data);
|
||||
}),
|
||||
);
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
console.error((error as Error).message);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Something went wrong!",
|
||||
description:
|
||||
"Please check the CSV file and make sure its according to our format",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm leading-6 text-neutral-600">
|
||||
Please download the{" "}
|
||||
<Link
|
||||
download
|
||||
href="/sample-csv/captable-team-members-template.csv"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded bg-gray-300/70 px-2 py-1 text-xs font-medium hover:bg-gray-400/50"
|
||||
>
|
||||
<span className="mr-1">sample csv file</span>
|
||||
<span aria-hidden="true"> ↓</span>
|
||||
</Link>
|
||||
, complete and upload it to import your existing or new team members.
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex h-24 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-gray-300"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<RiUploadLine className="h-7 w-7 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-500">
|
||||
{csvFile.length !== 0 ? csvFile[0]?.name : "Click here to import"}
|
||||
</span>
|
||||
<input
|
||||
onChange={onFileInputChange}
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
accept=".csv"
|
||||
hidden
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs">
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={""}
|
||||
className="text-teal-700 underline"
|
||||
>
|
||||
Learn more
|
||||
</Link>{" "}
|
||||
about the sample csv format
|
||||
</div>
|
||||
|
||||
<Button onClick={onImport} className="ml-auto block">
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamMemberUploader;
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { parseCSV } from "@/lib/csv-parser";
|
||||
import { parseStrakeholdersCSV } from "@/lib/stakeholders-csv-parser";
|
||||
import { api } from "@/trpc/react";
|
||||
import { type TypeStakeholderArray } from "@/trpc/routers/stakeholder-router/schema";
|
||||
import { RiUploadLine } from "@remixicon/react";
|
||||
@@ -46,7 +46,7 @@ const StakeholderUploader = ({ setOpen }: StakeholderUploaderType) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedData = await parseCSV(csvFile[0]);
|
||||
const parsedData = await parseStrakeholdersCSV(csvFile[0]);
|
||||
await mutateAsync(parsedData as TypeStakeholderArray);
|
||||
|
||||
setOpen(false);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ZodInviteMemberMutationSchema } from "@/trpc/routers/member-router/schema";
|
||||
import Papa, { type ParseResult } from "papaparse";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
export const parseInviteMembersCSV = async (csvFile: File) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = function (event) {
|
||||
const csvData = event.target?.result as string;
|
||||
const parsed: ParseResult<string[]> = Papa.parse(csvData, {
|
||||
skipEmptyLines: true,
|
||||
comments: "Full name,Work email,Job title",
|
||||
});
|
||||
|
||||
const keys = ["name", "email", "title"];
|
||||
|
||||
const mappedCSV = parsed.data.map((csv) => {
|
||||
const values = csv.map((value) => {
|
||||
value = value.trim();
|
||||
return value;
|
||||
});
|
||||
|
||||
if (values.length != keys.length) {
|
||||
reject(
|
||||
new Error(
|
||||
`Invalid values, Please make sure you have ${keys.length} values. You can put "" (empty string) for the optional fields.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = Object.fromEntries(
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
keys.map((key, index) => [key, values[index] || undefined]),
|
||||
);
|
||||
|
||||
const filtered = Object.fromEntries(
|
||||
Object.entries(entry).filter(([_, value]) => value !== undefined),
|
||||
);
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
mappedCSV.forEach((csv) => {
|
||||
try {
|
||||
ZodInviteMemberMutationSchema.parse(csv);
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return new Error(error.issues[0]?.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
resolve(mappedCSV);
|
||||
};
|
||||
|
||||
reader.onerror = function () {
|
||||
reject(new Error("Error reading the file"));
|
||||
};
|
||||
|
||||
reader.readAsText(csvFile);
|
||||
});
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { ZodAddStakeholderMutationSchema } from "@/trpc/routers/stakeholder-rout
|
||||
import Papa, { type ParseResult } from "papaparse";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
export const parseCSV = async (csvFile: File) => {
|
||||
export const parseStrakeholdersCSV = async (csvFile: File) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
@@ -12,6 +12,14 @@ export type TypeZodInviteMemberMutationSchema = z.infer<
|
||||
typeof ZodInviteMemberMutationSchema
|
||||
>;
|
||||
|
||||
export const ZodInviteMemberArrayMutationSchema = z.array(
|
||||
ZodInviteMemberMutationSchema,
|
||||
);
|
||||
|
||||
export type TypeZodInviteMemberArrayMutationSchema = z.infer<
|
||||
typeof ZodInviteMemberArrayMutationSchema
|
||||
>;
|
||||
|
||||
export const ZodAcceptMemberMutationSchema = z.object({
|
||||
memberId: z.string().min(1),
|
||||
name: z.string().min(1, "This field is required"),
|
||||
|
||||
Reference in New Issue
Block a user