diff --git a/frontend/src/components/opportunities/OpportunitiesMain.tsx b/frontend/src/components/opportunities/OpportunitiesMain.tsx index b2a4ec21..2edb9a80 100644 --- a/frontend/src/components/opportunities/OpportunitiesMain.tsx +++ b/frontend/src/components/opportunities/OpportunitiesMain.tsx @@ -1,7 +1,9 @@ "use client" +import { type User } from "@/types/user" import { type Icp } from "@/types/icp" import { type Opportunity } from "@/types/opportunity" +import { getUsers } from "@/utils/chapter/users" import { getIcps } from "@/utils/chapter/icp" import { getOpportunities } from "@/utils/chapter/opportunity" import { @@ -35,11 +37,12 @@ import { useEffect, useState, useCallback, useRef } from "react" import Link from "next/link" import { OpportunityStageList } from "./OpportunityStageList" - +import { OpportunityOwner } from "./OpportunityOwner" export function OpportunitiesMain() { const [isPopulated, setIsPopulated] = useState(false) const [sheetOpen, setSheetOpen] = useState(false) const [icp, setIcp] = useState(null) + const [allUsers, setAllUsers] = useState([]) const icpRef = useRef(icp) const [records, setRecords] = useState([]) const [recordColumns, setRecordColumns] = useState[]>( @@ -51,13 +54,14 @@ export function OpportunitiesMain() { const opportunityMapRef = useRef(opportunityMap) const [selectedRow, setSelectedRow] = useState(null) - const [selectedRows, setSelectedRows] = useState([]) const [preSelectedFilters, setPreSelectedFilters] = useState([]) useEffect(() => { const fetchIcpAndOpportunities = async () => { try { + const users = await getUsers() + const currentUserIcps = await getIcps() if (currentUserIcps === null || currentUserIcps.length <= 0) { throw new Error("Failed to fetch ICP") @@ -91,10 +95,12 @@ export function OpportunitiesMain() { tools: rec.jobPosts?.flatMap((jobPost) => jobPost.tools), processes: rec.jobPosts?.flatMap((jobPost) => jobPost.processes), investors: rec.company?.lastFunding?.investors, + owner: rec.owner, } return record }) ) + setAllUsers(users) setIcp(currentUserIcps[0]) setOpportunityMap(oppMap) setRecords(tableRecords) @@ -104,6 +110,7 @@ export function OpportunitiesMain() { setRecordColumns( getRecordColumns( currentUserIcps[0], + allUsers, updateOpportunityCallback, handleOpenDrawerCallback ) @@ -144,7 +151,6 @@ export function OpportunitiesMain() { } opportunities.push(opportunity) } - setSelectedRows(opportunities) } const handleCopyRecordLink = async (recordId: string | undefined) => { @@ -221,6 +227,7 @@ export function OpportunitiesMain() { setRecordColumns( getRecordColumns( icpRef.current, + allUsers, updateOpportunityCallback, handleOpenDrawerCallback ) @@ -246,13 +253,13 @@ export function OpportunitiesMain() {
- {isPopulated && icp ? ( + {isPopulated && icp && allUsers ? ( <>
+ {selectedRow !== null && ( - + <> +
+ + +
+ )}
{selectedRow !== null && ( - + )}
diff --git a/frontend/src/components/opportunities/OpportunityDrawer.tsx b/frontend/src/components/opportunities/OpportunityDrawer.tsx index 67c8a48c..e9786f46 100644 --- a/frontend/src/components/opportunities/OpportunityDrawer.tsx +++ b/frontend/src/components/opportunities/OpportunityDrawer.tsx @@ -1,28 +1,3 @@ -import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" -import Image from "next/image" -import { timeAgo } from "@/utils/misc" -import { type Person } from "@/types/person" -import { Tabs, TabsList, TabsContent, TabsTrigger } from "@/components/ui/tabs" - -import { - Divide, - ExternalLink, - Maximize2, - Users, - Factory, - MapPin, - Landmark, - Banknote, - Target, - Loader, - StickyNote, - ChevronRight, - CircleUserIcon, - Linkedin, - Mail, -} from "lucide-react" - import { type Icp } from "@/types/icp" import { type Opportunity } from "@/types/opportunity" import { OpportunityBrand } from "./OpportunityBrand" @@ -32,13 +7,11 @@ import { Separator } from "@/components/ui/separator" interface OpportunityDrawerProps { opportunity: Opportunity - updateOpportunity: (updatedOpportunity: Opportunity) => void icp: Icp | null } export function OpportunityDrawer({ opportunity, - updateOpportunity, icp, }: OpportunityDrawerProps) { return ( @@ -47,11 +20,7 @@ export function OpportunityDrawer({
- +
diff --git a/frontend/src/components/opportunities/OpportunityFull.tsx b/frontend/src/components/opportunities/OpportunityFull.tsx index 5d3e9fd6..52859c93 100644 --- a/frontend/src/components/opportunities/OpportunityFull.tsx +++ b/frontend/src/components/opportunities/OpportunityFull.tsx @@ -1,6 +1,5 @@ "use client" import { useEffect, useState } from "react" - import TextEditor from "@/components/editor/editor" import { type Icp } from "@/types/icp" import { type Opportunity } from "@/types/opportunity" @@ -22,6 +21,7 @@ import { Separator } from "@/components/ui/separator" import { OpportunityStageList } from "./OpportunityStageList" import { OpportunityTabs } from "./OpportunityTabs" +import { OpportunityOwner } from "./OpportunityOwner" interface OpportunityFullProps { opportunityId: string @@ -100,19 +100,21 @@ export function OpportunityFull({ opportunityId }: OpportunityFullProps) {
{opportunity.slug}
- +
+ + +
- + diff --git a/frontend/src/components/opportunities/OpportunityOwner.tsx b/frontend/src/components/opportunities/OpportunityOwner.tsx new file mode 100644 index 00000000..b919d5f2 --- /dev/null +++ b/frontend/src/components/opportunities/OpportunityOwner.tsx @@ -0,0 +1,145 @@ +"use client" +import { useEffect, useState } from "react" + +import { CircleUserRound, Check, UserCircleIcon } from "lucide-react" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { toast } from "sonner" + +import { User } from "@/types/user" +import { Opportunity } from "@/types/opportunity" +import { getNameInitials } from "@/utils/misc" +import { getUsers } from "@/utils/chapter/users" +import { + getOpportunity, + updateOpportunityOwner, +} from "@/utils/chapter/opportunity" + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuCheckboxItem, +} from "@/components/ui/dropdown-menu" + +interface OpportunityOwnerProps { + opportunity: Opportunity + updateOpportunity: (updatedOpportunity: Opportunity) => void +} + +export function OpportunityOwner({ + opportunity, + updateOpportunity, +}: OpportunityOwnerProps) { + const [allUsers, setAllUsers] = useState([]) + const [selectedOwner, setSelectedOwner] = useState( + opportunity.owner + ) + + const handleOwnerChange = async (newOwner: User | null) => { + try { + let newOwnerId: string | null = null + + if (newOwner !== null) { + const user = allUsers.find((u) => u.id === newOwner.id) + if (!user) { + toast.error("Failed to set owner.") + return + } + newOwnerId = newOwner.id + } + + const updatedOpportunity = await updateOpportunityOwner( + opportunity.id, + newOwnerId + ) + setSelectedOwner(newOwner) + updateOpportunity(updatedOpportunity) + } catch (error: any) { + toast.error("Failed to update owner.", { description: error.toString() }) + } + } + + useEffect(() => { + setSelectedOwner(opportunity.owner) + }, [opportunity]) + + useEffect(() => { + const fetchUsers = async () => { + try { + const users = await getUsers() + setAllUsers(users) + } catch (error: any) { + toast.error("Failed to load users.", { description: error.toString() }) + } + } + fetchUsers() + }, []) + + return ( + <> + + + {selectedOwner ? ( + <> + + + + {getNameInitials(selectedOwner.name)} + + + {selectedOwner.name} + + ) : ( + <> + + Assignee + + )} + + + {/* Render out the list of users as dropdown items, add an option at the top titled "no assignee" and if none is selected make that selected, else the selected user */} + Team members + + + handleOwnerChange(null)} + className="" + > +
+ + + +
+ No assignee +
+ + {allUsers.map((user) => ( + handleOwnerChange(user)} + checked={selectedOwner ? selectedOwner.id === user.id : false} + className="hover:bg-red-400 cursor-pointer" + > + + + + {getNameInitials(user.name)} + + + {user.name} + + ))} +
+
+ + ) +} diff --git a/frontend/src/components/opportunities/OpportunityPropList.tsx b/frontend/src/components/opportunities/OpportunityPropList.tsx index 61cff792..e70b5873 100644 --- a/frontend/src/components/opportunities/OpportunityPropList.tsx +++ b/frontend/src/components/opportunities/OpportunityPropList.tsx @@ -20,56 +20,23 @@ import { TooltipTrigger, } from "@/components/ui/tooltip" -import { AppleLogo } from "../icons" -import { GooglePlayLogo } from "../icons" - import { humanDate } from "@/utils/misc" import { getIcps } from "@/utils/chapter/icp" import { useState, useEffect } from "react" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" import { Separator } from "@/components/ui/separator" import { Opportunity } from "@/types/opportunity" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { toast } from "sonner" - -import { cn } from "@/lib/utils" - -import { Investor } from "@/types/company" import { OpportunityStage } from "@/types/opportunity" - -import { updateOpportunityStage } from "@/utils/chapter/opportunity" - -import { stageColors } from "@/types/opportunity" import { type Icp } from "@/types/icp" -import Image from "next/image" - interface OpportunityPropListProps { opportunity: Opportunity - updateOpportunity: (updatedOpportunity: Opportunity) => void -} - -function classNames(...classes: string[]) { - return classes.filter(Boolean).join(" ") } -export function OpportunityPropList({ - opportunity, - updateOpportunity, -}: OpportunityPropListProps) { - const [currentStage, setCurrentStage] = useState( - opportunity.stage - ) +export function OpportunityPropList({ opportunity }: OpportunityPropListProps) { const [icp, setIcp] = useState(null) const stages = Object.values(OpportunityStage) @@ -80,28 +47,6 @@ export function OpportunityPropList({ window.open(url) } - const handleStageChange = async (newStage: string) => { - try { - if (!stages.includes(newStage as OpportunityStage)) { - toast.error("Failed to set stage.") - return - } - - opportunity = await updateOpportunityStage( - opportunity.id, - newStage as OpportunityStage - ) - setCurrentStage(opportunity.stage) - updateOpportunity(opportunity) - } catch (error: any) { - toast.error("Failed to update stage.") - } - } - - useEffect(() => { - setCurrentStage(opportunity.stage) - }, [opportunity]) - useEffect(() => { const fetchIcp = async () => { try { diff --git a/frontend/src/components/opportunities/OpportunityTabs.tsx b/frontend/src/components/opportunities/OpportunityTabs.tsx index b80b5409..b0899470 100644 --- a/frontend/src/components/opportunities/OpportunityTabs.tsx +++ b/frontend/src/components/opportunities/OpportunityTabs.tsx @@ -1,29 +1,5 @@ -import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" -import Image from "next/image" -import { timeAgo } from "@/utils/misc" -import { type Person } from "@/types/person" import { Tabs, TabsList, TabsContent, TabsTrigger } from "@/components/ui/tabs" import { cn } from "@/lib/utils" - -import { - Divide, - ExternalLink, - Maximize2, - Users, - Factory, - MapPin, - Landmark, - Banknote, - Target, - Loader, - StickyNote, - ChevronRight, - CircleUserIcon, - Linkedin, - Mail, -} from "lucide-react" - import { type Icp } from "@/types/icp" import { type Opportunity } from "@/types/opportunity" import { OpportunityPropList } from "./OpportunityPropList" @@ -32,15 +8,10 @@ import { OpportunityContacts } from "./OpportunityContacts" interface OpportunityTabsProps { opportunity: Opportunity - updateOpportunity: (updatedOpportunity: Opportunity) => void icp: Icp | null } -export function OpportunityTabs({ - opportunity, - updateOpportunity, - icp, -}: OpportunityTabsProps) { +export function OpportunityTabs({ opportunity, icp }: OpportunityTabsProps) { return ( <> @@ -73,10 +44,7 @@ export function OpportunityTabs({ - + {" "} diff --git a/frontend/src/components/opportunities/columns.tsx b/frontend/src/components/opportunities/columns.tsx index f961de2e..b8f2f9fb 100644 --- a/frontend/src/components/opportunities/columns.tsx +++ b/frontend/src/components/opportunities/columns.tsx @@ -12,6 +12,7 @@ import { CircleUser, Dot, ChevronDown, + CircleUserRound, } from "lucide-react" import { @@ -25,6 +26,7 @@ import { DropdownMenuRadioItem, } from "@/components/ui/dropdown-menu" import { Button } from "@/components/ui/button" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { toast } from "sonner" import { ColumnDef, VisibilityState } from "@tanstack/react-table" @@ -32,22 +34,24 @@ import { z } from "zod" import { cn } from "@/lib/utils" -import { type Opportunity, OpportunityStage } from "@/types/opportunity" +import { type User } from "@/types/user" import { type Icp } from "@/types/icp" import { type Company, FundingRound, OrgSize } from "@/types/company" import { type Location } from "@/types/location" import { type Tool, type Process } from "@/types/job_post" import { type Scale, ScaleLabel } from "@/types/scale" +import { type Opportunity, OpportunityStage } from "@/types/opportunity" import { humanDate, titleCaseToCamelCase } from "@/utils/misc" import { updateOpportunityStage } from "@/utils/chapter/opportunity" import { toTitleCase, truncateString } from "@/utils/misc" +import { getNameInitials } from "@/utils/misc" export const TableRecord = z.record(z.any()) export type RecordSchema = z.infer import { stageColors } from "@/types/opportunity" -export function getFilters(icp: Icp) { +export function getFilters(icp: Icp, users: User[]) { const filters = [ { tableColumnName: "stage", @@ -308,6 +312,22 @@ export function getFilters(icp: Icp) { }, ], }, + { + tableColumnName: "owner", + label: "Owner", + filterOptions: [ + { + value: "", + label: "No asignee", + icon: CircleUser, + }, + ...users.map((user: User) => ({ + value: user.id, + label: user.name, + icon: CircleUser, + })), + ], + }, ] return filters @@ -317,8 +337,8 @@ function classNames(...classes: string[]) { return classes.filter(Boolean).join(" ") } -function getStageFromStage(icp: Icp, stage: OpportunityStage) { - const filters = getFilters(icp) +function getStageFromStage(icp: Icp, users: User[], stage: OpportunityStage) { + const filters = getFilters(icp, users) const stageLabel: string = stage const opportunityStage = filters[0].filterOptions.find( (opportunityStage) => opportunityStage.value === stageLabel @@ -327,8 +347,12 @@ function getStageFromStage(icp: Icp, stage: OpportunityStage) { return opportunityStage } -function getCompanySizeFromHeadcount(icp: Icp, headcount: number) { - const filters = getFilters(icp) +function getCompanySizeFromHeadcount( + icp: Icp, + users: User[], + headcount: number +) { + const filters = getFilters(icp, users) let companySizeLabel = "Unknown" if (headcount <= 10) { companySizeLabel = "1-10" @@ -350,8 +374,12 @@ function getCompanySizeFromHeadcount(icp: Icp, headcount: number) { return companySize } -function getFundingFromFundingRound(icp: Icp, fundingRound: FundingRound) { - const filters = getFilters(icp) +function getFundingFromFundingRound( + icp: Icp, + users: User[], + fundingRound: FundingRound +) { + const filters = getFilters(icp, users) const fundingRoundLabel: string = fundingRound const funding = filters[2].filterOptions.find( @@ -361,8 +389,8 @@ function getFundingFromFundingRound(icp: Icp, fundingRound: FundingRound) { return funding } -function getLocationFromCountry(icp: Icp, country: string) { - const filters = getFilters(icp) +function getLocationFromCountry(icp: Icp, users: User[], country: string) { + const filters = getFilters(icp, users) let locationLabel = "Rest of the World" if (country == "Canada") { locationLabel = "Canada" @@ -407,6 +435,7 @@ export function getDefaultColumnVisibility(icp: Icp): VisibilityState { export function getFixedColumns( icp: Icp, + users: User[], updateOpportunity: (updatedOpportunity: Opportunity) => void, handleOpenDrawer: (id: string) => void ) { @@ -459,7 +488,7 @@ export function getFixedColumns( const stage: OpportunityStage = row.getValue( "stage" ) as OpportunityStage - const opportunityStage = getStageFromStage(icp, stage) + const opportunityStage = getStageFromStage(icp, users, stage) const stages = Object.values(OpportunityStage) const handleStageChange = async (newStage: string) => { try { @@ -529,7 +558,7 @@ export function getFixedColumns( return false } - const opportunityStage = getStageFromStage(icp, stage) + const opportunityStage = getStageFromStage(icp, users, stage) if (!opportunityStage) { return false } @@ -557,6 +586,7 @@ export function getFixedColumns( cell: ({ row }) => { const companySize = getCompanySizeFromHeadcount( icp, + users, row.getValue("companySize") ) if (!companySize) { @@ -574,7 +604,7 @@ export function getFixedColumns( }, filterFn: (row, id, value) => { const headcount: number = row.getValue(id) - const companySize = getCompanySizeFromHeadcount(icp, headcount) + const companySize = getCompanySizeFromHeadcount(icp, users, headcount) if (!companySize) { return false } @@ -585,7 +615,7 @@ export function getFixedColumns( { accessorKey: "orgSize", header: ({ column }) => ( - + ), cell: ({ row }) => { const orgSize: OrgSize = row.getValue("orgSize") @@ -615,6 +645,7 @@ export function getFixedColumns( cell: ({ row }) => { const funding = getFundingFromFundingRound( icp, + users, row.getValue("fundingRound") ) if (!funding) { @@ -632,7 +663,7 @@ export function getFixedColumns( }, filterFn: (row, id, value) => { const fundingRound: FundingRound = row.getValue(id) - const funding = getFundingFromFundingRound(icp, fundingRound) + const funding = getFundingFromFundingRound(icp, users, fundingRound) if (!funding) { return false } @@ -659,7 +690,11 @@ export function getFixedColumns( return false } - const companyLocation = getLocationFromCountry(icp, location.country) + const companyLocation = getLocationFromCountry( + icp, + users, + location.country + ) if (!companyLocation) { return false } @@ -773,17 +808,53 @@ export function getFixedColumns( return investors.some((investor: string) => value.includes(investor)) }, }, + { + accessorKey: "owner", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const owner: User | null = row.original.owner + return ( + <> + {owner ? ( +
+ + + + {getNameInitials(owner.name)} + + + {owner.name} +
+ ) : ( +
+ + No asignee +
+ )} + + ) + }, + filterFn: (row, id, value) => { + const owner: User | null = row.original["owner"] + const ownerId: string = owner ? owner.id : "" + return value.includes(ownerId) + }, + }, ] return fixedRecordColumns } export function getRecordColumns( icp: Icp, + users: User[], updateOpportunity: (updatedOpportunity: Opportunity) => void, handleOpenDrawer: (id: string) => void ) { const finalColumns: ColumnDef[] = getFixedColumns( icp, + users, updateOpportunity, handleOpenDrawer ) diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts index 298dda55..8633b73a 100644 --- a/frontend/src/types/user.ts +++ b/frontend/src/types/user.ts @@ -1,4 +1,5 @@ export type User = { + id: string email: string name: string avatarUrl: string diff --git a/frontend/src/utils/chapter/opportunity.ts b/frontend/src/utils/chapter/opportunity.ts index f87e0c7b..79545fc0 100644 --- a/frontend/src/utils/chapter/opportunity.ts +++ b/frontend/src/utils/chapter/opportunity.ts @@ -114,3 +114,29 @@ export async function updateOpportunityNotes(id: string, notes: string) { const opportunity = data as Opportunity return opportunity } + +export async function updateOpportunityOwner( + id: string, + ownerId: string | null +) { + const token = await getUserToken() + const response = await fetch( + process.env.NEXT_PUBLIC_CHAPTER_API_URL! + "/opportunities/" + id, + { + method: "PATCH", + headers: { + Authorization: `Bearer ${token.value}`, + }, + body: JSON.stringify({ + ownerId: ownerId, + }), + } + ) + if (!response.ok) { + const msg = await response.json() + throw new Error(msg?.detail) + } + const data = await response.json() + const opportunity = data as Opportunity + return opportunity +} diff --git a/frontend/src/utils/chapter/users.ts b/frontend/src/utils/chapter/users.ts index 69d5be4d..81820cd5 100644 --- a/frontend/src/utils/chapter/users.ts +++ b/frontend/src/utils/chapter/users.ts @@ -3,6 +3,49 @@ import { getUserToken } from "@/utils/auth" import { type User } from "@/types/user" +export async function getUsers( + pageSize: number = 20, + currentPage: number = 1, + orderBy: string = "name", + sortOrder: string = "asc", + searchField: string = "", + searchString: string = "", + searchIgnoreCase: boolean = false +) { + const token = await getUserToken() + const searchParams: { [key: string]: any } = { + pageSize: pageSize.toString(), + currentPage: currentPage.toString(), + orderBy: orderBy, + sortOrder: sortOrder, + } + + if (!!searchField && searchField.trim().length > 0) { + searchParams["searchField"] = searchField + searchParams["searchString"] = searchString + searchParams["searchIgnoreCase"] = searchIgnoreCase ? "true" : "false" + } + + const response = await fetch( + process.env.NEXT_PUBLIC_CHAPTER_API_URL! + + "/users?" + + new URLSearchParams(searchParams), + { + method: "GET", + headers: { + Authorization: `Bearer ${token.value}`, + }, + } + ) + if (!response.ok) { + const msg = await response.json() + return [] + } + const data = await response.json() + const users = "items" in data ? (data["items"] as User[]) : [] + return users +} + export async function getUserProfile() { const token = await getUserToken() const response = await fetch(