Skip to content

Commit fa3914b

Browse files
anikdhabaldahal
andauthored
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]>
1 parent 371dbf2 commit fa3914b

File tree

10 files changed

+287
-23
lines changed

10 files changed

+287
-23
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Full name,Work email,Job title
2+
John Doe,[email protected],Co-Founder & CTO
3+
Jane Doe,[email protected],Lawyer at Law Firm LLP

src/app/(authenticated)/(dashboard)/[publicId]/settings/team/page.tsx

+39-16
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1+
import MemberBulkImportModal from "@/components/member/member-bulk-import-modal";
12
import MemberModal from "@/components/member/member-modal";
23
import MemberTable from "@/components/member/member-table";
34
import { Button } from "@/components/ui/button";
45
import { Card } from "@/components/ui/card";
56

7+
import {
8+
DropdownMenu,
9+
DropdownMenuContent,
10+
DropdownMenuItem,
11+
DropdownMenuSeparator,
12+
DropdownMenuTrigger,
13+
} from "@/components/ui/dropdown-menu";
614
import { api } from "@/trpc/server";
7-
import { RiAddLine } from "@remixicon/react";
15+
import { RiAddLine, RiUserLine } from "@remixicon/react";
816
import { type Metadata } from "next";
917

1018
export const metadata: Metadata = {
@@ -13,7 +21,6 @@ export const metadata: Metadata = {
1321

1422
const TeamMembersPage = async () => {
1523
const members = await api.member.getMembers.query();
16-
1724
return (
1825
<div className="flex flex-col gap-y-3">
1926
<div className="flex items-center justify-between gap-y-3 ">
@@ -25,20 +32,36 @@ const TeamMembersPage = async () => {
2532
</div>
2633

2734
<div>
28-
<MemberModal
29-
title="Invite a team member"
30-
subtitle="Invite a team member to your company."
31-
member={{
32-
name: "",
33-
email: "",
34-
title: "",
35-
}}
36-
>
37-
<Button className="w-full md:w-auto" size="sm">
38-
<RiAddLine className="inline-block h-5 w-5" />
39-
Team member
40-
</Button>
41-
</MemberModal>
35+
<DropdownMenu>
36+
<DropdownMenuTrigger>
37+
<Button className="w-full md:w-auto" size="sm">
38+
<RiAddLine className="inline-block h-5 w-5" />
39+
Invite member
40+
</Button>
41+
</DropdownMenuTrigger>
42+
<DropdownMenuContent>
43+
<DropdownMenuItem asChild>
44+
<MemberModal
45+
title="Invite a team member"
46+
subtitle="Invite a team member to your company."
47+
member={{
48+
name: "",
49+
email: "",
50+
title: "",
51+
}}
52+
>
53+
<div className="flex cursor-default items-center rounded-sm py-1.5 pr-2 text-sm">
54+
<RiUserLine className="mr-2 h-5 w-5" />
55+
Invite a team member
56+
</div>
57+
</MemberModal>
58+
</DropdownMenuItem>
59+
<DropdownMenuSeparator />
60+
<DropdownMenuItem asChild>
61+
<MemberBulkImportModal />
62+
</DropdownMenuItem>
63+
</DropdownMenuContent>
64+
</DropdownMenu>
4265
</div>
4366
</div>
4467

src/components/dashboard/sidebar/index.tsx

+11-3
Original file line numberDiff line numberDiff line change
@@ -168,21 +168,29 @@ const navigation = [
168168
const company = [
169169
{
170170
id: 1,
171+
name: "Team",
172+
rootPath: "/settings/team",
173+
href: "/settings/team",
174+
icon: RiGroup2Line,
175+
activeIcon: RiGroup2Fill,
176+
},
177+
{
178+
id: 2,
171179
name: "Settings",
172-
rootPath: "/settings/",
180+
rootPath: "/settings/company",
173181
href: "/settings/company",
174182
icon: RiEqualizer2Line,
175183
activeIcon: RiEqualizer2Fill,
176184
},
177185
{
178-
id: 2,
186+
id: 3,
179187
name: "Form 3921",
180188
href: "/3921",
181189
icon: RiFileTextLine,
182190
activeIcon: RiFileTextFill,
183191
},
184192
{
185-
id: 3,
193+
id: 4,
186194
name: "409A Valuation",
187195
href: "/409a",
188196
icon: RiFileTextLine,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"use client";
2+
import Modal from "@/components/common/modal";
3+
import TeamMemberUploader from "@/components/member/member-uploader";
4+
import { RiGroupLine } from "@remixicon/react";
5+
import { useState } from "react";
6+
7+
export default function MemberBulkImportModal() {
8+
const [open, setOpen] = useState(false);
9+
return (
10+
<Modal
11+
size="xl"
12+
title="Import multiple team members"
13+
subtitle="Import and invite multiple team members to join your company."
14+
dialogProps={{
15+
open,
16+
onOpenChange: (val) => {
17+
setOpen(val);
18+
},
19+
}}
20+
trigger={
21+
<div className="flex cursor-default items-center rounded-sm py-1.5 pr-2 text-sm">
22+
<RiGroupLine className="mr-2 h-5 w-5" />
23+
Invite multiple team members
24+
</div>
25+
}
26+
>
27+
<TeamMemberUploader setOpen={setOpen} />
28+
</Modal>
29+
);
30+
}

src/components/member/member-modal.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,9 @@ const MemberModal = ({
178178

179179
<Button
180180
loading={isSubmitting}
181-
loadingText="Sending invite..."
181+
loadingText={
182+
rest.isEditMode === true ? "Updating..." : "Sending invite..."
183+
}
182184
className="mt-5"
183185
>
184186
{rest.isEditMode === true ? "Update team member" : "Send an invite"}
+126
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"use client";
2+
3+
import { Button } from "@/components/ui/button";
4+
import { toast } from "@/components/ui/use-toast";
5+
import { parseInviteMembersCSV } from "@/lib/invite-team-members-csv-parser";
6+
import { api } from "@/trpc/react";
7+
import { type TypeZodInviteMemberArrayMutationSchema } from "@/trpc/routers/member-router/schema";
8+
import { RiUploadLine } from "@remixicon/react";
9+
import Link from "next/link";
10+
import { useRouter } from "next/navigation";
11+
import { useRef, useState } from "react";
12+
13+
type TeamMemberUploaderType = {
14+
setOpen: (val: boolean) => void;
15+
};
16+
17+
const TeamMemberUploader = ({ setOpen }: TeamMemberUploaderType) => {
18+
const [csvFile, setCSVFile] = useState<File[]>([]);
19+
const fileInputRef = useRef<HTMLInputElement>(null);
20+
21+
const router = useRouter();
22+
23+
const inviteMember = api.member.inviteMember.useMutation({
24+
onSuccess: () => {
25+
setOpen(false);
26+
toast({
27+
variant: "default",
28+
title: "🎉 Invited!",
29+
description: "You have successfully invited the stakeholder.",
30+
});
31+
router.refresh();
32+
},
33+
onError: (error) => {
34+
toast({
35+
variant: "destructive",
36+
title: error.message,
37+
description: "",
38+
});
39+
},
40+
});
41+
42+
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
43+
const files = Array.from(e.currentTarget.files ?? []);
44+
setCSVFile(files);
45+
};
46+
47+
const onImport = async () => {
48+
try {
49+
if (!csvFile[0]) {
50+
return;
51+
}
52+
53+
const parsedData = (await parseInviteMembersCSV(
54+
csvFile[0],
55+
)) as TypeZodInviteMemberArrayMutationSchema;
56+
await Promise.all(
57+
parsedData.map(async (data) => {
58+
await inviteMember.mutateAsync(data);
59+
}),
60+
);
61+
setOpen(false);
62+
} catch (error) {
63+
console.error((error as Error).message);
64+
toast({
65+
variant: "destructive",
66+
title: "Something went wrong!",
67+
description:
68+
"Please check the CSV file and make sure its according to our format",
69+
});
70+
}
71+
};
72+
73+
return (
74+
<div className="space-y-4">
75+
<div className="text-sm leading-6 text-neutral-600">
76+
Please download the{" "}
77+
<Link
78+
download
79+
href="/sample-csv/captable-team-members-template.csv"
80+
target="_blank"
81+
rel="noopener noreferrer"
82+
className="rounded bg-gray-300/70 px-2 py-1 text-xs font-medium hover:bg-gray-400/50"
83+
>
84+
<span className="mr-1">sample csv file</span>
85+
<span aria-hidden="true"> &darr;</span>
86+
</Link>
87+
, complete and upload it to import your existing or new team members.
88+
</div>
89+
90+
<div
91+
className="flex h-24 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-gray-300"
92+
onClick={() => fileInputRef.current?.click()}
93+
>
94+
<RiUploadLine className="h-7 w-7 text-neutral-500" />
95+
<span className="text-sm text-neutral-500">
96+
{csvFile.length !== 0 ? csvFile[0]?.name : "Click here to import"}
97+
</span>
98+
<input
99+
onChange={onFileInputChange}
100+
type="file"
101+
ref={fileInputRef}
102+
accept=".csv"
103+
hidden
104+
/>
105+
</div>
106+
107+
<div className="text-xs">
108+
<Link
109+
target="_blank"
110+
rel="noopener noreferrer"
111+
href={""}
112+
className="text-teal-700 underline"
113+
>
114+
Learn more
115+
</Link>{" "}
116+
about the sample csv format
117+
</div>
118+
119+
<Button onClick={onImport} className="ml-auto block">
120+
Import
121+
</Button>
122+
</div>
123+
);
124+
};
125+
126+
export default TeamMemberUploader;

src/components/stakeholder/stakeholder-uploader.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { Button } from "@/components/ui/button";
44
import { toast } from "@/components/ui/use-toast";
5-
import { parseCSV } from "@/lib/csv-parser";
5+
import { parseStrakeholdersCSV } from "@/lib/stakeholders-csv-parser";
66
import { api } from "@/trpc/react";
77
import { type TypeStakeholderArray } from "@/trpc/routers/stakeholder-router/schema";
88
import { RiUploadLine } from "@remixicon/react";
@@ -46,7 +46,7 @@ const StakeholderUploader = ({ setOpen }: StakeholderUploaderType) => {
4646
return;
4747
}
4848

49-
const parsedData = await parseCSV(csvFile[0]);
49+
const parsedData = await parseStrakeholdersCSV(csvFile[0]);
5050
await mutateAsync(parsedData as TypeStakeholderArray);
5151

5252
setOpen(false);
+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { ZodInviteMemberMutationSchema } from "@/trpc/routers/member-router/schema";
2+
import Papa, { type ParseResult } from "papaparse";
3+
import { ZodError } from "zod";
4+
5+
export const parseInviteMembersCSV = async (csvFile: File) => {
6+
return new Promise((resolve, reject) => {
7+
const reader = new FileReader();
8+
9+
reader.onload = function (event) {
10+
const csvData = event.target?.result as string;
11+
const parsed: ParseResult<string[]> = Papa.parse(csvData, {
12+
skipEmptyLines: true,
13+
comments: "Full name,Work email,Job title",
14+
});
15+
16+
const keys = ["name", "email", "title"];
17+
18+
const mappedCSV = parsed.data.map((csv) => {
19+
const values = csv.map((value) => {
20+
value = value.trim();
21+
return value;
22+
});
23+
24+
if (values.length != keys.length) {
25+
reject(
26+
new Error(
27+
`Invalid values, Please make sure you have ${keys.length} values. You can put "" (empty string) for the optional fields.`,
28+
),
29+
);
30+
return;
31+
}
32+
33+
const entry = Object.fromEntries(
34+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
35+
keys.map((key, index) => [key, values[index] || undefined]),
36+
);
37+
38+
const filtered = Object.fromEntries(
39+
Object.entries(entry).filter(([_, value]) => value !== undefined),
40+
);
41+
42+
return filtered;
43+
});
44+
45+
mappedCSV.forEach((csv) => {
46+
try {
47+
ZodInviteMemberMutationSchema.parse(csv);
48+
} catch (error) {
49+
if (error instanceof ZodError) {
50+
return new Error(error.issues[0]?.message);
51+
}
52+
}
53+
});
54+
55+
resolve(mappedCSV);
56+
};
57+
58+
reader.onerror = function () {
59+
reject(new Error("Error reading the file"));
60+
};
61+
62+
reader.readAsText(csvFile);
63+
});
64+
};

src/lib/csv-parser.ts src/lib/stakeholders-csv-parser.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { ZodAddStakeholderMutationSchema } from "@/trpc/routers/stakeholder-rout
22
import Papa, { type ParseResult } from "papaparse";
33
import { ZodError } from "zod";
44

5-
export const parseCSV = async (csvFile: File) => {
5+
export const parseStrakeholdersCSV = async (csvFile: File) => {
66
return new Promise((resolve, reject) => {
77
const reader = new FileReader();
88

src/trpc/routers/member-router/schema.ts

+8
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ export type TypeZodInviteMemberMutationSchema = z.infer<
1212
typeof ZodInviteMemberMutationSchema
1313
>;
1414

15+
export const ZodInviteMemberArrayMutationSchema = z.array(
16+
ZodInviteMemberMutationSchema,
17+
);
18+
19+
export type TypeZodInviteMemberArrayMutationSchema = z.infer<
20+
typeof ZodInviteMemberArrayMutationSchema
21+
>;
22+
1523
export const ZodAcceptMemberMutationSchema = z.object({
1624
memberId: z.string().min(1),
1725
name: z.string().min(1, "This field is required"),

0 commit comments

Comments
 (0)