because I got bored of customising my CV for every job

feat(client): add organization membership management

Changed files
+405
apps
client
src
features
+314
apps/client/src/features/organizations/components/CollapsibleOrganizationTable.tsx
··· 1 + import { 2 + ChevronDownIcon, 3 + Table, 4 + TableBody, 5 + TableCell, 6 + TableHeader, 7 + TableHeaderCell, 8 + TableRow, 9 + TextInput, 10 + } from "@cv/ui"; 11 + import { useVirtualizer } from "@tanstack/react-virtual"; 12 + import { useEffect, useRef, useState } from "react"; 13 + import { 14 + type MeOrganizationsQuery, 15 + useInfiniteOrganizationMembersQuery, 16 + } from "@/generated/graphql"; 17 + import { OrganizationMemberRow } from "./OrganizationMemberRow"; 18 + 19 + type Organization = NonNullable< 20 + NonNullable<MeOrganizationsQuery["me"]>["organizations"] 21 + >[0]; 22 + 23 + interface CollapsibleOrganizationTableProps { 24 + organization: Organization; 25 + defaultExpanded?: boolean; 26 + } 27 + 28 + export const CollapsibleOrganizationTable = ({ 29 + organization, 30 + defaultExpanded = false, 31 + }: CollapsibleOrganizationTableProps) => { 32 + const [isExpanded, setIsExpanded] = useState(defaultExpanded); 33 + const [searchTerm, setSearchTerm] = useState(""); 34 + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); 35 + const parentRef = useRef<HTMLDivElement>(null); 36 + 37 + // Debounce search term to avoid too many API calls 38 + useEffect(() => { 39 + const timer = setTimeout(() => { 40 + setDebouncedSearchTerm(searchTerm); 41 + }, 300); 42 + 43 + return () => clearTimeout(timer); 44 + }, [searchTerm]); 45 + 46 + // Only fetch members when expanded using infinite scroll 47 + const { 48 + data: membersData, 49 + isLoading: membersLoading, 50 + error: membersError, 51 + fetchNextPage, 52 + hasNextPage, 53 + isFetchingNextPage, 54 + } = useInfiniteOrganizationMembersQuery( 55 + { 56 + organizationId: organization.id, 57 + first: 20, 58 + searchTerm: debouncedSearchTerm.trim() || undefined, 59 + }, 60 + { 61 + enabled: isExpanded, 62 + initialPageParam: { after: null }, 63 + getNextPageParam: (lastPage) => { 64 + const pageInfo = lastPage.organization?.memberships?.pageInfo; 65 + return pageInfo?.hasNextPage 66 + ? { after: pageInfo.endCursor } 67 + : undefined; 68 + }, 69 + }, 70 + ); 71 + 72 + // Flatten all pages into a single array of members 73 + const members = 74 + membersData?.pages 75 + ?.flatMap( 76 + (page) => 77 + page.organization?.memberships?.edges?.map((edge) => edge.node) || [], 78 + ) 79 + .filter( 80 + (member): member is NonNullable<typeof member> => member != null, 81 + ) || []; 82 + 83 + // Setup virtualizer for performance 84 + const virtualizer = useVirtualizer({ 85 + count: members.length, 86 + getScrollElement: () => parentRef.current, 87 + estimateSize: () => 60, 88 + overscan: 3, 89 + }); 90 + 91 + // Auto-load more when scrolling near the end 92 + const virtualItems = virtualizer.getVirtualItems(); 93 + 94 + useEffect(() => { 95 + const [lastItem] = [...virtualItems].reverse(); 96 + 97 + if (!(lastItem && hasNextPage) || isFetchingNextPage) { 98 + return; 99 + } 100 + 101 + // Load more when we're within 3 items of the end 102 + if (lastItem.index >= members.length - 3) { 103 + fetchNextPage(); 104 + } 105 + }, [ 106 + hasNextPage, 107 + fetchNextPage, 108 + members.length, 109 + isFetchingNextPage, 110 + virtualItems, 111 + ]); 112 + 113 + const toggleExpanded = () => { 114 + setIsExpanded(!isExpanded); 115 + }; 116 + 117 + return ( 118 + <div className="w-full bg-ctp-surface0 rounded-lg border border-ctp-surface1"> 119 + {/* Organization Header - Always Visible */} 120 + <button 121 + type="button" 122 + className="w-full p-6 text-left hover:bg-ctp-surface1/50 transition-colors focus:outline-none focus:ring-2 focus:ring-ctp-blue focus:ring-inset" 123 + onClick={toggleExpanded} 124 + aria-expanded={isExpanded} 125 + aria-controls={`collapsible-content-${organization.id}`} 126 + > 127 + <div className="flex items-center justify-between"> 128 + <div className="flex-1"> 129 + <h2 className="text-xl font-semibold text-ctp-text"> 130 + {organization.name} 131 + </h2> 132 + {organization.description && ( 133 + <p className="mt-1 text-sm text-ctp-subtext0"> 134 + {organization.description} 135 + </p> 136 + )} 137 + <div className="mt-2 text-sm text-ctp-subtext0"> 138 + {isExpanded && membersLoading ? ( 139 + "Loading members..." 140 + ) : isExpanded && membersError ? ( 141 + "Error loading members" 142 + ) : isExpanded ? ( 143 + <div className="flex items-center gap-2"> 144 + <span> 145 + {debouncedSearchTerm.trim() ? ( 146 + <> 147 + {members.length} member{members.length !== 1 ? "s" : ""}{" "} 148 + found 149 + {members.length > 0 && ( 150 + <span className="text-ctp-subtext0"> 151 + {" "} 152 + of {organization.memberCount} total 153 + </span> 154 + )} 155 + </> 156 + ) : ( 157 + <> 158 + {members.length} of {organization.memberCount} member 159 + {organization.memberCount !== 1 ? "s" : ""} 160 + </> 161 + )} 162 + </span> 163 + {isFetchingNextPage && ( 164 + <div className="flex items-center gap-1"> 165 + <div className="h-3 w-3 animate-spin rounded-full border-2 border-ctp-blue border-t-transparent"></div> 166 + <span className="text-xs text-ctp-blue"> 167 + Loading more... 168 + </span> 169 + </div> 170 + )} 171 + </div> 172 + ) : ( 173 + `${organization.memberCount} member${organization.memberCount !== 1 ? "s" : ""} - Click to load` 174 + )} 175 + </div> 176 + </div> 177 + 178 + {/* Toggle Icon */} 179 + <div className="ml-4 flex items-center gap-2 text-sm text-ctp-subtext0"> 180 + <span>{isExpanded ? "Hide Members" : "Show Members"}</span> 181 + <ChevronDownIcon 182 + className="h-4 w-4 transition-transform duration-200" 183 + isOpen={isExpanded} 184 + /> 185 + </div> 186 + </div> 187 + </button> 188 + 189 + {/* Collapsible Content */} 190 + <div 191 + className={`w-full overflow-hidden transition-all duration-300 ease-in-out ${ 192 + isExpanded ? "max-h-[600px] opacity-100" : "max-h-0 opacity-0" 193 + }`} 194 + > 195 + <div className="w-full border-t border-ctp-surface1"> 196 + {/* Search Input */} 197 + {isExpanded && ( 198 + <div className="p-4 border-b border-ctp-surface1"> 199 + <TextInput 200 + placeholder="Search members by name..." 201 + value={searchTerm} 202 + onChange={setSearchTerm} 203 + className="max-w-md" 204 + /> 205 + </div> 206 + )} 207 + 208 + {membersLoading ? ( 209 + <div className="text-center py-8 px-6"> 210 + <div className="text-ctp-subtext0">Loading members...</div> 211 + </div> 212 + ) : membersError ? ( 213 + <div className="text-center py-8 px-6"> 214 + <div className="text-ctp-red mb-2">Error loading members</div> 215 + <div className="text-sm text-ctp-subtext0"> 216 + Please try again later 217 + </div> 218 + </div> 219 + ) : members.length === 0 ? ( 220 + <div className="text-center py-8 px-6"> 221 + <div className="text-ctp-subtext0 mb-2"> 222 + {searchTerm.trim() ? "No members found" : "No members found"} 223 + </div> 224 + <div className="text-sm text-ctp-subtext1"> 225 + {debouncedSearchTerm.trim() ? ( 226 + <>No members match "{debouncedSearchTerm}"</> 227 + ) : ( 228 + "This organization has no members yet" 229 + )} 230 + </div> 231 + </div> 232 + ) : ( 233 + <div className="w-full pb-4"> 234 + {/* Table with virtualization and proper table rows */} 235 + <div className="w-full"> 236 + {/* Scrollable wrapper for virtualization */} 237 + <div 238 + ref={parentRef} 239 + className="w-full max-h-[500px] overflow-y-auto" 240 + > 241 + <Table fullWidth> 242 + <TableHeader> 243 + <TableRow> 244 + <TableHeaderCell className="w-1/4"> 245 + Member 246 + </TableHeaderCell> 247 + <TableHeaderCell className="w-1/6"> 248 + Role 249 + </TableHeaderCell> 250 + <TableHeaderCell className="w-1/4"> 251 + Current Position 252 + </TableHeaderCell> 253 + <TableHeaderCell className="w-1/6"> 254 + Experience 255 + </TableHeaderCell> 256 + <TableHeaderCell className="w-1/6"> 257 + Joined 258 + </TableHeaderCell> 259 + </TableRow> 260 + </TableHeader> 261 + 262 + <TableBody> 263 + {/* Top spacer for rows before first visible */} 264 + {virtualItems.length > 0 && 265 + virtualItems[0]?.start > 0 && ( 266 + <TableRow> 267 + <TableCell 268 + colSpan={5} 269 + style={{ 270 + height: `${virtualItems[0].start}px`, 271 + padding: 0, 272 + }} 273 + /> 274 + </TableRow> 275 + )} 276 + 277 + {/* Virtualized rows - render only visible rows as proper tr elements */} 278 + {virtualItems.map((virtualRow) => { 279 + const member = members[virtualRow.index]; 280 + if (!member) { 281 + return null; 282 + } 283 + 284 + return ( 285 + <OrganizationMemberRow 286 + key={virtualRow.key} 287 + member={member} 288 + /> 289 + ); 290 + })} 291 + 292 + {/* Bottom spacer for rows after last visible */} 293 + {virtualItems.length > 0 && ( 294 + <TableRow> 295 + <TableCell 296 + colSpan={5} 297 + style={{ 298 + height: `${virtualizer.getTotalSize() - (virtualItems[virtualItems.length - 1]?.end ?? 0)}px`, 299 + padding: 0, 300 + }} 301 + /> 302 + </TableRow> 303 + )} 304 + </TableBody> 305 + </Table> 306 + </div> 307 + </div> 308 + </div> 309 + )} 310 + </div> 311 + </div> 312 + </div> 313 + ); 314 + };
+75
apps/client/src/features/organizations/queries/organization-members.graphql
··· 1 + query OrganizationMembers( 2 + $organizationId: String! 3 + $first: Int 4 + $after: String 5 + $sortBy: String 6 + $sortOrder: String 7 + $searchTerm: String 8 + ) { 9 + organization(id: $organizationId) { 10 + id 11 + name 12 + description 13 + memberships( 14 + first: $first 15 + after: $after 16 + sortBy: $sortBy 17 + sortOrder: $sortOrder 18 + searchTerm: $searchTerm 19 + ) { 20 + edges { 21 + node { 22 + id 23 + joinedAt 24 + role { 25 + id 26 + name 27 + description 28 + color 29 + } 30 + user { 31 + id 32 + name 33 + email 34 + createdAt 35 + experience { 36 + edges { 37 + node { 38 + id 39 + startDate 40 + endDate 41 + description 42 + company { 43 + id 44 + name 45 + website 46 + } 47 + role { 48 + id 49 + name 50 + } 51 + level { 52 + id 53 + name 54 + } 55 + skills { 56 + id 57 + name 58 + } 59 + } 60 + } 61 + } 62 + } 63 + } 64 + cursor 65 + } 66 + pageInfo { 67 + hasNextPage 68 + hasPreviousPage 69 + startCursor 70 + endCursor 71 + } 72 + totalCount 73 + } 74 + } 75 + }
+16
apps/client/src/features/user/queries/me-organizations.graphql
··· 1 + query MeOrganizations { 2 + me { 3 + id 4 + email 5 + name 6 + createdAt 7 + organizations { 8 + id 9 + name 10 + description 11 + createdAt 12 + updatedAt 13 + memberCount 14 + } 15 + } 16 + }