From b4e231588b254bdde67bef0e69e03f150be8150f Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Mon, 3 Aug 2026 21:17:32 -0400 Subject: [PATCH 01/27] the table view --- src/app/account/account.module.css | 1 + .../sections/AccountContributionsSection.tsx | 100 ++-- src/app/admin/layout/Sidebar.tsx | 16 +- .../panels/membership/membership.data.ts | 279 ++++++++++ .../admin/panels/membership/page.module.css | 230 ++++++++ src/app/admin/panels/membership/page.tsx | 517 ++++++++++++++++++ src/components/common/index.ts | 1 + src/components/common/table/Table.module.css | 85 +++ src/components/common/table/Table.tsx | 276 ++++++++++ src/components/common/table/index.ts | 2 + 10 files changed, 1451 insertions(+), 56 deletions(-) create mode 100644 src/app/admin/panels/membership/membership.data.ts create mode 100644 src/app/admin/panels/membership/page.module.css create mode 100644 src/app/admin/panels/membership/page.tsx create mode 100644 src/components/common/table/Table.module.css create mode 100644 src/components/common/table/Table.tsx create mode 100644 src/components/common/table/index.ts diff --git a/src/app/account/account.module.css b/src/app/account/account.module.css index b3ff21bb..42dcb9c0 100644 --- a/src/app/account/account.module.css +++ b/src/app/account/account.module.css @@ -229,6 +229,7 @@ display: flex; flex-flow: column nowrap; align-items: stretch; + overflow-x: auto; padding: 0 1rem 1rem 1rem; } diff --git a/src/app/account/sections/AccountContributionsSection.tsx b/src/app/account/sections/AccountContributionsSection.tsx index 6cea3bfe..a038f745 100644 --- a/src/app/account/sections/AccountContributionsSection.tsx +++ b/src/app/account/sections/AccountContributionsSection.tsx @@ -3,6 +3,7 @@ import styles from '@/app/account/account.module.css' import formStyles from '@/components/common/forms/Form.module.css' import formFieldStyles from '@/components/common/forms/FormField.module.css' +import { Table, Column } from '@/components/common/table' import { ActBlueContribution, User } from '@/contracts/data' import { cn } from '@/util' import React, { ChangeEvent, useState } from 'react' @@ -62,64 +63,53 @@ const getRecurrenceType = ( : 'monthly' } +type ContributionLineitem = ContributionsTableProps['lineitems'][number] + +const contributionColumns: Column[] = [ + { + key: 'recurring', + header: 'Recurring', + width: '8.5rem', + render: (row) => ( + + ), + }, + { + key: 'form', + header: 'Contribution Form', + render: (row) => + formatContributionForm(row.contribution.contributionForm), + }, + { + key: 'orderNumber', + header: 'Order Number', + width: '9rem', + render: (row) => row.contribution.orderNumber, + }, + { + key: 'amount', + header: 'Amount', + width: '7rem', + render: (row) => `$${row.amount.toFixed(2)}`, + }, + { + key: 'date', + header: 'Date', + width: '11rem', + render: (row) => row.paidAt.toLocaleDateString(), + }, +] + const ContributionsTable = ({ lineitems }: ContributionsTableProps) => { return (
-
-
- Recurring - Contribution Form - Order Number - Amount - Date -
- - {lineitems.map( - ({ contribution, lineitemId, amount, paidAt }) => { - return ( -
- - - - - {formatContributionForm( - contribution.contributionForm - )} - - - {contribution.orderNumber} - - - ${amount.toFixed(2)} - - - {paidAt.toLocaleDateString()} - -
- ) - } - )} -
+ + `${row.contribution.uniqueIdentifier}-${row.lineitemId}` + } + /> ) } diff --git a/src/app/admin/layout/Sidebar.tsx b/src/app/admin/layout/Sidebar.tsx index 224684b9..577e2fac 100644 --- a/src/app/admin/layout/Sidebar.tsx +++ b/src/app/admin/layout/Sidebar.tsx @@ -1,10 +1,17 @@ 'use client' import styles from './Sidebar.module.css' +import { members } from '@/app/admin/panels/membership/membership.data' import { cn } from '@/util' import Link from 'next/link' import { usePathname } from 'next/navigation' -import { FaUsers, FaUserShield, FaUserTag, FaDonate } from 'react-icons/fa' +import { + FaUsers, + FaUserShield, + FaUserTag, + FaDonate, + FaIdCard, +} from 'react-icons/fa' import { FaClipboardUser, FaDollarSign } from 'react-icons/fa6' import { FiChevronLeft } from 'react-icons/fi' import type { IconType } from 'react-icons/lib' @@ -91,6 +98,13 @@ export default function Sidebar({ count={2} open={open} /> +
m.id} + collapsedCategories={collapsedCategories} + /> + + + + ) +} diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 98574401..d917acba 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -17,3 +17,4 @@ export * from './PersonCard' export * from './toggle/Toggle' export * from './ToolTip' export * from './charts' +export * from './table' diff --git a/src/components/common/table/Table.module.css b/src/components/common/table/Table.module.css new file mode 100644 index 00000000..1b9e002b --- /dev/null +++ b/src/components/common/table/Table.module.css @@ -0,0 +1,85 @@ +.container { + display: flex; + flex-direction: column; + border: 1px solid rgba(17, 24, 39, 0.12); + border-radius: 0.85rem; + overflow: hidden; + background: #ffffff; + min-width: fit-content; +} + +.header, +.row { + display: grid; + gap: 0.75rem; + align-items: center; + padding: 0.65rem 0.9rem; +} + +.header { + font-size: 0.8rem; + font-weight: 700; + color: rgba(15, 23, 42, 0.78); + background: rgba(15, 23, 42, 0.06); + border-bottom: 1px solid rgba(17, 24, 39, 0.1); +} + +.sortableHeader { + display: inline-flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; + user-select: none; +} + +.sortIcon { + display: inline-flex; + align-items: center; + font-size: 0.85rem; + flex-shrink: 0; + color: rgba(15, 23, 42, 0.4); +} + +.sortableHeader:hover .sortIcon { + color: rgba(15, 23, 42, 0.7); +} + +.row { + border-bottom: 1px solid rgba(17, 24, 39, 0.08); + font-size: 0.9rem; + color: rgba(15, 23, 42, 0.9); + transition: background-color 0.12s ease; +} + +.row:hover { + background-color: rgba(15, 23, 42, 0.04); +} + +.row:last-child { + border-bottom: 0; +} + +.cell { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dotCell { + display: flex; + align-items: center; + gap: 0.3rem; +} + +.bullet { + color: rgba(15, 23, 42, 0.35); + font-size: 1rem; + line-height: 1; +} + +.dot { + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; +} diff --git a/src/components/common/table/Table.tsx b/src/components/common/table/Table.tsx new file mode 100644 index 00000000..6a08f5c8 --- /dev/null +++ b/src/components/common/table/Table.tsx @@ -0,0 +1,276 @@ +'use client' + +import styles from './Table.module.css' +import React, { useMemo, useState } from 'react' +import { FiChevronDown, FiChevronUp } from 'react-icons/fi' + +const DEFAULT_COLUMN_WIDTH = '10rem' + +export interface Column { + key: string + header: string + render: (row: T) => React.ReactNode + width?: string + sortValue?: (row: T) => string | number | boolean | null | undefined +} + +export interface ColumnCategory { + label: string + columns: Column[] + collapsedWidth?: string + dotColor?: (row: T, col: Column) => string | null + rowRender?: (row: T) => React.ReactNode | null +} + +export type ColumnEntry = Column | ColumnCategory + +function isCategory(entry: ColumnEntry): entry is ColumnCategory { + return 'columns' in entry && 'label' in entry +} + +export interface TableProps { + columns: ColumnEntry[] + data: T[] + rowKey: (row: T, index: number) => string | number + collapsedCategories?: string[] +} + +type SortDir = 'asc' | 'desc' + +export function Table({ + columns, + data, + rowKey, + collapsedCategories = [], +}: TableProps) { + const [sortKey, setSortKey] = useState(null) + const [sortDir, setSortDir] = useState('asc') + + const collapsedSet = useMemo( + () => new Set(collapsedCategories), + [collapsedCategories] + ) + + const handleSort = (key: string) => { + if (sortKey === key) { + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')) + } else { + setSortKey(key) + setSortDir('asc') + } + } + + const flatColumns = useMemo( + () => + columns.flatMap((entry) => + isCategory(entry) ? entry.columns : [entry] + ), + [columns] + ) + + const sortedData = useMemo(() => { + const col = flatColumns.find((c) => c.key === sortKey) + if (!col?.sortValue) return data + + const sorted = [...data].sort((a, b) => { + const aVal = col.sortValue!(a) + const bVal = col.sortValue!(b) + if (aVal == null && bVal == null) return 0 + if (aVal == null) return 1 + if (bVal == null) return -1 + if (aVal < bVal) return -1 + if (aVal > bVal) return 1 + return 0 + }) + + return sortDir === 'desc' ? sorted.reverse() : sorted + }, [data, flatColumns, sortKey, sortDir]) + + const visibleEntries = useMemo(() => { + const entries: { + type: 'column' | 'group-collapsed' + col?: Column + category?: ColumnCategory + width: string + }[] = [] + for (const entry of columns) { + if (isCategory(entry)) { + if (collapsedSet.has(entry.label)) { + entries.push({ + type: 'group-collapsed', + category: entry, + width: entry.collapsedWidth ?? '5rem', + }) + } else { + for (const col of entry.columns) { + entries.push({ + type: 'column', + col, + category: entry, + width: col.width ?? DEFAULT_COLUMN_WIDTH, + }) + } + } + } else { + entries.push({ + type: 'column', + col: entry, + width: entry.width ?? DEFAULT_COLUMN_WIDTH, + }) + } + } + return entries + }, [columns, collapsedSet]) + + const gridTemplateColumns = visibleEntries.map((e) => e.width).join(' ') + + return ( +
+
+ {visibleEntries.map((entry) => { + if (entry.type === 'group-collapsed') { + return ( + + ) + } + const col = entry.col! + return ( + handleSort(col.key) + : undefined + } + > + {col.header} + {col.sortValue && ( + + {sortKey === col.key ? ( + sortDir === 'asc' ? ( + + ) : ( + + ) + ) : ( + + )} + + )} + + ) + })} +
+ + {sortedData.map((row, index) => { + // Pre-compute rowRender results for categories + const rowOverrides = new Map() + for (const entry of columns) { + if (isCategory(entry) && entry.rowRender) { + const result = entry.rowRender(row) + if (result != null) { + rowOverrides.set(entry.label, result) + } + } + } + + return ( +
+ {(() => { + const rendered = new Set() + return visibleEntries.map((entry) => { + if (entry.type === 'group-collapsed') { + const category = entry.category! + return ( + + {category.columns.map((col) => { + const color = + category.dotColor?.( + row, + col + ) ?? null + if (color) { + return ( + + ) + } + return ( + + · + + ) + })} + + ) + } + // Check if this column belongs to a category with a rowRender override + if ( + entry.category && + rowOverrides.has(entry.category.label) + ) { + if (rendered.has(entry.category.label)) { + return null + } + rendered.add(entry.category.label) + const span = entry.category.columns.length + return ( + + {rowOverrides.get( + entry.category.label + )} + + ) + } + const col = entry.col! + return ( + + {col.render(row)} + + ) + }) + })()} +
+ ) + })} +
+ ) +} diff --git a/src/components/common/table/index.ts b/src/components/common/table/index.ts new file mode 100644 index 00000000..1ba4b902 --- /dev/null +++ b/src/components/common/table/index.ts @@ -0,0 +1,2 @@ +export { Table } from './Table' +export type { Column, ColumnCategory, ColumnEntry, TableProps } from './Table' From 91366273b381e96cdc7b79a4fa9654b5e0fc6e64 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Sat, 5 Sep 2026 10:55:04 -0400 Subject: [PATCH 02/27] implementing the api data --- src/app/admin/layout.tsx | 8 + src/app/admin/layout/Sidebar.tsx | 20 +- src/app/admin/panels/members/page.tsx | 6 +- .../panels/membership/membership.types.ts | 66 + .../admin/panels/membership/page.module.css | 353 ++++- src/app/admin/panels/membership/page.tsx | 1272 ++++++++++++++--- .../membership.data.ts | 0 .../panels/membershipexample/page.module.css | 239 ++++ .../admin/panels/membershipexample/page.tsx | 519 +++++++ .../panels/membershiptest/membership.data.ts | 279 ++++ .../panels/membershiptest/page.module.css | 323 +++++ src/app/admin/panels/membershiptest/page.tsx | 491 +++++++ .../common/dropdown/DropdownButton.tsx | 81 +- src/components/common/table/Table.module.css | 56 +- src/components/common/table/Table.tsx | 136 +- src/util/hooks/usePaginatedSearch.ts | 2 +- 16 files changed, 3625 insertions(+), 226 deletions(-) create mode 100644 src/app/admin/panels/membership/membership.types.ts rename src/app/admin/panels/{membership => membershipexample}/membership.data.ts (100%) create mode 100644 src/app/admin/panels/membershipexample/page.module.css create mode 100644 src/app/admin/panels/membershipexample/page.tsx create mode 100644 src/app/admin/panels/membershiptest/membership.data.ts create mode 100644 src/app/admin/panels/membershiptest/page.module.css create mode 100644 src/app/admin/panels/membershiptest/page.tsx diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 55cda7de..bd88d609 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -11,6 +11,7 @@ import { zRole, zUser, } from '@/contracts/data' +import { zMembershipsResponsePacket } from '@/contracts/responses' import { usePositionQueries } from '@/queries' import { usePaginatedSearch } from '@/util/hooks' import { useQuery } from '@tanstack/react-query' @@ -31,6 +32,11 @@ export default function Layout({ children }: { children: React.ReactNode }) { zActBlueDonationPacket, { search: { limit: 0 } } ) + const memberships = usePaginatedSearch( + '/actblue/memberships', + zMembershipsResponsePacket, + { search: { limit: 1 } } + ) const positionHierarchy = useQuery({ queryKey: ['positionHierarchy'], queryFn: positionQueries.getPositionHierarchy, @@ -52,6 +58,8 @@ export default function Layout({ children }: { children: React.ReactNode }) { } donorCount={donors.query.data?.count} contributionCount={contributions.query.data?.count} + membershipCount={memberships.query.data?.count} + membershipExampleCount={memberships.query.data?.count} />
{children}
diff --git a/src/app/admin/layout/Sidebar.tsx b/src/app/admin/layout/Sidebar.tsx index 577e2fac..76108bce 100644 --- a/src/app/admin/layout/Sidebar.tsx +++ b/src/app/admin/layout/Sidebar.tsx @@ -1,7 +1,6 @@ 'use client' import styles from './Sidebar.module.css' -import { members } from '@/app/admin/panels/membership/membership.data' import { cn } from '@/util' import Link from 'next/link' import { usePathname } from 'next/navigation' @@ -24,6 +23,8 @@ interface SidebarProps { positionCount?: number roleCount?: number permissionCount?: number + membershipCount?: number + membershipExampleCount?: number } export default function Sidebar({ @@ -33,6 +34,8 @@ export default function Sidebar({ positionCount, roleCount, permissionCount, + membershipCount, + membershipExampleCount, }: SidebarProps) { const [open, setOpen] = useLocalStorage('pv.admin-nav-open', true) @@ -102,7 +105,20 @@ export default function Sidebar({ title="Membership" href="/admin/panels/membership" icon={FaIdCard} - count={members.length} + count={membershipCount} + open={open} + /> + + diff --git a/src/app/admin/panels/members/page.tsx b/src/app/admin/panels/members/page.tsx index b165ade2..99335113 100644 --- a/src/app/admin/panels/members/page.tsx +++ b/src/app/admin/panels/members/page.tsx @@ -39,6 +39,7 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query' +import { useSearchParams } from 'next/navigation' import { useCallback, useMemo, useState } from 'react' import { FaUsers, FaUserTag } from 'react-icons/fa' import { FaClipboardUser, FaDollarSign, FaAddressCard } from 'react-icons/fa6' @@ -56,8 +57,11 @@ const tabs: TabSpec[] = [ export default function Page() { const queryClient = useQueryClient() const { ready, onGet, onPatch, onPost } = useFetch() + const navUser = useSearchParams().get('user') - const [selectedId, setSelectedId] = useState(null) + const [selectedId, setSelectedId] = useState( + navUser ? +navUser : null + ) const [selectedHistory, setSelectedHistory] = useState | null>(null) diff --git a/src/app/admin/panels/membership/membership.types.ts b/src/app/admin/panels/membership/membership.types.ts new file mode 100644 index 00000000..5b517605 --- /dev/null +++ b/src/app/admin/panels/membership/membership.types.ts @@ -0,0 +1,66 @@ +import { MembershipsResponsePacket } from '@/contracts/responses' + +export const membershipTiers = [ + 'Dues Paying Member', + 'Premium Member', + 'Signature Member', + 'Inner Circle Member', +] as const + +export type MembershipTier = (typeof membershipTiers)[number] + +export type ShirtSize = 'XS' | 'S' | 'M' | 'L' | 'XL' | 'XXL' + +export type PackageShipped = + | 'Yes' + | 'No' + | 'Returned' + | 'Not Received' + | 'Canceled' + +export interface Member { + packet: MembershipsResponsePacket + id: number + userId?: number + firstName?: string + lastName?: string + userName?: string + donorName?: string + discordUsername?: string + contributionDiscord?: string + phone?: string + userPhone?: string + donorPhone?: string + email?: string + userEmail?: string + discordEmail?: string + donorEmail?: string + address1?: string + address2?: string + city?: string + state?: string + zip?: string + country?: string + userAddress?: string + donorAddress?: string + shirtSize?: ShirtSize + + isMember?: boolean + eligibleForBenefits?: boolean + beganMembership?: string + membershipAmount?: number + membershipTier?: MembershipTier + numberOfContributions?: number + nameUpdatedAt?: Date + + discordConfirmed?: boolean + nameConfirmed?: boolean + addressConfirmed?: boolean + cardPrinted?: boolean + labelPrinted?: boolean + cardPacked?: boolean + benefitShipped?: boolean + packageShipped?: PackageShipped + + userMatched?: boolean +} diff --git a/src/app/admin/panels/membership/page.module.css b/src/app/admin/panels/membership/page.module.css index 584708d6..cb7413b5 100644 --- a/src/app/admin/panels/membership/page.module.css +++ b/src/app/admin/panels/membership/page.module.css @@ -13,8 +13,9 @@ .scrollView { flex: 1 1 auto; min-height: 0; - overflow-x: hidden; - overflow-y: auto; + display: flex; + flex-direction: column; + overflow: hidden; padding: 0 0 1rem; } @@ -62,9 +63,17 @@ } .galleryHeader { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 1rem; padding: 1.15rem 1.05rem 0.35rem; } +.galleryHeading { + min-width: 0; +} + .galleryTitle { margin: 0; font-size: 1.45rem; @@ -82,14 +91,56 @@ } .tableWrapper { + flex: 1 1 auto; + min-height: 0; padding: 0.75rem 1.05rem 1.5rem; - overflow-x: auto; + + &:has([aria-expanded='true']) .confirmBadge::after { + opacity: 0; + } +} + +.loadMore { + min-height: 1px; + padding: 0.75rem 0; + color: rgba(15, 23, 42, 0.55); + font-size: 0.85rem; + font-weight: 600; + text-align: center; } .tableToolbar { display: flex; + align-items: center; justify-content: flex-end; - padding: 0.5rem 1.05rem 0; + gap: 0.5rem; +} + +.toolbarButton { + display: flex; + cursor: pointer; + align-items: center; + gap: 0.5rem; + border-radius: 0.5rem; + border-width: 1px; + padding: 0.25rem 0.75rem; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 500; + color: white; + border-color: #0284c7; + background-color: #0ea5e9; + + &:disabled { + cursor: not-allowed; + border-color: #207aa7; + background-color: #1c6287; + } +} + +.discardButton { + border-color: #dc2626; + background-color: #ef4444; } .tableOptionsBox { @@ -117,10 +168,287 @@ color: rgba(15, 23, 42, 0.58); } +.valueCell { + display: block; + width: 100%; + max-width: 100%; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; +} + +.phoneOverlay { + min-width: 24rem; +} + +.phoneOverlayBody { + display: grid; + gap: 0.5rem; +} + +.phoneSourceRow { + display: grid; + grid-template-columns: 1rem 3.5rem minmax(0, 1fr); + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: rgba(15, 23, 42, 0.86); +} + +.phoneSourceLabel { + font-size: 0.72rem; + font-weight: 700; + color: rgba(15, 23, 42, 0.56); + text-transform: uppercase; +} + +.sourceCheck { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + color: rgba(15, 23, 42, 0.56); +} + +.sourceIndicator { + font-size: 0.9rem; + stroke-width: 3; +} + +.emailOverlay { + min-width: 28rem; +} + +.emailOverlayBody { + display: grid; + gap: 0.5rem; +} + +.emailSourceRow { + display: grid; + grid-template-columns: 1rem 5rem minmax(0, 1fr); + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: rgba(15, 23, 42, 0.86); +} + +.emailSourceLabel { + font-size: 0.72rem; + font-weight: 700; + color: rgba(15, 23, 42, 0.56); + text-transform: uppercase; +} + +.nameOverlay { + min-width: 28rem; +} + +.nameOverlayBody { + display: grid; + gap: 0.5rem; +} + +.historySection { + display: grid; + gap: 0.35rem; + margin-top: 0.35rem; + padding-top: 0.6rem; + border-top: 1px solid rgba(17, 24, 39, 0.1); +} + +.historySectionLabel { + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + color: rgba(15, 23, 42, 0.56); + text-transform: uppercase; +} + +.historyEmpty { + font-size: 0.8rem; + color: rgba(15, 23, 42, 0.55); +} + +.historyContainer { + display: flex; + flex-direction: column; + width: 100%; + overflow: hidden; + border: 1px solid rgba(17, 24, 39, 0.1); + border-radius: 0.75rem; + background: rgba(255, 255, 255, 0.72); +} + +.historyEntry { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.5rem 0.65rem; + border-bottom: 1px solid rgba(17, 24, 39, 0.08); + color: rgba(15, 23, 42, 0.85); + font-size: 0.8rem; + font-weight: 550; + letter-spacing: -0.01em; + + &:last-child { + border-bottom: 0; + } +} + +.historyEntryMain { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 0.25rem; + flex-wrap: wrap; +} + +.historyEntryPrefix { + color: rgba(15, 23, 42, 0.62); + font-weight: 550; +} + +.historyEntryName { + color: rgba(15, 23, 42, 0.9); + font-weight: 650; +} + +.historyEntryDateTag { + margin-left: auto; + white-space: nowrap; + padding-inline: 0.75rem; + padding-block: 0.15rem; + border-radius: 9999px; + font-size: 0.72rem; + font-weight: 600; + color: rgba(15, 23, 42, 0.72); + background: rgba(15, 23, 42, 0.06); +} + +.nameSourceRow { + display: grid; + grid-template-columns: 1rem 4rem minmax(0, 1fr); + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: rgba(15, 23, 42, 0.86); +} + +.nameSourceLabel { + font-size: 0.72rem; + font-weight: 700; + color: rgba(15, 23, 42, 0.56); + text-transform: uppercase; +} + +.discordOverlay { + min-width: 20rem; +} + +.discordOverlayBody { + display: grid; + gap: 0.5rem; +} + +.discordSourceRow { + display: grid; + grid-template-columns: 1rem 6rem minmax(0, 1fr); + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: rgba(15, 23, 42, 0.86); +} + +.discordSourceLabel { + font-size: 0.72rem; + font-weight: 700; + color: rgba(15, 23, 42, 0.56); + text-transform: uppercase; +} + +.addressOverlay { + min-width: 30rem; +} + +.addressOverlayBody { + display: grid; + gap: 0.5rem; +} + +.addressSourceRow { + display: grid; + grid-template-columns: 1rem 4rem minmax(0, 1fr); + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: rgba(15, 23, 42, 0.86); +} + +.addressSourceLabel { + font-size: 0.72rem; + font-weight: 700; + color: rgba(15, 23, 42, 0.56); + text-transform: uppercase; +} + .nameCell { display: inline-flex; align-items: center; gap: 0.3rem; + max-width: 100%; + min-width: 0; + + & > span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.confirmBadge { + position: relative; + display: inline-flex; + align-items: center; + flex-shrink: 0; + outline: none; + + &::after { + content: attr(data-tooltip); + position: absolute; + left: calc(100% + 0.6rem); + top: 50%; + + padding: 0.35rem 0.5rem; + border-radius: 0.45rem; + width: max-content; + max-width: 20rem; + + color: rgba(241, 245, 249, 0.95); + background: rgba(15, 23, 42, 0.92); + font-size: 0.72rem; + font-weight: 560; + line-height: 1.35; + + box-shadow: 0 8px 20px rgba(2, 6, 23, 0.28); + + opacity: 0; + transform: translate(-3px, -50%); + transition: + opacity 100ms ease, + transform 100ms ease; + pointer-events: none; + z-index: 8; + } + + &:hover::after, + &:focus-visible::after { + opacity: 1; + transform: translate(0, -50%); + } } .nameVerified { @@ -141,6 +469,11 @@ padding: 0.15rem; } +.userMatchedLink { + display: inline-flex; + cursor: pointer; +} + .tag { display: inline-flex; align-items: center; @@ -183,6 +516,10 @@ background: rgba(177, 2, 2, 1); color: rgba(255, 207, 201, 1); } +.tagGray { + background: #e6e6e6; + color: #3d3d3d; +} .tagMember { background: #ffe5ec; color: rgba(177, 2, 2, 1); @@ -200,6 +537,14 @@ color: rgba(90, 50, 134, 1); } +.tagGhost { + background: transparent; + color: rgba(15, 23, 42, 0.4); + font-style: italic; + outline: 1px dashed rgba(15, 23, 42, 0.2); + outline-offset: -1px; +} + .tagTier { width: 10rem; } diff --git a/src/app/admin/panels/membership/page.tsx b/src/app/admin/panels/membership/page.tsx index 7cd0a1d9..5a0921d3 100644 --- a/src/app/admin/panels/membership/page.tsx +++ b/src/app/admin/panels/membership/page.tsx @@ -1,12 +1,12 @@ 'use client' import { - members, Member, MembershipTier, + membershipTiers, ShirtSize, PackageShipped, -} from './membership.data' +} from './membership.types' import styles from './page.module.css' import { DropdownButton, @@ -14,9 +14,23 @@ import { ToggleGroup, } from '@/components/common' import { Table, Column, ColumnEntry } from '@/components/common/table' -import { useState } from 'react' +import { User, UserAddress, zUser } from '@/contracts/data' +import { + MembershipsResponsePacket, + zMembershipsResponsePacket, + zPaginatedResponse, +} from '@/contracts/responses' +import { cn } from '@/util' +import { useFetch } from '@/util/hooks' +import { skipToken, useInfiniteQuery, useQuery } from '@tanstack/react-query' +import Link from 'next/link' +import { useEffect, useMemo, useRef, useState } from 'react' +import { FaEdit, FaSave, FaTrashAlt } from 'react-icons/fa' import { FiCheck, FiX } from 'react-icons/fi' +const HISTORY_STALE_TIME = 5 * 60 * 1000 +const HISTORY_LIMIT = 5 + const Check = () => const Cross = () => @@ -57,13 +71,769 @@ const packageShippedClass: Record = { const formatPhone = (phone?: string) => { if (!phone) return '—' - const digits = phone.replace(/\D/g, '') + const digits = phone.replace(/\D/g, '').replace(/^1(?=\d{10}$)/, '') if (digits.length === 10) { return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}` } return phone } +const SourceCheck = ({ selected }: { selected: boolean }) => ( + + {selected && } + +) + +const joinName = (firstName?: string | null, lastName?: string | null) => + [firstName, lastName].filter(Boolean).join(' ').trim() || undefined + +const formatHistoryDate = (value: Date) => + value.toLocaleDateString([], { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + +type UserHistoryEntry = NonNullable[number] + +type DescribeChange = ( + update: UserHistoryEntry, + previous: UserHistoryEntry | undefined +) => { label: string; value: string } | undefined + +const describeNameChange: DescribeChange = (update, previous) => { + const action = previous ? 'Changed' : 'Set' + const firstChanged = + (update.firstName ?? '') !== (previous?.firstName ?? '') + const lastChanged = (update.lastName ?? '') !== (previous?.lastName ?? '') + + if (!firstChanged && !lastChanged) return undefined + + if (firstChanged && !lastChanged && update.firstName) + return { label: `First Name ${action}`, value: update.firstName } + + if (lastChanged && !firstChanged && update.lastName) + return { label: `Last Name ${action}`, value: update.lastName } + + const value = joinName(update.firstName, update.lastName) + return value ? { label: `Name ${action}`, value } : undefined +} + +const describeSimpleChange = + ( + field: 'phone' | 'email', + noun: string, + format: (value: string) => string = (value) => value + ): DescribeChange => + (update, previous) => { + const value = update[field] ?? '' + if (value === (previous?.[field] ?? '') || value === '') + return undefined + + return { + label: `${noun} ${previous ? 'Changed' : 'Set'}`, + value: format(value), + } + } + +const describePhoneChange = describeSimpleChange('phone', 'Phone', formatPhone) +const describeEmailChange = describeSimpleChange('email', 'Email') + +const formatUserAddress = (address?: UserAddress) => + [ + [address?.addressLine1, address?.addressLine2] + .filter(Boolean) + .join(', '), + [ + address?.city, + [address?.state, address?.zip].filter(Boolean).join(' '), + ] + .filter(Boolean) + .join(', '), + ] + .filter(Boolean) + .join(', ') || undefined + +const describeAddressChange: DescribeChange = (update, previous) => { + const value = formatUserAddress(update.address) + if (!value || value === formatUserAddress(previous?.address)) + return undefined + + return { label: `Address ${previous ? 'Changed' : 'Set'}`, value } +} + +const FieldHistory = ({ + member, + title, + emptyMessage, + describeChange, +}: { + member: Member + title: string + emptyMessage: string + describeChange: DescribeChange +}) => { + const { ready, onGet } = useFetch() + const userId = member.userId + + const userQuery = useQuery({ + queryKey: ['/users/:userId', userId, { includeHistory: true }], + queryFn: + ready && userId != null + ? ({ signal }: { signal: AbortSignal }) => + onGet('/users/:userId', zUser, { + params: { userId }, + query: { includeHistory: true }, + signal, + }) + : skipToken, + staleTime: HISTORY_STALE_TIME, + }) + + const history = useMemo(() => { + const ascending = (userQuery.data?.history ?? []) + .slice() + .sort( + (a, b) => + a.historyWhenUpdatedUtc.getTime() - + b.historyWhenUpdatedUtc.getTime() + ) + + return ascending + .flatMap((update, index) => { + const change = describeChange(update, ascending[index - 1]) + return change ? [{ update, ...change }] : [] + }) + .reverse() + .slice(0, HISTORY_LIMIT) + }, [userQuery.data, describeChange]) + + const statusMessage = (() => { + if (userId == null) return 'No linked user' + if (userQuery.isPending) return 'Loading…' + if (userQuery.isError) return 'Failed to load history' + if (history.length === 0) return emptyMessage + return undefined + })() + + return ( +
+ {title} + {statusMessage && ( +
{statusMessage}
+ )} + {!statusMessage && ( +
+ {history.map(({ update, label, value }) => ( +
+ + + {label} + + + {value} + + + + {formatHistoryDate( + update.historyWhenUpdatedUtc + )} + +
+ ))} +
+ )} +
+ ) +} + +const IdenticalTag = ({ source }: { source?: string }) => ( + + {source ? `Identical To ${source}` : 'Identical'} + +) + +const ConfirmedBadge = ({ + label, + confirmed, +}: { + label: string + confirmed?: boolean +}) => ( + + {confirmed ? ( + + ) : ( + + )} + +) + +const PhoneTag = ({ phone }: { phone?: string }) => + phone ? ( + + {formatPhone(phone)} + + ) : ( + + Unfilled + + ) + +const PhoneValue = ({ member }: { member: Member }) => ( + + {formatPhone(member.userPhone ?? member.donorPhone)} + +) + +const PhoneMenu = ({ + member, + closeDropdown, +}: { + member: Member + closeDropdown: () => void +}) => { + const displayedSource = member.userPhone + ? 'User' + : member.donorPhone + ? 'Donor' + : undefined + const sourcesMatch = + member.userPhone != null && + member.donorPhone != null && + formatPhone(member.userPhone) === formatPhone(member.donorPhone) + + return ( + +
+ + User + +
+
+ + Donor + {sourcesMatch ? ( + + ) : ( + + )} +
+ + + } + /> + ) +} + +const EmailTag = ({ email }: { email?: string }) => + email ? ( + + {email} + + ) : ( + + Unfilled + + ) + +const EmailValue = ({ member }: { member: Member }) => ( + + {member.userEmail ?? member.discordEmail ?? member.donorEmail ?? '—'} + +) + +const EmailMenu = ({ + member, + closeDropdown, +}: { + member: Member + closeDropdown: () => void +}) => { + const displayedSource = member.userEmail + ? 'User' + : member.discordEmail + ? 'Discord' + : member.donorEmail + ? 'Donor' + : undefined + const normalizedUserEmail = member.userEmail?.trim().toLowerCase() + const normalizedDiscordEmail = member.discordEmail?.trim().toLowerCase() + const normalizedDonorEmail = member.donorEmail?.trim().toLowerCase() + const discordMatchesUser = + normalizedDiscordEmail != null && + normalizedDiscordEmail === normalizedUserEmail + const donorMatchesSource = + normalizedDonorEmail == null + ? undefined + : normalizedDonorEmail === normalizedUserEmail + ? 'User' + : normalizedDonorEmail === normalizedDiscordEmail + ? 'Discord' + : undefined + const allEmailsMatch = + new Set( + [ + normalizedUserEmail, + normalizedDiscordEmail, + normalizedDonorEmail, + ].filter((email) => email != null) + ).size === 1 + + return ( + +
+ + User + +
+
+ + Discord + {discordMatchesUser ? ( + + ) : ( + + )} +
+
+ + Donor + {donorMatchesSource ? ( + + ) : ( + + )} +
+ + + } + /> + ) +} + +const NameTag = ({ + name, + confirmationStatus, +}: { + name?: string + confirmationStatus?: boolean +}) => + name ? ( + + {name} + + ) : ( + + Unfilled + + ) + +const NameMenu = ({ + member, + closeDropdown, +}: { + member: Member + closeDropdown: () => void +}) => { + const displayedSource = member.userName + ? 'User' + : member.donorName + ? 'Donor' + : undefined + const sourcesMatch = + member.userName != null && + member.userName.trim() === member.donorName?.trim() + + return ( + +
+ + User + +
+
+ + Donor + {sourcesMatch ? ( + + ) : ( + + )} +
+ + + } + /> + ) +} + +const NameValue = ({ member }: { member: Member }) => ( + + {member.userName ?? member.donorName ?? '—'} + + +) + +const normalizeDiscordHandle = (handle: string) => + handle.trim().replace(/^@/, '') + +const DiscordTag = ({ + username, + confirmationStatus, +}: { + username?: string + confirmationStatus?: boolean +}) => + username ? ( + + @{normalizeDiscordHandle(username)} + + ) : ( + + Unfilled + + ) + +const DiscordValue = ({ member }: { member: Member }) => { + const displayedHandle = member.discordUsername ?? member.contributionDiscord + + return ( + + + {displayedHandle + ? `@${normalizeDiscordHandle(displayedHandle)}` + : '—'} + + + + ) +} + +const DiscordMenu = ({ + member, + closeDropdown, +}: { + member: Member + closeDropdown: () => void +}) => { + const displayedSource = member.discordUsername + ? 'Discord' + : member.contributionDiscord + ? 'Contribution' + : undefined + const sourcesMatch = + member.discordUsername != null && + member.contributionDiscord != null && + normalizeDiscordHandle(member.discordUsername) === + normalizeDiscordHandle(member.contributionDiscord) + + return ( + +
+ + + Discord + + +
+
+ + + Contribution + + {sourcesMatch ? ( + + ) : ( + + )} +
+ + } + /> + ) +} + +const AddressTag = ({ address }: { address?: string }) => + address ? ( + + {address} + + ) : ( + + Unfilled + + ) + +const AddressValue = ({ member }: { member: Member }) => ( + + {member.userAddress ?? member.donorAddress ?? '—'} + + +) + +const AddressMenu = ({ + member, + closeDropdown, +}: { + member: Member + closeDropdown: () => void +}) => { + const displayedSource = member.userAddress + ? 'User' + : member.donorAddress + ? 'Donor' + : undefined + const sourcesMatch = + member.userAddress != null && + member.userAddress.trim() === member.donorAddress?.trim() + + return ( + +
+ + User + +
+
+ + Donor + {sourcesMatch ? ( + + ) : ( + + )} +
+ + + } + /> + ) +} + +const mapPacketToMember = ( + packet: MembershipsResponsePacket, + index: number +): Member => { + const { donor, user, customField } = packet + const membership = donor.membershipData + const cardStatus = membership?.membershipCardStatus + const merchStatus = membership?.membershipMerchStatus + + const userFullName = [user?.firstName, user?.lastName] + .filter(Boolean) + .join(' ') + const userName = + userFullName !== '' ? userFullName : (user?.preferredName ?? undefined) + const donorFullName = [donor.firstname, donor.lastname] + .filter(Boolean) + .join(' ') + const donorName = donorFullName !== '' ? donorFullName : undefined + + const userAddressStr = user?.address + ? [ + [user.address.addressLine1, user.address.addressLine2] + .filter(Boolean) + .join(', '), + [ + user.address.city, + [user.address.state, user.address.zip] + .filter(Boolean) + .join(' '), + ] + .filter(Boolean) + .join(', '), + ] + .filter(Boolean) + .join(', ') + : '' + const userAddress = userAddressStr !== '' ? userAddressStr : undefined + + const donorCountry = donor.country?.trim() + const donorZipDigits = donor.zip?.replace(/\D/g, '').slice(0, 5) + const donorZip = donorZipDigits === '' ? donor.zip : donorZipDigits + const donorAddressStr = [ + [donor.addr1].filter(Boolean).join(', '), + [donor.city, [donor.state, donorZip].filter(Boolean).join(' ')] + .filter(Boolean) + .join(', '), + donorCountry?.toLowerCase() === 'united states' + ? undefined + : donorCountry, + ] + .filter(Boolean) + .join(', ') + const donorAddress = donorAddressStr !== '' ? donorAddressStr : undefined + + const contributionDiscordAnswer = customField?.answer.trim() + const contributionDiscord = + contributionDiscordAnswer === '' ? undefined : contributionDiscordAnswer + + const tierLabel = customField?.label + const membershipTier = membershipTiers.find((tier) => tier === tierLabel) + + return { + packet, + id: index + 1, + userId: user?.id, + firstName: donor.firstname, + lastName: donor.lastname, + userName, + donorName, + discordUsername: user?.discordUsers?.[0]?.username, + contributionDiscord, + phone: user?.phone ?? donor.phone ?? undefined, + userPhone: user?.phone ?? undefined, + donorPhone: donor.phone ?? undefined, + email: user?.email ?? user?.discordUsers?.[0]?.email ?? donor.email, + donorEmail: donor.email, + userEmail: user?.email ?? undefined, + discordEmail: user?.discordUsers?.[0]?.email ?? undefined, + address1: donor.addr1 ?? undefined, + city: donor.city ?? undefined, + state: donor.state ?? undefined, + zip: donor.zip ?? undefined, + country: donor.country ?? undefined, + userAddress, + donorAddress, + shirtSize: + membership?.shirtSize === '2XL' + ? 'XXL' + : (membership?.shirtSize ?? undefined), + isMember: membership?.duesPayingMember, + eligibleForBenefits: membership?.membershipBenefitEligible, + membershipTier, + beganMembership: donor.contributions?.[0]?.createdAt + ?.toISOString() + .slice(0, 10), + numberOfContributions: donor.contributions?.length, + discordConfirmed: Boolean(user?.discordUsers?.length), + nameConfirmed: + membership?.nameConfirmed ?? user?.nameConfirmed ?? undefined, + addressConfirmed: + membership?.addressConfirmed ?? user?.addressConfirmed ?? undefined, + cardPrinted: cardStatus != null ? cardStatus >= 2 : undefined, + benefitShipped: merchStatus != null ? merchStatus >= 3 : undefined, + packageShipped: + merchStatus === 5 + ? 'Returned' + : merchStatus != null && merchStatus >= 4 + ? 'Yes' + : merchStatus != null + ? 'No' + : undefined, + userMatched: Boolean(user), + } +} + const statusDotColor = (m: Member, col: Column): string => { const boolFields: Record = { discordConfirmed: m.discordConfirmed, @@ -90,13 +860,6 @@ const statusDotColor = (m: Member, col: Column): string => { } const confirmedColumns: Column[] = [ - { - key: 'discordConfirmed', - header: 'Discord Confirmed', - width: '5rem', - sortValue: (m: Member) => (m.discordConfirmed ? 1 : 0), - render: (m: Member) => , - }, { key: 'nameConfirmed', header: 'Name Confirmed', @@ -104,6 +867,13 @@ const confirmedColumns: Column[] = [ sortValue: (m: Member) => (m.nameConfirmed ? 1 : 0), render: (m: Member) => , }, + { + key: 'discordConfirmed', + header: 'Discord Confirmed', + width: '5rem', + sortValue: (m: Member) => (m.discordConfirmed ? 1 : 0), + render: (m: Member) => , + }, { key: 'addressConfirmed', header: 'Address Confirmed', @@ -133,8 +903,19 @@ const statusColumns: Column[] = [ const buildColumns = ( showConfirmed: boolean, showFulfilled: boolean, - showStatus: boolean + showStatus: boolean, + showRowNumber: boolean ): ColumnEntry[] => [ + ...(showRowNumber + ? [ + { + key: 'rowNumber', + header: '#', + width: '3rem', + render: (_m: Member, index: number) => index + 1, + }, + ] + : ([] as Column[])), ...(showStatus ? statusColumns : ([] as Column[])), { key: 'membershipTier', @@ -231,65 +1012,34 @@ const buildColumns = ( key: 'name', header: 'Name', width: '11rem', - sortValue: (m: Member) => - [m.firstName, m.lastName].filter(Boolean).join(' '), - render: (m: Member) => ( - - {[m.firstName, m.lastName].filter(Boolean).join(' ') || '—'} - {m.nameConfirmed ? ( - - ) : ( - - )} - + allowOverflow: true, + sortValue: (m: Member) => m.userName ?? m.donorName ?? '', + render: (m: Member) => , + menu: (m: Member, { closeDropdown }) => ( + ), }, { key: 'discord', header: 'Discord', width: '11rem', + allowOverflow: true, sortValue: (m: Member) => m.discordUsername ?? '', - render: (m: Member) => ( - - {m.discordUsername ? `@${m.discordUsername}` : '—'} - {m.discordConfirmed ? ( - - ) : ( - - )} - + render: (m: Member) => , + menu: (m: Member, { closeDropdown }) => ( + ), }, { key: 'address', header: 'Address', width: '40rem', - sortValue: (m: Member) => m.state ?? m.city ?? '', - render: (m: Member) => { - const parts = [ - [m.address1, m.address2].filter(Boolean).join(', '), - [m.city, [m.state, m.zip].filter(Boolean).join(' ')] - .filter(Boolean) - .join(', '), - m.country, - ].filter(Boolean) - return ( - - {parts.join(', ') || '—'} - {m.addressConfirmed ? ( - - ) : ( - - )} - - ) - }, + allowOverflow: true, + sortValue: (m: Member) => m.userAddress ?? m.donorAddress ?? '', + render: (m: Member) => , + menu: (m: Member, { closeDropdown }) => ( + + ), }, { key: 'beganMembership', @@ -304,14 +1054,22 @@ const buildColumns = ( { key: 'phone', header: 'Phone', + allowOverflow: true, sortValue: (m: Member) => m.phone ?? '', - render: (m: Member) => formatPhone(m.phone), + render: (m: Member) => , + menu: (m: Member, { closeDropdown }) => ( + + ), }, { key: 'email', header: 'Email', + allowOverflow: true, sortValue: (m: Member) => m.email ?? '', - render: (m: Member) => m.email ?? '—', + render: (m: Member) => , + menu: (m: Member, { closeDropdown }) => ( + + ), }, { key: 'shirtSize', @@ -326,7 +1084,7 @@ const buildColumns = ( {m.shirtSize} ) : ( - '—' + N/A ), }, @@ -350,17 +1108,87 @@ const buildColumns = ( header: 'User Matched', width: '5rem', sortValue: (m: Member) => (m.userMatched ? 1 : 0), - render: (m: Member) => , + render: (m: Member) => + m.userId != null ? ( + + + + ) : ( + + ), }, ] export default function Page() { + const { ready, onGet } = useFetch() const [showConfirmed, setShowConfirmed] = useState(false) const [showFulfilled, setShowFulfilled] = useState(true) const [collapseFulfillment, setCollapseFulfillment] = useState(false) const [showStatus, setShowStatus] = useState(false) - const columns = buildColumns(showConfirmed, showFulfilled, showStatus) - const collapsedCategories = collapseFulfillment ? ['Fulfillment'] : [] + const [showRowNumber, setShowRowNumber] = useState(false) + const [isEditing, setIsEditing] = useState(false) + const columns = useMemo( + () => + buildColumns( + showConfirmed, + showFulfilled, + showStatus, + showRowNumber + ), + [showConfirmed, showFulfilled, showStatus, showRowNumber] + ) + const collapsedCategories = useMemo( + () => (collapseFulfillment ? ['Fulfillment'] : []), + [collapseFulfillment] + ) + const loadMoreRef = useRef(null) + const membershipsQuery = useInfiniteQuery({ + queryKey: ['/actblue/memberships', { limit: 25 }], + queryFn: ({ pageParam, signal }) => + onGet( + '/actblue/memberships', + zPaginatedResponse(zMembershipsResponsePacket), + { query: { page: pageParam, limit: 25 }, signal } + ), + initialPageParam: 0, + getNextPageParam: (lastPage, pages) => { + const loadedCount = pages.reduce( + (total, page) => total + page.data.length, + 0 + ) + return loadedCount < lastPage.count ? pages.length : undefined + }, + enabled: ready, + }) + const { fetchNextPage, hasNextPage, isFetchingNextPage } = membershipsQuery + + useEffect(() => { + const loadMoreElement = loadMoreRef.current + if (!loadMoreElement || !hasNextPage) return + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting && !isFetchingNextPage) + void fetchNextPage() + }, + { rootMargin: '300px' } + ) + + observer.observe(loadMoreElement) + return () => observer.disconnect() + }, [fetchNextPage, hasNextPage, isFetchingNextPage]) + + const members = useMemo( + () => + (membershipsQuery.data?.pages ?? []) + .flatMap((page) => page.data) + .map(mapPacketToMember), + [membershipsQuery.data] + ) + const totalEntries = membershipsQuery.data?.pages[0]?.count return (
@@ -371,136 +1199,215 @@ export default function Page() { Membership
- {/* logic will eventually need to be reworked to show last api fetch and not most recent contribution date */} -
Last Updated: N/A
+
+ Entries Loaded: {members.length.toLocaleString()} + {totalEntries != null && + ` of ${totalEntries.toLocaleString()}`} +
-

Membership

-

- Manage membership records and details. -

-
+
+

Membership

+

+ Manage membership records and details. +

+
-
- ( - -
- + {isEditing ? ( + <> + + + + ) : ( + + )} + ( + +
- Status columns - - - ariaLabel="Member Eligibility Columns" - orientation="horizontal" - value={showStatus} - options={[ - { - value: true, - label: 'Show', - }, - { - value: false, - label: 'Hide', - }, - ]} - onChange={setShowStatus} - /> -
-
- + Row column + + + ariaLabel="Row Column" + orientation="horizontal" + value={showRowNumber} + options={[ + { + value: true, + label: 'Show', + }, + { + value: false, + label: 'Hide', + }, + ]} + onChange={setShowRowNumber} + /> +
+
- Confirmed columns - - - ariaLabel="Member Details Confirmed Columns" - orientation="horizontal" - value={showConfirmed} - options={[ - { - value: true, - label: 'Show', - }, - { - value: false, - label: 'Hide', - }, - ]} - onChange={setShowConfirmed} - /> -
-
- + Status columns + + + ariaLabel="Member Eligibility Columns" + orientation="horizontal" + value={showStatus} + options={[ + { + value: true, + label: 'Show', + }, + { + value: false, + label: 'Hide', + }, + ]} + onChange={setShowStatus} + /> +
+
- Benefits fulfilled tag - - - ariaLabel="Tag Groups" - orientation="horizontal" - value={showFulfilled} - options={[ - { - value: true, - label: 'Show', - }, - { - value: false, - label: 'Hide', - }, - ]} - onChange={setShowFulfilled} - /> -
-
- + Confirmed columns + + + ariaLabel="Member Details Confirmed Columns" + orientation="horizontal" + value={showConfirmed} + options={[ + { + value: true, + label: 'Show', + }, + { + value: false, + label: 'Hide', + }, + ]} + onChange={setShowConfirmed} + /> +
+
- Fulfillment columns - - - ariaLabel="Fulfillment columns" - orientation="horizontal" - value={collapseFulfillment} - options={[ - { - value: false, - label: 'Expand', - }, - { - value: true, - label: 'Collapse', - }, - ]} - onChange={ - setCollapseFulfillment + + Benefits fulfilled tag + + + ariaLabel="Tag Groups" + orientation="horizontal" + value={showFulfilled} + options={[ + { + value: true, + label: 'Show', + }, + { + value: false, + label: 'Hide', + }, + ]} + onChange={setShowFulfilled} + /> +
+
-
- - } - /> - )} - /> + > + + Fulfillment columns + + + ariaLabel="Fulfillment columns" + orientation="horizontal" + value={collapseFulfillment} + options={[ + { + value: false, + label: 'Expand', + }, + { + value: true, + label: 'Collapse', + }, + ]} + onChange={ + setCollapseFulfillment + } + /> +
+ + } + /> + )} + /> +
@@ -509,6 +1416,17 @@ export default function Page() { data={members} rowKey={(m) => m.id} collapsedCategories={collapsedCategories} + footer={ + hasNextPage && ( +
+ {membershipsQuery.isFetchingNextPage && + 'Loading more membership records...'} +
+ ) + } />
diff --git a/src/app/admin/panels/membership/membership.data.ts b/src/app/admin/panels/membershipexample/membership.data.ts similarity index 100% rename from src/app/admin/panels/membership/membership.data.ts rename to src/app/admin/panels/membershipexample/membership.data.ts diff --git a/src/app/admin/panels/membershipexample/page.module.css b/src/app/admin/panels/membershipexample/page.module.css new file mode 100644 index 00000000..b3352b41 --- /dev/null +++ b/src/app/admin/panels/membershipexample/page.module.css @@ -0,0 +1,239 @@ +.panelContents { + width: 100%; + min-width: 0; + height: calc(100dvh - 100px); + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 0; +} + +.scrollView { + flex: 1 1 auto; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + padding: 0 0 1rem; +} + +.panelHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.35rem 1.25rem; + background-color: #f9fafb; + backdrop-filter: blur(14px) saturate(150%); + -webkit-backdrop-filter: blur(14px) saturate(150%); + border-bottom: 2px solid #e5e7eb; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.75) inset; +} + +.panelTimestamp { + font-size: 0.78rem; + font-weight: 700; + color: rgba(15, 23, 42, 0.45); +} + +.breadcrumbs { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: 0.86rem; + letter-spacing: -0.01em; + color: rgba(15, 23, 42, 0.72); + user-select: none; +} + +.prominentBreadcrumb { + font-weight: 700; + color: rgba(15, 23, 42, 0.72); +} + +.breadcrumbSeperator { + color: rgba(15, 23, 42, 0.35); + font-weight: 700; +} + +.panelBreadcrumb { + font-weight: 650; + color: rgba(15, 23, 42, 0.55); +} + +.galleryHeader { + padding: 1.15rem 1.05rem 0.35rem; +} + +.galleryTitle { + margin: 0; + font-size: 1.45rem; + font-weight: 800; + letter-spacing: -0.02em; + color: rgba(9, 34, 58, 0.9); +} + +.gallerySubTitle { + margin: 0.35rem 0 0; + font-size: 0.95rem; + font-weight: 600; + letter-spacing: -0.01em; + color: rgba(15, 23, 42, 0.55); +} + +.tableWrapper { + padding: 0.75rem 1.05rem 1.5rem; + overflow-x: auto; +} + +.loadMore { + min-height: 1px; + padding: 0.75rem 0; + color: rgba(15, 23, 42, 0.55); + font-size: 0.85rem; + font-weight: 600; + text-align: center; +} + +.tableToolbar { + display: flex; + justify-content: flex-end; + padding: 0.5rem 1.05rem 0; +} + +.tableOptionsBox { + min-width: 14rem; + background: rgba(255, 255, 255, 0.96); +} + +.tableOptionsBody { + display: grid; + grid-template-columns: 1fr; + gap: 0.55rem; +} + +.tableOptionRow { + display: grid; + gap: 0.3rem; + padding: 0 0.15rem; +} + +.tableOptionLabel { + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgba(15, 23, 42, 0.58); +} + +.nameCell { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + +.nameVerified { + color: rgba(17, 115, 75, 1); + font-size: 0.6rem; + flex-shrink: 0; + background: rgba(112, 195, 32, 0.3); + border-radius: 50%; + padding: 0.15rem; +} + +.nameUnverified { + color: rgba(177, 2, 2, 1); + font-size: 0.6rem; + flex-shrink: 0; + background: rgba(255, 95, 75, 0.3); + border-radius: 50%; + padding: 0.15rem; +} + +.tag { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.8rem; + font-size: 0.75rem; + font-weight: 700; + padding: 0.2rem 0.6rem; + border-radius: 9999px; + white-space: nowrap; + color: rgba(15, 23, 42, 0.72); + background: rgba(15, 23, 42, 0.06); +} + +.tagRed { + background: rgba(255, 95, 75, 0.3); + color: rgba(177, 2, 2, 1); +} +.tagOrange { + background: rgba(255, 72, 0, 0.3); + color: rgba(117, 56, 0, 1); +} +.tagYellow { + background: rgba(255, 168, 0, 0.3); + color: rgba(71, 56, 33, 1); +} +.tagGreen { + background: rgba(112, 195, 32, 0.3); + color: rgba(17, 115, 75, 1); +} +.tagBlue { + background: rgba(42, 155, 225, 0.3); + color: rgba(10, 83, 168, 1); +} +.tagPurple { + background: rgba(172, 95, 212, 0.3); + color: rgba(90, 50, 134, 1); +} +.tagDarkRed { + background: rgba(177, 2, 2, 1); + color: rgba(255, 207, 201, 1); +} +.tagMember { + background: #ffe5ec; + color: rgba(177, 2, 2, 1); +} +.tagPremium { + background: #ffe5ec; + color: rgb(223, 40, 76); +} +.tagSignature { + background: #ffa7cd; + color: rgba(177, 2, 2, 1); +} +.tagInnerCircle { + background: #da93ff; + color: rgba(90, 50, 134, 1); +} + +.tagTier { + width: 10rem; +} + +.tagWide { + min-width: 6.5rem; + font-size: 0.65rem; + padding: 0.1rem 0.5rem; + position: relative; + top: -2px; +} + +.tagFulfilled { + min-width: auto; + width: calc(100% - 8px); + font-size: 0.65rem; + padding: 0.1rem 0.5rem; + position: relative; + top: -2px; +} + +@media (max-width: 53rem) { + .panelHeader { + flex-direction: column; + align-items: flex-start; + gap: 0.4rem; + } +} diff --git a/src/app/admin/panels/membershipexample/page.tsx b/src/app/admin/panels/membershipexample/page.tsx new file mode 100644 index 00000000..10e01093 --- /dev/null +++ b/src/app/admin/panels/membershipexample/page.tsx @@ -0,0 +1,519 @@ +'use client' + +import { + members, + Member, + MembershipTier, + ShirtSize, + PackageShipped, +} from './membership.data' +import styles from './page.module.css' +import { + DropdownButton, + DropdownOverlay, + ToggleGroup, +} from '@/components/common' +import { Table, Column, ColumnEntry } from '@/components/common/table' +import { useState } from 'react' +import { FiCheck, FiX } from 'react-icons/fi' + +const Check = () => +const Cross = () => + +const BoolTag = ({ value }: { value?: boolean }) => + value ? ( + + + + ) : ( + + + + ) + +const shirtSizeClass: Record = { + XS: styles.tagRed, + S: styles.tagOrange, + M: styles.tagYellow, + L: styles.tagGreen, + XL: styles.tagBlue, + XXL: styles.tagPurple, +} + +const membershipTierClass: Record = { + 'Dues Paying Member': styles.tagMember, + 'Premium Member': styles.tagPremium, + 'Signature Member': styles.tagSignature, + 'Inner Circle Member': styles.tagInnerCircle, +} + +const packageShippedClass: Record = { + Yes: styles.tagGreen, + No: styles.tagRed, + Returned: styles.tagYellow, + 'Not Received': styles.tagDarkRed, + Canceled: styles.tagBlue, +} + +const formatPhone = (phone?: string) => { + if (!phone) return '—' + const digits = phone.replace(/\D/g, '') + if (digits.length === 10) { + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}` + } + return phone +} + +const statusDotColor = (m: Member, col: Column): string => { + const boolFields: Record = { + discordConfirmed: m.discordConfirmed, + nameConfirmed: m.nameConfirmed, + addressConfirmed: m.addressConfirmed, + cardPrinted: m.cardPrinted, + labelPrinted: m.labelPrinted, + cardPacked: m.cardPacked, + benefitShipped: m.benefitShipped, + } + if (col.key === 'packageShipped') { + const colors: Record = { + Yes: 'rgba(112, 195, 32, 0.6)', + No: 'rgba(255, 95, 75, 0.6)', + Returned: 'rgba(255, 168, 0, 0.6)', + 'Not Received': 'rgba(177, 2, 2, 1)', + Canceled: 'rgba(42, 155, 225, 0.6)', + } + return colors[m.packageShipped ?? 'No'] + } + return boolFields[col.key] + ? 'rgba(112, 195, 32, 0.6)' + : 'rgba(255, 95, 75, 0.6)' +} + +const confirmedColumns: Column[] = [ + { + key: 'discordConfirmed', + header: 'Discord Confirmed', + width: '5rem', + sortValue: (m: Member) => (m.discordConfirmed ? 1 : 0), + render: (m: Member) => , + }, + { + key: 'nameConfirmed', + header: 'Name Confirmed', + width: '5rem', + sortValue: (m: Member) => (m.nameConfirmed ? 1 : 0), + render: (m: Member) => , + }, + { + key: 'addressConfirmed', + header: 'Address Confirmed', + width: '5rem', + sortValue: (m: Member) => (m.addressConfirmed ? 1 : 0), + render: (m: Member) => , + }, +] + +const statusColumns: Column[] = [ + { + key: 'eligibleForBenefits', + header: 'Eligible', + width: '5rem', + sortValue: (m: Member) => (m.eligibleForBenefits ? 1 : 0), + render: (m: Member) => , + }, + { + key: 'isMember', + header: 'Member', + width: '5rem', + sortValue: (m: Member) => (m.isMember ? 1 : 0), + render: (m: Member) => , + }, +] + +const buildColumns = ( + showConfirmed: boolean, + showFulfilled: boolean, + showStatus: boolean +): ColumnEntry[] => [ + ...(showStatus ? statusColumns : ([] as Column[])), + { + key: 'membershipTier', + header: 'Tier', + width: '11rem', + sortValue: (m: Member) => m.membershipTier ?? '', + render: (m: Member) => + m.membershipTier ? ( + + {m.membershipTier} + + ) : ( + '—' + ), + }, + { + label: 'Fulfillment', + collapsedWidth: '6rem', + dotColor: statusDotColor, + rowRender: showFulfilled + ? (m: Member) => { + const allFulfilled = + (!showConfirmed || + (m.discordConfirmed && + m.nameConfirmed && + m.addressConfirmed)) && + m.cardPrinted && + m.labelPrinted && + m.cardPacked && + m.benefitShipped && + m.packageShipped === 'Yes' + if (!allFulfilled) return null + return ( + + Benefits Fulfilled + + ) + } + : undefined, + columns: [ + ...(showConfirmed ? confirmedColumns : ([] as Column[])), + { + key: 'cardPrinted', + header: 'Card Printed', + width: '5rem', + sortValue: (m: Member) => (m.cardPrinted ? 1 : 0), + render: (m: Member) => , + }, + { + key: 'labelPrinted', + header: 'Label Printed', + width: '5rem', + sortValue: (m: Member) => (m.labelPrinted ? 1 : 0), + render: (m: Member) => , + }, + { + key: 'cardPacked', + header: 'Packed', + width: '5rem', + sortValue: (m: Member) => (m.cardPacked ? 1 : 0), + render: (m: Member) => , + }, + { + key: 'benefitShipped', + header: 'Benefit Shipped', + width: '5rem', + sortValue: (m: Member) => (m.benefitShipped ? 1 : 0), + render: (m: Member) => , + }, + { + key: 'packageShipped', + header: 'Package Shipped', + width: '7rem', + sortValue: (m: Member) => m.packageShipped ?? '', + render: (m: Member) => + m.packageShipped ? ( + + {m.packageShipped} + + ) : ( + '—' + ), + }, + ], + }, + + { + key: 'name', + header: 'Name', + width: '11rem', + sortValue: (m: Member) => + [m.firstName, m.lastName].filter(Boolean).join(' '), + render: (m: Member) => ( + + {[m.firstName, m.lastName].filter(Boolean).join(' ') || '—'} + {m.nameConfirmed ? ( + + ) : ( + + )} + + ), + }, + { + key: 'discord', + header: 'Discord', + width: '11rem', + sortValue: (m: Member) => m.discordUsername ?? '', + render: (m: Member) => ( + + {m.discordUsername ? `@${m.discordUsername}` : '—'} + {m.discordConfirmed ? ( + + ) : ( + + )} + + ), + }, + { + key: 'address', + header: 'Address', + width: '40rem', + sortValue: (m: Member) => m.state ?? m.city ?? '', + render: (m: Member) => { + const parts = [ + [m.address1, m.address2].filter(Boolean).join(', '), + [m.city, [m.state, m.zip].filter(Boolean).join(' ')] + .filter(Boolean) + .join(', '), + m.country, + ].filter(Boolean) + return ( + + {parts.join(', ') || '—'} + {m.addressConfirmed ? ( + + ) : ( + + )} + + ) + }, + }, + { + key: 'beganMembership', + header: 'Member Since', + sortValue: (m: Member) => m.beganMembership ?? '', + render: (m: Member) => { + if (!m.beganMembership) return '—' + const [y, mo, d] = m.beganMembership.split('-') + return `${mo}-${d}-${y}` + }, + }, + { + key: 'phone', + header: 'Phone', + sortValue: (m: Member) => m.phone ?? '', + render: (m: Member) => formatPhone(m.phone), + }, + { + key: 'email', + header: 'Email', + sortValue: (m: Member) => m.email ?? '', + render: (m: Member) => m.email ?? '—', + }, + { + key: 'shirtSize', + header: 'Shirt', + width: '5rem', + sortValue: (m: Member) => m.shirtSize ?? '', + render: (m: Member) => + m.shirtSize ? ( + + {m.shirtSize} + + ) : ( + '—' + ), + }, + + { + key: 'membershipAmount', + header: 'Amount', + width: '6rem', + sortValue: (m: Member) => m.membershipAmount ?? 0, + render: (m: Member) => + m.membershipAmount != null ? `$${m.membershipAmount}` : '—', + }, + { + key: 'contributions', + header: 'Contributions', + width: '6rem', + sortValue: (m: Member) => m.numberOfContributions ?? 0, + render: (m: Member) => m.numberOfContributions?.toString() ?? '—', + }, + { + key: 'userMatched', + header: 'User Matched', + width: '5rem', + sortValue: (m: Member) => (m.userMatched ? 1 : 0), + render: (m: Member) => , + }, +] + +export default function Page() { + const [showConfirmed, setShowConfirmed] = useState(false) + const [showFulfilled, setShowFulfilled] = useState(true) + const [collapseFulfillment, setCollapseFulfillment] = useState(false) + const [showStatus, setShowStatus] = useState(false) + const columns = buildColumns(showConfirmed, showFulfilled, showStatus) + const collapsedCategories = collapseFulfillment ? ['Fulfillment'] : [] + + return ( +
+
+
+ Admin + / + + Membership Example + +
+ + {/* logic will eventually need to be reworked to show last api fetch and not most recent contribution date */} +
Last Updated: N/A
+
+
+
+

Membership Example

+

+ Manage membership records and details. +

+
+ +
+ ( + +
+ + Status columns + + + ariaLabel="Member Eligibility Columns" + orientation="horizontal" + value={showStatus} + options={[ + { + value: true, + label: 'Show', + }, + { + value: false, + label: 'Hide', + }, + ]} + onChange={setShowStatus} + /> +
+
+ + Confirmed columns + + + ariaLabel="Member Details Confirmed Columns" + orientation="horizontal" + value={showConfirmed} + options={[ + { + value: true, + label: 'Show', + }, + { + value: false, + label: 'Hide', + }, + ]} + onChange={setShowConfirmed} + /> +
+
+ + Benefits fulfilled tag + + + ariaLabel="Tag Groups" + orientation="horizontal" + value={showFulfilled} + options={[ + { + value: true, + label: 'Show', + }, + { + value: false, + label: 'Hide', + }, + ]} + onChange={setShowFulfilled} + /> +
+
+ + Fulfillment columns + + + ariaLabel="Fulfillment columns" + orientation="horizontal" + value={collapseFulfillment} + options={[ + { + value: false, + label: 'Expand', + }, + { + value: true, + label: 'Collapse', + }, + ]} + onChange={ + setCollapseFulfillment + } + /> +
+ + } + /> + )} + /> +
+ +
+
m.id} + collapsedCategories={collapsedCategories} + /> + + + + ) +} diff --git a/src/app/admin/panels/membershiptest/membership.data.ts b/src/app/admin/panels/membershiptest/membership.data.ts new file mode 100644 index 00000000..eaf63c46 --- /dev/null +++ b/src/app/admin/panels/membershiptest/membership.data.ts @@ -0,0 +1,279 @@ +export type MembershipTier = + | 'Dues Paying Member' + | 'Premium Member' + | 'Signature Member' + | 'Inner Circle Member' + +export type ShirtSize = 'XS' | 'S' | 'M' | 'L' | 'XL' | 'XXL' + +export type PackageShipped = + | 'Yes' + | 'No' + | 'Returned' + | 'Not Received' + | 'Canceled' + +export interface Member { + id: number + firstName?: string + lastName?: string + discordUsername?: string + phone?: string + email?: string + address1?: string + address2?: string + city?: string + state?: string + zip?: string + country?: string + shirtSize?: ShirtSize + + isMember?: boolean + eligibleForBenefits?: boolean + beganMembership?: string + membershipAmount?: number + membershipTier?: MembershipTier + numberOfContributions?: number + + discordConfirmed?: boolean + nameConfirmed?: boolean + addressConfirmed?: boolean + cardPrinted?: boolean + labelPrinted?: boolean + cardPacked?: boolean + benefitShipped?: boolean + packageShipped?: PackageShipped + + userMatched?: boolean +} + +export const members: Member[] = [ + { + id: 1, + firstName: 'Benjamin', + lastName: 'Gilbert-Lif', + email: 'benj11@me.com', + phone: '5613254822', + discordUsername: 'sleepy_porg', + address1: '6189 Vista Linda Ln', + city: 'Boca Raton', + state: 'Florida', + zip: '33433', + country: 'United States', + shirtSize: 'L', + isMember: true, + eligibleForBenefits: true, + beganMembership: '2024-08-01', + membershipAmount: 100, + membershipTier: 'Inner Circle Member', + numberOfContributions: 10, + discordConfirmed: true, + nameConfirmed: true, + addressConfirmed: true, + cardPrinted: true, + labelPrinted: true, + cardPacked: true, + benefitShipped: true, + packageShipped: 'Yes', + userMatched: true, + }, + { + id: 2, + firstName: 'Alex', + lastName: 'Richter', + email: 'alexandertherichter@gmail.com', + phone: '9524635015', + discordUsername: 'nopunchman', + address1: '5796 E 280th St', + city: 'Elko New Market', + state: 'Minnesota', + zip: '55020', + country: 'United States', + shirtSize: 'L', + isMember: true, + eligibleForBenefits: true, + beganMembership: '2024-09-13', + membershipAmount: 100, + membershipTier: 'Inner Circle Member', + numberOfContributions: 13, + discordConfirmed: true, + nameConfirmed: true, + addressConfirmed: true, + cardPrinted: true, + labelPrinted: true, + cardPacked: true, + benefitShipped: true, + packageShipped: 'Yes', + userMatched: true, + }, + { + id: 3, + firstName: 'Haley', + lastName: 'Hutson', + email: 'haleyhutson1996@gmail.com', + phone: '8177277036', + discordUsername: 'haleyhutson._30519', + address1: '3121 Nutmeg Ln', + city: 'Garland', + state: 'Texas', + zip: '75044', + country: 'United States', + isMember: false, + eligibleForBenefits: false, + beganMembership: '2025-10-06', + membershipAmount: 100, + membershipTier: 'Inner Circle Member', + numberOfContributions: 2, + discordConfirmed: true, + nameConfirmed: false, + addressConfirmed: false, + cardPrinted: false, + labelPrinted: false, + cardPacked: false, + benefitShipped: false, + packageShipped: 'No', + userMatched: false, + }, + { + id: 4, + firstName: 'Marshall', + lastName: 'Carter', + email: 'Headed9hydra@gmail.com ', + phone: '2526783567', + discordUsername: '9headedhydra', + address1: '7413 Pennbrook Ct.', + city: 'Chesterfield', + state: 'Virginia', + zip: '23832', + country: 'United States', + shirtSize: 'XXL', + isMember: true, + eligibleForBenefits: true, + beganMembership: '2025-09-22', + membershipAmount: 100, + membershipTier: 'Inner Circle Member', + numberOfContributions: 8, + discordConfirmed: true, + nameConfirmed: true, + addressConfirmed: true, + cardPrinted: true, + labelPrinted: true, + cardPacked: true, + benefitShipped: false, + packageShipped: 'No', + userMatched: true, + }, + { + id: 5, + firstName: 'Seafoam', + lastName: 'Handy', + email: '10seafoam10@gmail.com', + phone: '5189545956', + discordUsername: 's.eafo.am', + address1: '1 Kelly St', + city: 'Troy', + state: 'New York', + zip: '12180', + country: 'United States', + isMember: true, + eligibleForBenefits: true, + beganMembership: '2025-12-27', + membershipAmount: 10, + membershipTier: 'Premium Member', + numberOfContributions: 8, + discordConfirmed: true, + nameConfirmed: true, + addressConfirmed: true, + cardPrinted: false, + labelPrinted: false, + cardPacked: false, + benefitShipped: false, + packageShipped: 'No', + userMatched: false, + }, + { + id: 6, + firstName: 'Conner', + lastName: 'Hill', + email: 'connerhill05@icloud.com', + phone: '4252322522', + discordUsername: 'mxtallical', + address1: '11509 128th dr NE', + city: 'Lake Stevens', + state: 'Washington', + zip: '98258', + country: 'United States', + isMember: true, + eligibleForBenefits: true, + beganMembership: '2025-07-12', + membershipAmount: 20, + membershipTier: 'Signature Member', + numberOfContributions: 5, + discordConfirmed: true, + nameConfirmed: true, + addressConfirmed: false, + cardPrinted: true, + labelPrinted: false, + cardPacked: false, + benefitShipped: false, + packageShipped: 'Not Received', + userMatched: false, + }, + { + id: 7, + firstName: 'Jayden', + lastName: 'Stevens', + email: 'stevens.jayden.a@gmail.com', + phone: '7753439011', + discordUsername: 'brewmastercraft', + address1: '4959 Talbot Ln', + address2: 'Apt 37', + city: 'Reno', + state: 'Nevada', + zip: '89509', + country: 'United States', + isMember: false, + eligibleForBenefits: true, + beganMembership: '2025-05-11', + membershipAmount: 20, + membershipTier: 'Signature Member', + numberOfContributions: 14, + discordConfirmed: true, + nameConfirmed: true, + addressConfirmed: false, + cardPrinted: true, + labelPrinted: false, + cardPacked: false, + benefitShipped: false, + packageShipped: 'Canceled', + userMatched: false, + }, + { + id: 8, + firstName: 'Riley', + lastName: 'Pusins', + email: 'rileypusins@gmail.com', + phone: '8139974153', + discordUsername: 'Riley Pusins', + address1: '1901 Honeysuckle', + city: 'Tallahassee', + state: 'Florida', + zip: '32304', + country: 'United States', + isMember: false, + eligibleForBenefits: true, + beganMembership: '2025-11-28', + membershipAmount: 5, + membershipTier: 'Dues Paying Member', + numberOfContributions: 5, + discordConfirmed: false, + nameConfirmed: false, + addressConfirmed: false, + cardPrinted: false, + labelPrinted: false, + cardPacked: false, + benefitShipped: false, + packageShipped: 'No', + userMatched: false, + }, +] diff --git a/src/app/admin/panels/membershiptest/page.module.css b/src/app/admin/panels/membershiptest/page.module.css new file mode 100644 index 00000000..b1efaf7f --- /dev/null +++ b/src/app/admin/panels/membershiptest/page.module.css @@ -0,0 +1,323 @@ +.panelContents { + width: 100%; + min-width: 0; + height: calc(100dvh - 100px); + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 0; +} + +.scrollView { + flex: 1 1 auto; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + padding: 0 0 1rem; +} + +.panelHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.35rem 1.25rem; + background-color: #f9fafb; + backdrop-filter: blur(14px) saturate(150%); + -webkit-backdrop-filter: blur(14px) saturate(150%); + border-bottom: 2px solid #e5e7eb; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.75) inset; +} + +.panelTimestamp { + font-size: 0.78rem; + font-weight: 700; + color: rgba(15, 23, 42, 0.45); +} + +.breadcrumbs { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: 0.86rem; + letter-spacing: -0.01em; + color: rgba(15, 23, 42, 0.72); + user-select: none; +} + +.prominentBreadcrumb { + font-weight: 700; + color: rgba(15, 23, 42, 0.72); +} + +.breadcrumbSeperator { + color: rgba(15, 23, 42, 0.35); + font-weight: 700; +} + +.panelBreadcrumb { + font-weight: 650; + color: rgba(15, 23, 42, 0.55); +} + +.galleryHeader { + padding: 1.15rem 1.05rem 0.35rem; +} + +.galleryTitle { + margin: 0; + font-size: 1.45rem; + font-weight: 800; + letter-spacing: -0.02em; + color: rgba(9, 34, 58, 0.9); +} + +.gallerySubTitle { + margin: 0.35rem 0 0; + font-size: 0.95rem; + font-weight: 600; + letter-spacing: -0.01em; + color: rgba(15, 23, 42, 0.55); +} + +.tableWrapper { + padding: 0.75rem 1.05rem 1.5rem; + overflow-x: auto; +} + +.loadMore { + min-height: 1px; + padding: 0.75rem 0; + color: rgba(15, 23, 42, 0.55); + font-size: 0.85rem; + font-weight: 600; + text-align: center; +} + +.tableToolbar { + display: flex; + justify-content: flex-end; + padding: 0.5rem 1.05rem 0; +} + +.tableOptionsBox { + min-width: 14rem; + background: rgba(255, 255, 255, 0.96); +} + +.tableOptionsBody { + display: grid; + grid-template-columns: 1fr; + gap: 0.55rem; +} + +.tableOptionRow { + display: grid; + gap: 0.3rem; + padding: 0 0.15rem; +} + +.tableOptionLabel { + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgba(15, 23, 42, 0.58); +} + +.nameCell { + display: inline-flex; + align-items: center; + gap: 0.3rem; + max-width: 100%; + min-width: 0; + + & > span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.nameCellSplit { + width: 100%; +} + +.cellDropdown { + display: inline-flex; + align-items: center; + flex-shrink: 0; + position: relative; + z-index: 0; + margin-left: auto; + + & > div { + display: flex; + align-items: center; + } + + &:has(button[aria-expanded='true']) { + z-index: 1; + } +} + +.cellDropdownButton { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + background: transparent; + font-size: inherit; + line-height: 1; + color: rgba(15, 23, 42, 0.5); + cursor: pointer; + + &:hover { + color: rgba(15, 23, 42, 0.9); + } +} + +.nameOverlay { + min-width: 18rem; +} + +.nameOverlayBody { + display: grid; + gap: 0.5rem; +} + +.nameSourceRow { + display: grid; + grid-template-columns: 4rem minmax(0, 1fr); + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: rgba(15, 23, 42, 0.86); +} + +.nameSourceLabel { + font-size: 0.72rem; + font-weight: 700; + color: rgba(15, 23, 42, 0.56); + text-transform: uppercase; +} + +.tagGray { + background: rgba(15, 23, 42, 0.06); + color: rgba(15, 23, 42, 0.72); +} + +.tagGhost { + background: transparent; + color: rgba(15, 23, 42, 0.4); + border: 1px dashed rgba(15, 23, 42, 0.2); +} + +.nameVerified { + color: rgba(17, 115, 75, 1); + font-size: 0.6rem; + flex-shrink: 0; + background: rgba(112, 195, 32, 0.3); + border-radius: 50%; + padding: 0.15rem; +} + +.nameUnverified { + color: rgba(177, 2, 2, 1); + font-size: 0.6rem; + flex-shrink: 0; + background: rgba(255, 95, 75, 0.3); + border-radius: 50%; + padding: 0.15rem; +} + +.tag { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.8rem; + font-size: 0.75rem; + font-weight: 700; + padding: 0.2rem 0.6rem; + border-radius: 9999px; + white-space: nowrap; + color: rgba(15, 23, 42, 0.72); + background: rgba(15, 23, 42, 0.06); +} + +.tagRed { + background: rgba(255, 95, 75, 0.3); + color: rgba(177, 2, 2, 1); +} +.tagOrange { + background: rgba(255, 72, 0, 0.3); + color: rgba(117, 56, 0, 1); +} +.tagYellow { + background: rgba(255, 168, 0, 0.3); + color: rgba(71, 56, 33, 1); +} +.tagGreen { + background: rgba(112, 195, 32, 0.3); + color: rgba(17, 115, 75, 1); +} +.tagBlue { + background: rgba(42, 155, 225, 0.3); + color: rgba(10, 83, 168, 1); +} +.tagPurple { + background: rgba(172, 95, 212, 0.3); + color: rgba(90, 50, 134, 1); +} +.tagDarkRed { + background: rgba(177, 2, 2, 1); + color: rgba(255, 207, 201, 1); +} +.tagMember { + background: #ffe5ec; + color: rgba(177, 2, 2, 1); +} +.tagPremium { + background: #ffe5ec; + color: rgb(223, 40, 76); +} +.tagSignature { + background: #ffa7cd; + color: rgba(177, 2, 2, 1); +} +.tagInnerCircle { + background: #da93ff; + color: rgba(90, 50, 134, 1); +} + +.tagTier { + width: 10rem; +} + +.tagWide { + min-width: 6.5rem; + font-size: 0.65rem; + padding: 0.1rem 0.5rem; + position: relative; + top: -2px; +} + +.tagFulfilled { + min-width: auto; + width: calc(100% - 8px); + font-size: 0.65rem; + padding: 0.1rem 0.5rem; + position: relative; + top: -2px; +} + +@media (max-width: 53rem) { + .panelHeader { + flex-direction: column; + align-items: flex-start; + gap: 0.4rem; + } +} diff --git a/src/app/admin/panels/membershiptest/page.tsx b/src/app/admin/panels/membershiptest/page.tsx new file mode 100644 index 00000000..330f0376 --- /dev/null +++ b/src/app/admin/panels/membershiptest/page.tsx @@ -0,0 +1,491 @@ +'use client' + +import styles from './page.module.css' +import { DropdownButton, DropdownOverlay } from '@/components/common' +import { Table, ColumnEntry } from '@/components/common/table' +import { + MembershipsResponsePacket, + zMembershipsResponsePacket, + zPaginatedResponse, +} from '@/contracts/responses' +import { cn } from '@/util' +import { useFetch } from '@/util/hooks' +import { useInfiniteQuery } from '@tanstack/react-query' +import { useEffect, useRef } from 'react' +import { FiCheck, FiX } from 'react-icons/fi' +import { IoEllipsisVerticalCircleOutline } from 'react-icons/io5' + +interface MembershipRow { + id: string + donorName?: string + userName?: string + discordUsername?: string + customField?: string + membershipEmail?: string + donorEmail?: string + userEmail?: string + donorAddress?: string + userAddress?: string + donorPhone?: string + userPhone?: string + membershipTier?: string + contributionCount?: number + isRecurring?: boolean + duesPayingMember?: boolean + membershipBenefitEligible?: boolean + membershipCardStatus?: number + membershipMerchStatus?: number + nameConfirmed?: boolean + addressConfirmed?: boolean +} + +const formatPhone = (phone?: string) => { + if (!phone) return '—' + const digits = phone.replace(/\D/g, '') + if (digits.length === 10) { + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}` + } + return phone +} + +const NameTag = ({ name }: { name?: string }) => + name ? ( + + {name} + + ) : ( + + Unfilled + + ) + +const NameMenu = ({ + row, + closeDropdown, +}: { + row: MembershipRow + closeDropdown: () => void +}) => ( + +
+ User + +
+
+ Donor + +
+ + } + /> +) + +const buildColumns = (): ColumnEntry[] => [ + { + key: 'rowNumber', + header: '#', + width: '3rem', + render: (_m: MembershipRow, index: number) => index + 1, + }, + { + key: 'donorName', + header: 'Donor Name', + width: '11rem', + sortValue: (m: MembershipRow) => m.donorName ?? '', + render: (m: MembershipRow) => m.donorName ?? '—', + }, + { + key: 'userName', + header: 'User Name', + width: '11rem', + allowOverflow: true, + sortValue: (m: MembershipRow) => m.userName ?? '', + render: (m: MembershipRow) => ( + + {m.userName ?? '—'} + {m.userName && + (m.nameConfirmed ? ( + + ) : ( + + ))} + + ( + + )} + > + + + + + ), + }, + { + key: 'discordUsername', + header: 'Discord Username', + width: '11rem', + sortValue: (m: MembershipRow) => m.discordUsername ?? '', + render: (m: MembershipRow) => + m.discordUsername ? `@${m.discordUsername}` : '—', + }, + { + key: 'customField', + header: 'Custom Field', + width: '14rem', + sortValue: (m: MembershipRow) => m.customField ?? '', + render: (m: MembershipRow) => m.customField ?? '—', + }, + { + key: 'donorAddress', + header: 'Donor Address', + width: '40rem', + sortValue: (m: MembershipRow) => m.donorAddress ?? '', + render: (m: MembershipRow) => m.donorAddress ?? '—', + }, + { + key: 'userAddress', + header: 'User Address', + width: '40rem', + sortValue: (m: MembershipRow) => m.userAddress ?? '', + render: (m: MembershipRow) => { + return ( + + {m.userAddress ?? '—'} + {m.userAddress && + (m.addressConfirmed ? ( + + ) : ( + + ))} + + ) + }, + }, + { + key: 'membershipEmail', + header: 'Membership Email', + width: '13rem', + sortValue: (m: MembershipRow) => m.membershipEmail ?? '', + render: (m: MembershipRow) => m.membershipEmail ?? '—', + }, + { + key: 'donorEmail', + header: 'Donor Email', + width: '13rem', + sortValue: (m: MembershipRow) => m.donorEmail ?? '', + render: (m: MembershipRow) => m.donorEmail ?? '—', + }, + { + key: 'userEmail', + header: 'User Email', + width: '13rem', + sortValue: (m: MembershipRow) => m.userEmail ?? '', + render: (m: MembershipRow) => m.userEmail ?? '—', + }, + { + key: 'donorPhone', + header: 'Donor Phone', + width: '10rem', + sortValue: (m: MembershipRow) => m.donorPhone ?? '', + render: (m: MembershipRow) => formatPhone(m.donorPhone), + }, + { + key: 'userPhone', + header: 'User Phone', + width: '10rem', + sortValue: (m: MembershipRow) => m.userPhone ?? '', + render: (m: MembershipRow) => formatPhone(m.userPhone), + }, + { + key: 'membershipTier', + header: 'Tier', + width: '11rem', + sortValue: (m: MembershipRow) => m.membershipTier ?? '', + render: (m: MembershipRow) => m.membershipTier ?? '—', + }, + { + key: 'contributionCount', + header: 'Contributions', + width: '7rem', + sortValue: (m: MembershipRow) => m.contributionCount ?? -1, + render: (m: MembershipRow) => m.contributionCount ?? '—', + }, + { + key: 'isRecurring', + header: 'Recurring', + width: '6rem', + sortValue: (m: MembershipRow) => m.isRecurring ?? -1, + render: (m: MembershipRow) => + m.isRecurring == null ? '—' : m.isRecurring ? 'Yes' : 'No', + }, + { + key: 'duesPayingMember', + header: 'Dues Paying Member', + width: '7rem', + sortValue: (m: MembershipRow) => (m.duesPayingMember ? 1 : 0), + render: (m: MembershipRow) => ( + + {m.duesPayingMember ? 'Yes' : 'No'} + + ), + }, + { + key: 'membershipBenefitEligible', + header: 'Eligible', + width: '6rem', + sortValue: (m: MembershipRow) => m.membershipBenefitEligible ?? -1, + render: (m: MembershipRow) => + m.membershipBenefitEligible == null + ? '—' + : m.membershipBenefitEligible + ? 'Yes' + : 'No', + }, + { + key: 'membershipCardStatus', + header: 'Card Status', + width: '8rem', + sortValue: (m: MembershipRow) => m.membershipCardStatus ?? 0, + render: (m: MembershipRow) => + m.membershipCardStatus != null + ? String(m.membershipCardStatus) + : '—', + }, + { + key: 'membershipMerchStatus', + header: 'Merch Status', + width: '8rem', + sortValue: (m: MembershipRow) => m.membershipMerchStatus ?? 0, + render: (m: MembershipRow) => + m.membershipMerchStatus != null + ? String(m.membershipMerchStatus) + : '—', + }, +] + +const mapPacketToRow = ( + packet: MembershipsResponsePacket, + index: number +): MembershipRow => { + const donor = packet.donor + const user = packet.user + + const donorName = [donor.firstname, donor.lastname] + .filter(Boolean) + .join(' ') + const userName = [user?.firstName, user?.lastName].filter(Boolean).join(' ') + const discordUsername = user?.discordUsers?.[0]?.username ?? '' + const customField = packet.customField?.answer ?? '' + const membershipEmail = donor.membershipData?.donorEmail ?? '' + const donorEmail = donor.email ?? '' + const userEmail = user?.email ?? '' + const donorPhone = donor.phone ?? '' + const userPhone = user?.phone ?? '' + const donorAddress = [ + donor.addr1, + [donor.city, [donor.state, donor.zip].filter(Boolean).join(' ')] + .filter(Boolean) + .join(', '), + donor.country, + ] + .filter(Boolean) + .join(', ') + const userAddress = [ + [user?.address?.addressLine1, user?.address?.addressLine2] + .filter(Boolean) + .join(', '), + [ + user?.address?.city, + [user?.address?.state, user?.address?.zip] + .filter(Boolean) + .join(' '), + ] + .filter(Boolean) + .join(', '), + user?.address?.county, + ] + .filter(Boolean) + .join(', ') + const membershipTier = packet.customField?.label ?? '—' + const contributionCount = donor.contributions?.length + const isRecurring = donor.contributions?.some( + (contribution) => contribution.isRecurring + ) + + return { + id: `${user?.id ?? donor.userId ?? donor.email}-${index}`, + donorName: donorName || undefined, + userName: userName || undefined, + discordUsername: discordUsername || undefined, + customField: customField || undefined, + membershipEmail: membershipEmail || undefined, + donorEmail: donorEmail || undefined, + userEmail: userEmail || undefined, + donorPhone: donorPhone || undefined, + userPhone: userPhone || undefined, + donorAddress: donorAddress || undefined, + userAddress: userAddress || undefined, + membershipTier: membershipTier || undefined, + contributionCount, + isRecurring, + duesPayingMember: + donor.membershipData?.duesPayingMember ?? + user?.duesPayingMember ?? + false, + membershipBenefitEligible: + donor.membershipData?.membershipBenefitEligible ?? + user?.membershipBenefitEligible ?? + undefined, + membershipCardStatus: + donor.membershipData?.membershipCardStatus ?? + user?.membershipCardStatus ?? + undefined, + membershipMerchStatus: + donor.membershipData?.membershipMerchStatus ?? + user?.membershipMerchStatus ?? + undefined, + nameConfirmed: Boolean(user?.nameConfirmed), + addressConfirmed: Boolean(user?.addressConfirmed), + } +} + +export default function Page() { + const { ready, onGet } = useFetch() + const columns = buildColumns() + const loadMoreRef = useRef(null) + const membershipsQuery = useInfiniteQuery({ + queryKey: ['/actblue/memberships', { limit: 50 }], + queryFn: ({ pageParam, signal }) => + onGet( + '/actblue/memberships', + zPaginatedResponse(zMembershipsResponsePacket), + { + query: { page: pageParam, limit: 50 }, + signal, + } + ), + initialPageParam: 0, + getNextPageParam: (lastPage, pages) => { + const loadedCount = pages.reduce( + (total, page) => total + page.data.length, + 0 + ) + + return loadedCount < lastPage.count ? pages.length : undefined + }, + enabled: ready, + }) + + useEffect(() => { + const loadMoreElement = loadMoreRef.current + if (!loadMoreElement || !membershipsQuery.hasNextPage) return + + const observer = new IntersectionObserver( + ([entry]) => { + if ( + entry.isIntersecting && + !membershipsQuery.isFetchingNextPage + ) + void membershipsQuery.fetchNextPage() + }, + { rootMargin: '300px' } + ) + + observer.observe(loadMoreElement) + return () => observer.disconnect() + }, [ + membershipsQuery, + membershipsQuery.fetchNextPage, + membershipsQuery.hasNextPage, + membershipsQuery.isFetchingNextPage, + ]) + + const queryError = membershipsQuery.error + const rows = (membershipsQuery.data?.pages ?? []) + .flatMap((page) => page.data) + .map((packet, index) => mapPacketToRow(packet, index)) + const totalEntries = membershipsQuery.data?.pages[0]?.count + const showQueryError = membershipsQuery.isError && !membershipsQuery.data + + return ( +
+
+
+ Admin + / + + Membership Test + +
+ +
+ Entries Loaded: {rows.length.toLocaleString()} + {totalEntries != null && + ` of ${totalEntries.toLocaleString()}`} +
+
+
+
+

Membership Test

+

+ Manage membership records and details. +

+
+ +
+ {showQueryError && ( +
+ Unable to load membership records. The API endpoint + for /actblue/memberships could not be reached. + {queryError instanceof Error && ( + <> {queryError.message} + )} +
+ )} +
m.id} /> +
+ {membershipsQuery.isFetchingNextPage && + 'Loading more membership records...'} +
+ + + + ) +} diff --git a/src/components/common/dropdown/DropdownButton.tsx b/src/components/common/dropdown/DropdownButton.tsx index af11f5d4..5ca957d8 100644 --- a/src/components/common/dropdown/DropdownButton.tsx +++ b/src/components/common/dropdown/DropdownButton.tsx @@ -35,7 +35,7 @@ import { IoClose } from 'react-icons/io5' * - 'short': No label shown, only an Ellipsis Icon * - 'icon': No label shown, only a custom Icon * - 'minimal': Shows button label only. - * + * - 'plain': No predefined styling, you must provide your own styling via className. * * * @@ -52,7 +52,12 @@ import { IoClose } from 'react-icons/io5' * */ -export type DropdownButtonVariant = 'long' | 'short' | 'minimal' | 'icon' +export type DropdownButtonVariant = + | 'long' + | 'short' + | 'minimal' + | 'icon' + | 'plain' interface DropdownVariantConfig { ariaHasPopup: 'menu' | 'dialog' @@ -62,6 +67,7 @@ interface DropdownVariantConfig { showLabel: boolean showEllipsisIcon: boolean showCustomIcon: boolean + showChevron: boolean } function getDropdownVariantConfig( @@ -74,6 +80,18 @@ function getDropdownVariantConfig( showLabel: false, showEllipsisIcon: false, showCustomIcon: true, + showChevron: false, + } + } + + if (variant === 'plain') { + return { + ariaHasPopup: 'dialog', + buttonClassName: '', + showLabel: true, + showEllipsisIcon: false, + showCustomIcon: false, + showChevron: false, } } @@ -84,6 +102,7 @@ function getDropdownVariantConfig( showLabel: false, showEllipsisIcon: true, showCustomIcon: false, + showChevron: false, } } @@ -96,6 +115,7 @@ function getDropdownVariantConfig( showLabel: true, showEllipsisIcon: false, showCustomIcon: false, + showChevron: true, } } @@ -107,6 +127,7 @@ function getDropdownVariantConfig( showLabel: true, showEllipsisIcon: false, showCustomIcon: false, + showChevron: true, } } @@ -123,7 +144,16 @@ export const DropdownButton = forwardRef< HTMLButtonElement, DropdownButtonProps >(function DropdownButton( - { label, icon, menu, buttonVariant = 'long', className, onClick, ...props }, + { + label, + icon, + menu, + buttonVariant = 'long', + className, + onClick, + children, + ...props + }, ref ) { const [isOpen, setIsOpen] = useState(false) @@ -173,7 +203,10 @@ export const DropdownButton = forwardRef< } return ( -
+
{isOpen && renderedMenu} diff --git a/src/components/common/table/Table.module.css b/src/components/common/table/Table.module.css index 1b9e002b..56c35f01 100644 --- a/src/components/common/table/Table.module.css +++ b/src/components/common/table/Table.module.css @@ -3,9 +3,15 @@ flex-direction: column; border: 1px solid rgba(17, 24, 39, 0.12); border-radius: 0.85rem; - overflow: hidden; + overflow: auto; + overscroll-behavior: none; + scrollbar-gutter: stable; + max-height: 100%; background: #ffffff; - min-width: fit-content; + + &::-webkit-scrollbar:horizontal { + height: 0; + } } .header, @@ -14,14 +20,30 @@ gap: 0.75rem; align-items: center; padding: 0.65rem 0.9rem; + width: max-content; + min-width: 100%; } .header { + position: sticky; + top: 0; + z-index: 2; font-size: 0.8rem; font-weight: 700; color: rgba(15, 23, 42, 0.78); - background: rgba(15, 23, 42, 0.06); + background: rgb(240, 241, 243); border-bottom: 1px solid rgba(17, 24, 39, 0.1); + border-radius: calc(0.85rem - 1px) calc(0.85rem - 1px) 0 0; +} + +.menuAnchor { + position: absolute; + top: 100%; + left: 0; + width: 100%; + height: 0; + /* Above sibling rows but below the sticky header. */ + z-index: 1; } .sortableHeader { @@ -57,9 +79,33 @@ .row:last-child { border-bottom: 0; + border-radius: 0 0 calc(0.85rem - 1px) calc(0.85rem - 1px); +} + +/* Negative margins let the cell fill the grid gap and row padding so hover covers the whole cell */ +.cell, +.cellOverflowVisible, +.dotCell { + position: relative; + align-self: stretch; + display: flex; + align-items: center; + min-width: 0; + white-space: nowrap; + padding: 0.65rem 0.375rem; + margin: -0.65rem -0.375rem; + transition: background-color 0.12s ease; } -.cell { +.clickable { + cursor: pointer; + + &:hover { + background-color: rgba(15, 23, 42, 0.03); + } +} + +.cellContent { min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -67,8 +113,6 @@ } .dotCell { - display: flex; - align-items: center; gap: 0.3rem; } diff --git a/src/components/common/table/Table.tsx b/src/components/common/table/Table.tsx index 6a08f5c8..a5fbcd94 100644 --- a/src/components/common/table/Table.tsx +++ b/src/components/common/table/Table.tsx @@ -1,7 +1,8 @@ 'use client' import styles from './Table.module.css' -import React, { useMemo, useState } from 'react' +import { cn } from '@/util' +import React, { useEffect, useMemo, useState } from 'react' import { FiChevronDown, FiChevronUp } from 'react-icons/fi' const DEFAULT_COLUMN_WIDTH = '10rem' @@ -9,8 +10,11 @@ const DEFAULT_COLUMN_WIDTH = '10rem' export interface Column { key: string header: string - render: (row: T) => React.ReactNode + render: (row: T, index: number) => React.ReactNode width?: string + allowOverflow?: boolean + onCellClick?: (row: T, index: number) => void + menu?: (row: T, controls: { closeDropdown: () => void }) => React.ReactNode sortValue?: (row: T) => string | number | boolean | null | undefined } @@ -33,18 +37,108 @@ export interface TableProps { data: T[] rowKey: (row: T, index: number) => string | number collapsedCategories?: string[] + footer?: React.ReactNode } type SortDir = 'asc' | 'desc' +function TableCell({ + col, + row, + index, + isOpen, + onToggle, + onClose, +}: { + col: Column + row: T + index: number + isOpen: boolean + onToggle: () => void + onClose: () => void +}) { + const interactive = Boolean(col.menu ?? col.onCellClick) + + const activate = () => { + col.onCellClick?.(row, index) + if (col.menu) onToggle() + } + + return ( + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + activate() + } + } + : undefined + } + > + {col.allowOverflow ? ( + col.render(row, index) + ) : ( + + {col.render(row, index)} + + )} + {isOpen && col.menu && ( + event.stopPropagation()} + > + {col.menu(row, { closeDropdown: onClose })} + + )} + + ) +} + export function Table({ columns, data, rowKey, collapsedCategories = [], + footer, }: TableProps) { const [sortKey, setSortKey] = useState(null) const [sortDir, setSortDir] = useState('asc') + const [openCell, setOpenCell] = useState(null) + + useEffect(() => { + if (!openCell) return + + const onDocumentMouseDown = (event: MouseEvent) => { + const target = event.target as HTMLElement | null + if (target?.closest('[data-open-cell="true"]')) return + setOpenCell(null) + } + + const onDocumentKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpenCell(null) + } + + document.addEventListener('mousedown', onDocumentMouseDown) + document.addEventListener('keydown', onDocumentKeyDown) + + return () => { + document.removeEventListener('mousedown', onDocumentMouseDown) + document.removeEventListener('keydown', onDocumentKeyDown) + } + }, [openCell]) const collapsedSet = useMemo( () => new Set(collapsedCategories), @@ -78,6 +172,10 @@ export function Table({ if (aVal == null && bVal == null) return 0 if (aVal == null) return 1 if (bVal == null) return -1 + if (typeof aVal === 'string' && typeof bVal === 'string') + return aVal.localeCompare(bVal, undefined, { + sensitivity: 'base', + }) if (aVal < bVal) return -1 if (aVal > bVal) return 1 return 0 @@ -171,7 +269,6 @@ export function Table({
{sortedData.map((row, index) => { - // Pre-compute rowRender results for categories const rowOverrides = new Map() for (const entry of columns) { if (isCategory(entry) && entry.rowRender) { @@ -232,7 +329,6 @@ export function Table({ ) } - // Check if this column belongs to a category with a rowRender override if ( entry.category && rowOverrides.has(entry.category.label) @@ -250,27 +346,41 @@ export function Table({ gridColumn: `span ${span}`, }} > - {rowOverrides.get( - entry.category.label - )} + + {rowOverrides.get( + entry.category.label + )} + ) } const col = entry.col! + const cellId = `${rowKey(row, index)}::${col.key}` return ( - - {col.render(row)} - + col={col} + row={row} + index={index} + isOpen={openCell === cellId} + onToggle={() => + setOpenCell((current) => + current === cellId + ? null + : cellId + ) + } + onClose={() => setOpenCell(null)} + /> ) }) })()}
) })} + {footer} ) } diff --git a/src/util/hooks/usePaginatedSearch.ts b/src/util/hooks/usePaginatedSearch.ts index d28590fa..aacd28fb 100644 --- a/src/util/hooks/usePaginatedSearch.ts +++ b/src/util/hooks/usePaginatedSearch.ts @@ -44,7 +44,7 @@ export function usePaginatedSearch( return await onGet(endpoint, zPaginatedResponse(schema), { params: options?.endpointParams, - query: { ...search, truePage, limit }, + query: { ...search, page: truePage, limit }, signal, }) } From f89e0277094563925076e802fd67da2fd7bb8156 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Sun, 6 Sep 2026 17:21:14 -0400 Subject: [PATCH 03/27] implmenting edit mode --- .../panels/membership/membership.types.ts | 6 + .../admin/panels/membership/page.module.css | 214 +++- src/app/admin/panels/membership/page.tsx | 1036 ++++++++++++++++- src/components/common/table/Table.module.css | 10 +- src/components/common/table/Table.tsx | 36 +- src/components/common/table/index.ts | 8 +- 6 files changed, 1246 insertions(+), 64 deletions(-) diff --git a/src/app/admin/panels/membership/membership.types.ts b/src/app/admin/panels/membership/membership.types.ts index 5b517605..3079b2ef 100644 --- a/src/app/admin/panels/membership/membership.types.ts +++ b/src/app/admin/panels/membership/membership.types.ts @@ -1,3 +1,4 @@ +import { MembershipDeliverableStatus, UserAddress } from '@/contracts/data' import { MembershipsResponsePacket } from '@/contracts/responses' export const membershipTiers = [ @@ -24,6 +25,8 @@ export interface Member { userId?: number firstName?: string lastName?: string + userFirstName?: string + userLastName?: string userName?: string donorName?: string discordUsername?: string @@ -42,6 +45,7 @@ export interface Member { zip?: string country?: string userAddress?: string + userAddressParts?: UserAddress donorAddress?: string shirtSize?: ShirtSize @@ -60,6 +64,8 @@ export interface Member { labelPrinted?: boolean cardPacked?: boolean benefitShipped?: boolean + membershipCardStatus?: MembershipDeliverableStatus + membershipMerchStatus?: MembershipDeliverableStatus packageShipped?: PackageShipped userMatched?: boolean diff --git a/src/app/admin/panels/membership/page.module.css b/src/app/admin/panels/membership/page.module.css index cb7413b5..a0139cd7 100644 --- a/src/app/admin/panels/membership/page.module.css +++ b/src/app/admin/panels/membership/page.module.css @@ -143,6 +143,200 @@ background-color: #ef4444; } +.editStatus { + font-size: 0.8rem; + font-weight: 600; + color: rgba(15, 23, 42, 0.6); +} + +.editInput { + position: absolute; + top: 0.4rem; + right: 0.375rem; + bottom: 0.4rem; + left: 0.375rem; + width: auto; + min-width: 0; + font-size: 0.85rem; + line-height: 1; + padding: 0 0.45rem; + border-radius: 0.4rem; + border: 1px solid rgba(15, 23, 42, 0.2); + background: #ffffff; + color: rgba(15, 23, 42, 0.9); + + &:focus { + outline: none; + border-color: #0ea5e9; + box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.25); + } +} + +.editInputDirty { + &, + &:focus { + box-shadow: 0 0 0 2px rgba(255, 168, 0, 0.3); + } +} + +.editInputInvalid { + &, + &:focus { + border-color: rgba(177, 2, 2, 1); + box-shadow: 0 0 0 2px rgba(177, 2, 2, 1); + } +} + +.editAddressRow { + position: absolute; + top: 0.4rem; + right: 0.375rem; + bottom: 0.4rem; + left: 0.375rem; + display: flex; + align-items: stretch; + gap: 0.3rem; +} + +.editAddressInput { + position: static; + inset: auto; + min-width: 0; +} + +.editAddressLine1 { + flex: 3 1 0; +} + +.editAddressLine2 { + flex: 1.5 1 0; +} + +.editAddressCity { + flex: 2 1 0; +} + +.editAddressState { + flex: 0 0 3rem; + text-transform: uppercase; +} + +.editAddressZip { + flex: 0 0 4.5rem; +} + +.editNameRow { + position: absolute; + top: 0.4rem; + right: 0.375rem; + bottom: 0.4rem; + left: 0.375rem; + display: flex; + align-items: stretch; + gap: 0.3rem; +} + +.editNameInput { + position: static; + inset: auto; + min-width: 0; + flex: 1 1 0; +} + +.editSelectTag { + position: relative; + justify-content: flex-start; + gap: 0.15rem; + width: 4.25rem; + padding-right: 0.45rem; + cursor: pointer; +} + +.editSelectLabel { + flex: none; + width: 1.6rem; + text-align: center; +} + +.editSelectChevron { + margin-left: auto; + font-size: 0.7rem; + flex-shrink: 0; +} + +.editPackageShippedTag.editPackageShippedTag { + display: flex; + position: relative; + cursor: pointer; +} + +.editPackageShippedLabel { + text-align: center; +} + +.editPackageShippedChevron { + position: absolute; + right: 0.35rem; + top: 50%; + transform: translateY(-50%); + font-size: 0.65rem; + flex-shrink: 0; +} + +.editSelectTag, +.editPackageShippedTag { + will-change: transform; + backface-visibility: hidden; + transform-origin: center center; + transition: transform 0.12s ease; + + &:hover, + &:focus-visible { + transform: scale(1.05); + } +} + +.editSelectOverlay.editSelectOverlay { + z-index: 1; + min-width: 0; + padding: 0.35rem; + overflow: visible; +} + +.editSelectMenu { + display: grid; + gap: 0.25rem; + justify-items: start; +} + +.editSelectOption { + display: flex; + padding: 0.15rem 0.2rem; + cursor: pointer; + + & > span { + will-change: transform; + backface-visibility: hidden; + transform-origin: center center; + transition: transform 0.12s ease; + } + + &:hover > span, + &:focus-visible > span { + transform: scale(1.05); + } +} + +.editSelectDirty { + box-shadow: 0 0 0 2px rgba(255, 168, 0, 0.3); +} + +.editUnavailable { + font-size: 0.75rem; + font-style: italic; + color: rgba(15, 23, 42, 0.45); +} + .tableOptionsBox { min-width: 14rem; background: rgba(255, 255, 255, 0.96); @@ -504,6 +698,24 @@ background: rgba(112, 195, 32, 0.3); color: rgba(17, 115, 75, 1); } + +.editableBoolTag { + cursor: pointer; + will-change: transform; + backface-visibility: hidden; + transform-origin: center center; + transition: transform 0.12s ease; + + &:hover { + transform: scale(1.05); + } + + &:focus-visible { + outline: 2px solid rgba(14, 165, 233, 0.8); + outline-offset: 2px; + } +} + .tagBlue { background: rgba(42, 155, 225, 0.3); color: rgba(10, 83, 168, 1); @@ -559,7 +771,7 @@ .tagFulfilled { min-width: auto; - width: calc(100% - 8px); + width: calc(100% - 0.5rem); font-size: 0.65rem; padding: 0.1rem 0.5rem; position: relative; diff --git a/src/app/admin/panels/membership/page.tsx b/src/app/admin/panels/membership/page.tsx index 5a0921d3..60967c3a 100644 --- a/src/app/admin/panels/membership/page.tsx +++ b/src/app/admin/panels/membership/page.tsx @@ -14,23 +14,91 @@ import { ToggleGroup, } from '@/components/common' import { Table, Column, ColumnEntry } from '@/components/common/table' -import { User, UserAddress, zUser } from '@/contracts/data' +import { + MembershipDeliverableStatus, + ShirtSize as ApiShirtSize, + User, + UserAddress, + zUser, +} from '@/contracts/data' +import { + UpdateMembershipRequest, + UpdateUserAddressRequest, + UpdateUserRequest, +} from '@/contracts/requests' import { MembershipsResponsePacket, zMembershipsResponsePacket, zPaginatedResponse, } from '@/contracts/responses' import { cn } from '@/util' -import { useFetch } from '@/util/hooks' -import { skipToken, useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { useCurrentUser, useFetch } from '@/util/hooks' +import { + skipToken, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' import Link from 'next/link' import { useEffect, useMemo, useRef, useState } from 'react' import { FaEdit, FaSave, FaTrashAlt } from 'react-icons/fa' -import { FiCheck, FiX } from 'react-icons/fi' +import { FiCheck, FiChevronDown, FiX } from 'react-icons/fi' const HISTORY_STALE_TIME = 5 * 60 * 1000 const HISTORY_LIMIT = 5 +type AddressField = 'addressLine1' | 'addressLine2' | 'city' | 'state' | 'zip' +type MembershipTableMode = 'view' | 'edit' + +type AddressDraft = Partial> + +type MemberEdits = Partial< + Pick +> & { + shirtSize?: ShirtSize | null + packageShipped?: PackageShipped | null + address?: AddressDraft + nameConfirmed?: boolean + addressConfirmed?: boolean + discordConfirmed?: boolean + cardPrinted?: boolean + labelPrinted?: boolean + cardPacked?: boolean + benefitShipped?: boolean +} + +const hasNameDraftChange = (member: Member, draft: MemberEdits) => + [ + ['userFirstName', member.userFirstName], + ['userLastName', member.userLastName], + ].some(([key, original]) => { + const value = draft[key as 'userFirstName' | 'userLastName'] + return value != null && value.trim() !== (original ?? '').trim() + }) + +const hasAddressDraftChange = (member: Member, draft: MemberEdits) => + draft.address != null && + addressFields.some(({ key }) => { + const value = draft.address?.[key] + return ( + value != null && + value.trim() !== (member.userAddressParts?.[key] ?? '').trim() + ) + }) + +interface EditController { + draftOf: (member: Member) => MemberEdits + update: (member: Member, patch: MemberEdits) => void +} + +interface PendingUpdate { + donorEmail: string + userId?: number + user?: UpdateUserRequest + membership?: UpdateMembershipRequest +} + const Check = () => const Cross = () => @@ -45,6 +113,35 @@ const BoolTag = ({ value }: { value?: boolean }) => ) +const EditableBoolTag = ({ + value, + onToggle, +}: { + value?: boolean + onToggle: () => void +}) => ( + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onToggle() + } + }} + > + {value ? : } + +) + const shirtSizeClass: Record = { XS: styles.tagRed, S: styles.tagOrange, @@ -54,6 +151,85 @@ const shirtSizeClass: Record = { XXL: styles.tagPurple, } +const shirtSizes: ShirtSize[] = ['XS', 'S', 'M', 'L', 'XL', 'XXL'] + +const toApiShirtSize = (size: ShirtSize): ApiShirtSize => + size === 'XXL' ? ApiShirtSize.DoubleExtraLarge : (size as ApiShirtSize) + +const ShirtSizeValue = ({ member }: { member: Member }) => + member.shirtSize ? ( + + {member.shirtSize} + + ) : ( + N/A + ) + +const ShirtSizeEdit = ({ + member, + edit, +}: { + member: Member + edit: EditController +}) => { + const draft = edit.draftOf(member).shirtSize + const value = (draft === undefined ? member.shirtSize : draft) ?? '' + + return ( + ( + ( + + ))} + /> + )} + > + + {value === '' ? 'N/A' : value} + + + ) +} + const membershipTierClass: Record = { 'Dues Paying Member': styles.tagMember, 'Premium Member': styles.tagPremium, @@ -69,6 +245,98 @@ const packageShippedClass: Record = { Canceled: styles.tagBlue, } +const packageShippedOptions: PackageShipped[] = [ + 'Yes', + 'No', + 'Returned', + 'Not Received', + 'Canceled', +] + +const PackageShippedValue = ({ member }: { member: Member }) => + member.packageShipped ? ( + + {member.packageShipped} + + ) : ( + '—' + ) + +const PackageShippedEdit = ({ + member, + edit, +}: { + member: Member + edit: EditController +}) => { + const draft = edit.draftOf(member).packageShipped + const value = (draft === undefined ? member.packageShipped : draft) ?? '' + + return ( + ( + ( + + ))} + /> + )} + > + + {value === '' ? 'N/A' : value} + + + ) +} + const formatPhone = (phone?: string) => { if (!phone) return '—' const digits = phone.replace(/\D/g, '').replace(/^1(?=\d{10}$)/, '') @@ -96,9 +364,16 @@ const formatHistoryDate = (value: Date) => type UserHistoryEntry = NonNullable[number] -type DescribeChange = ( - update: UserHistoryEntry, - previous: UserHistoryEntry | undefined +interface HistoryEntry { + historyId: number + historyWhenUpdatedUtc: Date +} + +const selectUserHistory = (user: User) => user.history ?? [] + +type DescribeChange = ( + update: T, + previous: T | undefined ) => { label: string; value: string } | undefined const describeNameChange: DescribeChange = (update, previous) => { @@ -162,16 +437,18 @@ const describeAddressChange: DescribeChange = (update, previous) => { return { label: `Address ${previous ? 'Changed' : 'Set'}`, value } } -const FieldHistory = ({ +const FieldHistory = ({ member, title, emptyMessage, + selectHistory, describeChange, }: { member: Member title: string emptyMessage: string - describeChange: DescribeChange + selectHistory: (user: User) => T[] + describeChange: DescribeChange }) => { const { ready, onGet } = useFetch() const userId = member.userId @@ -191,7 +468,8 @@ const FieldHistory = ({ }) const history = useMemo(() => { - const ascending = (userQuery.data?.history ?? []) + const user = userQuery.data + const ascending = (user ? selectHistory(user) : []) .slice() .sort( (a, b) => @@ -199,14 +477,22 @@ const FieldHistory = ({ b.historyWhenUpdatedUtc.getTime() ) - return ascending - .flatMap((update, index) => { - const change = describeChange(update, ascending[index - 1]) - return change ? [{ update, ...change }] : [] - }) - .reverse() - .slice(0, HISTORY_LIMIT) - }, [userQuery.data, describeChange]) + const entries: { + update: T + label: string + value: string + }[] = [] + let previous: T | undefined + + for (const update of ascending) { + const change = describeChange(update, previous) + if (!change) continue + entries.push({ update, ...change }) + previous = update + } + + return entries.reverse().slice(0, HISTORY_LIMIT) + }, [userQuery.data, selectHistory, describeChange]) const statusMessage = (() => { if (userId == null) return 'No linked user' @@ -293,6 +579,53 @@ const PhoneValue = ({ member }: { member: Member }) => ( ) +const normalizePhone = (phone: string) => phone.replace(/\D/g, '') + +const isValidPhone = (phone: string) => { + const digits = normalizePhone(phone) + return digits.length === 0 || digits.length >= 10 +} + +const PhoneEdit = ({ + member, + edit, +}: { + member: Member + edit: EditController +}) => { + if (member.userId == null) + return No linked user + + const original = member.userPhone ?? '' + const draft = edit.draftOf(member).userPhone + const invalid = !isValidPhone(draft ?? original) + + return ( + + edit.update(member, { + userPhone: normalizePhone(event.target.value), + }) + } + /> + ) +} + const PhoneMenu = ({ member, closeDropdown, @@ -337,6 +670,7 @@ const PhoneMenu = ({ member={member} title="Recent Phone Changes" emptyMessage="No phone changes found" + selectHistory={selectUserHistory} describeChange={describePhoneChange} /> @@ -362,6 +696,38 @@ const EmailValue = ({ member }: { member: Member }) => ( ) +const EmailEdit = ({ + member, + edit, +}: { + member: Member + edit: EditController +}) => { + if (member.userId == null) + return No linked user + + const original = member.userEmail ?? '' + const value = edit.draftOf(member).userEmail ?? original + + return ( + + edit.update(member, { userEmail: event.target.value }) + } + /> + ) +} + const EmailMenu = ({ member, closeDropdown, @@ -443,6 +809,7 @@ const EmailMenu = ({ member={member} title="Recent Email Changes" emptyMessage="No email changes found" + selectHistory={selectUserHistory} describeChange={describeEmailChange} /> @@ -533,6 +900,7 @@ const NameMenu = ({ member={member} title="Recent Name Changes" emptyMessage="No name changes found" + selectHistory={selectUserHistory} describeChange={describeNameChange} /> @@ -548,6 +916,63 @@ const NameValue = ({ member }: { member: Member }) => ( ) +const NameEdit = ({ + member, + edit, +}: { + member: Member + edit: EditController +}) => { + if (member.userId == null) + return No linked user + + const draft = edit.draftOf(member) + const fields = [ + { + key: 'userFirstName' as const, + label: 'User first name', + original: member.userFirstName ?? '', + placeholder: member.firstName ?? 'First name', + }, + { + key: 'userLastName' as const, + label: 'User last name', + original: member.userLastName ?? '', + placeholder: member.lastName ?? 'Last name', + }, + ] + + return ( +
+ {fields.map(({ key, label, original, placeholder }) => { + const value = draft[key] ?? original + + return ( + + edit.update(member, { + [key]: event.target.value, + }) + } + /> + ) + })} +
+ ) +} + const normalizeDiscordHandle = (handle: string) => handle.trim().replace(/^@/, '') @@ -668,6 +1093,114 @@ const AddressValue = ({ member }: { member: Member }) => ( ) +const addressFields: { + key: AddressField + label: string + placeholder: (member: Member) => string + maxLength: number + className: string +}[] = [ + { + key: 'addressLine1', + label: 'Address line 1', + placeholder: (m) => m.address1 ?? 'Address line 1', + maxLength: 100, + className: styles.editAddressLine1, + }, + { + key: 'addressLine2', + label: 'Address line 2', + placeholder: () => 'Apt, suite', + maxLength: 100, + className: styles.editAddressLine2, + }, + { + key: 'city', + label: 'City', + placeholder: (m) => m.city ?? 'City', + maxLength: 50, + className: styles.editAddressCity, + }, + { + key: 'state', + label: 'State', + placeholder: (m) => m.state ?? 'ST', + maxLength: 2, + className: styles.editAddressState, + }, + { + key: 'zip', + label: 'Zip', + placeholder: (m) => m.zip?.slice(0, 5) ?? 'Zip', + maxLength: 5, + className: styles.editAddressZip, + }, +] + +const isValidAddressField = (field: AddressField, value: string) => { + const trimmed = value.trim() + if (trimmed === '') return true + if (field === 'state') return /^[A-Za-z]{2}$/.test(trimmed) + if (field === 'zip') return /^\d{5}$/.test(trimmed) + return true +} + +const isValidAddressDraft = (draft: AddressDraft) => + addressFields.every(({ key }) => isValidAddressField(key, draft[key] ?? '')) + +const AddressEdit = ({ + member, + edit, +}: { + member: Member + edit: EditController +}) => { + if (member.userId == null) + return No linked user + + const draft = edit.draftOf(member).address ?? {} + + return ( +
+ {addressFields.map( + ({ key, label, placeholder, maxLength, className }) => { + const original = member.userAddressParts?.[key] ?? '' + const value = draft[key] ?? original + const invalid = !isValidAddressField(key, value) + + return ( + + edit.update(member, { + address: { + ...draft, + [key]: event.target.value, + }, + }) + } + /> + ) + } + )} +
+ ) +} + const AddressMenu = ({ member, closeDropdown, @@ -711,6 +1244,7 @@ const AddressMenu = ({ member={member} title="Recent Address Changes" emptyMessage="No address changes found" + selectHistory={selectUserHistory} describeChange={describeAddressChange} /> @@ -786,6 +1320,8 @@ const mapPacketToMember = ( userId: user?.id, firstName: donor.firstname, lastName: donor.lastname, + userFirstName: user?.firstName ?? undefined, + userLastName: user?.lastName ?? undefined, userName, donorName, discordUsername: user?.discordUsers?.[0]?.username, @@ -803,6 +1339,7 @@ const mapPacketToMember = ( zip: donor.zip ?? undefined, country: donor.country ?? undefined, userAddress, + userAddressParts: user?.address, donorAddress, shirtSize: membership?.shirtSize === '2XL' @@ -821,7 +1358,11 @@ const mapPacketToMember = ( addressConfirmed: membership?.addressConfirmed ?? user?.addressConfirmed ?? undefined, cardPrinted: cardStatus != null ? cardStatus >= 2 : undefined, + labelPrinted: merchStatus != null ? merchStatus >= 2 : undefined, + cardPacked: merchStatus != null ? merchStatus >= 3 : undefined, benefitShipped: merchStatus != null ? merchStatus >= 3 : undefined, + membershipCardStatus: cardStatus, + membershipMerchStatus: merchStatus, packageShipped: merchStatus === 5 ? 'Returned' @@ -859,27 +1400,75 @@ const statusDotColor = (m: Member, col: Column): string => { : 'rgba(255, 95, 75, 0.6)' } -const confirmedColumns: Column[] = [ +const confirmedColumns = (edit: EditController): Column[] => [ { key: 'nameConfirmed', header: 'Name Confirmed', width: '5rem', + allowOverflow: true, sortValue: (m: Member) => (m.nameConfirmed ? 1 : 0), render: (m: Member) => , + renderEdit: (m: Member) => { + const memberDraft = edit.draftOf(m) + const value = + memberDraft.nameConfirmed ?? + (hasNameDraftChange(m, memberDraft) + ? false + : (m.nameConfirmed ?? false)) + + return ( + edit.update(m, { nameConfirmed: !value })} + /> + ) + }, }, { key: 'discordConfirmed', header: 'Discord Confirmed', width: '5rem', + allowOverflow: true, sortValue: (m: Member) => (m.discordConfirmed ? 1 : 0), render: (m: Member) => , + renderEdit: (m: Member) => { + const draft = edit.draftOf(m).discordConfirmed + const value = draft ?? m.discordConfirmed ?? false + + return ( + + edit.update(m, { discordConfirmed: !value }) + } + /> + ) + }, }, { key: 'addressConfirmed', header: 'Address Confirmed', width: '5rem', + allowOverflow: true, sortValue: (m: Member) => (m.addressConfirmed ? 1 : 0), render: (m: Member) => , + renderEdit: (m: Member) => { + const memberDraft = edit.draftOf(m) + const value = + memberDraft.addressConfirmed ?? + (hasAddressDraftChange(m, memberDraft) + ? false + : (m.addressConfirmed ?? false)) + + return ( + + edit.update(m, { addressConfirmed: !value }) + } + /> + ) + }, }, ] @@ -904,7 +1493,8 @@ const buildColumns = ( showConfirmed: boolean, showFulfilled: boolean, showStatus: boolean, - showRowNumber: boolean + showRowNumber: boolean, + edit: EditController ): ColumnEntry[] => [ ...(showRowNumber ? [ @@ -960,50 +1550,103 @@ const buildColumns = ( } : undefined, columns: [ - ...(showConfirmed ? confirmedColumns : ([] as Column[])), + ...(showConfirmed + ? confirmedColumns(edit) + : ([] as Column[])), { key: 'cardPrinted', header: 'Card Printed', width: '5rem', + allowOverflow: true, sortValue: (m: Member) => (m.cardPrinted ? 1 : 0), render: (m: Member) => , + renderEdit: (m: Member) => { + const draft = edit.draftOf(m).cardPrinted + const value = draft ?? m.cardPrinted ?? false + + return ( + + edit.update(m, { cardPrinted: !value }) + } + /> + ) + }, }, { key: 'labelPrinted', header: 'Label Printed', width: '5rem', + allowOverflow: true, sortValue: (m: Member) => (m.labelPrinted ? 1 : 0), render: (m: Member) => , + renderEdit: (m: Member) => { + const draft = edit.draftOf(m).labelPrinted + const value = draft ?? m.labelPrinted ?? false + + return ( + + edit.update(m, { labelPrinted: !value }) + } + /> + ) + }, }, { key: 'cardPacked', - header: 'Packed', + header: 'Items Packed', width: '5rem', + allowOverflow: true, sortValue: (m: Member) => (m.cardPacked ? 1 : 0), render: (m: Member) => , + renderEdit: (m: Member) => { + const draft = edit.draftOf(m).cardPacked + const value = draft ?? m.cardPacked ?? false + + return ( + + edit.update(m, { cardPacked: !value }) + } + /> + ) + }, }, { key: 'benefitShipped', header: 'Benefit Shipped', width: '5rem', + allowOverflow: true, sortValue: (m: Member) => (m.benefitShipped ? 1 : 0), render: (m: Member) => , + renderEdit: (m: Member) => { + const draft = edit.draftOf(m).benefitShipped + const value = draft ?? m.benefitShipped ?? false + + return ( + + edit.update(m, { benefitShipped: !value }) + } + /> + ) + }, }, { key: 'packageShipped', header: 'Package Shipped', width: '7rem', + allowOverflow: true, sortValue: (m: Member) => m.packageShipped ?? '', - render: (m: Member) => - m.packageShipped ? ( - - {m.packageShipped} - - ) : ( - '—' - ), + render: (m: Member) => , + renderEdit: (m: Member) => ( + + ), }, ], }, @@ -1015,6 +1658,7 @@ const buildColumns = ( allowOverflow: true, sortValue: (m: Member) => m.userName ?? m.donorName ?? '', render: (m: Member) => , + renderEdit: (m: Member) => , menu: (m: Member, { closeDropdown }) => ( ), @@ -1037,6 +1681,7 @@ const buildColumns = ( allowOverflow: true, sortValue: (m: Member) => m.userAddress ?? m.donorAddress ?? '', render: (m: Member) => , + renderEdit: (m: Member) => , menu: (m: Member, { closeDropdown }) => ( ), @@ -1054,9 +1699,11 @@ const buildColumns = ( { key: 'phone', header: 'Phone', + width: '11rem', allowOverflow: true, sortValue: (m: Member) => m.phone ?? '', render: (m: Member) => , + renderEdit: (m: Member) => , menu: (m: Member, { closeDropdown }) => ( ), @@ -1064,9 +1711,11 @@ const buildColumns = ( { key: 'email', header: 'Email', + width: '14rem', allowOverflow: true, sortValue: (m: Member) => m.email ?? '', render: (m: Member) => , + renderEdit: (m: Member) => , menu: (m: Member, { closeDropdown }) => ( ), @@ -1075,17 +1724,10 @@ const buildColumns = ( key: 'shirtSize', header: 'Shirt', width: '5rem', + allowOverflow: true, sortValue: (m: Member) => m.shirtSize ?? '', - render: (m: Member) => - m.shirtSize ? ( - - {m.shirtSize} - - ) : ( - N/A - ), + render: (m: Member) => , + renderEdit: (m: Member) => , }, { @@ -1123,22 +1765,50 @@ const buildColumns = ( ] export default function Page() { - const { ready, onGet } = useFetch() + const { ready, onGet, onPatch } = useFetch() + const loggedInUser = useCurrentUser() + const queryClient = useQueryClient() const [showConfirmed, setShowConfirmed] = useState(false) const [showFulfilled, setShowFulfilled] = useState(true) const [collapseFulfillment, setCollapseFulfillment] = useState(false) const [showStatus, setShowStatus] = useState(false) const [showRowNumber, setShowRowNumber] = useState(false) - const [isEditing, setIsEditing] = useState(false) + const [tableMode, setTableMode] = useState('view') + const [edits, setEdits] = useState>({}) + const editController = useMemo( + () => ({ + draftOf: (member) => + member.donorEmail != null + ? (edits[member.donorEmail] ?? {}) + : {}, + update: (member, patch) => { + const donorEmail = member.donorEmail + if (donorEmail == null) return + setEdits((current) => ({ + ...current, + [donorEmail]: { ...current[donorEmail], ...patch }, + })) + }, + }), + [edits] + ) const columns = useMemo( () => buildColumns( showConfirmed, - showFulfilled, + showFulfilled && tableMode === 'view', showStatus, - showRowNumber + showRowNumber, + editController ), - [showConfirmed, showFulfilled, showStatus, showRowNumber] + [ + showConfirmed, + showFulfilled, + tableMode, + showStatus, + showRowNumber, + editController, + ] ) const collapsedCategories = useMemo( () => (collapseFulfillment ? ['Fulfillment'] : []), @@ -1190,6 +1860,249 @@ export default function Page() { ) const totalEntries = membershipsQuery.data?.pages[0]?.count + const pendingUpdates = useMemo( + () => + members.flatMap((member) => { + const donorEmail = member.donorEmail + const draft = donorEmail != null ? edits[donorEmail] : undefined + if (donorEmail == null || draft == null) return [] + + const userRequest: UpdateUserRequest = {} + + if (member.userId != null) { + if (draft.userEmail != null) { + const email = draft.userEmail.trim() + if (email !== (member.userEmail ?? '')) + userRequest.email = email === '' ? null : email + } + + if (draft.userFirstName != null) { + const firstName = draft.userFirstName.trim() + if (firstName !== (member.userFirstName ?? '')) + userRequest.firstName = + firstName === '' ? null : firstName + } + + if (draft.userLastName != null) { + const lastName = draft.userLastName.trim() + if (lastName !== (member.userLastName ?? '')) + userRequest.lastName = + lastName === '' ? null : lastName + } + + if (draft.userPhone != null) { + const phone = normalizePhone(draft.userPhone) + if (phone !== normalizePhone(member.userPhone ?? '')) + userRequest.phone = phone === '' ? null : phone + } + + if (draft.address != null) { + const addressRequest: UpdateUserAddressRequest = {} + + for (const { key } of addressFields) { + const value = draft.address[key] + if (value == null) continue + + const trimmed = value.trim() + const original = ( + member.userAddressParts?.[key] ?? '' + ).trim() + if (trimmed === original) continue + + addressRequest[key] = + trimmed === '' ? null : trimmed + } + + if (Object.keys(addressRequest).length > 0) + userRequest.address = addressRequest + } + } + + const membershipRequest: UpdateMembershipRequest = {} + + if ( + draft.shirtSize !== undefined && + (draft.shirtSize ?? undefined) !== member.shirtSize + ) { + const shirtSize = + draft.shirtSize == null + ? null + : toApiShirtSize(draft.shirtSize) + + if (member.userId != null) userRequest.shirtSize = shirtSize + else membershipRequest.shirtSize = shirtSize + } + + if ( + draft.nameConfirmed !== undefined && + draft.nameConfirmed !== member.nameConfirmed + ) + membershipRequest.nameConfirmed = draft.nameConfirmed + + if ( + draft.nameConfirmed === undefined && + hasNameDraftChange(member, draft) && + member.nameConfirmed !== false + ) + membershipRequest.nameConfirmed = false + + if ( + draft.addressConfirmed !== undefined && + draft.addressConfirmed !== member.addressConfirmed + ) + membershipRequest.addressConfirmed = draft.addressConfirmed + + if ( + draft.addressConfirmed === undefined && + hasAddressDraftChange(member, draft) && + member.addressConfirmed !== false + ) + membershipRequest.addressConfirmed = false + + if ( + draft.cardPrinted !== undefined && + draft.cardPrinted !== member.cardPrinted + ) + membershipRequest.membershipCardStatus = draft.cardPrinted + ? MembershipDeliverableStatus.Printed + : MembershipDeliverableStatus.NotStarted + + if ( + draft.packageShipped !== undefined && + (draft.packageShipped ?? undefined) !== + member.packageShipped + ) { + const statusMap: Record< + PackageShipped, + MembershipDeliverableStatus + > = { + Yes: MembershipDeliverableStatus.Recieved, + No: MembershipDeliverableStatus.NotStarted, + Returned: MembershipDeliverableStatus.Returned, + 'Not Received': MembershipDeliverableStatus.InTransit, + Canceled: MembershipDeliverableStatus.NotEligible, + } + const membershipMerchStatus = + draft.packageShipped == null + ? undefined + : statusMap[draft.packageShipped] + if (membershipMerchStatus !== undefined) { + membershipRequest.membershipMerchStatus = + membershipMerchStatus + } + } + + const merchStatusDraft = + draft.benefitShipped ?? + draft.cardPacked ?? + draft.labelPrinted + if ( + merchStatusDraft !== undefined && + draft.packageShipped === undefined + ) { + const labelPrinted = + draft.labelPrinted ?? member.labelPrinted ?? false + const cardPacked = + draft.cardPacked ?? member.cardPacked ?? false + const benefitShipped = + draft.benefitShipped ?? member.benefitShipped ?? false + const membershipMerchStatus = + benefitShipped || cardPacked + ? MembershipDeliverableStatus.InTransit + : labelPrinted + ? MembershipDeliverableStatus.Printed + : MembershipDeliverableStatus.NotStarted + + if (membershipMerchStatus !== member.membershipMerchStatus) + membershipRequest.membershipMerchStatus = + membershipMerchStatus + } + + const linkedUserRequest = + member.userId != null + ? { ...userRequest, ...membershipRequest } + : userRequest + const donorMembershipRequest = + member.userId == null ? membershipRequest : undefined + const hasUser = Object.keys(linkedUserRequest).length > 0 + const hasMembership = + donorMembershipRequest != null && + Object.keys(donorMembershipRequest).length > 0 + if (!hasUser && !hasMembership) return [] + + return [ + { + donorEmail, + userId: member.userId, + user: hasUser ? linkedUserRequest : undefined, + membership: hasMembership + ? donorMembershipRequest + : undefined, + }, + ] + }), + [members, edits] + ) + + const hasInvalidEdits = useMemo( + () => + Object.values(edits).some( + (draft) => + (draft.userPhone != null && + !isValidPhone(draft.userPhone)) || + (draft.address != null && + !isValidAddressDraft(draft.address)) + ), + [edits] + ) + + const saveMutation = useMutation({ + mutationFn: async (updates: PendingUpdate[]) => { + const metaData = { + dataSource: 'Membership Panel', + userWhoUpdatedId: loggedInUser.data?.id, + } + + for (const { donorEmail, userId, user, membership } of updates) { + if (user && userId != null) { + await onPatch( + '/users/:userId', + { ...user, metaData }, + zUser, + { params: { userId } } + ) + } + + if (membership) { + await onPatch( + '/actblue/donors/:donorEmail/membership', + { ...membership, metaData }, + null, + { params: { donorEmail } } + ) + } + } + }, + onSuccess: async () => { + setEdits({}) + setTableMode('view') + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ['/actblue/memberships'], + }), + queryClient.invalidateQueries({ + queryKey: ['/users/:userId'], + }), + ]) + }, + onError: (error) => console.error(error), + }) + + const discardEdits = () => { + setEdits({}) + setTableMode('view') + } + return (
@@ -1215,14 +2128,31 @@ export default function Page() {
- {isEditing ? ( + {tableMode === 'edit' ? ( <> + + {hasInvalidEdits + ? 'Fix invalid fields to save' + : pendingUpdates.length === 0 + ? 'No changes' + : `${pendingUpdates.length} pending change${pendingUpdates.length === 1 ? '' : 's'}`} + @@ -1239,7 +2170,7 @@ export default function Page() { @@ -1416,6 +2347,7 @@ export default function Page() { data={members} rowKey={(m) => m.id} collapsedCategories={collapsedCategories} + mode={tableMode} footer={ hasNextPage && (
{ key: string header: string render: (row: T, index: number) => React.ReactNode + renderEdit?: (row: T, index: number) => React.ReactNode width?: string allowOverflow?: boolean onCellClick?: (row: T, index: number) => void @@ -32,11 +33,14 @@ function isCategory(entry: ColumnEntry): entry is ColumnCategory { return 'columns' in entry && 'label' in entry } +export type TableMode = 'view' | 'edit' + export interface TableProps { columns: ColumnEntry[] data: T[] rowKey: (row: T, index: number) => string | number collapsedCategories?: string[] + mode?: TableMode footer?: React.ReactNode } @@ -46,6 +50,7 @@ function TableCell({ col, row, index, + mode, isOpen, onToggle, onClose, @@ -53,22 +58,30 @@ function TableCell({ col: Column row: T index: number + mode: TableMode isOpen: boolean onToggle: () => void onClose: () => void }) { - const interactive = Boolean(col.menu ?? col.onCellClick) + const editing = mode === 'edit' + const editable = editing && col.renderEdit != null + const interactive = !editing && Boolean(col.menu ?? col.onCellClick) const activate = () => { col.onCellClick?.(row, index) if (col.menu) onToggle() } + const content = editable + ? col.renderEdit!(row, index) + : col.render(row, index) + return ( ({ } > {col.allowOverflow ? ( - col.render(row, index) + content ) : ( - - {col.render(row, index)} - + {content} )} {isOpen && col.menu && ( ({ data, rowKey, collapsedCategories = [], + mode = 'view', footer, }: TableProps) { const [sortKey, setSortKey] = useState(null) const [sortDir, setSortDir] = useState('asc') const [openCell, setOpenCell] = useState(null) + useEffect(() => { + if (mode === 'edit') setOpenCell(null) + }, [mode]) + useEffect(() => { if (!openCell) return @@ -223,7 +239,7 @@ export function Table({ const gridTemplateColumns = visibleEntries.map((e) => e.width).join(' ') return ( -
+
{visibleEntries.map((entry) => { if (entry.type === 'group-collapsed') { @@ -347,7 +363,10 @@ export function Table({ }} > {rowOverrides.get( entry.category.label @@ -364,6 +383,7 @@ export function Table({ col={col} row={row} index={index} + mode={mode} isOpen={openCell === cellId} onToggle={() => setOpenCell((current) => diff --git a/src/components/common/table/index.ts b/src/components/common/table/index.ts index 1ba4b902..6d77550e 100644 --- a/src/components/common/table/index.ts +++ b/src/components/common/table/index.ts @@ -1,2 +1,8 @@ export { Table } from './Table' -export type { Column, ColumnCategory, ColumnEntry, TableProps } from './Table' +export type { + Column, + ColumnCategory, + ColumnEntry, + TableMode, + TableProps, +} from './Table' From 37d4a3e92a4f254e5baad040d1fe88164bee93e1 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Sun, 6 Sep 2026 19:05:53 -0400 Subject: [PATCH 04/27] abstracting --- .../components/EditableSelectTag.module.css | 87 + .../components/EditableSelectTag.tsx | 103 + .../components/FieldHistory.module.css | 76 + .../membership/components/FieldHistory.tsx | 77 + .../components/SourceMenu.module.css | 37 + .../membership/components/SourceMenu.tsx | 155 ++ .../membership/components/Tags.module.css | 168 ++ .../panels/membership/components/Tags.tsx | 65 + src/app/admin/panels/membership/hooks.ts | 280 ++ .../panels/membership/membership.helpers.ts | 435 ++++ .../panels/membership/membership.types.ts | 86 +- .../membership/membershipCells.module.css | 113 + .../panels/membership/membershipCells.tsx | 468 ++++ .../panels/membership/membershipColumns.tsx | 347 +++ .../admin/panels/membership/page.module.css | 603 +---- src/app/admin/panels/membership/page.tsx | 2281 +---------------- src/util/hooks/index.ts | 1 + src/util/hooks/useInfiniteScroll.ts | 37 + 18 files changed, 2609 insertions(+), 2810 deletions(-) create mode 100644 src/app/admin/panels/membership/components/EditableSelectTag.module.css create mode 100644 src/app/admin/panels/membership/components/EditableSelectTag.tsx create mode 100644 src/app/admin/panels/membership/components/FieldHistory.module.css create mode 100644 src/app/admin/panels/membership/components/FieldHistory.tsx create mode 100644 src/app/admin/panels/membership/components/SourceMenu.module.css create mode 100644 src/app/admin/panels/membership/components/SourceMenu.tsx create mode 100644 src/app/admin/panels/membership/components/Tags.module.css create mode 100644 src/app/admin/panels/membership/components/Tags.tsx create mode 100644 src/app/admin/panels/membership/hooks.ts create mode 100644 src/app/admin/panels/membership/membership.helpers.ts create mode 100644 src/app/admin/panels/membership/membershipCells.module.css create mode 100644 src/app/admin/panels/membership/membershipCells.tsx create mode 100644 src/app/admin/panels/membership/membershipColumns.tsx create mode 100644 src/util/hooks/useInfiniteScroll.ts diff --git a/src/app/admin/panels/membership/components/EditableSelectTag.module.css b/src/app/admin/panels/membership/components/EditableSelectTag.module.css new file mode 100644 index 00000000..0d5831d6 --- /dev/null +++ b/src/app/admin/panels/membership/components/EditableSelectTag.module.css @@ -0,0 +1,87 @@ +.editSelectTag.editSelectTag { + position: relative; + justify-content: flex-start; + gap: 0.15rem; + width: 4.25rem; + padding-right: 0.45rem; + cursor: pointer; +} + +.editSelectLabel { + flex: none; + width: 1.6rem; + text-align: center; +} + +.editSelectChevron { + margin-left: auto; + font-size: 0.7rem; + flex-shrink: 0; +} + +.editPackageShippedTag.editPackageShippedTag { + display: flex; + position: relative; + cursor: pointer; +} + +.editPackageShippedLabel { + text-align: center; +} + +.editPackageShippedChevron { + position: absolute; + right: 0.35rem; + top: 50%; + transform: translateY(-50%); + font-size: 0.65rem; + flex-shrink: 0; +} + +.editSelectTag, +.editPackageShippedTag { + will-change: transform; + backface-visibility: hidden; + transform-origin: center center; + transition: transform 0.12s ease; + + &:hover, + &:focus-visible { + transform: scale(1.05); + } +} + +.editSelectOverlay.editSelectOverlay { + z-index: 1; + min-width: 0; + padding: 0.35rem; + overflow: visible; +} + +.editSelectMenu { + display: grid; + gap: 0.25rem; + justify-items: start; +} + +.editSelectOption { + display: flex; + padding: 0.15rem 0.2rem; + cursor: pointer; + + & > span { + will-change: transform; + backface-visibility: hidden; + transform-origin: center center; + transition: transform 0.12s ease; + } + + &:hover > span, + &:focus-visible > span { + transform: scale(1.05); + } +} + +.editSelectDirty.editSelectDirty { + box-shadow: 0 0 0 2px rgba(255, 168, 0, 0.3); +} diff --git a/src/app/admin/panels/membership/components/EditableSelectTag.tsx b/src/app/admin/panels/membership/components/EditableSelectTag.tsx new file mode 100644 index 00000000..7c20c670 --- /dev/null +++ b/src/app/admin/panels/membership/components/EditableSelectTag.tsx @@ -0,0 +1,103 @@ +'use client' + +import styles from './EditableSelectTag.module.css' +import tags from './Tags.module.css' +import { DropdownButton, DropdownOverlay } from '@/components/common' +import { cn } from '@/util' +import { FiChevronDown } from 'react-icons/fi' + +const selectVariants = { + compact: { + tag: undefined, + trigger: styles.editSelectTag, + label: styles.editSelectLabel, + chevron: styles.editSelectChevron, + }, + wide: { + tag: tags.tagWide, + trigger: styles.editPackageShippedTag, + label: styles.editPackageShippedLabel, + chevron: styles.editPackageShippedChevron, + }, +} as const + +export interface EditableSelectTagProps { + ariaLabel: string + variant: keyof typeof selectVariants + value: T | null + options: readonly T[] + optionClass: Record + dirty: boolean + onSelect: (value: T | null) => void + allowClear?: boolean + menuLabel?: string +} + +export const EditableSelectTag = ({ + ariaLabel, + variant, + value, + options, + optionClass, + dirty, + onSelect, + allowClear = false, + menuLabel, +}: EditableSelectTagProps) => { + const variantClasses = selectVariants[variant] + const choices: (T | null)[] = allowClear ? [null, ...options] : [...options] + + return ( + ( + ( + + ))} + /> + )} + > + {value ?? 'N/A'} + + ) +} diff --git a/src/app/admin/panels/membership/components/FieldHistory.module.css b/src/app/admin/panels/membership/components/FieldHistory.module.css new file mode 100644 index 00000000..dab48288 --- /dev/null +++ b/src/app/admin/panels/membership/components/FieldHistory.module.css @@ -0,0 +1,76 @@ +.historySection { + display: grid; + gap: 0.35rem; + margin-top: 0.35rem; + padding-top: 0.6rem; + border-top: 1px solid rgba(17, 24, 39, 0.1); +} + +.historySectionLabel { + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + color: rgba(15, 23, 42, 0.56); + text-transform: uppercase; +} + +.historyEmpty { + font-size: 0.8rem; + color: rgba(15, 23, 42, 0.55); +} + +.historyContainer { + display: flex; + flex-direction: column; + width: 100%; + overflow: hidden; + border: 1px solid rgba(17, 24, 39, 0.1); + border-radius: 0.75rem; + background: rgba(255, 255, 255, 0.72); +} + +.historyEntry { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.5rem 0.65rem; + border-bottom: 1px solid rgba(17, 24, 39, 0.08); + color: rgba(15, 23, 42, 0.85); + font-size: 0.8rem; + font-weight: 550; + letter-spacing: -0.01em; + + &:last-child { + border-bottom: 0; + } +} + +.historyEntryMain { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 0.25rem; + flex-wrap: wrap; +} + +.historyEntryPrefix { + color: rgba(15, 23, 42, 0.62); + font-weight: 550; +} + +.historyEntryName { + color: rgba(15, 23, 42, 0.9); + font-weight: 650; +} + +.historyEntryDateTag { + margin-left: auto; + white-space: nowrap; + padding-inline: 0.75rem; + padding-block: 0.15rem; + border-radius: 9999px; + font-size: 0.72rem; + font-weight: 600; + color: rgba(15, 23, 42, 0.72); + background: rgba(15, 23, 42, 0.06); +} diff --git a/src/app/admin/panels/membership/components/FieldHistory.tsx b/src/app/admin/panels/membership/components/FieldHistory.tsx new file mode 100644 index 00000000..8aa012f8 --- /dev/null +++ b/src/app/admin/panels/membership/components/FieldHistory.tsx @@ -0,0 +1,77 @@ +'use client' + +import { useFieldHistory } from '../hooks' +import { DescribeChange, Member } from '../membership.types' +import styles from './FieldHistory.module.css' +import { User } from '@/contracts/data' + +export const selectUserHistory = (user: User) => user.history ?? [] + +const formatHistoryDate = (value: Date) => + value.toLocaleDateString([], { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + +export interface FieldHistoryProps { + member: Member + title: string + emptyMessage: string + describeChange: DescribeChange +} + +export const FieldHistory = ({ + member, + title, + emptyMessage, + describeChange, +}: FieldHistoryProps) => { + const userId = member.userId + const { history, isPending, isError } = useFieldHistory({ + userId, + selectHistory: selectUserHistory, + describeChange, + }) + + const statusMessage = (() => { + if (userId == null) return 'No linked user' + if (isPending) return 'Loading…' + if (isError) return 'Failed to load history' + if (history.length === 0) return emptyMessage + return undefined + })() + + return ( +
+ {title} + {statusMessage && ( +
{statusMessage}
+ )} + {!statusMessage && ( +
+ {history.map(({ update, label, value }) => ( +
+ + + {label} + + + {value} + + + + {formatHistoryDate( + update.historyWhenUpdatedUtc + )} + +
+ ))} +
+ )} +
+ ) +} diff --git a/src/app/admin/panels/membership/components/SourceMenu.module.css b/src/app/admin/panels/membership/components/SourceMenu.module.css new file mode 100644 index 00000000..3a236e6b --- /dev/null +++ b/src/app/admin/panels/membership/components/SourceMenu.module.css @@ -0,0 +1,37 @@ +.sourceOverlay { + min-width: var(--source-overlay-width, 24rem); +} + +.sourceOverlayBody { + display: grid; + gap: 0.5rem; +} + +.sourceRow { + display: grid; + grid-template-columns: 1rem var(--source-label-width, 4rem) minmax(0, 1fr); + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: rgba(15, 23, 42, 0.86); +} + +.sourceLabel { + font-size: 0.72rem; + font-weight: 700; + color: rgba(15, 23, 42, 0.56); + text-transform: uppercase; +} + +.sourceCheck { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + color: rgba(15, 23, 42, 0.56); +} + +.sourceIndicator { + font-size: 0.9rem; + stroke-width: 3; +} diff --git a/src/app/admin/panels/membership/components/SourceMenu.tsx b/src/app/admin/panels/membership/components/SourceMenu.tsx new file mode 100644 index 00000000..90e7263a --- /dev/null +++ b/src/app/admin/panels/membership/components/SourceMenu.tsx @@ -0,0 +1,155 @@ +'use client' + +import { Member } from '../membership.types' +import { FieldHistory, FieldHistoryProps } from './FieldHistory' +import styles from './SourceMenu.module.css' +import tags from './Tags.module.css' +import { DropdownOverlay } from '@/components/common' +import { cn } from '@/util' +import { CSSProperties } from 'react' +import { FiCheck } from 'react-icons/fi' + +export interface MemberSource { + label: string + value?: string +} + +const SourceCheck = ({ selected }: { selected: boolean }) => ( + + {selected && } + +) + +const SourceValueTag = ({ + value, + confirmed, +}: { + value?: string + confirmed?: boolean +}) => + value ? ( + + {value} + + ) : ( + + Unfilled + + ) + +const IdenticalTag = ({ source }: { source?: string }) => ( + + {source ? `Identical To ${source}` : 'Identical'} + +) + +export interface SourceMenuProps { + member: Member + title: string + sources: MemberSource[] + onClose: () => void + overlayWidth: string + labelWidth: string + format?: (value: string) => string + normalize?: (value: string) => string + confirmed?: boolean + confirmedSource?: string + history?: Omit +} + +export const SourceMenu = ({ + member, + title, + sources, + onClose, + overlayWidth, + labelWidth, + format = (value) => value, + normalize = (value) => value.trim(), + confirmed, + confirmedSource, + history, +}: SourceMenuProps) => { + const rows = sources.map((source) => ({ + ...source, + matchKey: source.value ? normalize(source.value) : undefined, + })) + + const displayedLabel = rows.find((row) => row.value)?.label + const allIdentical = + new Set(rows.flatMap((row) => row.matchKey ?? [])).size === 1 + + return ( + + {rows.map((row, index) => { + const identicalTo = row.matchKey + ? rows + .slice(0, index) + .find( + (earlier) => + earlier.matchKey === row.matchKey + ) + : undefined + + return ( +
+ + + {row.label} + + {identicalTo ? ( + + ) : ( + + )} +
+ ) + })} + {history && } + + } + /> + ) +} diff --git a/src/app/admin/panels/membership/components/Tags.module.css b/src/app/admin/panels/membership/components/Tags.module.css new file mode 100644 index 00000000..f59cfdbe --- /dev/null +++ b/src/app/admin/panels/membership/components/Tags.module.css @@ -0,0 +1,168 @@ +.tag { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.8rem; + font-size: 0.75rem; + font-weight: 700; + padding: 0.2rem 0.6rem; + border-radius: 9999px; + white-space: nowrap; + color: rgba(15, 23, 42, 0.72); + background: rgba(15, 23, 42, 0.06); +} + +.tagRed { + background: rgba(255, 95, 75, 0.3); + color: rgba(177, 2, 2, 1); +} +.tagOrange { + background: rgba(255, 72, 0, 0.3); + color: rgba(117, 56, 0, 1); +} +.tagYellow { + background: rgba(255, 168, 0, 0.3); + color: rgba(71, 56, 33, 1); +} +.tagGreen { + background: rgba(112, 195, 32, 0.3); + color: rgba(17, 115, 75, 1); +} +.tagBlue { + background: rgba(42, 155, 225, 0.3); + color: rgba(10, 83, 168, 1); +} +.tagPurple { + background: rgba(172, 95, 212, 0.3); + color: rgba(90, 50, 134, 1); +} +.tagDarkRed { + background: rgba(177, 2, 2, 1); + color: rgba(255, 207, 201, 1); +} +.tagGray { + background: #e6e6e6; + color: #3d3d3d; +} +.tagMember { + background: #ffe5ec; + color: rgba(177, 2, 2, 1); +} +.tagPremium { + background: #ffe5ec; + color: rgb(223, 40, 76); +} +.tagSignature { + background: #ffa7cd; + color: rgba(177, 2, 2, 1); +} +.tagInnerCircle { + background: #da93ff; + color: rgba(90, 50, 134, 1); +} + +.tagGhost { + background: transparent; + color: rgba(15, 23, 42, 0.4); + font-style: italic; + outline: 1px dashed rgba(15, 23, 42, 0.2); + outline-offset: -1px; +} + +.tagTier { + width: 10rem; +} + +.tagWide { + min-width: 6.5rem; + font-size: 0.65rem; + padding: 0.1rem 0.5rem; + position: relative; + top: -2px; +} + +.tagFulfilled { + min-width: auto; + width: calc(100% - 0.5rem); + font-size: 0.65rem; + padding: 0.1rem 0.5rem; + position: relative; + top: -2px; +} + +.editableBoolTag { + cursor: pointer; + will-change: transform; + backface-visibility: hidden; + transform-origin: center center; + transition: transform 0.12s ease; + + &:hover { + transform: scale(1.05); + } + + &:focus-visible { + outline: 2px solid rgba(14, 165, 233, 0.8); + outline-offset: 2px; + } +} + +.confirmBadge { + position: relative; + display: inline-flex; + align-items: center; + flex-shrink: 0; + outline: none; + + &::after { + content: attr(data-tooltip); + position: absolute; + left: calc(100% + 0.6rem); + top: 50%; + + padding: 0.35rem 0.5rem; + border-radius: 0.45rem; + width: max-content; + max-width: 20rem; + + color: rgba(241, 245, 249, 0.95); + background: rgba(15, 23, 42, 0.92); + font-size: 0.72rem; + font-weight: 560; + line-height: 1.35; + + box-shadow: 0 8px 20px rgba(2, 6, 23, 0.28); + + opacity: 0; + transform: translate(-3px, -50%); + transition: + opacity 100ms ease, + transform 100ms ease; + pointer-events: none; + z-index: 8; + } + + &:hover::after, + &:focus-visible::after { + opacity: var(--confirm-tooltip-opacity, 1); + transform: translate(0, -50%); + } +} + +.nameVerified { + color: rgba(17, 115, 75, 1); + font-size: 0.6rem; + flex-shrink: 0; + background: rgba(112, 195, 32, 0.3); + border-radius: 50%; + padding: 0.15rem; +} + +.nameUnverified { + color: rgba(177, 2, 2, 1); + font-size: 0.6rem; + flex-shrink: 0; + background: rgba(255, 95, 75, 0.3); + border-radius: 50%; + padding: 0.15rem; +} diff --git a/src/app/admin/panels/membership/components/Tags.tsx b/src/app/admin/panels/membership/components/Tags.tsx new file mode 100644 index 00000000..132cb00e --- /dev/null +++ b/src/app/admin/panels/membership/components/Tags.tsx @@ -0,0 +1,65 @@ +'use client' + +import styles from './Tags.module.css' +import { cn } from '@/util' +import { FiCheck, FiX } from 'react-icons/fi' + +const Check = () => +const Cross = () => + +export const BoolTag = ({ value }: { value?: boolean }) => ( + + {value ? : } + +) + +export const EditableBoolTag = ({ + label, + value, + onToggle, +}: { + label: string + value?: boolean + onToggle: () => void +}) => ( + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onToggle() + } + }} + > + {value ? : } + +) + +export const ConfirmedBadge = ({ + label, + confirmed, +}: { + label: string + confirmed?: boolean +}) => ( + + {confirmed ? ( + + ) : ( + + )} + +) diff --git a/src/app/admin/panels/membership/hooks.ts b/src/app/admin/panels/membership/hooks.ts new file mode 100644 index 00000000..3a0e432e --- /dev/null +++ b/src/app/admin/panels/membership/hooks.ts @@ -0,0 +1,280 @@ +'use client' + +import { + buildPendingUpdates, + isValidAddressDraft, + isValidPhone, + mapPacketToMember, +} from './membership.helpers' +import { + DescribeChange, + EditController, + HistoryEntry, + Member, + MemberEdits, + MembershipTableMode, + PendingUpdate, +} from './membership.types' +import { User, zUser } from '@/contracts/data' +import { + zMembershipsResponsePacket, + zPaginatedResponse, +} from '@/contracts/responses' +import { useCurrentUser, useFetch, useInfiniteScroll } from '@/util/hooks' +import { + skipToken, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { useCallback, useMemo, useState } from 'react' + +const HISTORY_STALE_TIME = 5 * 60 * 1000 +const HISTORY_LIMIT = 5 +const PAGE_SIZE = 25 + +export function useFieldHistory({ + userId, + selectHistory, + describeChange, +}: { + userId?: number + selectHistory: (user: User) => T[] + describeChange: DescribeChange +}) { + const { ready, onGet } = useFetch() + + const userQuery = useQuery({ + queryKey: ['/users/:userId', userId, { includeHistory: true }], + queryFn: + ready && userId != null + ? ({ signal }: { signal: AbortSignal }) => + onGet('/users/:userId', zUser, { + params: { userId }, + query: { includeHistory: true }, + signal, + }) + : skipToken, + staleTime: HISTORY_STALE_TIME, + }) + + const history = useMemo(() => { + const user = userQuery.data + const ascending = (user ? selectHistory(user) : []) + .slice() + .sort( + (a, b) => + a.historyWhenUpdatedUtc.getTime() - + b.historyWhenUpdatedUtc.getTime() + ) + + const entries: { + update: T + label: string + value: string + }[] = [] + let previous: T | undefined + + for (const update of ascending) { + const change = describeChange(update, previous) + if (!change) continue + entries.push({ update, ...change }) + previous = update + } + + return entries.reverse().slice(0, HISTORY_LIMIT) + }, [userQuery.data, selectHistory, describeChange]) + + return { + history, + isPending: userQuery.isPending, + isError: userQuery.isError, + } +} + +export function useMemberEdits() { + const [edits, setEdits] = useState>({}) + + const editController = useMemo( + () => ({ + draftOf: (member: Member) => + member.donorEmail != null + ? (edits[member.donorEmail] ?? {}) + : {}, + update: (member: Member, patch: MemberEdits) => { + const donorEmail = member.donorEmail + if (donorEmail == null) return + setEdits((current) => ({ + ...current, + [donorEmail]: { ...current[donorEmail], ...patch }, + })) + }, + }), + [edits] + ) + + const clearEdits = useCallback(() => setEdits({}), []) + + return { edits, editController, clearEdits } +} + +export function useSaveMemberships(onSaved: () => void) { + const { onPatch } = useFetch() + const loggedInUser = useCurrentUser() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (updates: PendingUpdate[]) => { + const metaData = { + dataSource: 'Membership Panel', + userWhoUpdatedId: loggedInUser.data?.id, + } + + for (const { donorEmail, userId, user, membership } of updates) { + if (user && userId != null) { + await onPatch( + '/users/:userId', + { ...user, metaData }, + zUser, + { params: { userId } } + ) + } + + if (membership) { + await onPatch( + '/actblue/donors/:donorEmail/membership', + { ...membership, metaData }, + null, + { params: { donorEmail } } + ) + } + } + }, + onSuccess: async () => { + onSaved() + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ['/actblue/memberships'], + }), + queryClient.invalidateQueries({ + queryKey: ['/users/:userId'], + }), + ]) + }, + onError: (error) => console.error(error), + }) +} + +export interface MembershipTableOptions { + showRowNumber: boolean + showStatus: boolean + showConfirmed: boolean + showFulfilled: boolean + collapseFulfillment: boolean +} + +const defaultTableOptions: MembershipTableOptions = { + showRowNumber: false, + showStatus: false, + showConfirmed: false, + showFulfilled: true, + collapseFulfillment: false, +} + +function useMembershipsQuery() { + const { ready, onGet } = useFetch() + + const query = useInfiniteQuery({ + queryKey: ['/actblue/memberships', { limit: PAGE_SIZE }], + queryFn: ({ pageParam, signal }) => + onGet( + '/actblue/memberships', + zPaginatedResponse(zMembershipsResponsePacket), + { query: { page: pageParam, limit: PAGE_SIZE }, signal } + ), + initialPageParam: 0, + getNextPageParam: (lastPage, pages) => { + const loadedCount = pages.reduce( + (total, page) => total + page.data.length, + 0 + ) + return loadedCount < lastPage.count ? pages.length : undefined + }, + enabled: ready, + }) + + const members = useMemo( + () => + (query.data?.pages ?? []) + .flatMap((page) => page.data) + .map(mapPacketToMember), + [query.data] + ) + + return { query, members, totalEntries: query.data?.pages[0]?.count } +} + +export function useMembershipPanel() { + const [options, setOptions] = + useState(defaultTableOptions) + const [tableMode, setTableMode] = useState('view') + const { edits, editController, clearEdits } = useMemberEdits() + const { query, members, totalEntries } = useMembershipsQuery() + + const { fetchNextPage, hasNextPage, isFetchingNextPage } = query + const { sentinelRef } = useInfiniteScroll({ + hasNextPage, + isFetchingNextPage, + fetchNextPage, + }) + + const setOption = useCallback( + ( + key: K, + value: MembershipTableOptions[K] + ) => setOptions((current) => ({ ...current, [key]: value })), + [] + ) + + const pendingUpdates = useMemo( + () => buildPendingUpdates(members, edits), + [members, edits] + ) + + const hasInvalidEdits = useMemo( + () => + Object.values(edits).some( + (draft) => + (draft.userPhone != null && + !isValidPhone(draft.userPhone)) || + (draft.address != null && + !isValidAddressDraft(draft.address)) + ), + [edits] + ) + + const stopEditing = useCallback(() => { + clearEdits() + setTableMode('view') + }, [clearEdits]) + + const saveMutation = useSaveMemberships(stopEditing) + + return { + members, + totalEntries, + options, + setOption, + tableMode, + setTableMode, + editController, + pendingUpdates, + hasInvalidEdits, + saveMutation, + discardEdits: stopEditing, + hasNextPage, + isFetchingNextPage, + sentinelRef, + } +} diff --git a/src/app/admin/panels/membership/membership.helpers.ts b/src/app/admin/panels/membership/membership.helpers.ts new file mode 100644 index 00000000..7bf1b2ec --- /dev/null +++ b/src/app/admin/panels/membership/membership.helpers.ts @@ -0,0 +1,435 @@ +import { + addressFields, + AddressDraft, + AddressField, + DescribeChange, + Member, + MemberEdits, + membershipTiers, + PackageShipped, + PendingUpdate, + ShirtSize, +} from './membership.types' +import { + MembershipDeliverableStatus, + ShirtSize as ApiShirtSize, + UserAddress, +} from '@/contracts/data' +import { + UpdateMembershipRequest, + UpdateUserAddressRequest, + UpdateUserRequest, +} from '@/contracts/requests' +import { MembershipsResponsePacket } from '@/contracts/responses' + +export const joinName = (firstName?: string | null, lastName?: string | null) => + [firstName, lastName].filter(Boolean).join(' ').trim() || undefined + +export const formatUserAddress = (address?: UserAddress) => + [ + [address?.addressLine1, address?.addressLine2] + .filter(Boolean) + .join(', '), + [ + address?.city, + [address?.state, address?.zip].filter(Boolean).join(' '), + ] + .filter(Boolean) + .join(', '), + ] + .filter(Boolean) + .join(', ') || undefined + +export const normalizePhone = (phone: string) => phone.replace(/\D/g, '') + +export const formatPhone = (phone?: string) => { + if (!phone) return '—' + const digits = normalizePhone(phone).replace(/^1(?=\d{10}$)/, '') + if (digits.length === 10) { + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}` + } + return phone +} + +export const normalizeEmail = (email: string) => email.trim().toLowerCase() + +export const normalizeDiscordHandle = (handle: string) => + handle.trim().replace(/^@/, '') + +export const resolveSelectDraft = ( + draft: T | null | undefined, + saved: T | undefined +) => ({ + value: (draft === undefined ? saved : draft) ?? null, + dirty: draft !== undefined && (draft ?? undefined) !== saved, +}) + +export const isValidPhone = (phone: string) => { + const digits = normalizePhone(phone) + return digits.length === 0 || digits.length >= 10 +} + +export const isValidAddressField = (field: AddressField, value: string) => { + const trimmed = value.trim() + if (trimmed === '') return true + if (field === 'state') return /^[A-Za-z]{2}$/.test(trimmed) + if (field === 'zip') return /^\d{5}$/.test(trimmed) + return true +} + +export const isValidAddressDraft = (draft: AddressDraft) => + addressFields.every((key) => isValidAddressField(key, draft[key] ?? '')) + +export const hasNameDraftChange = (member: Member, draft: MemberEdits) => + (['userFirstName', 'userLastName'] as const).some((key) => { + const value = draft[key] + return value != null && value.trim() !== (member[key] ?? '').trim() + }) + +export const hasAddressDraftChange = (member: Member, draft: MemberEdits) => + draft.address != null && + addressFields.some((key) => { + const value = draft.address?.[key] + return ( + value != null && + value.trim() !== (member.userAddressParts?.[key] ?? '').trim() + ) + }) + +const formatDonorAddress = ( + donor: MembershipsResponsePacket['donor'] +): string | undefined => { + const country = donor.country?.trim() + const zipDigits = donor.zip?.replace(/\D/g, '').slice(0, 5) + const zip = zipDigits === '' ? donor.zip : zipDigits + + return ( + [ + donor.addr1, + [donor.city, [donor.state, zip].filter(Boolean).join(' ')] + .filter(Boolean) + .join(', '), + country?.toLowerCase() === 'united states' ? undefined : country, + ] + .filter(Boolean) + .join(', ') || undefined + ) +} + +const packageShippedFromStatus = ( + merchStatus?: MembershipDeliverableStatus +): PackageShipped | undefined => { + if (merchStatus == null) return undefined + if (merchStatus === MembershipDeliverableStatus.Returned) return 'Returned' + return merchStatus >= MembershipDeliverableStatus.Recieved ? 'Yes' : 'No' +} + +export const mapPacketToMember = ( + packet: MembershipsResponsePacket, + index: number +): Member => { + const { donor, user, customField } = packet + const membership = donor.membershipData + const cardStatus = membership?.membershipCardStatus + const merchStatus = membership?.membershipMerchStatus + + const contributionDiscord = customField?.answer.trim() + + return { + packet, + id: index + 1, + userId: user?.id, + firstName: donor.firstname, + lastName: donor.lastname, + userFirstName: user?.firstName ?? undefined, + userLastName: user?.lastName ?? undefined, + userName: + joinName(user?.firstName, user?.lastName) ?? + user?.preferredName ?? + undefined, + donorName: joinName(donor.firstname, donor.lastname), + discordUsername: user?.discordUsers?.[0]?.username, + contributionDiscord: + contributionDiscord === '' ? undefined : contributionDiscord, + phone: user?.phone ?? donor.phone ?? undefined, + userPhone: user?.phone ?? undefined, + donorPhone: donor.phone ?? undefined, + email: user?.email ?? user?.discordUsers?.[0]?.email ?? donor.email, + donorEmail: donor.email, + userEmail: user?.email ?? undefined, + discordEmail: user?.discordUsers?.[0]?.email ?? undefined, + address1: donor.addr1 ?? undefined, + city: donor.city ?? undefined, + state: donor.state ?? undefined, + zip: donor.zip ?? undefined, + country: donor.country ?? undefined, + userAddress: formatUserAddress(user?.address), + userAddressParts: user?.address, + donorAddress: formatDonorAddress(donor), + shirtSize: + membership?.shirtSize === '2XL' + ? 'XXL' + : (membership?.shirtSize ?? undefined), + isMember: membership?.duesPayingMember, + eligibleForBenefits: membership?.membershipBenefitEligible, + membershipTier: membershipTiers.find( + (tier) => tier === customField?.label + ), + beganMembership: donor.contributions?.[0]?.createdAt + ?.toISOString() + .slice(0, 10), + numberOfContributions: donor.contributions?.length, + discordConfirmed: Boolean(user?.discordUsers?.length), + nameConfirmed: + membership?.nameConfirmed ?? user?.nameConfirmed ?? undefined, + addressConfirmed: + membership?.addressConfirmed ?? user?.addressConfirmed ?? undefined, + cardPrinted: cardStatus != null ? cardStatus >= 2 : undefined, + labelPrinted: merchStatus != null ? merchStatus >= 2 : undefined, + cardPacked: merchStatus != null ? merchStatus >= 3 : undefined, + benefitShipped: merchStatus != null ? merchStatus >= 3 : undefined, + membershipCardStatus: cardStatus, + membershipMerchStatus: merchStatus, + packageShipped: packageShippedFromStatus(merchStatus), + userMatched: Boolean(user), + } +} + +const toApiShirtSize = (size: ShirtSize): ApiShirtSize => + size === 'XXL' ? ApiShirtSize.DoubleExtraLarge : (size as ApiShirtSize) + +const packageShippedStatus: Record< + PackageShipped, + MembershipDeliverableStatus +> = { + Yes: MembershipDeliverableStatus.Recieved, + No: MembershipDeliverableStatus.NotStarted, + Returned: MembershipDeliverableStatus.Returned, + 'Not Received': MembershipDeliverableStatus.InTransit, + Canceled: MembershipDeliverableStatus.NotEligible, +} + +type FieldChange = T | null | undefined + +const textChange = ( + draft: string | undefined, + saved: string | null | undefined +): FieldChange => { + if (draft == null) return undefined + const value = draft.trim() + if (value === (saved ?? '').trim()) return undefined + return value === '' ? null : value +} + +const phoneChange = ( + draft: string | undefined, + saved: string | null | undefined +): FieldChange => { + if (draft == null) return undefined + const phone = normalizePhone(draft) + if (phone === normalizePhone(saved ?? '')) return undefined + return phone === '' ? null : phone +} + +const buildAddressRequest = (member: Member, draft: AddressDraft) => { + const request: UpdateUserAddressRequest = {} + + for (const key of addressFields) { + const change = textChange(draft[key], member.userAddressParts?.[key]) + if (change !== undefined) request[key] = change + } + + return Object.keys(request).length > 0 ? request : undefined +} + +const resolveConfirmation = ( + draft: boolean | undefined, + saved: boolean | undefined, + invalidatedByEdit: boolean +) => { + if (draft !== undefined) return draft === saved ? undefined : draft + return invalidatedByEdit && saved !== false ? false : undefined +} + +const resolveShirtSize = ( + member: Member, + draft: MemberEdits +): FieldChange => { + if (draft.shirtSize === undefined) return undefined + if ((draft.shirtSize ?? undefined) === member.shirtSize) return undefined + return draft.shirtSize == null ? null : toApiShirtSize(draft.shirtSize) +} + +const resolveMerchStatus = (member: Member, draft: MemberEdits) => { + if (draft.packageShipped !== undefined) { + if ((draft.packageShipped ?? undefined) === member.packageShipped) + return undefined + return draft.packageShipped == null + ? undefined + : packageShippedStatus[draft.packageShipped] + } + + const merchFlagEdited = + draft.labelPrinted !== undefined || + draft.cardPacked !== undefined || + draft.benefitShipped !== undefined + if (!merchFlagEdited) return undefined + + const labelPrinted = draft.labelPrinted ?? member.labelPrinted ?? false + const cardPacked = draft.cardPacked ?? member.cardPacked ?? false + const benefitShipped = + draft.benefitShipped ?? member.benefitShipped ?? false + + const status = + benefitShipped || cardPacked + ? MembershipDeliverableStatus.InTransit + : labelPrinted + ? MembershipDeliverableStatus.Printed + : MembershipDeliverableStatus.NotStarted + + return status === member.membershipMerchStatus ? undefined : status +} + +const buildUserRequest = (member: Member, draft: MemberEdits) => { + const request: UpdateUserRequest = {} + + const email = textChange(draft.userEmail, member.userEmail) + if (email !== undefined) request.email = email + + const firstName = textChange(draft.userFirstName, member.userFirstName) + if (firstName !== undefined) request.firstName = firstName + + const lastName = textChange(draft.userLastName, member.userLastName) + if (lastName !== undefined) request.lastName = lastName + + const phone = phoneChange(draft.userPhone, member.userPhone) + if (phone !== undefined) request.phone = phone + + const address = draft.address && buildAddressRequest(member, draft.address) + if (address) request.address = address + + return request +} + +const buildMembershipRequest = (member: Member, draft: MemberEdits) => { + const request: UpdateMembershipRequest = {} + + const nameConfirmed = resolveConfirmation( + draft.nameConfirmed, + member.nameConfirmed, + hasNameDraftChange(member, draft) + ) + if (nameConfirmed !== undefined) request.nameConfirmed = nameConfirmed + + const addressConfirmed = resolveConfirmation( + draft.addressConfirmed, + member.addressConfirmed, + hasAddressDraftChange(member, draft) + ) + if (addressConfirmed !== undefined) + request.addressConfirmed = addressConfirmed + + if ( + draft.cardPrinted !== undefined && + draft.cardPrinted !== member.cardPrinted + ) + request.membershipCardStatus = draft.cardPrinted + ? MembershipDeliverableStatus.Printed + : MembershipDeliverableStatus.NotStarted + + const merchStatus = resolveMerchStatus(member, draft) + if (merchStatus !== undefined) request.membershipMerchStatus = merchStatus + + return request +} + +export const buildPendingUpdates = ( + members: Member[], + edits: Record +): PendingUpdate[] => + members.flatMap((member) => { + const donorEmail = member.donorEmail + const draft = donorEmail != null ? edits[donorEmail] : undefined + if (donorEmail == null || draft == null) return [] + + const userRequest = + member.userId != null ? buildUserRequest(member, draft) : {} + const membershipRequest = buildMembershipRequest(member, draft) + + const shirtSize = resolveShirtSize(member, draft) + if (shirtSize !== undefined) { + if (member.userId != null) userRequest.shirtSize = shirtSize + else membershipRequest.shirtSize = shirtSize + } + + const user = + member.userId != null + ? { ...userRequest, ...membershipRequest } + : userRequest + const membership = member.userId == null ? membershipRequest : undefined + + const hasUser = Object.keys(user).length > 0 + const hasMembership = + membership != null && Object.keys(membership).length > 0 + if (!hasUser && !hasMembership) return [] + + return [ + { + donorEmail, + userId: member.userId, + user: hasUser ? user : undefined, + membership: hasMembership ? membership : undefined, + }, + ] + }) + +export const describeNameChange: DescribeChange = (update, previous) => { + const action = previous ? 'Changed' : 'Set' + const firstChanged = + (update.firstName ?? '') !== (previous?.firstName ?? '') + const lastChanged = (update.lastName ?? '') !== (previous?.lastName ?? '') + + if (!firstChanged && !lastChanged) return undefined + + if (firstChanged && !lastChanged && update.firstName) + return { label: `First Name ${action}`, value: update.firstName } + + if (lastChanged && !firstChanged && update.lastName) + return { label: `Last Name ${action}`, value: update.lastName } + + const value = joinName(update.firstName, update.lastName) + return value ? { label: `Name ${action}`, value } : undefined +} + +const describeSimpleChange = + ( + field: 'phone' | 'email', + noun: string, + format: (value: string) => string = (value) => value + ): DescribeChange => + (update, previous) => { + const value = update[field] ?? '' + if (value === (previous?.[field] ?? '') || value === '') + return undefined + + return { + label: `${noun} ${previous ? 'Changed' : 'Set'}`, + value: format(value), + } + } + +export const describePhoneChange = describeSimpleChange( + 'phone', + 'Phone', + formatPhone +) + +export const describeEmailChange = describeSimpleChange('email', 'Email') + +export const describeAddressChange: DescribeChange = (update, previous) => { + const value = formatUserAddress(update.address) + if (!value || value === formatUserAddress(previous?.address)) + return undefined + + return { label: `Address ${previous ? 'Changed' : 'Set'}`, value } +} diff --git a/src/app/admin/panels/membership/membership.types.ts b/src/app/admin/panels/membership/membership.types.ts index 3079b2ef..6b950adf 100644 --- a/src/app/admin/panels/membership/membership.types.ts +++ b/src/app/admin/panels/membership/membership.types.ts @@ -1,4 +1,12 @@ -import { MembershipDeliverableStatus, UserAddress } from '@/contracts/data' +import { + MembershipDeliverableStatus, + User, + UserAddress, +} from '@/contracts/data' +import { + UpdateMembershipRequest, + UpdateUserRequest, +} from '@/contracts/requests' import { MembershipsResponsePacket } from '@/contracts/responses' export const membershipTiers = [ @@ -70,3 +78,79 @@ export interface Member { userMatched?: boolean } + +export type MembershipTableMode = 'view' | 'edit' + +export type MemberFlag = + | 'nameConfirmed' + | 'discordConfirmed' + | 'addressConfirmed' + | 'cardPrinted' + | 'labelPrinted' + | 'cardPacked' + | 'benefitShipped' + +export const addressFields = [ + 'addressLine1', + 'addressLine2', + 'city', + 'state', + 'zip', +] as const + +export type AddressField = (typeof addressFields)[number] + +export type AddressDraft = Partial> + +export type MemberEdits = Partial< + Pick +> & { + shirtSize?: ShirtSize | null + packageShipped?: PackageShipped | null + address?: AddressDraft + nameConfirmed?: boolean + addressConfirmed?: boolean + discordConfirmed?: boolean + cardPrinted?: boolean + labelPrinted?: boolean + cardPacked?: boolean + benefitShipped?: boolean +} + +export interface EditController { + draftOf: (member: Member) => MemberEdits + update: (member: Member, patch: MemberEdits) => void +} + +export interface MemberValueProps { + member: Member +} + +export interface MemberEditProps { + member: Member + edit: EditController +} + +export interface MemberMenuProps { + member: Member + closeDropdown: () => void +} + +export interface PendingUpdate { + donorEmail: string + userId?: number + user?: UpdateUserRequest + membership?: UpdateMembershipRequest +} + +export type UserHistoryEntry = NonNullable[number] + +export interface HistoryEntry { + historyId: number + historyWhenUpdatedUtc: Date +} + +export type DescribeChange = ( + update: T, + previous: T | undefined +) => { label: string; value: string } | undefined diff --git a/src/app/admin/panels/membership/membershipCells.module.css b/src/app/admin/panels/membership/membershipCells.module.css new file mode 100644 index 00000000..be3a0cb5 --- /dev/null +++ b/src/app/admin/panels/membership/membershipCells.module.css @@ -0,0 +1,113 @@ +.valueCell { + display: block; + width: 100%; + max-width: 100%; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; +} + +.nameCell { + display: inline-flex; + align-items: center; + gap: 0.3rem; + max-width: 100%; + min-width: 0; + + & > span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.editUnavailable { + font-size: 0.75rem; + font-style: italic; + color: rgba(15, 23, 42, 0.45); +} + +.editInput { + position: absolute; + top: 0.4rem; + right: 0.375rem; + bottom: 0.4rem; + left: 0.375rem; + width: auto; + min-width: 0; + font-size: 0.85rem; + line-height: 1; + padding: 0 0.45rem; + border-radius: 0.4rem; + border: 1px solid rgba(15, 23, 42, 0.2); + background: #ffffff; + color: rgba(15, 23, 42, 0.9); + + &:focus { + outline: none; + border-color: #0ea5e9; + box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.25); + } +} + +.editInputDirty { + &, + &:focus { + box-shadow: 0 0 0 2px rgba(255, 168, 0, 0.3); + } +} + +.editInputInvalid { + &, + &:focus { + border-color: rgba(177, 2, 2, 1); + box-shadow: 0 0 0 2px rgba(177, 2, 2, 1); + } +} + +.editNameRow, +.editAddressRow { + position: absolute; + top: 0.4rem; + right: 0.375rem; + bottom: 0.4rem; + left: 0.375rem; + display: flex; + align-items: stretch; + gap: 0.3rem; +} + +.editNameInput, +.editAddressInput { + position: static; + inset: auto; + min-width: 0; +} + +.editNameInput { + flex: 1 1 0; +} + +.editAddressLine1 { + flex: 3 1 0; +} + +.editAddressLine2 { + flex: 1.5 1 0; +} + +.editAddressCity { + flex: 2 1 0; +} + +.editAddressState { + flex: 0 0 3rem; + text-transform: uppercase; +} + +.editAddressZip { + flex: 0 0 4.5rem; +} diff --git a/src/app/admin/panels/membership/membershipCells.tsx b/src/app/admin/panels/membership/membershipCells.tsx new file mode 100644 index 00000000..a0d00bad --- /dev/null +++ b/src/app/admin/panels/membership/membershipCells.tsx @@ -0,0 +1,468 @@ +'use client' + +import { EditableSelectTag } from './components/EditableSelectTag' +import { SourceMenu } from './components/SourceMenu' +import { ConfirmedBadge } from './components/Tags' +import tags from './components/Tags.module.css' +import { + describeAddressChange, + describeEmailChange, + describeNameChange, + describePhoneChange, + formatPhone, + isValidAddressField, + isValidPhone, + normalizeDiscordHandle, + normalizeEmail, + normalizePhone, + resolveSelectDraft, +} from './membership.helpers' +import { + AddressField, + Member, + MemberEditProps, + MemberMenuProps, + MemberValueProps, + PackageShipped, + ShirtSize, +} from './membership.types' +import styles from './membershipCells.module.css' +import { cn } from '@/util' + +const NoLinkedUser = () => ( + No linked user +) + +export const NameValue = ({ member }: MemberValueProps) => ( + + {member.userName ?? member.donorName ?? '—'} + + +) + +const nameInputs = [ + { + key: 'userFirstName', + label: 'User first name', + saved: (m: Member) => m.userFirstName ?? '', + placeholder: (m: Member) => m.firstName ?? 'First name', + }, + { + key: 'userLastName', + label: 'User last name', + saved: (m: Member) => m.userLastName ?? '', + placeholder: (m: Member) => m.lastName ?? 'Last name', + }, +] as const + +export const NameEdit = ({ member, edit }: MemberEditProps) => { + if (member.userId == null) return + + const draft = edit.draftOf(member) + + return ( +
+ {nameInputs.map(({ key, label, saved, placeholder }) => { + const original = saved(member) + const value = draft[key] ?? original + + return ( + + edit.update(member, { [key]: event.target.value }) + } + /> + ) + })} +
+ ) +} + +export const NameMenu = ({ member, closeDropdown }: MemberMenuProps) => ( + +) + +export const DiscordValue = ({ member }: MemberValueProps) => { + const displayedHandle = member.discordUsername ?? member.contributionDiscord + + return ( + + + {displayedHandle + ? `@${normalizeDiscordHandle(displayedHandle)}` + : '—'} + + + + ) +} + +export const DiscordMenu = ({ member, closeDropdown }: MemberMenuProps) => ( + `@${normalizeDiscordHandle(handle)}`} + normalize={normalizeDiscordHandle} + confirmed={member.discordConfirmed} + confirmedSource="Discord" + sources={[ + { label: 'Discord', value: member.discordUsername }, + { label: 'Contribution', value: member.contributionDiscord }, + ]} + /> +) + +export const AddressValue = ({ member }: MemberValueProps) => ( + + {member.userAddress ?? member.donorAddress ?? '—'} + + +) + +const addressInputs: { + key: AddressField + label: string + placeholder: (member: Member) => string + maxLength: number + className: string +}[] = [ + { + key: 'addressLine1', + label: 'Address line 1', + placeholder: (m) => m.address1 ?? 'Address line 1', + maxLength: 100, + className: styles.editAddressLine1, + }, + { + key: 'addressLine2', + label: 'Address line 2', + placeholder: () => 'Apt, suite', + maxLength: 100, + className: styles.editAddressLine2, + }, + { + key: 'city', + label: 'City', + placeholder: (m) => m.city ?? 'City', + maxLength: 50, + className: styles.editAddressCity, + }, + { + key: 'state', + label: 'State', + placeholder: (m) => m.state ?? 'ST', + maxLength: 2, + className: styles.editAddressState, + }, + { + key: 'zip', + label: 'Zip', + placeholder: (m) => m.zip?.slice(0, 5) ?? 'Zip', + maxLength: 5, + className: styles.editAddressZip, + }, +] + +export const AddressEdit = ({ member, edit }: MemberEditProps) => { + if (member.userId == null) return + + const draft = edit.draftOf(member).address ?? {} + + return ( +
+ {addressInputs.map( + ({ key, label, placeholder, maxLength, className }) => { + const original = member.userAddressParts?.[key] ?? '' + const value = draft[key] ?? original + const invalid = !isValidAddressField(key, value) + + return ( + + edit.update(member, { + address: { + ...draft, + [key]: event.target.value, + }, + }) + } + /> + ) + } + )} +
+ ) +} + +export const AddressMenu = ({ member, closeDropdown }: MemberMenuProps) => ( + +) + +export const PhoneValue = ({ member }: MemberValueProps) => ( + + {formatPhone(member.userPhone ?? member.donorPhone)} + +) + +export const PhoneEdit = ({ member, edit }: MemberEditProps) => { + if (member.userId == null) return + + const original = member.userPhone ?? '' + const draft = edit.draftOf(member).userPhone + const invalid = !isValidPhone(draft ?? original) + + return ( + + edit.update(member, { + userPhone: normalizePhone(event.target.value), + }) + } + /> + ) +} + +export const PhoneMenu = ({ member, closeDropdown }: MemberMenuProps) => ( + +) + +export const EmailValue = ({ member }: MemberValueProps) => ( + + {member.userEmail ?? member.discordEmail ?? member.donorEmail ?? '—'} + +) + +export const EmailEdit = ({ member, edit }: MemberEditProps) => { + if (member.userId == null) return + + const original = member.userEmail ?? '' + const value = edit.draftOf(member).userEmail ?? original + + return ( + + edit.update(member, { userEmail: event.target.value }) + } + /> + ) +} + +export const EmailMenu = ({ member, closeDropdown }: MemberMenuProps) => ( + +) + +const shirtSizes: ShirtSize[] = ['XS', 'S', 'M', 'L', 'XL', 'XXL'] + +const shirtSizeClass: Record = { + XS: tags.tagRed, + S: tags.tagOrange, + M: tags.tagYellow, + L: tags.tagGreen, + XL: tags.tagBlue, + XXL: tags.tagPurple, +} + +export const ShirtSizeValue = ({ member }: MemberValueProps) => + member.shirtSize ? ( + + {member.shirtSize} + + ) : ( + N/A + ) + +export const ShirtSizeEdit = ({ member, edit }: MemberEditProps) => { + const { value, dirty } = resolveSelectDraft( + edit.draftOf(member).shirtSize, + member.shirtSize + ) + + return ( + edit.update(member, { shirtSize })} + /> + ) +} + +const packageShippedOptions: PackageShipped[] = [ + 'Yes', + 'No', + 'Returned', + 'Not Received', + 'Canceled', +] + +const packageShippedClass: Record = { + Yes: tags.tagGreen, + No: tags.tagRed, + Returned: tags.tagYellow, + 'Not Received': tags.tagDarkRed, + Canceled: tags.tagBlue, +} + +export const PackageShippedValue = ({ member }: MemberValueProps) => + member.packageShipped ? ( + + {member.packageShipped} + + ) : ( + '—' + ) + +export const PackageShippedEdit = ({ member, edit }: MemberEditProps) => { + const { value, dirty } = resolveSelectDraft( + edit.draftOf(member).packageShipped, + member.packageShipped + ) + + return ( + + edit.update(member, { packageShipped }) + } + /> + ) +} diff --git a/src/app/admin/panels/membership/membershipColumns.tsx b/src/app/admin/panels/membership/membershipColumns.tsx new file mode 100644 index 00000000..da65ce3d --- /dev/null +++ b/src/app/admin/panels/membership/membershipColumns.tsx @@ -0,0 +1,347 @@ +'use client' + +import { BoolTag, EditableBoolTag } from './components/Tags' +import tags from './components/Tags.module.css' +import { MembershipTableOptions } from './hooks' +import { hasAddressDraftChange, hasNameDraftChange } from './membership.helpers' +import { + EditController, + Member, + MemberEdits, + MemberFlag, + MembershipTableMode, + MembershipTier, + PackageShipped, +} from './membership.types' +import { + AddressEdit, + AddressMenu, + AddressValue, + DiscordMenu, + DiscordValue, + EmailEdit, + EmailMenu, + EmailValue, + NameEdit, + NameMenu, + NameValue, + PackageShippedEdit, + PackageShippedValue, + PhoneEdit, + PhoneMenu, + PhoneValue, + ShirtSizeEdit, + ShirtSizeValue, +} from './membershipCells' +import styles from './page.module.css' +import { Column, ColumnEntry } from '@/components/common/table' +import { cn } from '@/util' +import Link from 'next/link' + +const membershipTierClass: Record = { + 'Dues Paying Member': tags.tagMember, + 'Premium Member': tags.tagPremium, + 'Signature Member': tags.tagSignature, + 'Inner Circle Member': tags.tagInnerCircle, +} + +const DOT_COLOR_TRUE = 'rgba(112, 195, 32, 0.6)' +const DOT_COLOR_FALSE = 'rgba(255, 95, 75, 0.6)' + +const packageShippedDotColor: Record = { + Yes: DOT_COLOR_TRUE, + No: DOT_COLOR_FALSE, + Returned: 'rgba(255, 168, 0, 0.6)', + 'Not Received': 'rgba(177, 2, 2, 1)', + Canceled: 'rgba(42, 155, 225, 0.6)', +} + +const statusDotColor = (m: Member, col: Column): string => + col.key === 'packageShipped' + ? packageShippedDotColor[m.packageShipped ?? 'No'] + : m[col.key as MemberFlag] + ? DOT_COLOR_TRUE + : DOT_COLOR_FALSE + +interface FlagColumnConfig { + key: MemberFlag + header: string + resolveDraft?: (member: Member, draft: MemberEdits) => boolean +} + +const flagColumn = ( + edit: EditController, + { key, header, resolveDraft }: FlagColumnConfig +): Column => ({ + key, + header, + width: '5rem', + allowOverflow: true, + sortValue: (m) => (m[key] ? 1 : 0), + render: (m) => , + renderEdit: (m) => { + const draft = edit.draftOf(m) + const value = resolveDraft + ? resolveDraft(m, draft) + : (draft[key] ?? m[key] ?? false) + + return ( + edit.update(m, { [key]: !value })} + /> + ) + }, +}) + +const readOnlyBoolColumn = ( + key: 'eligibleForBenefits' | 'isMember', + header: string +): Column => ({ + key, + header, + width: '5rem', + sortValue: (m) => (m[key] ? 1 : 0), + render: (m) => , +}) + +const confirmedColumnConfigs: FlagColumnConfig[] = [ + { + key: 'nameConfirmed', + header: 'Name Confirmed', + resolveDraft: (m, draft) => + draft.nameConfirmed ?? + (hasNameDraftChange(m, draft) ? false : (m.nameConfirmed ?? false)), + }, + { key: 'discordConfirmed', header: 'Discord Confirmed' }, + { + key: 'addressConfirmed', + header: 'Address Confirmed', + resolveDraft: (m, draft) => + draft.addressConfirmed ?? + (hasAddressDraftChange(m, draft) + ? false + : (m.addressConfirmed ?? false)), + }, +] + +const fulfillmentColumnConfigs: FlagColumnConfig[] = [ + { key: 'cardPrinted', header: 'Card Printed' }, + { key: 'labelPrinted', header: 'Label Printed' }, + { key: 'cardPacked', header: 'Items Packed' }, + { key: 'benefitShipped', header: 'Benefit Shipped' }, +] + +const statusColumns: Column[] = [ + readOnlyBoolColumn('eligibleForBenefits', 'Eligible'), + readOnlyBoolColumn('isMember', 'Member'), +] + +const rowNumberColumn: Column = { + key: 'rowNumber', + header: '#', + width: '3rem', + render: (_m, index) => index + 1, +} + +const isFullyFulfilled = (m: Member, requireConfirmations: boolean) => + Boolean( + (!requireConfirmations || + (m.discordConfirmed && m.nameConfirmed && m.addressConfirmed)) && + m.cardPrinted && + m.labelPrinted && + m.cardPacked && + m.benefitShipped && + m.packageShipped === 'Yes' + ) + +export const FULFILLMENT_CATEGORY = 'Fulfillment' + +export const buildColumns = ({ + options, + tableMode, + edit, +}: { + options: MembershipTableOptions + tableMode: MembershipTableMode + edit: EditController +}): ColumnEntry[] => { + const { showConfirmed, showStatus, showRowNumber } = options + const showFulfilledTag = options.showFulfilled && tableMode === 'view' + + return [ + ...(showRowNumber ? [rowNumberColumn] : []), + ...(showStatus ? statusColumns : []), + { + key: 'membershipTier', + header: 'Tier', + width: '11rem', + sortValue: (m) => m.membershipTier ?? '', + render: (m) => + m.membershipTier ? ( + + {m.membershipTier} + + ) : ( + '—' + ), + }, + { + label: FULFILLMENT_CATEGORY, + collapsedWidth: '6rem', + dotColor: statusDotColor, + rowRender: showFulfilledTag + ? (m) => + isFullyFulfilled(m, showConfirmed) ? ( + + Benefits Fulfilled + + ) : null + : undefined, + columns: [ + ...(showConfirmed + ? confirmedColumnConfigs.map((config) => + flagColumn(edit, config) + ) + : []), + ...fulfillmentColumnConfigs.map((config) => + flagColumn(edit, config) + ), + { + key: 'packageShipped', + header: 'Package Shipped', + width: '7rem', + allowOverflow: true, + sortValue: (m) => m.packageShipped ?? '', + render: (m) => , + renderEdit: (m) => ( + + ), + }, + ], + }, + { + key: 'name', + header: 'Name', + width: '11rem', + allowOverflow: true, + sortValue: (m) => m.userName ?? m.donorName ?? '', + render: (m) => , + renderEdit: (m) => , + menu: (m, { closeDropdown }) => ( + + ), + }, + { + key: 'discord', + header: 'Discord', + width: '11rem', + allowOverflow: true, + sortValue: (m) => m.discordUsername ?? '', + render: (m) => , + menu: (m, { closeDropdown }) => ( + + ), + }, + { + key: 'address', + header: 'Address', + width: '40rem', + allowOverflow: true, + sortValue: (m) => m.userAddress ?? m.donorAddress ?? '', + render: (m) => , + renderEdit: (m) => , + menu: (m, { closeDropdown }) => ( + + ), + }, + { + key: 'beganMembership', + header: 'Member Since', + sortValue: (m) => m.beganMembership ?? '', + render: (m) => { + if (!m.beganMembership) return '—' + const [y, mo, d] = m.beganMembership.split('-') + return `${mo}-${d}-${y}` + }, + }, + { + key: 'phone', + header: 'Phone', + width: '11rem', + allowOverflow: true, + sortValue: (m) => m.phone ?? '', + render: (m) => , + renderEdit: (m) => , + menu: (m, { closeDropdown }) => ( + + ), + }, + { + key: 'email', + header: 'Email', + width: '14rem', + allowOverflow: true, + sortValue: (m) => m.email ?? '', + render: (m) => , + renderEdit: (m) => , + menu: (m, { closeDropdown }) => ( + + ), + }, + { + key: 'shirtSize', + header: 'Shirt', + width: '5rem', + allowOverflow: true, + sortValue: (m) => m.shirtSize ?? '', + render: (m) => , + renderEdit: (m) => , + }, + { + key: 'membershipAmount', + header: 'Amount', + width: '6rem', + sortValue: (m) => m.membershipAmount ?? 0, + render: (m) => + m.membershipAmount != null ? `$${m.membershipAmount}` : '—', + }, + { + key: 'contributions', + header: 'Contributions', + width: '6rem', + sortValue: (m) => m.numberOfContributions ?? 0, + render: (m) => m.numberOfContributions?.toString() ?? '—', + }, + { + key: 'userMatched', + header: 'User Matched', + width: '5rem', + sortValue: (m) => (m.userMatched ? 1 : 0), + render: (m) => + m.userId != null ? ( + + + + ) : ( + + ), + }, + ] +} diff --git a/src/app/admin/panels/membership/page.module.css b/src/app/admin/panels/membership/page.module.css index a0139cd7..42d41fd3 100644 --- a/src/app/admin/panels/membership/page.module.css +++ b/src/app/admin/panels/membership/page.module.css @@ -95,8 +95,8 @@ min-height: 0; padding: 0.75rem 1.05rem 1.5rem; - &:has([aria-expanded='true']) .confirmBadge::after { - opacity: 0; + &:has([aria-expanded='true']) { + --confirm-tooltip-opacity: 0; } } @@ -149,194 +149,6 @@ color: rgba(15, 23, 42, 0.6); } -.editInput { - position: absolute; - top: 0.4rem; - right: 0.375rem; - bottom: 0.4rem; - left: 0.375rem; - width: auto; - min-width: 0; - font-size: 0.85rem; - line-height: 1; - padding: 0 0.45rem; - border-radius: 0.4rem; - border: 1px solid rgba(15, 23, 42, 0.2); - background: #ffffff; - color: rgba(15, 23, 42, 0.9); - - &:focus { - outline: none; - border-color: #0ea5e9; - box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.25); - } -} - -.editInputDirty { - &, - &:focus { - box-shadow: 0 0 0 2px rgba(255, 168, 0, 0.3); - } -} - -.editInputInvalid { - &, - &:focus { - border-color: rgba(177, 2, 2, 1); - box-shadow: 0 0 0 2px rgba(177, 2, 2, 1); - } -} - -.editAddressRow { - position: absolute; - top: 0.4rem; - right: 0.375rem; - bottom: 0.4rem; - left: 0.375rem; - display: flex; - align-items: stretch; - gap: 0.3rem; -} - -.editAddressInput { - position: static; - inset: auto; - min-width: 0; -} - -.editAddressLine1 { - flex: 3 1 0; -} - -.editAddressLine2 { - flex: 1.5 1 0; -} - -.editAddressCity { - flex: 2 1 0; -} - -.editAddressState { - flex: 0 0 3rem; - text-transform: uppercase; -} - -.editAddressZip { - flex: 0 0 4.5rem; -} - -.editNameRow { - position: absolute; - top: 0.4rem; - right: 0.375rem; - bottom: 0.4rem; - left: 0.375rem; - display: flex; - align-items: stretch; - gap: 0.3rem; -} - -.editNameInput { - position: static; - inset: auto; - min-width: 0; - flex: 1 1 0; -} - -.editSelectTag { - position: relative; - justify-content: flex-start; - gap: 0.15rem; - width: 4.25rem; - padding-right: 0.45rem; - cursor: pointer; -} - -.editSelectLabel { - flex: none; - width: 1.6rem; - text-align: center; -} - -.editSelectChevron { - margin-left: auto; - font-size: 0.7rem; - flex-shrink: 0; -} - -.editPackageShippedTag.editPackageShippedTag { - display: flex; - position: relative; - cursor: pointer; -} - -.editPackageShippedLabel { - text-align: center; -} - -.editPackageShippedChevron { - position: absolute; - right: 0.35rem; - top: 50%; - transform: translateY(-50%); - font-size: 0.65rem; - flex-shrink: 0; -} - -.editSelectTag, -.editPackageShippedTag { - will-change: transform; - backface-visibility: hidden; - transform-origin: center center; - transition: transform 0.12s ease; - - &:hover, - &:focus-visible { - transform: scale(1.05); - } -} - -.editSelectOverlay.editSelectOverlay { - z-index: 1; - min-width: 0; - padding: 0.35rem; - overflow: visible; -} - -.editSelectMenu { - display: grid; - gap: 0.25rem; - justify-items: start; -} - -.editSelectOption { - display: flex; - padding: 0.15rem 0.2rem; - cursor: pointer; - - & > span { - will-change: transform; - backface-visibility: hidden; - transform-origin: center center; - transition: transform 0.12s ease; - } - - &:hover > span, - &:focus-visible > span { - transform: scale(1.05); - } -} - -.editSelectDirty { - box-shadow: 0 0 0 2px rgba(255, 168, 0, 0.3); -} - -.editUnavailable { - font-size: 0.75rem; - font-style: italic; - color: rgba(15, 23, 42, 0.45); -} - .tableOptionsBox { min-width: 14rem; background: rgba(255, 255, 255, 0.96); @@ -362,422 +174,11 @@ color: rgba(15, 23, 42, 0.58); } -.valueCell { - display: block; - width: 100%; - max-width: 100%; - min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - text-align: left; -} - -.phoneOverlay { - min-width: 24rem; -} - -.phoneOverlayBody { - display: grid; - gap: 0.5rem; -} - -.phoneSourceRow { - display: grid; - grid-template-columns: 1rem 3.5rem minmax(0, 1fr); - align-items: center; - gap: 0.5rem; - font-size: 0.85rem; - color: rgba(15, 23, 42, 0.86); -} - -.phoneSourceLabel { - font-size: 0.72rem; - font-weight: 700; - color: rgba(15, 23, 42, 0.56); - text-transform: uppercase; -} - -.sourceCheck { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1rem; - color: rgba(15, 23, 42, 0.56); -} - -.sourceIndicator { - font-size: 0.9rem; - stroke-width: 3; -} - -.emailOverlay { - min-width: 28rem; -} - -.emailOverlayBody { - display: grid; - gap: 0.5rem; -} - -.emailSourceRow { - display: grid; - grid-template-columns: 1rem 5rem minmax(0, 1fr); - align-items: center; - gap: 0.5rem; - font-size: 0.85rem; - color: rgba(15, 23, 42, 0.86); -} - -.emailSourceLabel { - font-size: 0.72rem; - font-weight: 700; - color: rgba(15, 23, 42, 0.56); - text-transform: uppercase; -} - -.nameOverlay { - min-width: 28rem; -} - -.nameOverlayBody { - display: grid; - gap: 0.5rem; -} - -.historySection { - display: grid; - gap: 0.35rem; - margin-top: 0.35rem; - padding-top: 0.6rem; - border-top: 1px solid rgba(17, 24, 39, 0.1); -} - -.historySectionLabel { - font-size: 0.72rem; - font-weight: 700; - letter-spacing: 0.06em; - color: rgba(15, 23, 42, 0.56); - text-transform: uppercase; -} - -.historyEmpty { - font-size: 0.8rem; - color: rgba(15, 23, 42, 0.55); -} - -.historyContainer { - display: flex; - flex-direction: column; - width: 100%; - overflow: hidden; - border: 1px solid rgba(17, 24, 39, 0.1); - border-radius: 0.75rem; - background: rgba(255, 255, 255, 0.72); -} - -.historyEntry { - display: flex; - align-items: center; - gap: 0.35rem; - padding: 0.5rem 0.65rem; - border-bottom: 1px solid rgba(17, 24, 39, 0.08); - color: rgba(15, 23, 42, 0.85); - font-size: 0.8rem; - font-weight: 550; - letter-spacing: -0.01em; - - &:last-child { - border-bottom: 0; - } -} - -.historyEntryMain { - min-width: 0; - display: inline-flex; - align-items: center; - gap: 0.25rem; - flex-wrap: wrap; -} - -.historyEntryPrefix { - color: rgba(15, 23, 42, 0.62); - font-weight: 550; -} - -.historyEntryName { - color: rgba(15, 23, 42, 0.9); - font-weight: 650; -} - -.historyEntryDateTag { - margin-left: auto; - white-space: nowrap; - padding-inline: 0.75rem; - padding-block: 0.15rem; - border-radius: 9999px; - font-size: 0.72rem; - font-weight: 600; - color: rgba(15, 23, 42, 0.72); - background: rgba(15, 23, 42, 0.06); -} - -.nameSourceRow { - display: grid; - grid-template-columns: 1rem 4rem minmax(0, 1fr); - align-items: center; - gap: 0.5rem; - font-size: 0.85rem; - color: rgba(15, 23, 42, 0.86); -} - -.nameSourceLabel { - font-size: 0.72rem; - font-weight: 700; - color: rgba(15, 23, 42, 0.56); - text-transform: uppercase; -} - -.discordOverlay { - min-width: 20rem; -} - -.discordOverlayBody { - display: grid; - gap: 0.5rem; -} - -.discordSourceRow { - display: grid; - grid-template-columns: 1rem 6rem minmax(0, 1fr); - align-items: center; - gap: 0.5rem; - font-size: 0.85rem; - color: rgba(15, 23, 42, 0.86); -} - -.discordSourceLabel { - font-size: 0.72rem; - font-weight: 700; - color: rgba(15, 23, 42, 0.56); - text-transform: uppercase; -} - -.addressOverlay { - min-width: 30rem; -} - -.addressOverlayBody { - display: grid; - gap: 0.5rem; -} - -.addressSourceRow { - display: grid; - grid-template-columns: 1rem 4rem minmax(0, 1fr); - align-items: center; - gap: 0.5rem; - font-size: 0.85rem; - color: rgba(15, 23, 42, 0.86); -} - -.addressSourceLabel { - font-size: 0.72rem; - font-weight: 700; - color: rgba(15, 23, 42, 0.56); - text-transform: uppercase; -} - -.nameCell { - display: inline-flex; - align-items: center; - gap: 0.3rem; - max-width: 100%; - min-width: 0; - - & > span:first-child { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } -} - -.confirmBadge { - position: relative; - display: inline-flex; - align-items: center; - flex-shrink: 0; - outline: none; - - &::after { - content: attr(data-tooltip); - position: absolute; - left: calc(100% + 0.6rem); - top: 50%; - - padding: 0.35rem 0.5rem; - border-radius: 0.45rem; - width: max-content; - max-width: 20rem; - - color: rgba(241, 245, 249, 0.95); - background: rgba(15, 23, 42, 0.92); - font-size: 0.72rem; - font-weight: 560; - line-height: 1.35; - - box-shadow: 0 8px 20px rgba(2, 6, 23, 0.28); - - opacity: 0; - transform: translate(-3px, -50%); - transition: - opacity 100ms ease, - transform 100ms ease; - pointer-events: none; - z-index: 8; - } - - &:hover::after, - &:focus-visible::after { - opacity: 1; - transform: translate(0, -50%); - } -} - -.nameVerified { - color: rgba(17, 115, 75, 1); - font-size: 0.6rem; - flex-shrink: 0; - background: rgba(112, 195, 32, 0.3); - border-radius: 50%; - padding: 0.15rem; -} - -.nameUnverified { - color: rgba(177, 2, 2, 1); - font-size: 0.6rem; - flex-shrink: 0; - background: rgba(255, 95, 75, 0.3); - border-radius: 50%; - padding: 0.15rem; -} - .userMatchedLink { display: inline-flex; cursor: pointer; } -.tag { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 2.8rem; - font-size: 0.75rem; - font-weight: 700; - padding: 0.2rem 0.6rem; - border-radius: 9999px; - white-space: nowrap; - color: rgba(15, 23, 42, 0.72); - background: rgba(15, 23, 42, 0.06); -} - -.tagRed { - background: rgba(255, 95, 75, 0.3); - color: rgba(177, 2, 2, 1); -} -.tagOrange { - background: rgba(255, 72, 0, 0.3); - color: rgba(117, 56, 0, 1); -} -.tagYellow { - background: rgba(255, 168, 0, 0.3); - color: rgba(71, 56, 33, 1); -} -.tagGreen { - background: rgba(112, 195, 32, 0.3); - color: rgba(17, 115, 75, 1); -} - -.editableBoolTag { - cursor: pointer; - will-change: transform; - backface-visibility: hidden; - transform-origin: center center; - transition: transform 0.12s ease; - - &:hover { - transform: scale(1.05); - } - - &:focus-visible { - outline: 2px solid rgba(14, 165, 233, 0.8); - outline-offset: 2px; - } -} - -.tagBlue { - background: rgba(42, 155, 225, 0.3); - color: rgba(10, 83, 168, 1); -} -.tagPurple { - background: rgba(172, 95, 212, 0.3); - color: rgba(90, 50, 134, 1); -} -.tagDarkRed { - background: rgba(177, 2, 2, 1); - color: rgba(255, 207, 201, 1); -} -.tagGray { - background: #e6e6e6; - color: #3d3d3d; -} -.tagMember { - background: #ffe5ec; - color: rgba(177, 2, 2, 1); -} -.tagPremium { - background: #ffe5ec; - color: rgb(223, 40, 76); -} -.tagSignature { - background: #ffa7cd; - color: rgba(177, 2, 2, 1); -} -.tagInnerCircle { - background: #da93ff; - color: rgba(90, 50, 134, 1); -} - -.tagGhost { - background: transparent; - color: rgba(15, 23, 42, 0.4); - font-style: italic; - outline: 1px dashed rgba(15, 23, 42, 0.2); - outline-offset: -1px; -} - -.tagTier { - width: 10rem; -} - -.tagWide { - min-width: 6.5rem; - font-size: 0.65rem; - padding: 0.1rem 0.5rem; - position: relative; - top: -2px; -} - -.tagFulfilled { - min-width: auto; - width: calc(100% - 0.5rem); - font-size: 0.65rem; - padding: 0.1rem 0.5rem; - position: relative; - top: -2px; -} - @media (max-width: 53rem) { .panelHeader { flex-direction: column; diff --git a/src/app/admin/panels/membership/page.tsx b/src/app/admin/panels/membership/page.tsx index 60967c3a..cceb2e23 100644 --- a/src/app/admin/panels/membership/page.tsx +++ b/src/app/admin/panels/membership/page.tsx @@ -1,2108 +1,92 @@ 'use client' -import { - Member, - MembershipTier, - membershipTiers, - ShirtSize, - PackageShipped, -} from './membership.types' +import { useMembershipPanel, MembershipTableOptions } from './hooks' +import { buildColumns, FULFILLMENT_CATEGORY } from './membershipColumns' import styles from './page.module.css' import { DropdownButton, DropdownOverlay, ToggleGroup, } from '@/components/common' -import { Table, Column, ColumnEntry } from '@/components/common/table' -import { - MembershipDeliverableStatus, - ShirtSize as ApiShirtSize, - User, - UserAddress, - zUser, -} from '@/contracts/data' -import { - UpdateMembershipRequest, - UpdateUserAddressRequest, - UpdateUserRequest, -} from '@/contracts/requests' -import { - MembershipsResponsePacket, - zMembershipsResponsePacket, - zPaginatedResponse, -} from '@/contracts/responses' +import { Table } from '@/components/common/table' import { cn } from '@/util' -import { useCurrentUser, useFetch } from '@/util/hooks' -import { - skipToken, - useInfiniteQuery, - useMutation, - useQuery, - useQueryClient, -} from '@tanstack/react-query' -import Link from 'next/link' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useMemo } from 'react' import { FaEdit, FaSave, FaTrashAlt } from 'react-icons/fa' -import { FiCheck, FiChevronDown, FiX } from 'react-icons/fi' - -const HISTORY_STALE_TIME = 5 * 60 * 1000 -const HISTORY_LIMIT = 5 - -type AddressField = 'addressLine1' | 'addressLine2' | 'city' | 'state' | 'zip' -type MembershipTableMode = 'view' | 'edit' - -type AddressDraft = Partial> - -type MemberEdits = Partial< - Pick -> & { - shirtSize?: ShirtSize | null - packageShipped?: PackageShipped | null - address?: AddressDraft - nameConfirmed?: boolean - addressConfirmed?: boolean - discordConfirmed?: boolean - cardPrinted?: boolean - labelPrinted?: boolean - cardPacked?: boolean - benefitShipped?: boolean -} - -const hasNameDraftChange = (member: Member, draft: MemberEdits) => - [ - ['userFirstName', member.userFirstName], - ['userLastName', member.userLastName], - ].some(([key, original]) => { - const value = draft[key as 'userFirstName' | 'userLastName'] - return value != null && value.trim() !== (original ?? '').trim() - }) - -const hasAddressDraftChange = (member: Member, draft: MemberEdits) => - draft.address != null && - addressFields.some(({ key }) => { - const value = draft.address?.[key] - return ( - value != null && - value.trim() !== (member.userAddressParts?.[key] ?? '').trim() - ) - }) - -interface EditController { - draftOf: (member: Member) => MemberEdits - update: (member: Member, patch: MemberEdits) => void -} - -interface PendingUpdate { - donorEmail: string - userId?: number - user?: UpdateUserRequest - membership?: UpdateMembershipRequest -} - -const Check = () => -const Cross = () => - -const BoolTag = ({ value }: { value?: boolean }) => - value ? ( - - - - ) : ( - - - - ) - -const EditableBoolTag = ({ - value, - onToggle, -}: { - value?: boolean - onToggle: () => void -}) => ( - { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - onToggle() - } - }} - > - {value ? : } - -) - -const shirtSizeClass: Record = { - XS: styles.tagRed, - S: styles.tagOrange, - M: styles.tagYellow, - L: styles.tagGreen, - XL: styles.tagBlue, - XXL: styles.tagPurple, -} - -const shirtSizes: ShirtSize[] = ['XS', 'S', 'M', 'L', 'XL', 'XXL'] -const toApiShirtSize = (size: ShirtSize): ApiShirtSize => - size === 'XXL' ? ApiShirtSize.DoubleExtraLarge : (size as ApiShirtSize) - -const ShirtSizeValue = ({ member }: { member: Member }) => - member.shirtSize ? ( - - {member.shirtSize} - - ) : ( - N/A - ) - -const ShirtSizeEdit = ({ - member, - edit, -}: { - member: Member - edit: EditController -}) => { - const draft = edit.draftOf(member).shirtSize - const value = (draft === undefined ? member.shirtSize : draft) ?? '' - - return ( - ( - ( - - ))} - /> - )} - > - - {value === '' ? 'N/A' : value} - - - ) -} - -const membershipTierClass: Record = { - 'Dues Paying Member': styles.tagMember, - 'Premium Member': styles.tagPremium, - 'Signature Member': styles.tagSignature, - 'Inner Circle Member': styles.tagInnerCircle, -} - -const packageShippedClass: Record = { - Yes: styles.tagGreen, - No: styles.tagRed, - Returned: styles.tagYellow, - 'Not Received': styles.tagDarkRed, - Canceled: styles.tagBlue, -} - -const packageShippedOptions: PackageShipped[] = [ - 'Yes', - 'No', - 'Returned', - 'Not Received', - 'Canceled', +const showHideChoices = [ + { value: true, label: 'Show' }, + { value: false, label: 'Hide' }, ] -const PackageShippedValue = ({ member }: { member: Member }) => - member.packageShipped ? ( - - {member.packageShipped} - - ) : ( - '—' - ) - -const PackageShippedEdit = ({ - member, - edit, -}: { - member: Member - edit: EditController -}) => { - const draft = edit.draftOf(member).packageShipped - const value = (draft === undefined ? member.packageShipped : draft) ?? '' - - return ( - ( - ( - - ))} - /> - )} - > - - {value === '' ? 'N/A' : value} - - - ) -} - -const formatPhone = (phone?: string) => { - if (!phone) return '—' - const digits = phone.replace(/\D/g, '').replace(/^1(?=\d{10}$)/, '') - if (digits.length === 10) { - return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}` - } - return phone -} - -const SourceCheck = ({ selected }: { selected: boolean }) => ( - - {selected && } - -) - -const joinName = (firstName?: string | null, lastName?: string | null) => - [firstName, lastName].filter(Boolean).join(' ').trim() || undefined - -const formatHistoryDate = (value: Date) => - value.toLocaleDateString([], { - year: 'numeric', - month: 'short', - day: 'numeric', - }) - -type UserHistoryEntry = NonNullable[number] - -interface HistoryEntry { - historyId: number - historyWhenUpdatedUtc: Date -} - -const selectUserHistory = (user: User) => user.history ?? [] - -type DescribeChange = ( - update: T, - previous: T | undefined -) => { label: string; value: string } | undefined - -const describeNameChange: DescribeChange = (update, previous) => { - const action = previous ? 'Changed' : 'Set' - const firstChanged = - (update.firstName ?? '') !== (previous?.firstName ?? '') - const lastChanged = (update.lastName ?? '') !== (previous?.lastName ?? '') - - if (!firstChanged && !lastChanged) return undefined - - if (firstChanged && !lastChanged && update.firstName) - return { label: `First Name ${action}`, value: update.firstName } - - if (lastChanged && !firstChanged && update.lastName) - return { label: `Last Name ${action}`, value: update.lastName } - - const value = joinName(update.firstName, update.lastName) - return value ? { label: `Name ${action}`, value } : undefined -} - -const describeSimpleChange = - ( - field: 'phone' | 'email', - noun: string, - format: (value: string) => string = (value) => value - ): DescribeChange => - (update, previous) => { - const value = update[field] ?? '' - if (value === (previous?.[field] ?? '') || value === '') - return undefined - - return { - label: `${noun} ${previous ? 'Changed' : 'Set'}`, - value: format(value), - } - } - -const describePhoneChange = describeSimpleChange('phone', 'Phone', formatPhone) -const describeEmailChange = describeSimpleChange('email', 'Email') - -const formatUserAddress = (address?: UserAddress) => - [ - [address?.addressLine1, address?.addressLine2] - .filter(Boolean) - .join(', '), - [ - address?.city, - [address?.state, address?.zip].filter(Boolean).join(' '), - ] - .filter(Boolean) - .join(', '), - ] - .filter(Boolean) - .join(', ') || undefined - -const describeAddressChange: DescribeChange = (update, previous) => { - const value = formatUserAddress(update.address) - if (!value || value === formatUserAddress(previous?.address)) - return undefined - - return { label: `Address ${previous ? 'Changed' : 'Set'}`, value } -} - -const FieldHistory = ({ - member, - title, - emptyMessage, - selectHistory, - describeChange, -}: { - member: Member - title: string - emptyMessage: string - selectHistory: (user: User) => T[] - describeChange: DescribeChange -}) => { - const { ready, onGet } = useFetch() - const userId = member.userId - - const userQuery = useQuery({ - queryKey: ['/users/:userId', userId, { includeHistory: true }], - queryFn: - ready && userId != null - ? ({ signal }: { signal: AbortSignal }) => - onGet('/users/:userId', zUser, { - params: { userId }, - query: { includeHistory: true }, - signal, - }) - : skipToken, - staleTime: HISTORY_STALE_TIME, - }) - - const history = useMemo(() => { - const user = userQuery.data - const ascending = (user ? selectHistory(user) : []) - .slice() - .sort( - (a, b) => - a.historyWhenUpdatedUtc.getTime() - - b.historyWhenUpdatedUtc.getTime() - ) - - const entries: { - update: T - label: string - value: string - }[] = [] - let previous: T | undefined - - for (const update of ascending) { - const change = describeChange(update, previous) - if (!change) continue - entries.push({ update, ...change }) - previous = update - } - - return entries.reverse().slice(0, HISTORY_LIMIT) - }, [userQuery.data, selectHistory, describeChange]) - - const statusMessage = (() => { - if (userId == null) return 'No linked user' - if (userQuery.isPending) return 'Loading…' - if (userQuery.isError) return 'Failed to load history' - if (history.length === 0) return emptyMessage - return undefined - })() - - return ( -
- {title} - {statusMessage && ( -
{statusMessage}
- )} - {!statusMessage && ( -
- {history.map(({ update, label, value }) => ( -
- - - {label} - - - {value} - - - - {formatHistoryDate( - update.historyWhenUpdatedUtc - )} - -
- ))} -
- )} -
- ) -} - -const IdenticalTag = ({ source }: { source?: string }) => ( - - {source ? `Identical To ${source}` : 'Identical'} - -) - -const ConfirmedBadge = ({ - label, - confirmed, -}: { - label: string - confirmed?: boolean -}) => ( - - {confirmed ? ( - - ) : ( - - )} - -) - -const PhoneTag = ({ phone }: { phone?: string }) => - phone ? ( - - {formatPhone(phone)} - - ) : ( - - Unfilled - - ) - -const PhoneValue = ({ member }: { member: Member }) => ( - - {formatPhone(member.userPhone ?? member.donorPhone)} - -) - -const normalizePhone = (phone: string) => phone.replace(/\D/g, '') - -const isValidPhone = (phone: string) => { - const digits = normalizePhone(phone) - return digits.length === 0 || digits.length >= 10 -} - -const PhoneEdit = ({ - member, - edit, -}: { - member: Member - edit: EditController -}) => { - if (member.userId == null) - return No linked user - - const original = member.userPhone ?? '' - const draft = edit.draftOf(member).userPhone - const invalid = !isValidPhone(draft ?? original) - - return ( - - edit.update(member, { - userPhone: normalizePhone(event.target.value), - }) - } - /> - ) -} - -const PhoneMenu = ({ - member, - closeDropdown, -}: { - member: Member - closeDropdown: () => void -}) => { - const displayedSource = member.userPhone - ? 'User' - : member.donorPhone - ? 'Donor' - : undefined - const sourcesMatch = - member.userPhone != null && - member.donorPhone != null && - formatPhone(member.userPhone) === formatPhone(member.donorPhone) - - return ( - -
- - User - -
-
- - Donor - {sourcesMatch ? ( - - ) : ( - - )} -
- - - } - /> - ) -} - -const EmailTag = ({ email }: { email?: string }) => - email ? ( - - {email} - - ) : ( - - Unfilled - - ) - -const EmailValue = ({ member }: { member: Member }) => ( - - {member.userEmail ?? member.discordEmail ?? member.donorEmail ?? '—'} - -) - -const EmailEdit = ({ - member, - edit, -}: { - member: Member - edit: EditController -}) => { - if (member.userId == null) - return No linked user - - const original = member.userEmail ?? '' - const value = edit.draftOf(member).userEmail ?? original - - return ( - - edit.update(member, { userEmail: event.target.value }) - } - /> - ) -} - -const EmailMenu = ({ - member, - closeDropdown, -}: { - member: Member - closeDropdown: () => void -}) => { - const displayedSource = member.userEmail - ? 'User' - : member.discordEmail - ? 'Discord' - : member.donorEmail - ? 'Donor' - : undefined - const normalizedUserEmail = member.userEmail?.trim().toLowerCase() - const normalizedDiscordEmail = member.discordEmail?.trim().toLowerCase() - const normalizedDonorEmail = member.donorEmail?.trim().toLowerCase() - const discordMatchesUser = - normalizedDiscordEmail != null && - normalizedDiscordEmail === normalizedUserEmail - const donorMatchesSource = - normalizedDonorEmail == null - ? undefined - : normalizedDonorEmail === normalizedUserEmail - ? 'User' - : normalizedDonorEmail === normalizedDiscordEmail - ? 'Discord' - : undefined - const allEmailsMatch = - new Set( - [ - normalizedUserEmail, - normalizedDiscordEmail, - normalizedDonorEmail, - ].filter((email) => email != null) - ).size === 1 - - return ( - -
- - User - -
-
- - Discord - {discordMatchesUser ? ( - - ) : ( - - )} -
-
- - Donor - {donorMatchesSource ? ( - - ) : ( - - )} -
- - - } - /> - ) -} - -const NameTag = ({ - name, - confirmationStatus, -}: { - name?: string - confirmationStatus?: boolean -}) => - name ? ( - - {name} - - ) : ( - - Unfilled - - ) - -const NameMenu = ({ - member, - closeDropdown, -}: { - member: Member - closeDropdown: () => void -}) => { - const displayedSource = member.userName - ? 'User' - : member.donorName - ? 'Donor' - : undefined - const sourcesMatch = - member.userName != null && - member.userName.trim() === member.donorName?.trim() - - return ( - -
- - User - -
-
- - Donor - {sourcesMatch ? ( - - ) : ( - - )} -
- - - } - /> - ) -} - -const NameValue = ({ member }: { member: Member }) => ( - - {member.userName ?? member.donorName ?? '—'} - - -) - -const NameEdit = ({ - member, - edit, -}: { - member: Member - edit: EditController -}) => { - if (member.userId == null) - return No linked user - - const draft = edit.draftOf(member) - const fields = [ - { - key: 'userFirstName' as const, - label: 'User first name', - original: member.userFirstName ?? '', - placeholder: member.firstName ?? 'First name', - }, - { - key: 'userLastName' as const, - label: 'User last name', - original: member.userLastName ?? '', - placeholder: member.lastName ?? 'Last name', - }, - ] - - return ( -
- {fields.map(({ key, label, original, placeholder }) => { - const value = draft[key] ?? original - - return ( - - edit.update(member, { - [key]: event.target.value, - }) - } - /> - ) - })} -
- ) -} - -const normalizeDiscordHandle = (handle: string) => - handle.trim().replace(/^@/, '') - -const DiscordTag = ({ - username, - confirmationStatus, -}: { - username?: string - confirmationStatus?: boolean -}) => - username ? ( - - @{normalizeDiscordHandle(username)} - - ) : ( - - Unfilled - - ) - -const DiscordValue = ({ member }: { member: Member }) => { - const displayedHandle = member.discordUsername ?? member.contributionDiscord - - return ( - - - {displayedHandle - ? `@${normalizeDiscordHandle(displayedHandle)}` - : '—'} - - - - ) -} - -const DiscordMenu = ({ - member, - closeDropdown, -}: { - member: Member - closeDropdown: () => void -}) => { - const displayedSource = member.discordUsername - ? 'Discord' - : member.contributionDiscord - ? 'Contribution' - : undefined - const sourcesMatch = - member.discordUsername != null && - member.contributionDiscord != null && - normalizeDiscordHandle(member.discordUsername) === - normalizeDiscordHandle(member.contributionDiscord) - - return ( - -
- - - Discord - - -
-
- - - Contribution - - {sourcesMatch ? ( - - ) : ( - - )} -
- - } - /> - ) -} - -const AddressTag = ({ address }: { address?: string }) => - address ? ( - - {address} - - ) : ( - - Unfilled - - ) - -const AddressValue = ({ member }: { member: Member }) => ( - - {member.userAddress ?? member.donorAddress ?? '—'} - - -) - -const addressFields: { - key: AddressField +const tableOptionRows: { + key: keyof MembershipTableOptions label: string - placeholder: (member: Member) => string - maxLength: number - className: string + ariaLabel: string + choices: { value: boolean; label: string }[] }[] = [ { - key: 'addressLine1', - label: 'Address line 1', - placeholder: (m) => m.address1 ?? 'Address line 1', - maxLength: 100, - className: styles.editAddressLine1, - }, - { - key: 'addressLine2', - label: 'Address line 2', - placeholder: () => 'Apt, suite', - maxLength: 100, - className: styles.editAddressLine2, - }, - { - key: 'city', - label: 'City', - placeholder: (m) => m.city ?? 'City', - maxLength: 50, - className: styles.editAddressCity, - }, - { - key: 'state', - label: 'State', - placeholder: (m) => m.state ?? 'ST', - maxLength: 2, - className: styles.editAddressState, + key: 'showRowNumber', + label: 'Row column', + ariaLabel: 'Row Column', + choices: showHideChoices, }, { - key: 'zip', - label: 'Zip', - placeholder: (m) => m.zip?.slice(0, 5) ?? 'Zip', - maxLength: 5, - className: styles.editAddressZip, + key: 'showStatus', + label: 'Status columns', + ariaLabel: 'Member Eligibility Columns', + choices: showHideChoices, }, -] - -const isValidAddressField = (field: AddressField, value: string) => { - const trimmed = value.trim() - if (trimmed === '') return true - if (field === 'state') return /^[A-Za-z]{2}$/.test(trimmed) - if (field === 'zip') return /^\d{5}$/.test(trimmed) - return true -} - -const isValidAddressDraft = (draft: AddressDraft) => - addressFields.every(({ key }) => isValidAddressField(key, draft[key] ?? '')) - -const AddressEdit = ({ - member, - edit, -}: { - member: Member - edit: EditController -}) => { - if (member.userId == null) - return No linked user - - const draft = edit.draftOf(member).address ?? {} - - return ( -
- {addressFields.map( - ({ key, label, placeholder, maxLength, className }) => { - const original = member.userAddressParts?.[key] ?? '' - const value = draft[key] ?? original - const invalid = !isValidAddressField(key, value) - - return ( - - edit.update(member, { - address: { - ...draft, - [key]: event.target.value, - }, - }) - } - /> - ) - } - )} -
- ) -} - -const AddressMenu = ({ - member, - closeDropdown, -}: { - member: Member - closeDropdown: () => void -}) => { - const displayedSource = member.userAddress - ? 'User' - : member.donorAddress - ? 'Donor' - : undefined - const sourcesMatch = - member.userAddress != null && - member.userAddress.trim() === member.donorAddress?.trim() - - return ( - -
- - User - -
-
- - Donor - {sourcesMatch ? ( - - ) : ( - - )} -
- - - } - /> - ) -} - -const mapPacketToMember = ( - packet: MembershipsResponsePacket, - index: number -): Member => { - const { donor, user, customField } = packet - const membership = donor.membershipData - const cardStatus = membership?.membershipCardStatus - const merchStatus = membership?.membershipMerchStatus - - const userFullName = [user?.firstName, user?.lastName] - .filter(Boolean) - .join(' ') - const userName = - userFullName !== '' ? userFullName : (user?.preferredName ?? undefined) - const donorFullName = [donor.firstname, donor.lastname] - .filter(Boolean) - .join(' ') - const donorName = donorFullName !== '' ? donorFullName : undefined - - const userAddressStr = user?.address - ? [ - [user.address.addressLine1, user.address.addressLine2] - .filter(Boolean) - .join(', '), - [ - user.address.city, - [user.address.state, user.address.zip] - .filter(Boolean) - .join(' '), - ] - .filter(Boolean) - .join(', '), - ] - .filter(Boolean) - .join(', ') - : '' - const userAddress = userAddressStr !== '' ? userAddressStr : undefined - - const donorCountry = donor.country?.trim() - const donorZipDigits = donor.zip?.replace(/\D/g, '').slice(0, 5) - const donorZip = donorZipDigits === '' ? donor.zip : donorZipDigits - const donorAddressStr = [ - [donor.addr1].filter(Boolean).join(', '), - [donor.city, [donor.state, donorZip].filter(Boolean).join(' ')] - .filter(Boolean) - .join(', '), - donorCountry?.toLowerCase() === 'united states' - ? undefined - : donorCountry, - ] - .filter(Boolean) - .join(', ') - const donorAddress = donorAddressStr !== '' ? donorAddressStr : undefined - - const contributionDiscordAnswer = customField?.answer.trim() - const contributionDiscord = - contributionDiscordAnswer === '' ? undefined : contributionDiscordAnswer - - const tierLabel = customField?.label - const membershipTier = membershipTiers.find((tier) => tier === tierLabel) - - return { - packet, - id: index + 1, - userId: user?.id, - firstName: donor.firstname, - lastName: donor.lastname, - userFirstName: user?.firstName ?? undefined, - userLastName: user?.lastName ?? undefined, - userName, - donorName, - discordUsername: user?.discordUsers?.[0]?.username, - contributionDiscord, - phone: user?.phone ?? donor.phone ?? undefined, - userPhone: user?.phone ?? undefined, - donorPhone: donor.phone ?? undefined, - email: user?.email ?? user?.discordUsers?.[0]?.email ?? donor.email, - donorEmail: donor.email, - userEmail: user?.email ?? undefined, - discordEmail: user?.discordUsers?.[0]?.email ?? undefined, - address1: donor.addr1 ?? undefined, - city: donor.city ?? undefined, - state: donor.state ?? undefined, - zip: donor.zip ?? undefined, - country: donor.country ?? undefined, - userAddress, - userAddressParts: user?.address, - donorAddress, - shirtSize: - membership?.shirtSize === '2XL' - ? 'XXL' - : (membership?.shirtSize ?? undefined), - isMember: membership?.duesPayingMember, - eligibleForBenefits: membership?.membershipBenefitEligible, - membershipTier, - beganMembership: donor.contributions?.[0]?.createdAt - ?.toISOString() - .slice(0, 10), - numberOfContributions: donor.contributions?.length, - discordConfirmed: Boolean(user?.discordUsers?.length), - nameConfirmed: - membership?.nameConfirmed ?? user?.nameConfirmed ?? undefined, - addressConfirmed: - membership?.addressConfirmed ?? user?.addressConfirmed ?? undefined, - cardPrinted: cardStatus != null ? cardStatus >= 2 : undefined, - labelPrinted: merchStatus != null ? merchStatus >= 2 : undefined, - cardPacked: merchStatus != null ? merchStatus >= 3 : undefined, - benefitShipped: merchStatus != null ? merchStatus >= 3 : undefined, - membershipCardStatus: cardStatus, - membershipMerchStatus: merchStatus, - packageShipped: - merchStatus === 5 - ? 'Returned' - : merchStatus != null && merchStatus >= 4 - ? 'Yes' - : merchStatus != null - ? 'No' - : undefined, - userMatched: Boolean(user), - } -} - -const statusDotColor = (m: Member, col: Column): string => { - const boolFields: Record = { - discordConfirmed: m.discordConfirmed, - nameConfirmed: m.nameConfirmed, - addressConfirmed: m.addressConfirmed, - cardPrinted: m.cardPrinted, - labelPrinted: m.labelPrinted, - cardPacked: m.cardPacked, - benefitShipped: m.benefitShipped, - } - if (col.key === 'packageShipped') { - const colors: Record = { - Yes: 'rgba(112, 195, 32, 0.6)', - No: 'rgba(255, 95, 75, 0.6)', - Returned: 'rgba(255, 168, 0, 0.6)', - 'Not Received': 'rgba(177, 2, 2, 1)', - Canceled: 'rgba(42, 155, 225, 0.6)', - } - return colors[m.packageShipped ?? 'No'] - } - return boolFields[col.key] - ? 'rgba(112, 195, 32, 0.6)' - : 'rgba(255, 95, 75, 0.6)' -} - -const confirmedColumns = (edit: EditController): Column[] => [ { - key: 'nameConfirmed', - header: 'Name Confirmed', - width: '5rem', - allowOverflow: true, - sortValue: (m: Member) => (m.nameConfirmed ? 1 : 0), - render: (m: Member) => , - renderEdit: (m: Member) => { - const memberDraft = edit.draftOf(m) - const value = - memberDraft.nameConfirmed ?? - (hasNameDraftChange(m, memberDraft) - ? false - : (m.nameConfirmed ?? false)) - - return ( - edit.update(m, { nameConfirmed: !value })} - /> - ) - }, + key: 'showConfirmed', + label: 'Confirmed columns', + ariaLabel: 'Member Details Confirmed Columns', + choices: showHideChoices, }, { - key: 'discordConfirmed', - header: 'Discord Confirmed', - width: '5rem', - allowOverflow: true, - sortValue: (m: Member) => (m.discordConfirmed ? 1 : 0), - render: (m: Member) => , - renderEdit: (m: Member) => { - const draft = edit.draftOf(m).discordConfirmed - const value = draft ?? m.discordConfirmed ?? false - - return ( - - edit.update(m, { discordConfirmed: !value }) - } - /> - ) - }, + key: 'showFulfilled', + label: 'Benefits fulfilled tag', + ariaLabel: 'Tag Groups', + choices: showHideChoices, }, { - key: 'addressConfirmed', - header: 'Address Confirmed', - width: '5rem', - allowOverflow: true, - sortValue: (m: Member) => (m.addressConfirmed ? 1 : 0), - render: (m: Member) => , - renderEdit: (m: Member) => { - const memberDraft = edit.draftOf(m) - const value = - memberDraft.addressConfirmed ?? - (hasAddressDraftChange(m, memberDraft) - ? false - : (m.addressConfirmed ?? false)) - - return ( - - edit.update(m, { addressConfirmed: !value }) - } - /> - ) - }, - }, -] - -const statusColumns: Column[] = [ - { - key: 'eligibleForBenefits', - header: 'Eligible', - width: '5rem', - sortValue: (m: Member) => (m.eligibleForBenefits ? 1 : 0), - render: (m: Member) => , - }, - { - key: 'isMember', - header: 'Member', - width: '5rem', - sortValue: (m: Member) => (m.isMember ? 1 : 0), - render: (m: Member) => , - }, -] - -const buildColumns = ( - showConfirmed: boolean, - showFulfilled: boolean, - showStatus: boolean, - showRowNumber: boolean, - edit: EditController -): ColumnEntry[] => [ - ...(showRowNumber - ? [ - { - key: 'rowNumber', - header: '#', - width: '3rem', - render: (_m: Member, index: number) => index + 1, - }, - ] - : ([] as Column[])), - ...(showStatus ? statusColumns : ([] as Column[])), - { - key: 'membershipTier', - header: 'Tier', - width: '11rem', - sortValue: (m: Member) => m.membershipTier ?? '', - render: (m: Member) => - m.membershipTier ? ( - - {m.membershipTier} - - ) : ( - '—' - ), - }, - { - label: 'Fulfillment', - collapsedWidth: '6rem', - dotColor: statusDotColor, - rowRender: showFulfilled - ? (m: Member) => { - const allFulfilled = - (!showConfirmed || - (m.discordConfirmed && - m.nameConfirmed && - m.addressConfirmed)) && - m.cardPrinted && - m.labelPrinted && - m.cardPacked && - m.benefitShipped && - m.packageShipped === 'Yes' - if (!allFulfilled) return null - return ( - - Benefits Fulfilled - - ) - } - : undefined, - columns: [ - ...(showConfirmed - ? confirmedColumns(edit) - : ([] as Column[])), - { - key: 'cardPrinted', - header: 'Card Printed', - width: '5rem', - allowOverflow: true, - sortValue: (m: Member) => (m.cardPrinted ? 1 : 0), - render: (m: Member) => , - renderEdit: (m: Member) => { - const draft = edit.draftOf(m).cardPrinted - const value = draft ?? m.cardPrinted ?? false - - return ( - - edit.update(m, { cardPrinted: !value }) - } - /> - ) - }, - }, - { - key: 'labelPrinted', - header: 'Label Printed', - width: '5rem', - allowOverflow: true, - sortValue: (m: Member) => (m.labelPrinted ? 1 : 0), - render: (m: Member) => , - renderEdit: (m: Member) => { - const draft = edit.draftOf(m).labelPrinted - const value = draft ?? m.labelPrinted ?? false - - return ( - - edit.update(m, { labelPrinted: !value }) - } - /> - ) - }, - }, - { - key: 'cardPacked', - header: 'Items Packed', - width: '5rem', - allowOverflow: true, - sortValue: (m: Member) => (m.cardPacked ? 1 : 0), - render: (m: Member) => , - renderEdit: (m: Member) => { - const draft = edit.draftOf(m).cardPacked - const value = draft ?? m.cardPacked ?? false - - return ( - - edit.update(m, { cardPacked: !value }) - } - /> - ) - }, - }, - { - key: 'benefitShipped', - header: 'Benefit Shipped', - width: '5rem', - allowOverflow: true, - sortValue: (m: Member) => (m.benefitShipped ? 1 : 0), - render: (m: Member) => , - renderEdit: (m: Member) => { - const draft = edit.draftOf(m).benefitShipped - const value = draft ?? m.benefitShipped ?? false - - return ( - - edit.update(m, { benefitShipped: !value }) - } - /> - ) - }, - }, - { - key: 'packageShipped', - header: 'Package Shipped', - width: '7rem', - allowOverflow: true, - sortValue: (m: Member) => m.packageShipped ?? '', - render: (m: Member) => , - renderEdit: (m: Member) => ( - - ), - }, + key: 'collapseFulfillment', + label: 'Fulfillment columns', + ariaLabel: 'Fulfillment columns', + choices: [ + { value: false, label: 'Expand' }, + { value: true, label: 'Collapse' }, ], }, - - { - key: 'name', - header: 'Name', - width: '11rem', - allowOverflow: true, - sortValue: (m: Member) => m.userName ?? m.donorName ?? '', - render: (m: Member) => , - renderEdit: (m: Member) => , - menu: (m: Member, { closeDropdown }) => ( - - ), - }, - { - key: 'discord', - header: 'Discord', - width: '11rem', - allowOverflow: true, - sortValue: (m: Member) => m.discordUsername ?? '', - render: (m: Member) => , - menu: (m: Member, { closeDropdown }) => ( - - ), - }, - { - key: 'address', - header: 'Address', - width: '40rem', - allowOverflow: true, - sortValue: (m: Member) => m.userAddress ?? m.donorAddress ?? '', - render: (m: Member) => , - renderEdit: (m: Member) => , - menu: (m: Member, { closeDropdown }) => ( - - ), - }, - { - key: 'beganMembership', - header: 'Member Since', - sortValue: (m: Member) => m.beganMembership ?? '', - render: (m: Member) => { - if (!m.beganMembership) return '—' - const [y, mo, d] = m.beganMembership.split('-') - return `${mo}-${d}-${y}` - }, - }, - { - key: 'phone', - header: 'Phone', - width: '11rem', - allowOverflow: true, - sortValue: (m: Member) => m.phone ?? '', - render: (m: Member) => , - renderEdit: (m: Member) => , - menu: (m: Member, { closeDropdown }) => ( - - ), - }, - { - key: 'email', - header: 'Email', - width: '14rem', - allowOverflow: true, - sortValue: (m: Member) => m.email ?? '', - render: (m: Member) => , - renderEdit: (m: Member) => , - menu: (m: Member, { closeDropdown }) => ( - - ), - }, - { - key: 'shirtSize', - header: 'Shirt', - width: '5rem', - allowOverflow: true, - sortValue: (m: Member) => m.shirtSize ?? '', - render: (m: Member) => , - renderEdit: (m: Member) => , - }, - - { - key: 'membershipAmount', - header: 'Amount', - width: '6rem', - sortValue: (m: Member) => m.membershipAmount ?? 0, - render: (m: Member) => - m.membershipAmount != null ? `$${m.membershipAmount}` : '—', - }, - { - key: 'contributions', - header: 'Contributions', - width: '6rem', - sortValue: (m: Member) => m.numberOfContributions ?? 0, - render: (m: Member) => m.numberOfContributions?.toString() ?? '—', - }, - { - key: 'userMatched', - header: 'User Matched', - width: '5rem', - sortValue: (m: Member) => (m.userMatched ? 1 : 0), - render: (m: Member) => - m.userId != null ? ( - - - - ) : ( - - ), - }, ] export default function Page() { - const { ready, onGet, onPatch } = useFetch() - const loggedInUser = useCurrentUser() - const queryClient = useQueryClient() - const [showConfirmed, setShowConfirmed] = useState(false) - const [showFulfilled, setShowFulfilled] = useState(true) - const [collapseFulfillment, setCollapseFulfillment] = useState(false) - const [showStatus, setShowStatus] = useState(false) - const [showRowNumber, setShowRowNumber] = useState(false) - const [tableMode, setTableMode] = useState('view') - const [edits, setEdits] = useState>({}) - const editController = useMemo( - () => ({ - draftOf: (member) => - member.donorEmail != null - ? (edits[member.donorEmail] ?? {}) - : {}, - update: (member, patch) => { - const donorEmail = member.donorEmail - if (donorEmail == null) return - setEdits((current) => ({ - ...current, - [donorEmail]: { ...current[donorEmail], ...patch }, - })) - }, - }), - [edits] - ) - const columns = useMemo( - () => - buildColumns( - showConfirmed, - showFulfilled && tableMode === 'view', - showStatus, - showRowNumber, - editController - ), - [ - showConfirmed, - showFulfilled, - tableMode, - showStatus, - showRowNumber, - editController, - ] - ) - const collapsedCategories = useMemo( - () => (collapseFulfillment ? ['Fulfillment'] : []), - [collapseFulfillment] - ) - const loadMoreRef = useRef(null) - const membershipsQuery = useInfiniteQuery({ - queryKey: ['/actblue/memberships', { limit: 25 }], - queryFn: ({ pageParam, signal }) => - onGet( - '/actblue/memberships', - zPaginatedResponse(zMembershipsResponsePacket), - { query: { page: pageParam, limit: 25 }, signal } - ), - initialPageParam: 0, - getNextPageParam: (lastPage, pages) => { - const loadedCount = pages.reduce( - (total, page) => total + page.data.length, - 0 - ) - return loadedCount < lastPage.count ? pages.length : undefined - }, - enabled: ready, - }) - const { fetchNextPage, hasNextPage, isFetchingNextPage } = membershipsQuery - - useEffect(() => { - const loadMoreElement = loadMoreRef.current - if (!loadMoreElement || !hasNextPage) return - - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting && !isFetchingNextPage) - void fetchNextPage() - }, - { rootMargin: '300px' } - ) - - observer.observe(loadMoreElement) - return () => observer.disconnect() - }, [fetchNextPage, hasNextPage, isFetchingNextPage]) - - const members = useMemo( - () => - (membershipsQuery.data?.pages ?? []) - .flatMap((page) => page.data) - .map(mapPacketToMember), - [membershipsQuery.data] - ) - const totalEntries = membershipsQuery.data?.pages[0]?.count - - const pendingUpdates = useMemo( - () => - members.flatMap((member) => { - const donorEmail = member.donorEmail - const draft = donorEmail != null ? edits[donorEmail] : undefined - if (donorEmail == null || draft == null) return [] + const { + members, + totalEntries, + options, + setOption, + tableMode, + setTableMode, + editController, + pendingUpdates, + hasInvalidEdits, + saveMutation, + discardEdits, + hasNextPage, + isFetchingNextPage, + sentinelRef, + } = useMembershipPanel() - const userRequest: UpdateUserRequest = {} - - if (member.userId != null) { - if (draft.userEmail != null) { - const email = draft.userEmail.trim() - if (email !== (member.userEmail ?? '')) - userRequest.email = email === '' ? null : email - } - - if (draft.userFirstName != null) { - const firstName = draft.userFirstName.trim() - if (firstName !== (member.userFirstName ?? '')) - userRequest.firstName = - firstName === '' ? null : firstName - } - - if (draft.userLastName != null) { - const lastName = draft.userLastName.trim() - if (lastName !== (member.userLastName ?? '')) - userRequest.lastName = - lastName === '' ? null : lastName - } - - if (draft.userPhone != null) { - const phone = normalizePhone(draft.userPhone) - if (phone !== normalizePhone(member.userPhone ?? '')) - userRequest.phone = phone === '' ? null : phone - } - - if (draft.address != null) { - const addressRequest: UpdateUserAddressRequest = {} - - for (const { key } of addressFields) { - const value = draft.address[key] - if (value == null) continue - - const trimmed = value.trim() - const original = ( - member.userAddressParts?.[key] ?? '' - ).trim() - if (trimmed === original) continue - - addressRequest[key] = - trimmed === '' ? null : trimmed - } - - if (Object.keys(addressRequest).length > 0) - userRequest.address = addressRequest - } - } - - const membershipRequest: UpdateMembershipRequest = {} - - if ( - draft.shirtSize !== undefined && - (draft.shirtSize ?? undefined) !== member.shirtSize - ) { - const shirtSize = - draft.shirtSize == null - ? null - : toApiShirtSize(draft.shirtSize) - - if (member.userId != null) userRequest.shirtSize = shirtSize - else membershipRequest.shirtSize = shirtSize - } - - if ( - draft.nameConfirmed !== undefined && - draft.nameConfirmed !== member.nameConfirmed - ) - membershipRequest.nameConfirmed = draft.nameConfirmed - - if ( - draft.nameConfirmed === undefined && - hasNameDraftChange(member, draft) && - member.nameConfirmed !== false - ) - membershipRequest.nameConfirmed = false - - if ( - draft.addressConfirmed !== undefined && - draft.addressConfirmed !== member.addressConfirmed - ) - membershipRequest.addressConfirmed = draft.addressConfirmed - - if ( - draft.addressConfirmed === undefined && - hasAddressDraftChange(member, draft) && - member.addressConfirmed !== false - ) - membershipRequest.addressConfirmed = false - - if ( - draft.cardPrinted !== undefined && - draft.cardPrinted !== member.cardPrinted - ) - membershipRequest.membershipCardStatus = draft.cardPrinted - ? MembershipDeliverableStatus.Printed - : MembershipDeliverableStatus.NotStarted - - if ( - draft.packageShipped !== undefined && - (draft.packageShipped ?? undefined) !== - member.packageShipped - ) { - const statusMap: Record< - PackageShipped, - MembershipDeliverableStatus - > = { - Yes: MembershipDeliverableStatus.Recieved, - No: MembershipDeliverableStatus.NotStarted, - Returned: MembershipDeliverableStatus.Returned, - 'Not Received': MembershipDeliverableStatus.InTransit, - Canceled: MembershipDeliverableStatus.NotEligible, - } - const membershipMerchStatus = - draft.packageShipped == null - ? undefined - : statusMap[draft.packageShipped] - if (membershipMerchStatus !== undefined) { - membershipRequest.membershipMerchStatus = - membershipMerchStatus - } - } - - const merchStatusDraft = - draft.benefitShipped ?? - draft.cardPacked ?? - draft.labelPrinted - if ( - merchStatusDraft !== undefined && - draft.packageShipped === undefined - ) { - const labelPrinted = - draft.labelPrinted ?? member.labelPrinted ?? false - const cardPacked = - draft.cardPacked ?? member.cardPacked ?? false - const benefitShipped = - draft.benefitShipped ?? member.benefitShipped ?? false - const membershipMerchStatus = - benefitShipped || cardPacked - ? MembershipDeliverableStatus.InTransit - : labelPrinted - ? MembershipDeliverableStatus.Printed - : MembershipDeliverableStatus.NotStarted - - if (membershipMerchStatus !== member.membershipMerchStatus) - membershipRequest.membershipMerchStatus = - membershipMerchStatus - } - - const linkedUserRequest = - member.userId != null - ? { ...userRequest, ...membershipRequest } - : userRequest - const donorMembershipRequest = - member.userId == null ? membershipRequest : undefined - const hasUser = Object.keys(linkedUserRequest).length > 0 - const hasMembership = - donorMembershipRequest != null && - Object.keys(donorMembershipRequest).length > 0 - if (!hasUser && !hasMembership) return [] - - return [ - { - donorEmail, - userId: member.userId, - user: hasUser ? linkedUserRequest : undefined, - membership: hasMembership - ? donorMembershipRequest - : undefined, - }, - ] - }), - [members, edits] + const columns = useMemo( + () => buildColumns({ options, tableMode, edit: editController }), + [options, tableMode, editController] ) - const hasInvalidEdits = useMemo( - () => - Object.values(edits).some( - (draft) => - (draft.userPhone != null && - !isValidPhone(draft.userPhone)) || - (draft.address != null && - !isValidAddressDraft(draft.address)) - ), - [edits] + const collapsedCategories = useMemo( + () => (options.collapseFulfillment ? [FULFILLMENT_CATEGORY] : []), + [options.collapseFulfillment] ) - const saveMutation = useMutation({ - mutationFn: async (updates: PendingUpdate[]) => { - const metaData = { - dataSource: 'Membership Panel', - userWhoUpdatedId: loggedInUser.data?.id, - } - - for (const { donorEmail, userId, user, membership } of updates) { - if (user && userId != null) { - await onPatch( - '/users/:userId', - { ...user, metaData }, - zUser, - { params: { userId } } - ) - } - - if (membership) { - await onPatch( - '/actblue/donors/:donorEmail/membership', - { ...membership, metaData }, - null, - { params: { donorEmail } } - ) - } - } - }, - onSuccess: async () => { - setEdits({}) - setTableMode('view') - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['/actblue/memberships'], - }), - queryClient.invalidateQueries({ - queryKey: ['/users/:userId'], - }), - ]) - }, - onError: (error) => console.error(error), - }) - - const discardEdits = () => { - setEdits({}) - setTableMode('view') - } - return (
@@ -2184,9 +168,15 @@ export default function Page() { label="Table Options" onClose={closeDropdown} bodyClassName={styles.tableOptionsBody} - body={ - <> + body={tableOptionRows.map( + ({ + key, + label, + ariaLabel, + choices, + }) => (
- Row column + {label} - ariaLabel="Row Column" + ariaLabel={ariaLabel} orientation="horizontal" - value={showRowNumber} - options={[ - { - value: true, - label: 'Show', - }, - { - value: false, - label: 'Hide', - }, - ]} - onChange={setShowRowNumber} - /> -
-
- + setOption(key, value) } - > - Status columns - - - ariaLabel="Member Eligibility Columns" - orientation="horizontal" - value={showStatus} - options={[ - { - value: true, - label: 'Show', - }, - { - value: false, - label: 'Hide', - }, - ]} - onChange={setShowStatus} />
-
- - Confirmed columns - - - ariaLabel="Member Details Confirmed Columns" - orientation="horizontal" - value={showConfirmed} - options={[ - { - value: true, - label: 'Show', - }, - { - value: false, - label: 'Hide', - }, - ]} - onChange={setShowConfirmed} - /> -
-
- - Benefits fulfilled tag - - - ariaLabel="Tag Groups" - orientation="horizontal" - value={showFulfilled} - options={[ - { - value: true, - label: 'Show', - }, - { - value: false, - label: 'Hide', - }, - ]} - onChange={setShowFulfilled} - /> -
-
- - Fulfillment columns - - - ariaLabel="Fulfillment columns" - orientation="horizontal" - value={collapseFulfillment} - options={[ - { - value: false, - label: 'Expand', - }, - { - value: true, - label: 'Collapse', - }, - ]} - onChange={ - setCollapseFulfillment - } - /> -
- - } + ) + )} /> )} /> @@ -2352,9 +217,9 @@ export default function Page() { hasNextPage && (
- {membershipsQuery.isFetchingNextPage && + {isFetchingNextPage && 'Loading more membership records...'}
) diff --git a/src/util/hooks/index.ts b/src/util/hooks/index.ts index 5b4823e0..d3945763 100644 --- a/src/util/hooks/index.ts +++ b/src/util/hooks/index.ts @@ -4,6 +4,7 @@ export * from './useClickAway' export * from './useCurrentUser' export * from './useFetch' export * from './useHydration' +export * from './useInfiniteScroll' export * from './useInit' export * from './useInView' export * from './useOptimisticMutation' diff --git a/src/util/hooks/useInfiniteScroll.ts b/src/util/hooks/useInfiniteScroll.ts new file mode 100644 index 00000000..017d57d1 --- /dev/null +++ b/src/util/hooks/useInfiniteScroll.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useState } from 'react' + +export interface UseInfiniteScrollOptions { + hasNextPage: boolean + isFetchingNextPage: boolean + fetchNextPage: () => unknown + rootMargin?: string +} + +export function useInfiniteScroll({ + hasNextPage, + isFetchingNextPage, + fetchNextPage, + rootMargin = '300px', +}: UseInfiniteScrollOptions) { + const [sentinel, setSentinel] = useState(null) + + useEffect(() => { + if (!sentinel || !hasNextPage) return + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting && !isFetchingNextPage) { + void fetchNextPage() + } + }, + { rootMargin } + ) + + observer.observe(sentinel) + return () => observer.disconnect() + }, [sentinel, hasNextPage, isFetchingNextPage, fetchNextPage, rootMargin]) + + const sentinelRef = useCallback((node: T | null) => setSentinel(node), []) + + return { sentinelRef } +} From 13f970e09738b5fb176122abb591ec0d79820bec Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Mon, 7 Sep 2026 10:04:21 -0400 Subject: [PATCH 05/27] performance optimizations --- src/app/admin/panels/membership/hooks.ts | 144 ++++-- .../panels/membership/membership.helpers.ts | 9 +- .../panels/membership/membership.types.ts | 3 + .../panels/membership/membershipCells.tsx | 20 +- .../panels/membership/membershipColumns.tsx | 50 ++- src/app/admin/panels/membership/page.tsx | 102 +++-- .../common/dropdown/DropdownButton.tsx | 6 +- src/components/common/table/Table.tsx | 409 ++++++++++++------ 8 files changed, 504 insertions(+), 239 deletions(-) diff --git a/src/app/admin/panels/membership/hooks.ts b/src/app/admin/panels/membership/hooks.ts index 3a0e432e..ba5cfe9c 100644 --- a/src/app/admin/panels/membership/hooks.ts +++ b/src/app/admin/panels/membership/hooks.ts @@ -28,11 +28,19 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query' -import { useCallback, useMemo, useState } from 'react' +import { + useCallback, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react' const HISTORY_STALE_TIME = 5 * 60 * 1000 const HISTORY_LIMIT = 5 const PAGE_SIZE = 25 +const EMPTY_DRAFT: MemberEdits = {} +const noopUnsubscribe = () => undefined export function useFieldHistory({ userId, @@ -93,30 +101,80 @@ export function useFieldHistory({ } } +// Edits live outside React state so typing only re-renders the affected cells +// instead of rebuilding every column and row of the table. export function useMemberEdits() { - const [edits, setEdits] = useState>({}) + const editsRef = useRef>({}) + const memberListeners = useRef(new Map void>>()) + const allListeners = useRef(new Set<() => void>()) + + const editController = useMemo(() => { + const subscribeTo = (set: Set<() => void>, listener: () => void) => { + set.add(listener) + return () => { + set.delete(listener) + } + } - const editController = useMemo( - () => ({ + return { draftOf: (member: Member) => - member.donorEmail != null - ? (edits[member.donorEmail] ?? {}) - : {}, + (member.donorEmail != null + ? editsRef.current[member.donorEmail] + : undefined) ?? EMPTY_DRAFT, update: (member: Member, patch: MemberEdits) => { const donorEmail = member.donorEmail if (donorEmail == null) return - setEdits((current) => ({ - ...current, - [donorEmail]: { ...current[donorEmail], ...patch }, - })) + editsRef.current = { + ...editsRef.current, + [donorEmail]: { + ...editsRef.current[donorEmail], + ...patch, + }, + } + memberListeners.current + .get(donorEmail) + ?.forEach((listener) => listener()) + allListeners.current.forEach((listener) => listener()) }, - }), - [edits] + subscribeMember: (donorEmail: string, listener: () => void) => { + let listeners = memberListeners.current.get(donorEmail) + if (!listeners) { + listeners = new Set() + memberListeners.current.set(donorEmail, listeners) + } + return subscribeTo(listeners, listener) + }, + subscribeAll: (listener: () => void) => + subscribeTo(allListeners.current, listener), + getEdits: () => editsRef.current, + } + }, []) + + const clearEdits = useCallback(() => { + editsRef.current = {} + memberListeners.current.forEach((listeners) => + listeners.forEach((listener) => listener()) + ) + allListeners.current.forEach((listener) => listener()) + }, []) + + return { editController, clearEdits } +} + +export function useMemberDraft(edit: EditController, member: Member) { + const donorEmail = member.donorEmail + + const subscribe = useCallback( + (listener: () => void) => + donorEmail != null + ? edit.subscribeMember(donorEmail, listener) + : noopUnsubscribe, + [edit, donorEmail] ) - const clearEdits = useCallback(() => setEdits({}), []) + const getDraft = useCallback(() => edit.draftOf(member), [edit, member]) - return { edits, editController, clearEdits } + return useSyncExternalStore(subscribe, getDraft, getDraft) } export function useSaveMemberships(onSaved: () => void) { @@ -215,11 +273,46 @@ function useMembershipsQuery() { return { query, members, totalEntries: query.data?.pages[0]?.count } } +export function usePendingUpdates(edit: EditController, members: Member[]) { + const edits = useSyncExternalStore( + edit.subscribeAll, + edit.getEdits, + edit.getEdits + ) + + const membersByEmail = useMemo(() => { + const map = new Map() + for (const member of members) { + if (member.donorEmail != null) map.set(member.donorEmail, member) + } + return map + }, [members]) + + const pendingUpdates = useMemo( + () => buildPendingUpdates(membersByEmail, edits), + [membersByEmail, edits] + ) + + const hasInvalidEdits = useMemo( + () => + Object.values(edits).some( + (draft) => + (draft.userPhone != null && + !isValidPhone(draft.userPhone)) || + (draft.address != null && + !isValidAddressDraft(draft.address)) + ), + [edits] + ) + + return { pendingUpdates, hasInvalidEdits } +} + export function useMembershipPanel() { const [options, setOptions] = useState(defaultTableOptions) const [tableMode, setTableMode] = useState('view') - const { edits, editController, clearEdits } = useMemberEdits() + const { editController, clearEdits } = useMemberEdits() const { query, members, totalEntries } = useMembershipsQuery() const { fetchNextPage, hasNextPage, isFetchingNextPage } = query @@ -237,23 +330,6 @@ export function useMembershipPanel() { [] ) - const pendingUpdates = useMemo( - () => buildPendingUpdates(members, edits), - [members, edits] - ) - - const hasInvalidEdits = useMemo( - () => - Object.values(edits).some( - (draft) => - (draft.userPhone != null && - !isValidPhone(draft.userPhone)) || - (draft.address != null && - !isValidAddressDraft(draft.address)) - ), - [edits] - ) - const stopEditing = useCallback(() => { clearEdits() setTableMode('view') @@ -269,8 +345,6 @@ export function useMembershipPanel() { tableMode, setTableMode, editController, - pendingUpdates, - hasInvalidEdits, saveMutation, discardEdits: stopEditing, hasNextPage, diff --git a/src/app/admin/panels/membership/membership.helpers.ts b/src/app/admin/panels/membership/membership.helpers.ts index 7bf1b2ec..238a2977 100644 --- a/src/app/admin/panels/membership/membership.helpers.ts +++ b/src/app/admin/panels/membership/membership.helpers.ts @@ -344,13 +344,12 @@ const buildMembershipRequest = (member: Member, draft: MemberEdits) => { } export const buildPendingUpdates = ( - members: Member[], + membersByEmail: Map, edits: Record ): PendingUpdate[] => - members.flatMap((member) => { - const donorEmail = member.donorEmail - const draft = donorEmail != null ? edits[donorEmail] : undefined - if (donorEmail == null || draft == null) return [] + Object.entries(edits).flatMap(([donorEmail, draft]) => { + const member = membersByEmail.get(donorEmail) + if (member == null) return [] const userRequest = member.userId != null ? buildUserRequest(member, draft) : {} diff --git a/src/app/admin/panels/membership/membership.types.ts b/src/app/admin/panels/membership/membership.types.ts index 6b950adf..da43ede9 100644 --- a/src/app/admin/panels/membership/membership.types.ts +++ b/src/app/admin/panels/membership/membership.types.ts @@ -120,6 +120,9 @@ export type MemberEdits = Partial< export interface EditController { draftOf: (member: Member) => MemberEdits update: (member: Member, patch: MemberEdits) => void + subscribeMember: (donorEmail: string, listener: () => void) => () => void + subscribeAll: (listener: () => void) => () => void + getEdits: () => Record } export interface MemberValueProps { diff --git a/src/app/admin/panels/membership/membershipCells.tsx b/src/app/admin/panels/membership/membershipCells.tsx index a0d00bad..812d9318 100644 --- a/src/app/admin/panels/membership/membershipCells.tsx +++ b/src/app/admin/panels/membership/membershipCells.tsx @@ -4,6 +4,7 @@ import { EditableSelectTag } from './components/EditableSelectTag' import { SourceMenu } from './components/SourceMenu' import { ConfirmedBadge } from './components/Tags' import tags from './components/Tags.module.css' +import { useMemberDraft } from './hooks' import { describeAddressChange, describeEmailChange, @@ -56,9 +57,9 @@ const nameInputs = [ ] as const export const NameEdit = ({ member, edit }: MemberEditProps) => { - if (member.userId == null) return + const draft = useMemberDraft(edit, member) - const draft = edit.draftOf(member) + if (member.userId == null) return return (
@@ -198,9 +199,9 @@ const addressInputs: { ] export const AddressEdit = ({ member, edit }: MemberEditProps) => { - if (member.userId == null) return + const draft = useMemberDraft(edit, member).address ?? {} - const draft = edit.draftOf(member).address ?? {} + if (member.userId == null) return return (
@@ -269,10 +270,11 @@ export const PhoneValue = ({ member }: MemberValueProps) => ( ) export const PhoneEdit = ({ member, edit }: MemberEditProps) => { + const draft = useMemberDraft(edit, member).userPhone + if (member.userId == null) return const original = member.userPhone ?? '' - const draft = edit.draftOf(member).userPhone const invalid = !isValidPhone(draft ?? original) return ( @@ -329,10 +331,12 @@ export const EmailValue = ({ member }: MemberValueProps) => ( ) export const EmailEdit = ({ member, edit }: MemberEditProps) => { + const userEmailDraft = useMemberDraft(edit, member).userEmail + if (member.userId == null) return const original = member.userEmail ?? '' - const value = edit.draftOf(member).userEmail ?? original + const value = userEmailDraft ?? original return ( export const ShirtSizeEdit = ({ member, edit }: MemberEditProps) => { const { value, dirty } = resolveSelectDraft( - edit.draftOf(member).shirtSize, + useMemberDraft(edit, member).shirtSize, member.shirtSize ) @@ -447,7 +451,7 @@ export const PackageShippedValue = ({ member }: MemberValueProps) => export const PackageShippedEdit = ({ member, edit }: MemberEditProps) => { const { value, dirty } = resolveSelectDraft( - edit.draftOf(member).packageShipped, + useMemberDraft(edit, member).packageShipped, member.packageShipped ) diff --git a/src/app/admin/panels/membership/membershipColumns.tsx b/src/app/admin/panels/membership/membershipColumns.tsx index da65ce3d..3857eefc 100644 --- a/src/app/admin/panels/membership/membershipColumns.tsx +++ b/src/app/admin/panels/membership/membershipColumns.tsx @@ -2,7 +2,7 @@ import { BoolTag, EditableBoolTag } from './components/Tags' import tags from './components/Tags.module.css' -import { MembershipTableOptions } from './hooks' +import { MembershipTableOptions, useMemberDraft } from './hooks' import { hasAddressDraftChange, hasNameDraftChange } from './membership.helpers' import { EditController, @@ -69,30 +69,40 @@ interface FlagColumnConfig { resolveDraft?: (member: Member, draft: MemberEdits) => boolean } +const FlagEdit = ({ + member, + edit, + config: { key, header, resolveDraft }, +}: { + member: Member + edit: EditController + config: FlagColumnConfig +}) => { + const draft = useMemberDraft(edit, member) + const value = resolveDraft + ? resolveDraft(member, draft) + : (draft[key] ?? member[key] ?? false) + + return ( + edit.update(member, { [key]: !value })} + /> + ) +} + const flagColumn = ( edit: EditController, - { key, header, resolveDraft }: FlagColumnConfig + config: FlagColumnConfig ): Column => ({ - key, - header, + key: config.key, + header: config.header, width: '5rem', allowOverflow: true, - sortValue: (m) => (m[key] ? 1 : 0), - render: (m) => , - renderEdit: (m) => { - const draft = edit.draftOf(m) - const value = resolveDraft - ? resolveDraft(m, draft) - : (draft[key] ?? m[key] ?? false) - - return ( - edit.update(m, { [key]: !value })} - /> - ) - }, + sortValue: (m) => (m[config.key] ? 1 : 0), + render: (m) => , + renderEdit: (m) => , }) const readOnlyBoolColumn = ( diff --git a/src/app/admin/panels/membership/page.tsx b/src/app/admin/panels/membership/page.tsx index cceb2e23..e330e4b4 100644 --- a/src/app/admin/panels/membership/page.tsx +++ b/src/app/admin/panels/membership/page.tsx @@ -1,6 +1,12 @@ 'use client' -import { useMembershipPanel, MembershipTableOptions } from './hooks' +import { + useMembershipPanel, + usePendingUpdates, + useSaveMemberships, + MembershipTableOptions, +} from './hooks' +import { EditController, Member } from './membership.types' import { buildColumns, FULFILLMENT_CATEGORY } from './membershipColumns' import styles from './page.module.css' import { @@ -59,6 +65,55 @@ const tableOptionRows: { }, ] +function EditToolbar({ + members, + editController, + saveMutation, + discardEdits, +}: { + members: Member[] + editController: EditController + saveMutation: ReturnType + discardEdits: () => void +}) { + const { pendingUpdates, hasInvalidEdits } = usePendingUpdates( + editController, + members + ) + + return ( + <> + + {hasInvalidEdits + ? 'Fix invalid fields to save' + : pendingUpdates.length === 0 + ? 'No changes' + : `${pendingUpdates.length} pending change${pendingUpdates.length === 1 ? '' : 's'}`} + + + + + ) +} + export default function Page() { const { members, @@ -68,8 +123,6 @@ export default function Page() { tableMode, setTableMode, editController, - pendingUpdates, - hasInvalidEdits, saveMutation, discardEdits, hasNextPage, @@ -113,43 +166,12 @@ export default function Page() {
{tableMode === 'edit' ? ( - <> - - {hasInvalidEdits - ? 'Fix invalid fields to save' - : pendingUpdates.length === 0 - ? 'No changes' - : `${pendingUpdates.length} pending change${pendingUpdates.length === 1 ? '' : 's'}`} - - - - + ) : ( - {isOpen && renderedMenu} + {isOpen && + (typeof menu === 'function' ? menu({ closeDropdown }) : menu)}
) }) diff --git a/src/components/common/table/Table.tsx b/src/components/common/table/Table.tsx index 9b91caed..78dde5dd 100644 --- a/src/components/common/table/Table.tsx +++ b/src/components/common/table/Table.tsx @@ -2,10 +2,19 @@ import styles from './Table.module.css' import { cn } from '@/util' -import React, { useEffect, useMemo, useState } from 'react' +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' import { FiChevronDown, FiChevronUp } from 'react-icons/fi' const DEFAULT_COLUMN_WIDTH = '10rem' +const VIRTUALIZE_THRESHOLD = 60 +const OVERSCAN = 10 export interface Column { key: string @@ -46,30 +55,42 @@ export interface TableProps { type SortDir = 'asc' | 'desc' -function TableCell({ +type OpenCellSetter = React.Dispatch> + +interface VisibleEntry { + type: 'column' | 'group-collapsed' + col?: Column + category?: ColumnCategory + width: string +} + +function TableCellInner({ col, row, index, mode, isOpen, - onToggle, - onClose, + cellId, + setOpenCell, }: { col: Column row: T index: number mode: TableMode isOpen: boolean - onToggle: () => void - onClose: () => void + cellId: string + setOpenCell: OpenCellSetter }) { const editing = mode === 'edit' const editable = editing && col.renderEdit != null const interactive = !editing && Boolean(col.menu ?? col.onCellClick) + const onClose = useCallback(() => setOpenCell(null), [setOpenCell]) + const activate = () => { col.onCellClick?.(row, index) - if (col.menu) onToggle() + if (col.menu) + setOpenCell((current) => (current === cellId ? null : cellId)) } const content = editable @@ -118,6 +139,178 @@ function TableCell({ ) } +const TableCell = React.memo(TableCellInner) as typeof TableCellInner + +function TableRowInner({ + row, + index, + rowId, + columns, + visibleEntries, + gridTemplateColumns, + mode, + openColKey, + setOpenCell, +}: { + row: T + index: number + rowId: string | number + columns: ColumnEntry[] + visibleEntries: VisibleEntry[] + gridTemplateColumns: string + mode: TableMode + openColKey: string | null + setOpenCell: OpenCellSetter +}) { + const rowOverrides = new Map() + for (const entry of columns) { + if (isCategory(entry) && entry.rowRender) { + const result = entry.rowRender(row) + if (result != null) { + rowOverrides.set(entry.label, result) + } + } + } + + const rendered = new Set() + + return ( +
+ {visibleEntries.map((entry) => { + if (entry.type === 'group-collapsed') { + const category = entry.category! + return ( + + {category.columns.map((col) => { + const color = + category.dotColor?.(row, col) ?? null + if (color) { + return ( + + ) + } + return ( + + · + + ) + })} + + ) + } + if (entry.category && rowOverrides.has(entry.category.label)) { + if (rendered.has(entry.category.label)) { + return null + } + rendered.add(entry.category.label) + const span = entry.category.columns.length + return ( + + + {rowOverrides.get(entry.category.label)} + + + ) + } + const col = entry.col! + return ( + + ) + })} +
+ ) +} + +const TableRow = React.memo(TableRowInner) as typeof TableRowInner + +interface RowMetrics { + rowHeight: number + headerHeight: number +} + +function useVirtualRange( + containerRef: React.RefObject, + { rowHeight, headerHeight }: RowMetrics, + rowCount: number, + enabled: boolean +) { + const [range, setRange] = useState({ start: 0, end: rowCount }) + + useEffect(() => { + const container = containerRef.current + + if (!enabled || !container) { + setRange({ start: 0, end: rowCount }) + return + } + + let frame = 0 + + const update = () => { + frame = 0 + const offset = Math.max(0, container.scrollTop - headerHeight) + const start = Math.max(0, Math.floor(offset / rowHeight) - OVERSCAN) + const visible = Math.ceil(container.clientHeight / rowHeight) + const end = Math.min(rowCount, start + visible + OVERSCAN * 2) + + setRange((current) => + current.start === start && current.end === end + ? current + : { start, end } + ) + } + + const schedule = () => { + if (frame === 0) frame = requestAnimationFrame(update) + } + + update() + container.addEventListener('scroll', schedule, { passive: true }) + const observer = new ResizeObserver(schedule) + observer.observe(container) + + return () => { + container.removeEventListener('scroll', schedule) + observer.disconnect() + if (frame !== 0) cancelAnimationFrame(frame) + } + }, [containerRef, rowHeight, headerHeight, rowCount, enabled]) + + return range +} + export function Table({ columns, data, @@ -129,6 +322,12 @@ export function Table({ const [sortKey, setSortKey] = useState(null) const [sortDir, setSortDir] = useState('asc') const [openCell, setOpenCell] = useState(null) + const [metrics, setMetrics] = useState({ + rowHeight: 0, + headerHeight: 0, + }) + const containerRef = useRef(null) + const headerRef = useRef(null) useEffect(() => { if (mode === 'edit') setOpenCell(null) @@ -201,12 +400,7 @@ export function Table({ }, [data, flatColumns, sortKey, sortDir]) const visibleEntries = useMemo(() => { - const entries: { - type: 'column' | 'group-collapsed' - col?: Column - category?: ColumnCategory - width: string - }[] = [] + const entries: VisibleEntry[] = [] for (const entry of columns) { if (isCategory(entry)) { if (collapsedSet.has(entry.label)) { @@ -238,9 +432,44 @@ export function Table({ const gridTemplateColumns = visibleEntries.map((e) => e.width).join(' ') + const rowCount = sortedData.length + const hasRows = rowCount > 0 + + useLayoutEffect(() => { + const row = + containerRef.current?.querySelector('[data-table-row]') + const header = headerRef.current + if (!row || !header) return + + const rowHeight = row.getBoundingClientRect().height + const headerHeight = header.getBoundingClientRect().height + + setMetrics((current) => + current.rowHeight === rowHeight && + current.headerHeight === headerHeight + ? current + : { rowHeight, headerHeight } + ) + }, [mode, visibleEntries, hasRows]) + + const { start, end } = useVirtualRange( + containerRef, + metrics, + rowCount, + metrics.rowHeight > 0 && rowCount > VIRTUALIZE_THRESHOLD + ) + return ( -
-
+
+
{visibleEntries.map((entry) => { if (entry.type === 'group-collapsed') { return ( @@ -284,122 +513,48 @@ export function Table({ })}
- {sortedData.map((row, index) => { - const rowOverrides = new Map() - for (const entry of columns) { - if (isCategory(entry) && entry.rowRender) { - const result = entry.rowRender(row) - if (result != null) { - rowOverrides.set(entry.label, result) - } - } - } + {start > 0 && ( +
+ )} + + {sortedData.slice(start, end).map((row, offset) => { + const index = start + offset + const rowId = rowKey(row, index) + const openPrefix = `${rowId}::` return ( -
- {(() => { - const rendered = new Set() - return visibleEntries.map((entry) => { - if (entry.type === 'group-collapsed') { - const category = entry.category! - return ( - - {category.columns.map((col) => { - const color = - category.dotColor?.( - row, - col - ) ?? null - if (color) { - return ( - - ) - } - return ( - - · - - ) - })} - - ) - } - if ( - entry.category && - rowOverrides.has(entry.category.label) - ) { - if (rendered.has(entry.category.label)) { - return null - } - rendered.add(entry.category.label) - const span = entry.category.columns.length - return ( - - - {rowOverrides.get( - entry.category.label - )} - - - ) - } - const col = entry.col! - const cellId = `${rowKey(row, index)}::${col.key}` - return ( - - setOpenCell((current) => - current === cellId - ? null - : cellId - ) - } - onClose={() => setOpenCell(null)} - /> - ) - }) - })()} -
+ ) })} + + {end < rowCount && ( +
+ )} {footer}
) From 2d45b644fcb1eba38e99f0132ee9bb30626ee64b Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Mon, 7 Sep 2026 14:01:49 -0400 Subject: [PATCH 06/27] adding matching functionality --- .../panels/membership/membershipColumns.tsx | 11 ++- .../admin/panels/membership/page.module.css | 3 + src/app/admin/panels/membership/page.tsx | 93 ++++++++++++++++++- 3 files changed, 104 insertions(+), 3 deletions(-) diff --git a/src/app/admin/panels/membership/membershipColumns.tsx b/src/app/admin/panels/membership/membershipColumns.tsx index 3857eefc..8e00901c 100644 --- a/src/app/admin/panels/membership/membershipColumns.tsx +++ b/src/app/admin/panels/membership/membershipColumns.tsx @@ -172,10 +172,12 @@ export const buildColumns = ({ options, tableMode, edit, + onMatchUser, }: { options: MembershipTableOptions tableMode: MembershipTableMode edit: EditController + onMatchUser: (member: Member) => void }): ColumnEntry[] => { const { showConfirmed, showStatus, showRowNumber } = options const showFulfilledTag = options.showFulfilled && tableMode === 'view' @@ -350,7 +352,14 @@ export const buildColumns = ({ ) : ( - + ), }, ] diff --git a/src/app/admin/panels/membership/page.module.css b/src/app/admin/panels/membership/page.module.css index 42d41fd3..ff9769ab 100644 --- a/src/app/admin/panels/membership/page.module.css +++ b/src/app/admin/panels/membership/page.module.css @@ -177,6 +177,9 @@ .userMatchedLink { display: inline-flex; cursor: pointer; + border: 0; + padding: 0; + background: transparent; } @media (max-width: 53rem) { diff --git a/src/app/admin/panels/membership/page.tsx b/src/app/admin/panels/membership/page.tsx index e330e4b4..ea225c1a 100644 --- a/src/app/admin/panels/membership/page.tsx +++ b/src/app/admin/panels/membership/page.tsx @@ -9,14 +9,20 @@ import { import { EditController, Member } from './membership.types' import { buildColumns, FULFILLMENT_CATEGORY } from './membershipColumns' import styles from './page.module.css' +import { ListBody, ListElement } from '@/app/admin/layout/List' +import { SearchModal } from '@/app/admin/layout/SearchModal' import { DropdownButton, DropdownOverlay, ToggleGroup, } from '@/components/common' import { Table } from '@/components/common/table' +import { UserProfile, zUserProfile } from '@/contracts/data' +import { ActBlueDonorLinkRequest } from '@/contracts/requests' import { cn } from '@/util' -import { useMemo } from 'react' +import { useCurrentUser, useFetch, usePaginatedSearch } from '@/util/hooks' +import { useQueryClient } from '@tanstack/react-query' +import { ChangeEvent, useCallback, useMemo, useState } from 'react' import { FaEdit, FaSave, FaTrashAlt } from 'react-icons/fa' const showHideChoices = [ @@ -115,6 +121,10 @@ function EditToolbar({ } export default function Page() { + const { onPost } = useFetch() + const loggedInUser = useCurrentUser() + const queryClient = useQueryClient() + const [memberToMatch, setMemberToMatch] = useState(null) const { members, totalEntries, @@ -130,8 +140,56 @@ export default function Page() { sentinelRef, } = useMembershipPanel() + const { + query: userSearchQuery, + search: userSearch, + onSearch: onUserSearch, + } = usePaginatedSearch('/users', zUserProfile) + + const handleMatchUser = useCallback( + async (user: UserProfile) => { + const donorEmail = memberToMatch?.donorEmail + if (donorEmail == null) return + + await onPost( + '/actblue/donors/:donorEmail/link', + { + userId: user.id, + metaData: { + dataSource: 'Membership Panel', + userWhoUpdatedId: loggedInUser.data?.id, + }, + } satisfies ActBlueDonorLinkRequest, + null, + { params: { donorEmail } } + ) + + setMemberToMatch(null) + await queryClient.invalidateQueries({ + queryKey: ['/actblue/memberships'], + }) + }, + [loggedInUser.data?.id, memberToMatch?.donorEmail, onPost, queryClient] + ) + + const handleUserSearch = useCallback( + (event: ChangeEvent) => + onUserSearch({ + ...userSearch, + query: event.target.value, + page: 0, + }), + [onUserSearch, userSearch] + ) + const columns = useMemo( - () => buildColumns({ options, tableMode, edit: editController }), + () => + buildColumns({ + options, + tableMode, + edit: editController, + onMatchUser: setMemberToMatch, + }), [options, tableMode, editController] ) @@ -249,6 +307,37 @@ export default function Page() { />
+ setMemberToMatch(null)} + title="Match User" + subtitle="Search users and link one to this membership record." + searchValue={userSearch.query ?? ''} + onSearchChange={handleUserSearch} + > + + {userSearchQuery.data?.data.map((user) => ( + void handleMatchUser(user)} + > + + {user.preferredName ?? + [user.firstName, user.lastName] + .filter(Boolean) + .join(' ') ?? + user.email ?? + `User ${user.id}`} + + {user.email && {user.email}} + + ))} + +
) } From 2115b17ea51acdb6a615b7797f8482ae633ebd97 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Thu, 10 Sep 2026 17:47:23 -0400 Subject: [PATCH 07/27] updating to use new contract --- src/app/admin/panels/members/page.tsx | 27 ++++- .../panels/membership/membership.helpers.ts | 98 ++++++++++--------- .../panels/membership/membershipColumns.tsx | 21 +++- src/contracts/data/Membership.ts | 9 +- .../requests/UpdateMembershipRequest.ts | 9 +- src/contracts/responses/PaginatedResponse.ts | 2 - src/queries/users.queries.ts | 24 ++++- src/util/hooks/useFetch.ts | 20 +++- 8 files changed, 148 insertions(+), 62 deletions(-) diff --git a/src/app/admin/panels/members/page.tsx b/src/app/admin/panels/members/page.tsx index 3926f9ac..b4ae2cc1 100644 --- a/src/app/admin/panels/members/page.tsx +++ b/src/app/admin/panels/members/page.tsx @@ -29,6 +29,7 @@ import { import { ActBlueDonorLinkRequest, SortDirection, + UpdateMembershipRequest, UpdateUserRequest, zUpdateUserRequest, } from '@/contracts/requests' @@ -411,24 +412,40 @@ export default function Page() { async (value: ActBlueDonor, userId: number) => { setPickingDonor(false) + const metaData = { + dataSource: 'Member Panel', + userWhoUpdatedId: loggedInUser.data?.id, + } + await onPost( '/actblue/donors/:donorEmail/link', { userId, - metaData: { - dataSource: 'Member Panel', - userWhoUpdatedId: loggedInUser.data?.id, - }, + metaData, } satisfies ActBlueDonorLinkRequest, null, { params: { donorEmail: value.email } } ) + try { + await onPatch( + '/actblue/donors/:donorEmail/membership', + { + discordConfirmed: true, + metaData, + } satisfies UpdateMembershipRequest, + null, + { params: { donorEmail: value.email } } + ) + } catch (error) { + console.error(error) + } + await queryClient.invalidateQueries({ queryKey: [`/users/${userId}`], }) }, - [onPost, queryClient, loggedInUser.data] + [onPatch, onPost, queryClient, loggedInUser.data] ) const handleDeleteDonorItem = useCallback( diff --git a/src/app/admin/panels/membership/membership.helpers.ts b/src/app/admin/panels/membership/membership.helpers.ts index 238a2977..a9e0d593 100644 --- a/src/app/admin/panels/membership/membership.helpers.ts +++ b/src/app/admin/panels/membership/membership.helpers.ts @@ -86,6 +86,10 @@ export const hasNameDraftChange = (member: Member, draft: MemberEdits) => return value != null && value.trim() !== (member[key] ?? '').trim() }) +export const hasDiscordDraftChange = (member: Member, draft: MemberEdits) => + draft.discordConfirmed != null && + draft.discordConfirmed !== (member.discordConfirmed ?? false) + export const hasAddressDraftChange = (member: Member, draft: MemberEdits) => draft.address != null && addressFields.some((key) => { @@ -179,18 +183,19 @@ export const mapPacketToMember = ( ?.toISOString() .slice(0, 10), numberOfContributions: donor.contributions?.length, - discordConfirmed: Boolean(user?.discordUsers?.length), + discordConfirmed: + membership?.discordConfirmed ?? Boolean(user?.discordUsers?.length), nameConfirmed: membership?.nameConfirmed ?? user?.nameConfirmed ?? undefined, addressConfirmed: membership?.addressConfirmed ?? user?.addressConfirmed ?? undefined, - cardPrinted: cardStatus != null ? cardStatus >= 2 : undefined, - labelPrinted: merchStatus != null ? merchStatus >= 2 : undefined, - cardPacked: merchStatus != null ? merchStatus >= 3 : undefined, - benefitShipped: merchStatus != null ? merchStatus >= 3 : undefined, + cardPrinted: membership?.cardPrinted ?? undefined, + labelPrinted: membership?.labelPrinted ?? undefined, + cardPacked: membership?.itemsPackaged ?? undefined, + benefitShipped: membership?.benefitsShipped ?? undefined, membershipCardStatus: cardStatus, membershipMerchStatus: merchStatus, - packageShipped: packageShippedFromStatus(merchStatus), + packageShipped: packageShippedFromStatus(cardStatus), userMatched: Boolean(user), } } @@ -260,7 +265,7 @@ const resolveShirtSize = ( return draft.shirtSize == null ? null : toApiShirtSize(draft.shirtSize) } -const resolveMerchStatus = (member: Member, draft: MemberEdits) => { +const resolveCardStatus = (member: Member, draft: MemberEdits) => { if (draft.packageShipped !== undefined) { if ((draft.packageShipped ?? undefined) === member.packageShipped) return undefined @@ -269,25 +274,7 @@ const resolveMerchStatus = (member: Member, draft: MemberEdits) => { : packageShippedStatus[draft.packageShipped] } - const merchFlagEdited = - draft.labelPrinted !== undefined || - draft.cardPacked !== undefined || - draft.benefitShipped !== undefined - if (!merchFlagEdited) return undefined - - const labelPrinted = draft.labelPrinted ?? member.labelPrinted ?? false - const cardPacked = draft.cardPacked ?? member.cardPacked ?? false - const benefitShipped = - draft.benefitShipped ?? member.benefitShipped ?? false - - const status = - benefitShipped || cardPacked - ? MembershipDeliverableStatus.InTransit - : labelPrinted - ? MembershipDeliverableStatus.Printed - : MembershipDeliverableStatus.NotStarted - - return status === member.membershipMerchStatus ? undefined : status + return undefined } const buildUserRequest = (member: Member, draft: MemberEdits) => { @@ -314,6 +301,9 @@ const buildUserRequest = (member: Member, draft: MemberEdits) => { const buildMembershipRequest = (member: Member, draft: MemberEdits) => { const request: UpdateMembershipRequest = {} + const shirtSize = resolveShirtSize(member, draft) + if (shirtSize !== undefined) request.shirtSize = shirtSize + const nameConfirmed = resolveConfirmation( draft.nameConfirmed, member.nameConfirmed, @@ -321,6 +311,14 @@ const buildMembershipRequest = (member: Member, draft: MemberEdits) => { ) if (nameConfirmed !== undefined) request.nameConfirmed = nameConfirmed + const discordConfirmed = resolveConfirmation( + draft.discordConfirmed, + member.discordConfirmed, + hasDiscordDraftChange(member, draft) + ) + if (discordConfirmed !== undefined) + request.discordConfirmed = discordConfirmed + const addressConfirmed = resolveConfirmation( draft.addressConfirmed, member.addressConfirmed, @@ -329,16 +327,36 @@ const buildMembershipRequest = (member: Member, draft: MemberEdits) => { if (addressConfirmed !== undefined) request.addressConfirmed = addressConfirmed + const cardStatus = resolveCardStatus(member, draft) + if (cardStatus !== undefined) request.membershipCardStatus = cardStatus + if ( draft.cardPrinted !== undefined && draft.cardPrinted !== member.cardPrinted - ) - request.membershipCardStatus = draft.cardPrinted - ? MembershipDeliverableStatus.Printed - : MembershipDeliverableStatus.NotStarted + ) { + request.cardPrinted = draft.cardPrinted + } - const merchStatus = resolveMerchStatus(member, draft) - if (merchStatus !== undefined) request.membershipMerchStatus = merchStatus + if ( + draft.labelPrinted !== undefined && + draft.labelPrinted !== member.labelPrinted + ) { + request.labelPrinted = draft.labelPrinted + } + + if ( + draft.cardPacked !== undefined && + draft.cardPacked !== member.cardPacked + ) { + request.itemsPackaged = draft.cardPacked + } + + if ( + draft.benefitShipped !== undefined && + draft.benefitShipped !== member.benefitShipped + ) { + request.benefitsShipped = draft.benefitShipped + } return request } @@ -355,21 +373,11 @@ export const buildPendingUpdates = ( member.userId != null ? buildUserRequest(member, draft) : {} const membershipRequest = buildMembershipRequest(member, draft) - const shirtSize = resolveShirtSize(member, draft) - if (shirtSize !== undefined) { - if (member.userId != null) userRequest.shirtSize = shirtSize - else membershipRequest.shirtSize = shirtSize - } - - const user = - member.userId != null - ? { ...userRequest, ...membershipRequest } - : userRequest - const membership = member.userId == null ? membershipRequest : undefined + const user = userRequest + const membership = membershipRequest const hasUser = Object.keys(user).length > 0 - const hasMembership = - membership != null && Object.keys(membership).length > 0 + const hasMembership = Object.keys(membership).length > 0 if (!hasUser && !hasMembership) return [] return [ diff --git a/src/app/admin/panels/membership/membershipColumns.tsx b/src/app/admin/panels/membership/membershipColumns.tsx index 8e00901c..0691299f 100644 --- a/src/app/admin/panels/membership/membershipColumns.tsx +++ b/src/app/admin/panels/membership/membershipColumns.tsx @@ -3,7 +3,11 @@ import { BoolTag, EditableBoolTag } from './components/Tags' import tags from './components/Tags.module.css' import { MembershipTableOptions, useMemberDraft } from './hooks' -import { hasAddressDraftChange, hasNameDraftChange } from './membership.helpers' +import { + hasAddressDraftChange, + hasNameDraftChange, + hasDiscordDraftChange, +} from './membership.helpers' import { EditController, Member, @@ -67,6 +71,10 @@ interface FlagColumnConfig { key: MemberFlag header: string resolveDraft?: (member: Member, draft: MemberEdits) => boolean + menu?: ( + member: Member, + controls: { closeDropdown: () => void } + ) => React.ReactNode } const FlagEdit = ({ @@ -103,6 +111,7 @@ const flagColumn = ( sortValue: (m) => (m[config.key] ? 1 : 0), render: (m) => , renderEdit: (m) => , + menu: config.menu, }) const readOnlyBoolColumn = ( @@ -124,7 +133,15 @@ const confirmedColumnConfigs: FlagColumnConfig[] = [ draft.nameConfirmed ?? (hasNameDraftChange(m, draft) ? false : (m.nameConfirmed ?? false)), }, - { key: 'discordConfirmed', header: 'Discord Confirmed' }, + { + key: 'discordConfirmed', + header: 'Discord Confirmed', + resolveDraft: (m, draft) => + draft.discordConfirmed ?? + (hasDiscordDraftChange(m, draft) + ? false + : (m.discordConfirmed ?? false)), + }, { key: 'addressConfirmed', header: 'Address Confirmed', diff --git a/src/contracts/data/Membership.ts b/src/contracts/data/Membership.ts index 3eee8b50..8cf88306 100644 --- a/src/contracts/data/Membership.ts +++ b/src/contracts/data/Membership.ts @@ -32,14 +32,19 @@ export const zMembershipDeliverableStatus = z.enum(MembershipDeliverableStatus) export const zMembership = z.object({ donorEmail: z.string(), - membershipCardStatus: zMembershipDeliverableStatus, - membershipMerchStatus: zMembershipDeliverableStatus, shirtSize: zShirtSize.nullable(), duesPayingMember: z.boolean(), membershipFulfillmentStatus: zMembershipFulfillmentStatus, nameConfirmed: z.boolean(), + discordConfirmed: z.boolean(), addressConfirmed: z.boolean(), membershipBenefitEligible: z.boolean(), + cardPrinted: z.boolean(), + labelPrinted: z.boolean(), + itemsPackaged: z.boolean(), + benefitsShipped: z.boolean(), + membershipCardStatus: zMembershipDeliverableStatus, + membershipMerchStatus: zMembershipDeliverableStatus, }) export type Membership = z.infer diff --git a/src/contracts/requests/UpdateMembershipRequest.ts b/src/contracts/requests/UpdateMembershipRequest.ts index ff8b610e..5b0db6a9 100644 --- a/src/contracts/requests/UpdateMembershipRequest.ts +++ b/src/contracts/requests/UpdateMembershipRequest.ts @@ -7,14 +7,19 @@ import { zMutationRequest } from './MutationRequest' import z from 'zod' export const zUpdateMembershipRequest = zMutationRequest.extend({ - membershipCardStatus: zMembershipDeliverableStatus.optional(), - membershipMerchStatus: zMembershipDeliverableStatus.optional(), shirtSize: zShirtSize.nullish(), duesPayingMember: z.boolean().optional(), membershipFulfillmentStatus: zMembershipFulfillmentStatus.optional(), nameConfirmed: z.boolean().optional(), + discordConfirmed: z.boolean().optional(), addressConfirmed: z.boolean().optional(), membershipBenefitEligible: z.boolean().optional(), + cardPrinted: z.boolean().optional(), + labelPrinted: z.boolean().optional(), + itemsPackaged: z.boolean().optional(), + benefitsShipped: z.boolean().optional(), + membershipCardStatus: zMembershipDeliverableStatus.optional(), + membershipMerchStatus: zMembershipDeliverableStatus.optional(), }) export type UpdateMembershipRequest = z.infer diff --git a/src/contracts/responses/PaginatedResponse.ts b/src/contracts/responses/PaginatedResponse.ts index 9b0f3edd..b8d54c00 100644 --- a/src/contracts/responses/PaginatedResponse.ts +++ b/src/contracts/responses/PaginatedResponse.ts @@ -1,7 +1,5 @@ import z from 'zod' -// import { $ZodShape } from 'zod/v4/core' - export function zPaginatedResponse(zData: S) { return z.object({ page: z.number(), diff --git a/src/queries/users.queries.ts b/src/queries/users.queries.ts index 70d4a536..6c65ab48 100644 --- a/src/queries/users.queries.ts +++ b/src/queries/users.queries.ts @@ -1,6 +1,9 @@ import { ManualDonorLinkRequest } from '@/app/account/sections' import { User, zUser } from '@/contracts/data' -import { UpdateUserRequest } from '@/contracts/requests' +import { + UpdateMembershipRequest, + UpdateUserRequest, +} from '@/contracts/requests' import { FetchError } from '@/models' import { useFetch } from '@/util/hooks' import { skipToken, useMutation, useQueryClient } from '@tanstack/react-query' @@ -97,6 +100,25 @@ export function useUpdatedUser({ query: { orderId }, }) + // Confirming the donor link also confirms their Discord identity; + // don't fail the link if the donor has no membership record. + try { + await onPatch( + '/actblue/donors/:donorEmail/membership', + { + discordConfirmed: true, + metaData: { + userWhoUpdatedId: id, + dataSource: 'Account Page', + }, + } satisfies UpdateMembershipRequest, + null, + { params: { donorEmail } } + ) + } catch (error) { + console.error(error) + } + return await onGet('/users/:userId', zUser, { params: { userId: id }, query: { includeDonors: true }, diff --git a/src/util/hooks/useFetch.ts b/src/util/hooks/useFetch.ts index 203c8a21..7f3e319c 100644 --- a/src/util/hooks/useFetch.ts +++ b/src/util/hooks/useFetch.ts @@ -33,7 +33,7 @@ export function useFetch() { `Invalid fetch! Substitution key :${key} does not exist in params (value ${value})` ) - return encodeURIComponent(value) + return encodeURIComponent(value).replace(/%40/g, '@') } const addQueryParam = (url: URL, key: string, param: QueryParam) => { @@ -120,12 +120,26 @@ export function useFetch() { } if (!res.ok) { - const error = (await res.json()) as ApiError + const text = await res.text() + let error: ApiError = { + message: res.statusText || 'An error occurred', + error: null, + } + if (text.trim().length > 0) { + try { + error = JSON.parse(text) as ApiError + } catch { + error = { message: text, error: null } + } + } throw new FetchError(error.message, res.status, error.error) } + const text = await res.text() const content = - res.status === 204 ? undefined : ((await res.json()) as unknown) + res.status === 204 || text.trim().length === 0 + ? undefined + : (JSON.parse(text) as unknown) const parsed = z.parse(schema ?? z.undefined(), content) return parsed as S extends null ? void : z.infer From af16b9915858058765b8a1bf52f8e84796f3c180 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Thu, 10 Sep 2026 17:56:40 -0400 Subject: [PATCH 08/27] Fixing account page --- .../account/sections/AccountDetailsSection.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/app/account/sections/AccountDetailsSection.tsx b/src/app/account/sections/AccountDetailsSection.tsx index 618e1305..ebe91400 100644 --- a/src/app/account/sections/AccountDetailsSection.tsx +++ b/src/app/account/sections/AccountDetailsSection.tsx @@ -57,16 +57,19 @@ export function AccountDetailsSection({ setUpdatedUser(userData) }, [userData]) + // Mirrors the admin membership panel's "Package Shipped" values + // (Yes / No / Returned / Not Received / Canceled). const membershipDeliverableLabels: Record< MembershipDeliverableStatus, string > = { - [MembershipDeliverableStatus.NotEligible]: 'Not Eligible', - [MembershipDeliverableStatus.NotStarted]: 'Not Started', - [MembershipDeliverableStatus.Printed]: 'Printed', - [MembershipDeliverableStatus.InTransit]: 'In Transit', - [MembershipDeliverableStatus.Recieved]: 'Received', - [MembershipDeliverableStatus.Returned]: 'Returned (Update Address)', + [MembershipDeliverableStatus.NotEligible]: 'Requested To Cancel', + [MembershipDeliverableStatus.NotStarted]: 'Not Shipped Yet', + [MembershipDeliverableStatus.Printed]: 'Not Shipped Yet', + [MembershipDeliverableStatus.InTransit]: + 'Delivery Failed - Fix Address', + [MembershipDeliverableStatus.Recieved]: 'Card Shipped', + [MembershipDeliverableStatus.Returned]: 'Delivery Failed - Fix Address', } const normalizeEmail = (value?: string | null) => From c1e9a839a19dbe27e8d7c4bd60bd0c4812492dcd Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Sun, 13 Sep 2026 12:33:56 -0400 Subject: [PATCH 09/27] membership panel implementation --- src/app/admin/layout.tsx | 1 - src/app/admin/layout/Sidebar.tsx | 15 - .../components/ContributionsMenu.module.css | 129 +++++ .../components/ContributionsMenu.tsx | 329 +++++++++++ .../membership/components/Tags.module.css | 4 + .../panels/membership/membership.helpers.ts | 199 +++++++ .../panels/membership/membership.types.ts | 31 ++ .../panels/membership/membershipColumns.tsx | 109 +++- src/app/admin/panels/membership/page.tsx | 2 +- .../membershipexample/membership.data.ts | 279 ---------- .../panels/membershipexample/page.module.css | 239 -------- .../admin/panels/membershipexample/page.tsx | 519 ------------------ .../panels/membershiptest/membership.data.ts | 279 ---------- .../panels/membershiptest/page.module.css | 323 ----------- src/app/admin/panels/membershiptest/page.tsx | 491 ----------------- 15 files changed, 785 insertions(+), 2164 deletions(-) create mode 100644 src/app/admin/panels/membership/components/ContributionsMenu.module.css create mode 100644 src/app/admin/panels/membership/components/ContributionsMenu.tsx delete mode 100644 src/app/admin/panels/membershipexample/membership.data.ts delete mode 100644 src/app/admin/panels/membershipexample/page.module.css delete mode 100644 src/app/admin/panels/membershipexample/page.tsx delete mode 100644 src/app/admin/panels/membershiptest/membership.data.ts delete mode 100644 src/app/admin/panels/membershiptest/page.module.css delete mode 100644 src/app/admin/panels/membershiptest/page.tsx diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index ef0b59b5..46af8958 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -66,7 +66,6 @@ export default function Layout({ children }: { children: React.ReactNode }) { contributionCount={contributions.query.data?.count} endorsementCount={endorsements.data?.length} membershipCount={memberships.query.data?.count} - membershipExampleCount={memberships.query.data?.count} />
{children}
diff --git a/src/app/admin/layout/Sidebar.tsx b/src/app/admin/layout/Sidebar.tsx index d9c907ef..d41c13f6 100644 --- a/src/app/admin/layout/Sidebar.tsx +++ b/src/app/admin/layout/Sidebar.tsx @@ -26,7 +26,6 @@ interface SidebarProps { permissionCount?: number endorsementCount?: number membershipCount?: number - membershipExampleCount?: number } export default function Sidebar({ @@ -38,7 +37,6 @@ export default function Sidebar({ permissionCount, endorsementCount, membershipCount, - membershipExampleCount, }: SidebarProps) { const [open, setOpen] = useLocalStorage('pv.admin-nav-open', true) @@ -119,19 +117,6 @@ export default function Sidebar({ count={membershipCount} open={open} /> - -
- Entries Loaded: {members.length.toLocaleString()} + Showing {members.length.toLocaleString()} {totalEntries != null && ` of ${totalEntries.toLocaleString()}`}
diff --git a/src/app/admin/panels/membershipexample/membership.data.ts b/src/app/admin/panels/membershipexample/membership.data.ts deleted file mode 100644 index eaf63c46..00000000 --- a/src/app/admin/panels/membershipexample/membership.data.ts +++ /dev/null @@ -1,279 +0,0 @@ -export type MembershipTier = - | 'Dues Paying Member' - | 'Premium Member' - | 'Signature Member' - | 'Inner Circle Member' - -export type ShirtSize = 'XS' | 'S' | 'M' | 'L' | 'XL' | 'XXL' - -export type PackageShipped = - | 'Yes' - | 'No' - | 'Returned' - | 'Not Received' - | 'Canceled' - -export interface Member { - id: number - firstName?: string - lastName?: string - discordUsername?: string - phone?: string - email?: string - address1?: string - address2?: string - city?: string - state?: string - zip?: string - country?: string - shirtSize?: ShirtSize - - isMember?: boolean - eligibleForBenefits?: boolean - beganMembership?: string - membershipAmount?: number - membershipTier?: MembershipTier - numberOfContributions?: number - - discordConfirmed?: boolean - nameConfirmed?: boolean - addressConfirmed?: boolean - cardPrinted?: boolean - labelPrinted?: boolean - cardPacked?: boolean - benefitShipped?: boolean - packageShipped?: PackageShipped - - userMatched?: boolean -} - -export const members: Member[] = [ - { - id: 1, - firstName: 'Benjamin', - lastName: 'Gilbert-Lif', - email: 'benj11@me.com', - phone: '5613254822', - discordUsername: 'sleepy_porg', - address1: '6189 Vista Linda Ln', - city: 'Boca Raton', - state: 'Florida', - zip: '33433', - country: 'United States', - shirtSize: 'L', - isMember: true, - eligibleForBenefits: true, - beganMembership: '2024-08-01', - membershipAmount: 100, - membershipTier: 'Inner Circle Member', - numberOfContributions: 10, - discordConfirmed: true, - nameConfirmed: true, - addressConfirmed: true, - cardPrinted: true, - labelPrinted: true, - cardPacked: true, - benefitShipped: true, - packageShipped: 'Yes', - userMatched: true, - }, - { - id: 2, - firstName: 'Alex', - lastName: 'Richter', - email: 'alexandertherichter@gmail.com', - phone: '9524635015', - discordUsername: 'nopunchman', - address1: '5796 E 280th St', - city: 'Elko New Market', - state: 'Minnesota', - zip: '55020', - country: 'United States', - shirtSize: 'L', - isMember: true, - eligibleForBenefits: true, - beganMembership: '2024-09-13', - membershipAmount: 100, - membershipTier: 'Inner Circle Member', - numberOfContributions: 13, - discordConfirmed: true, - nameConfirmed: true, - addressConfirmed: true, - cardPrinted: true, - labelPrinted: true, - cardPacked: true, - benefitShipped: true, - packageShipped: 'Yes', - userMatched: true, - }, - { - id: 3, - firstName: 'Haley', - lastName: 'Hutson', - email: 'haleyhutson1996@gmail.com', - phone: '8177277036', - discordUsername: 'haleyhutson._30519', - address1: '3121 Nutmeg Ln', - city: 'Garland', - state: 'Texas', - zip: '75044', - country: 'United States', - isMember: false, - eligibleForBenefits: false, - beganMembership: '2025-10-06', - membershipAmount: 100, - membershipTier: 'Inner Circle Member', - numberOfContributions: 2, - discordConfirmed: true, - nameConfirmed: false, - addressConfirmed: false, - cardPrinted: false, - labelPrinted: false, - cardPacked: false, - benefitShipped: false, - packageShipped: 'No', - userMatched: false, - }, - { - id: 4, - firstName: 'Marshall', - lastName: 'Carter', - email: 'Headed9hydra@gmail.com ', - phone: '2526783567', - discordUsername: '9headedhydra', - address1: '7413 Pennbrook Ct.', - city: 'Chesterfield', - state: 'Virginia', - zip: '23832', - country: 'United States', - shirtSize: 'XXL', - isMember: true, - eligibleForBenefits: true, - beganMembership: '2025-09-22', - membershipAmount: 100, - membershipTier: 'Inner Circle Member', - numberOfContributions: 8, - discordConfirmed: true, - nameConfirmed: true, - addressConfirmed: true, - cardPrinted: true, - labelPrinted: true, - cardPacked: true, - benefitShipped: false, - packageShipped: 'No', - userMatched: true, - }, - { - id: 5, - firstName: 'Seafoam', - lastName: 'Handy', - email: '10seafoam10@gmail.com', - phone: '5189545956', - discordUsername: 's.eafo.am', - address1: '1 Kelly St', - city: 'Troy', - state: 'New York', - zip: '12180', - country: 'United States', - isMember: true, - eligibleForBenefits: true, - beganMembership: '2025-12-27', - membershipAmount: 10, - membershipTier: 'Premium Member', - numberOfContributions: 8, - discordConfirmed: true, - nameConfirmed: true, - addressConfirmed: true, - cardPrinted: false, - labelPrinted: false, - cardPacked: false, - benefitShipped: false, - packageShipped: 'No', - userMatched: false, - }, - { - id: 6, - firstName: 'Conner', - lastName: 'Hill', - email: 'connerhill05@icloud.com', - phone: '4252322522', - discordUsername: 'mxtallical', - address1: '11509 128th dr NE', - city: 'Lake Stevens', - state: 'Washington', - zip: '98258', - country: 'United States', - isMember: true, - eligibleForBenefits: true, - beganMembership: '2025-07-12', - membershipAmount: 20, - membershipTier: 'Signature Member', - numberOfContributions: 5, - discordConfirmed: true, - nameConfirmed: true, - addressConfirmed: false, - cardPrinted: true, - labelPrinted: false, - cardPacked: false, - benefitShipped: false, - packageShipped: 'Not Received', - userMatched: false, - }, - { - id: 7, - firstName: 'Jayden', - lastName: 'Stevens', - email: 'stevens.jayden.a@gmail.com', - phone: '7753439011', - discordUsername: 'brewmastercraft', - address1: '4959 Talbot Ln', - address2: 'Apt 37', - city: 'Reno', - state: 'Nevada', - zip: '89509', - country: 'United States', - isMember: false, - eligibleForBenefits: true, - beganMembership: '2025-05-11', - membershipAmount: 20, - membershipTier: 'Signature Member', - numberOfContributions: 14, - discordConfirmed: true, - nameConfirmed: true, - addressConfirmed: false, - cardPrinted: true, - labelPrinted: false, - cardPacked: false, - benefitShipped: false, - packageShipped: 'Canceled', - userMatched: false, - }, - { - id: 8, - firstName: 'Riley', - lastName: 'Pusins', - email: 'rileypusins@gmail.com', - phone: '8139974153', - discordUsername: 'Riley Pusins', - address1: '1901 Honeysuckle', - city: 'Tallahassee', - state: 'Florida', - zip: '32304', - country: 'United States', - isMember: false, - eligibleForBenefits: true, - beganMembership: '2025-11-28', - membershipAmount: 5, - membershipTier: 'Dues Paying Member', - numberOfContributions: 5, - discordConfirmed: false, - nameConfirmed: false, - addressConfirmed: false, - cardPrinted: false, - labelPrinted: false, - cardPacked: false, - benefitShipped: false, - packageShipped: 'No', - userMatched: false, - }, -] diff --git a/src/app/admin/panels/membershipexample/page.module.css b/src/app/admin/panels/membershipexample/page.module.css deleted file mode 100644 index b3352b41..00000000 --- a/src/app/admin/panels/membershipexample/page.module.css +++ /dev/null @@ -1,239 +0,0 @@ -.panelContents { - width: 100%; - min-width: 0; - height: calc(100dvh - 100px); - flex: 1 1 auto; - min-height: 0; - display: flex; - flex-direction: column; - overflow: hidden; - padding: 0; -} - -.scrollView { - flex: 1 1 auto; - min-height: 0; - overflow-x: hidden; - overflow-y: auto; - padding: 0 0 1rem; -} - -.panelHeader { - display: flex; - align-items: center; - justify-content: space-between; - padding: 1.35rem 1.25rem; - background-color: #f9fafb; - backdrop-filter: blur(14px) saturate(150%); - -webkit-backdrop-filter: blur(14px) saturate(150%); - border-bottom: 2px solid #e5e7eb; - box-shadow: 0 1px 0 rgba(255, 255, 255, 0.75) inset; -} - -.panelTimestamp { - font-size: 0.78rem; - font-weight: 700; - color: rgba(15, 23, 42, 0.45); -} - -.breadcrumbs { - display: inline-flex; - align-items: center; - gap: 0.5rem; - font-size: 0.86rem; - letter-spacing: -0.01em; - color: rgba(15, 23, 42, 0.72); - user-select: none; -} - -.prominentBreadcrumb { - font-weight: 700; - color: rgba(15, 23, 42, 0.72); -} - -.breadcrumbSeperator { - color: rgba(15, 23, 42, 0.35); - font-weight: 700; -} - -.panelBreadcrumb { - font-weight: 650; - color: rgba(15, 23, 42, 0.55); -} - -.galleryHeader { - padding: 1.15rem 1.05rem 0.35rem; -} - -.galleryTitle { - margin: 0; - font-size: 1.45rem; - font-weight: 800; - letter-spacing: -0.02em; - color: rgba(9, 34, 58, 0.9); -} - -.gallerySubTitle { - margin: 0.35rem 0 0; - font-size: 0.95rem; - font-weight: 600; - letter-spacing: -0.01em; - color: rgba(15, 23, 42, 0.55); -} - -.tableWrapper { - padding: 0.75rem 1.05rem 1.5rem; - overflow-x: auto; -} - -.loadMore { - min-height: 1px; - padding: 0.75rem 0; - color: rgba(15, 23, 42, 0.55); - font-size: 0.85rem; - font-weight: 600; - text-align: center; -} - -.tableToolbar { - display: flex; - justify-content: flex-end; - padding: 0.5rem 1.05rem 0; -} - -.tableOptionsBox { - min-width: 14rem; - background: rgba(255, 255, 255, 0.96); -} - -.tableOptionsBody { - display: grid; - grid-template-columns: 1fr; - gap: 0.55rem; -} - -.tableOptionRow { - display: grid; - gap: 0.3rem; - padding: 0 0.15rem; -} - -.tableOptionLabel { - font-size: 0.72rem; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - color: rgba(15, 23, 42, 0.58); -} - -.nameCell { - display: inline-flex; - align-items: center; - gap: 0.3rem; -} - -.nameVerified { - color: rgba(17, 115, 75, 1); - font-size: 0.6rem; - flex-shrink: 0; - background: rgba(112, 195, 32, 0.3); - border-radius: 50%; - padding: 0.15rem; -} - -.nameUnverified { - color: rgba(177, 2, 2, 1); - font-size: 0.6rem; - flex-shrink: 0; - background: rgba(255, 95, 75, 0.3); - border-radius: 50%; - padding: 0.15rem; -} - -.tag { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 2.8rem; - font-size: 0.75rem; - font-weight: 700; - padding: 0.2rem 0.6rem; - border-radius: 9999px; - white-space: nowrap; - color: rgba(15, 23, 42, 0.72); - background: rgba(15, 23, 42, 0.06); -} - -.tagRed { - background: rgba(255, 95, 75, 0.3); - color: rgba(177, 2, 2, 1); -} -.tagOrange { - background: rgba(255, 72, 0, 0.3); - color: rgba(117, 56, 0, 1); -} -.tagYellow { - background: rgba(255, 168, 0, 0.3); - color: rgba(71, 56, 33, 1); -} -.tagGreen { - background: rgba(112, 195, 32, 0.3); - color: rgba(17, 115, 75, 1); -} -.tagBlue { - background: rgba(42, 155, 225, 0.3); - color: rgba(10, 83, 168, 1); -} -.tagPurple { - background: rgba(172, 95, 212, 0.3); - color: rgba(90, 50, 134, 1); -} -.tagDarkRed { - background: rgba(177, 2, 2, 1); - color: rgba(255, 207, 201, 1); -} -.tagMember { - background: #ffe5ec; - color: rgba(177, 2, 2, 1); -} -.tagPremium { - background: #ffe5ec; - color: rgb(223, 40, 76); -} -.tagSignature { - background: #ffa7cd; - color: rgba(177, 2, 2, 1); -} -.tagInnerCircle { - background: #da93ff; - color: rgba(90, 50, 134, 1); -} - -.tagTier { - width: 10rem; -} - -.tagWide { - min-width: 6.5rem; - font-size: 0.65rem; - padding: 0.1rem 0.5rem; - position: relative; - top: -2px; -} - -.tagFulfilled { - min-width: auto; - width: calc(100% - 8px); - font-size: 0.65rem; - padding: 0.1rem 0.5rem; - position: relative; - top: -2px; -} - -@media (max-width: 53rem) { - .panelHeader { - flex-direction: column; - align-items: flex-start; - gap: 0.4rem; - } -} diff --git a/src/app/admin/panels/membershipexample/page.tsx b/src/app/admin/panels/membershipexample/page.tsx deleted file mode 100644 index 10e01093..00000000 --- a/src/app/admin/panels/membershipexample/page.tsx +++ /dev/null @@ -1,519 +0,0 @@ -'use client' - -import { - members, - Member, - MembershipTier, - ShirtSize, - PackageShipped, -} from './membership.data' -import styles from './page.module.css' -import { - DropdownButton, - DropdownOverlay, - ToggleGroup, -} from '@/components/common' -import { Table, Column, ColumnEntry } from '@/components/common/table' -import { useState } from 'react' -import { FiCheck, FiX } from 'react-icons/fi' - -const Check = () => -const Cross = () => - -const BoolTag = ({ value }: { value?: boolean }) => - value ? ( - - - - ) : ( - - - - ) - -const shirtSizeClass: Record = { - XS: styles.tagRed, - S: styles.tagOrange, - M: styles.tagYellow, - L: styles.tagGreen, - XL: styles.tagBlue, - XXL: styles.tagPurple, -} - -const membershipTierClass: Record = { - 'Dues Paying Member': styles.tagMember, - 'Premium Member': styles.tagPremium, - 'Signature Member': styles.tagSignature, - 'Inner Circle Member': styles.tagInnerCircle, -} - -const packageShippedClass: Record = { - Yes: styles.tagGreen, - No: styles.tagRed, - Returned: styles.tagYellow, - 'Not Received': styles.tagDarkRed, - Canceled: styles.tagBlue, -} - -const formatPhone = (phone?: string) => { - if (!phone) return '—' - const digits = phone.replace(/\D/g, '') - if (digits.length === 10) { - return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}` - } - return phone -} - -const statusDotColor = (m: Member, col: Column): string => { - const boolFields: Record = { - discordConfirmed: m.discordConfirmed, - nameConfirmed: m.nameConfirmed, - addressConfirmed: m.addressConfirmed, - cardPrinted: m.cardPrinted, - labelPrinted: m.labelPrinted, - cardPacked: m.cardPacked, - benefitShipped: m.benefitShipped, - } - if (col.key === 'packageShipped') { - const colors: Record = { - Yes: 'rgba(112, 195, 32, 0.6)', - No: 'rgba(255, 95, 75, 0.6)', - Returned: 'rgba(255, 168, 0, 0.6)', - 'Not Received': 'rgba(177, 2, 2, 1)', - Canceled: 'rgba(42, 155, 225, 0.6)', - } - return colors[m.packageShipped ?? 'No'] - } - return boolFields[col.key] - ? 'rgba(112, 195, 32, 0.6)' - : 'rgba(255, 95, 75, 0.6)' -} - -const confirmedColumns: Column[] = [ - { - key: 'discordConfirmed', - header: 'Discord Confirmed', - width: '5rem', - sortValue: (m: Member) => (m.discordConfirmed ? 1 : 0), - render: (m: Member) => , - }, - { - key: 'nameConfirmed', - header: 'Name Confirmed', - width: '5rem', - sortValue: (m: Member) => (m.nameConfirmed ? 1 : 0), - render: (m: Member) => , - }, - { - key: 'addressConfirmed', - header: 'Address Confirmed', - width: '5rem', - sortValue: (m: Member) => (m.addressConfirmed ? 1 : 0), - render: (m: Member) => , - }, -] - -const statusColumns: Column[] = [ - { - key: 'eligibleForBenefits', - header: 'Eligible', - width: '5rem', - sortValue: (m: Member) => (m.eligibleForBenefits ? 1 : 0), - render: (m: Member) => , - }, - { - key: 'isMember', - header: 'Member', - width: '5rem', - sortValue: (m: Member) => (m.isMember ? 1 : 0), - render: (m: Member) => , - }, -] - -const buildColumns = ( - showConfirmed: boolean, - showFulfilled: boolean, - showStatus: boolean -): ColumnEntry[] => [ - ...(showStatus ? statusColumns : ([] as Column[])), - { - key: 'membershipTier', - header: 'Tier', - width: '11rem', - sortValue: (m: Member) => m.membershipTier ?? '', - render: (m: Member) => - m.membershipTier ? ( - - {m.membershipTier} - - ) : ( - '—' - ), - }, - { - label: 'Fulfillment', - collapsedWidth: '6rem', - dotColor: statusDotColor, - rowRender: showFulfilled - ? (m: Member) => { - const allFulfilled = - (!showConfirmed || - (m.discordConfirmed && - m.nameConfirmed && - m.addressConfirmed)) && - m.cardPrinted && - m.labelPrinted && - m.cardPacked && - m.benefitShipped && - m.packageShipped === 'Yes' - if (!allFulfilled) return null - return ( - - Benefits Fulfilled - - ) - } - : undefined, - columns: [ - ...(showConfirmed ? confirmedColumns : ([] as Column[])), - { - key: 'cardPrinted', - header: 'Card Printed', - width: '5rem', - sortValue: (m: Member) => (m.cardPrinted ? 1 : 0), - render: (m: Member) => , - }, - { - key: 'labelPrinted', - header: 'Label Printed', - width: '5rem', - sortValue: (m: Member) => (m.labelPrinted ? 1 : 0), - render: (m: Member) => , - }, - { - key: 'cardPacked', - header: 'Packed', - width: '5rem', - sortValue: (m: Member) => (m.cardPacked ? 1 : 0), - render: (m: Member) => , - }, - { - key: 'benefitShipped', - header: 'Benefit Shipped', - width: '5rem', - sortValue: (m: Member) => (m.benefitShipped ? 1 : 0), - render: (m: Member) => , - }, - { - key: 'packageShipped', - header: 'Package Shipped', - width: '7rem', - sortValue: (m: Member) => m.packageShipped ?? '', - render: (m: Member) => - m.packageShipped ? ( - - {m.packageShipped} - - ) : ( - '—' - ), - }, - ], - }, - - { - key: 'name', - header: 'Name', - width: '11rem', - sortValue: (m: Member) => - [m.firstName, m.lastName].filter(Boolean).join(' '), - render: (m: Member) => ( - - {[m.firstName, m.lastName].filter(Boolean).join(' ') || '—'} - {m.nameConfirmed ? ( - - ) : ( - - )} - - ), - }, - { - key: 'discord', - header: 'Discord', - width: '11rem', - sortValue: (m: Member) => m.discordUsername ?? '', - render: (m: Member) => ( - - {m.discordUsername ? `@${m.discordUsername}` : '—'} - {m.discordConfirmed ? ( - - ) : ( - - )} - - ), - }, - { - key: 'address', - header: 'Address', - width: '40rem', - sortValue: (m: Member) => m.state ?? m.city ?? '', - render: (m: Member) => { - const parts = [ - [m.address1, m.address2].filter(Boolean).join(', '), - [m.city, [m.state, m.zip].filter(Boolean).join(' ')] - .filter(Boolean) - .join(', '), - m.country, - ].filter(Boolean) - return ( - - {parts.join(', ') || '—'} - {m.addressConfirmed ? ( - - ) : ( - - )} - - ) - }, - }, - { - key: 'beganMembership', - header: 'Member Since', - sortValue: (m: Member) => m.beganMembership ?? '', - render: (m: Member) => { - if (!m.beganMembership) return '—' - const [y, mo, d] = m.beganMembership.split('-') - return `${mo}-${d}-${y}` - }, - }, - { - key: 'phone', - header: 'Phone', - sortValue: (m: Member) => m.phone ?? '', - render: (m: Member) => formatPhone(m.phone), - }, - { - key: 'email', - header: 'Email', - sortValue: (m: Member) => m.email ?? '', - render: (m: Member) => m.email ?? '—', - }, - { - key: 'shirtSize', - header: 'Shirt', - width: '5rem', - sortValue: (m: Member) => m.shirtSize ?? '', - render: (m: Member) => - m.shirtSize ? ( - - {m.shirtSize} - - ) : ( - '—' - ), - }, - - { - key: 'membershipAmount', - header: 'Amount', - width: '6rem', - sortValue: (m: Member) => m.membershipAmount ?? 0, - render: (m: Member) => - m.membershipAmount != null ? `$${m.membershipAmount}` : '—', - }, - { - key: 'contributions', - header: 'Contributions', - width: '6rem', - sortValue: (m: Member) => m.numberOfContributions ?? 0, - render: (m: Member) => m.numberOfContributions?.toString() ?? '—', - }, - { - key: 'userMatched', - header: 'User Matched', - width: '5rem', - sortValue: (m: Member) => (m.userMatched ? 1 : 0), - render: (m: Member) => , - }, -] - -export default function Page() { - const [showConfirmed, setShowConfirmed] = useState(false) - const [showFulfilled, setShowFulfilled] = useState(true) - const [collapseFulfillment, setCollapseFulfillment] = useState(false) - const [showStatus, setShowStatus] = useState(false) - const columns = buildColumns(showConfirmed, showFulfilled, showStatus) - const collapsedCategories = collapseFulfillment ? ['Fulfillment'] : [] - - return ( -
-
-
- Admin - / - - Membership Example - -
- - {/* logic will eventually need to be reworked to show last api fetch and not most recent contribution date */} -
Last Updated: N/A
-
-
-
-

Membership Example

-

- Manage membership records and details. -

-
- -
- ( - -
- - Status columns - - - ariaLabel="Member Eligibility Columns" - orientation="horizontal" - value={showStatus} - options={[ - { - value: true, - label: 'Show', - }, - { - value: false, - label: 'Hide', - }, - ]} - onChange={setShowStatus} - /> -
-
- - Confirmed columns - - - ariaLabel="Member Details Confirmed Columns" - orientation="horizontal" - value={showConfirmed} - options={[ - { - value: true, - label: 'Show', - }, - { - value: false, - label: 'Hide', - }, - ]} - onChange={setShowConfirmed} - /> -
-
- - Benefits fulfilled tag - - - ariaLabel="Tag Groups" - orientation="horizontal" - value={showFulfilled} - options={[ - { - value: true, - label: 'Show', - }, - { - value: false, - label: 'Hide', - }, - ]} - onChange={setShowFulfilled} - /> -
-
- - Fulfillment columns - - - ariaLabel="Fulfillment columns" - orientation="horizontal" - value={collapseFulfillment} - options={[ - { - value: false, - label: 'Expand', - }, - { - value: true, - label: 'Collapse', - }, - ]} - onChange={ - setCollapseFulfillment - } - /> -
- - } - /> - )} - /> -
- -
-
m.id} - collapsedCategories={collapsedCategories} - /> - - - - ) -} diff --git a/src/app/admin/panels/membershiptest/membership.data.ts b/src/app/admin/panels/membershiptest/membership.data.ts deleted file mode 100644 index eaf63c46..00000000 --- a/src/app/admin/panels/membershiptest/membership.data.ts +++ /dev/null @@ -1,279 +0,0 @@ -export type MembershipTier = - | 'Dues Paying Member' - | 'Premium Member' - | 'Signature Member' - | 'Inner Circle Member' - -export type ShirtSize = 'XS' | 'S' | 'M' | 'L' | 'XL' | 'XXL' - -export type PackageShipped = - | 'Yes' - | 'No' - | 'Returned' - | 'Not Received' - | 'Canceled' - -export interface Member { - id: number - firstName?: string - lastName?: string - discordUsername?: string - phone?: string - email?: string - address1?: string - address2?: string - city?: string - state?: string - zip?: string - country?: string - shirtSize?: ShirtSize - - isMember?: boolean - eligibleForBenefits?: boolean - beganMembership?: string - membershipAmount?: number - membershipTier?: MembershipTier - numberOfContributions?: number - - discordConfirmed?: boolean - nameConfirmed?: boolean - addressConfirmed?: boolean - cardPrinted?: boolean - labelPrinted?: boolean - cardPacked?: boolean - benefitShipped?: boolean - packageShipped?: PackageShipped - - userMatched?: boolean -} - -export const members: Member[] = [ - { - id: 1, - firstName: 'Benjamin', - lastName: 'Gilbert-Lif', - email: 'benj11@me.com', - phone: '5613254822', - discordUsername: 'sleepy_porg', - address1: '6189 Vista Linda Ln', - city: 'Boca Raton', - state: 'Florida', - zip: '33433', - country: 'United States', - shirtSize: 'L', - isMember: true, - eligibleForBenefits: true, - beganMembership: '2024-08-01', - membershipAmount: 100, - membershipTier: 'Inner Circle Member', - numberOfContributions: 10, - discordConfirmed: true, - nameConfirmed: true, - addressConfirmed: true, - cardPrinted: true, - labelPrinted: true, - cardPacked: true, - benefitShipped: true, - packageShipped: 'Yes', - userMatched: true, - }, - { - id: 2, - firstName: 'Alex', - lastName: 'Richter', - email: 'alexandertherichter@gmail.com', - phone: '9524635015', - discordUsername: 'nopunchman', - address1: '5796 E 280th St', - city: 'Elko New Market', - state: 'Minnesota', - zip: '55020', - country: 'United States', - shirtSize: 'L', - isMember: true, - eligibleForBenefits: true, - beganMembership: '2024-09-13', - membershipAmount: 100, - membershipTier: 'Inner Circle Member', - numberOfContributions: 13, - discordConfirmed: true, - nameConfirmed: true, - addressConfirmed: true, - cardPrinted: true, - labelPrinted: true, - cardPacked: true, - benefitShipped: true, - packageShipped: 'Yes', - userMatched: true, - }, - { - id: 3, - firstName: 'Haley', - lastName: 'Hutson', - email: 'haleyhutson1996@gmail.com', - phone: '8177277036', - discordUsername: 'haleyhutson._30519', - address1: '3121 Nutmeg Ln', - city: 'Garland', - state: 'Texas', - zip: '75044', - country: 'United States', - isMember: false, - eligibleForBenefits: false, - beganMembership: '2025-10-06', - membershipAmount: 100, - membershipTier: 'Inner Circle Member', - numberOfContributions: 2, - discordConfirmed: true, - nameConfirmed: false, - addressConfirmed: false, - cardPrinted: false, - labelPrinted: false, - cardPacked: false, - benefitShipped: false, - packageShipped: 'No', - userMatched: false, - }, - { - id: 4, - firstName: 'Marshall', - lastName: 'Carter', - email: 'Headed9hydra@gmail.com ', - phone: '2526783567', - discordUsername: '9headedhydra', - address1: '7413 Pennbrook Ct.', - city: 'Chesterfield', - state: 'Virginia', - zip: '23832', - country: 'United States', - shirtSize: 'XXL', - isMember: true, - eligibleForBenefits: true, - beganMembership: '2025-09-22', - membershipAmount: 100, - membershipTier: 'Inner Circle Member', - numberOfContributions: 8, - discordConfirmed: true, - nameConfirmed: true, - addressConfirmed: true, - cardPrinted: true, - labelPrinted: true, - cardPacked: true, - benefitShipped: false, - packageShipped: 'No', - userMatched: true, - }, - { - id: 5, - firstName: 'Seafoam', - lastName: 'Handy', - email: '10seafoam10@gmail.com', - phone: '5189545956', - discordUsername: 's.eafo.am', - address1: '1 Kelly St', - city: 'Troy', - state: 'New York', - zip: '12180', - country: 'United States', - isMember: true, - eligibleForBenefits: true, - beganMembership: '2025-12-27', - membershipAmount: 10, - membershipTier: 'Premium Member', - numberOfContributions: 8, - discordConfirmed: true, - nameConfirmed: true, - addressConfirmed: true, - cardPrinted: false, - labelPrinted: false, - cardPacked: false, - benefitShipped: false, - packageShipped: 'No', - userMatched: false, - }, - { - id: 6, - firstName: 'Conner', - lastName: 'Hill', - email: 'connerhill05@icloud.com', - phone: '4252322522', - discordUsername: 'mxtallical', - address1: '11509 128th dr NE', - city: 'Lake Stevens', - state: 'Washington', - zip: '98258', - country: 'United States', - isMember: true, - eligibleForBenefits: true, - beganMembership: '2025-07-12', - membershipAmount: 20, - membershipTier: 'Signature Member', - numberOfContributions: 5, - discordConfirmed: true, - nameConfirmed: true, - addressConfirmed: false, - cardPrinted: true, - labelPrinted: false, - cardPacked: false, - benefitShipped: false, - packageShipped: 'Not Received', - userMatched: false, - }, - { - id: 7, - firstName: 'Jayden', - lastName: 'Stevens', - email: 'stevens.jayden.a@gmail.com', - phone: '7753439011', - discordUsername: 'brewmastercraft', - address1: '4959 Talbot Ln', - address2: 'Apt 37', - city: 'Reno', - state: 'Nevada', - zip: '89509', - country: 'United States', - isMember: false, - eligibleForBenefits: true, - beganMembership: '2025-05-11', - membershipAmount: 20, - membershipTier: 'Signature Member', - numberOfContributions: 14, - discordConfirmed: true, - nameConfirmed: true, - addressConfirmed: false, - cardPrinted: true, - labelPrinted: false, - cardPacked: false, - benefitShipped: false, - packageShipped: 'Canceled', - userMatched: false, - }, - { - id: 8, - firstName: 'Riley', - lastName: 'Pusins', - email: 'rileypusins@gmail.com', - phone: '8139974153', - discordUsername: 'Riley Pusins', - address1: '1901 Honeysuckle', - city: 'Tallahassee', - state: 'Florida', - zip: '32304', - country: 'United States', - isMember: false, - eligibleForBenefits: true, - beganMembership: '2025-11-28', - membershipAmount: 5, - membershipTier: 'Dues Paying Member', - numberOfContributions: 5, - discordConfirmed: false, - nameConfirmed: false, - addressConfirmed: false, - cardPrinted: false, - labelPrinted: false, - cardPacked: false, - benefitShipped: false, - packageShipped: 'No', - userMatched: false, - }, -] diff --git a/src/app/admin/panels/membershiptest/page.module.css b/src/app/admin/panels/membershiptest/page.module.css deleted file mode 100644 index b1efaf7f..00000000 --- a/src/app/admin/panels/membershiptest/page.module.css +++ /dev/null @@ -1,323 +0,0 @@ -.panelContents { - width: 100%; - min-width: 0; - height: calc(100dvh - 100px); - flex: 1 1 auto; - min-height: 0; - display: flex; - flex-direction: column; - overflow: hidden; - padding: 0; -} - -.scrollView { - flex: 1 1 auto; - min-height: 0; - overflow-x: hidden; - overflow-y: auto; - padding: 0 0 1rem; -} - -.panelHeader { - display: flex; - align-items: center; - justify-content: space-between; - padding: 1.35rem 1.25rem; - background-color: #f9fafb; - backdrop-filter: blur(14px) saturate(150%); - -webkit-backdrop-filter: blur(14px) saturate(150%); - border-bottom: 2px solid #e5e7eb; - box-shadow: 0 1px 0 rgba(255, 255, 255, 0.75) inset; -} - -.panelTimestamp { - font-size: 0.78rem; - font-weight: 700; - color: rgba(15, 23, 42, 0.45); -} - -.breadcrumbs { - display: inline-flex; - align-items: center; - gap: 0.5rem; - font-size: 0.86rem; - letter-spacing: -0.01em; - color: rgba(15, 23, 42, 0.72); - user-select: none; -} - -.prominentBreadcrumb { - font-weight: 700; - color: rgba(15, 23, 42, 0.72); -} - -.breadcrumbSeperator { - color: rgba(15, 23, 42, 0.35); - font-weight: 700; -} - -.panelBreadcrumb { - font-weight: 650; - color: rgba(15, 23, 42, 0.55); -} - -.galleryHeader { - padding: 1.15rem 1.05rem 0.35rem; -} - -.galleryTitle { - margin: 0; - font-size: 1.45rem; - font-weight: 800; - letter-spacing: -0.02em; - color: rgba(9, 34, 58, 0.9); -} - -.gallerySubTitle { - margin: 0.35rem 0 0; - font-size: 0.95rem; - font-weight: 600; - letter-spacing: -0.01em; - color: rgba(15, 23, 42, 0.55); -} - -.tableWrapper { - padding: 0.75rem 1.05rem 1.5rem; - overflow-x: auto; -} - -.loadMore { - min-height: 1px; - padding: 0.75rem 0; - color: rgba(15, 23, 42, 0.55); - font-size: 0.85rem; - font-weight: 600; - text-align: center; -} - -.tableToolbar { - display: flex; - justify-content: flex-end; - padding: 0.5rem 1.05rem 0; -} - -.tableOptionsBox { - min-width: 14rem; - background: rgba(255, 255, 255, 0.96); -} - -.tableOptionsBody { - display: grid; - grid-template-columns: 1fr; - gap: 0.55rem; -} - -.tableOptionRow { - display: grid; - gap: 0.3rem; - padding: 0 0.15rem; -} - -.tableOptionLabel { - font-size: 0.72rem; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - color: rgba(15, 23, 42, 0.58); -} - -.nameCell { - display: inline-flex; - align-items: center; - gap: 0.3rem; - max-width: 100%; - min-width: 0; - - & > span:first-child { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } -} - -.nameCellSplit { - width: 100%; -} - -.cellDropdown { - display: inline-flex; - align-items: center; - flex-shrink: 0; - position: relative; - z-index: 0; - margin-left: auto; - - & > div { - display: flex; - align-items: center; - } - - &:has(button[aria-expanded='true']) { - z-index: 1; - } -} - -.cellDropdownButton { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0; - border: 0; - background: transparent; - font-size: inherit; - line-height: 1; - color: rgba(15, 23, 42, 0.5); - cursor: pointer; - - &:hover { - color: rgba(15, 23, 42, 0.9); - } -} - -.nameOverlay { - min-width: 18rem; -} - -.nameOverlayBody { - display: grid; - gap: 0.5rem; -} - -.nameSourceRow { - display: grid; - grid-template-columns: 4rem minmax(0, 1fr); - align-items: center; - gap: 0.5rem; - font-size: 0.85rem; - color: rgba(15, 23, 42, 0.86); -} - -.nameSourceLabel { - font-size: 0.72rem; - font-weight: 700; - color: rgba(15, 23, 42, 0.56); - text-transform: uppercase; -} - -.tagGray { - background: rgba(15, 23, 42, 0.06); - color: rgba(15, 23, 42, 0.72); -} - -.tagGhost { - background: transparent; - color: rgba(15, 23, 42, 0.4); - border: 1px dashed rgba(15, 23, 42, 0.2); -} - -.nameVerified { - color: rgba(17, 115, 75, 1); - font-size: 0.6rem; - flex-shrink: 0; - background: rgba(112, 195, 32, 0.3); - border-radius: 50%; - padding: 0.15rem; -} - -.nameUnverified { - color: rgba(177, 2, 2, 1); - font-size: 0.6rem; - flex-shrink: 0; - background: rgba(255, 95, 75, 0.3); - border-radius: 50%; - padding: 0.15rem; -} - -.tag { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 2.8rem; - font-size: 0.75rem; - font-weight: 700; - padding: 0.2rem 0.6rem; - border-radius: 9999px; - white-space: nowrap; - color: rgba(15, 23, 42, 0.72); - background: rgba(15, 23, 42, 0.06); -} - -.tagRed { - background: rgba(255, 95, 75, 0.3); - color: rgba(177, 2, 2, 1); -} -.tagOrange { - background: rgba(255, 72, 0, 0.3); - color: rgba(117, 56, 0, 1); -} -.tagYellow { - background: rgba(255, 168, 0, 0.3); - color: rgba(71, 56, 33, 1); -} -.tagGreen { - background: rgba(112, 195, 32, 0.3); - color: rgba(17, 115, 75, 1); -} -.tagBlue { - background: rgba(42, 155, 225, 0.3); - color: rgba(10, 83, 168, 1); -} -.tagPurple { - background: rgba(172, 95, 212, 0.3); - color: rgba(90, 50, 134, 1); -} -.tagDarkRed { - background: rgba(177, 2, 2, 1); - color: rgba(255, 207, 201, 1); -} -.tagMember { - background: #ffe5ec; - color: rgba(177, 2, 2, 1); -} -.tagPremium { - background: #ffe5ec; - color: rgb(223, 40, 76); -} -.tagSignature { - background: #ffa7cd; - color: rgba(177, 2, 2, 1); -} -.tagInnerCircle { - background: #da93ff; - color: rgba(90, 50, 134, 1); -} - -.tagTier { - width: 10rem; -} - -.tagWide { - min-width: 6.5rem; - font-size: 0.65rem; - padding: 0.1rem 0.5rem; - position: relative; - top: -2px; -} - -.tagFulfilled { - min-width: auto; - width: calc(100% - 8px); - font-size: 0.65rem; - padding: 0.1rem 0.5rem; - position: relative; - top: -2px; -} - -@media (max-width: 53rem) { - .panelHeader { - flex-direction: column; - align-items: flex-start; - gap: 0.4rem; - } -} diff --git a/src/app/admin/panels/membershiptest/page.tsx b/src/app/admin/panels/membershiptest/page.tsx deleted file mode 100644 index 330f0376..00000000 --- a/src/app/admin/panels/membershiptest/page.tsx +++ /dev/null @@ -1,491 +0,0 @@ -'use client' - -import styles from './page.module.css' -import { DropdownButton, DropdownOverlay } from '@/components/common' -import { Table, ColumnEntry } from '@/components/common/table' -import { - MembershipsResponsePacket, - zMembershipsResponsePacket, - zPaginatedResponse, -} from '@/contracts/responses' -import { cn } from '@/util' -import { useFetch } from '@/util/hooks' -import { useInfiniteQuery } from '@tanstack/react-query' -import { useEffect, useRef } from 'react' -import { FiCheck, FiX } from 'react-icons/fi' -import { IoEllipsisVerticalCircleOutline } from 'react-icons/io5' - -interface MembershipRow { - id: string - donorName?: string - userName?: string - discordUsername?: string - customField?: string - membershipEmail?: string - donorEmail?: string - userEmail?: string - donorAddress?: string - userAddress?: string - donorPhone?: string - userPhone?: string - membershipTier?: string - contributionCount?: number - isRecurring?: boolean - duesPayingMember?: boolean - membershipBenefitEligible?: boolean - membershipCardStatus?: number - membershipMerchStatus?: number - nameConfirmed?: boolean - addressConfirmed?: boolean -} - -const formatPhone = (phone?: string) => { - if (!phone) return '—' - const digits = phone.replace(/\D/g, '') - if (digits.length === 10) { - return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}` - } - return phone -} - -const NameTag = ({ name }: { name?: string }) => - name ? ( - - {name} - - ) : ( - - Unfilled - - ) - -const NameMenu = ({ - row, - closeDropdown, -}: { - row: MembershipRow - closeDropdown: () => void -}) => ( - -
- User - -
-
- Donor - -
- - } - /> -) - -const buildColumns = (): ColumnEntry[] => [ - { - key: 'rowNumber', - header: '#', - width: '3rem', - render: (_m: MembershipRow, index: number) => index + 1, - }, - { - key: 'donorName', - header: 'Donor Name', - width: '11rem', - sortValue: (m: MembershipRow) => m.donorName ?? '', - render: (m: MembershipRow) => m.donorName ?? '—', - }, - { - key: 'userName', - header: 'User Name', - width: '11rem', - allowOverflow: true, - sortValue: (m: MembershipRow) => m.userName ?? '', - render: (m: MembershipRow) => ( - - {m.userName ?? '—'} - {m.userName && - (m.nameConfirmed ? ( - - ) : ( - - ))} - - ( - - )} - > - - - - - ), - }, - { - key: 'discordUsername', - header: 'Discord Username', - width: '11rem', - sortValue: (m: MembershipRow) => m.discordUsername ?? '', - render: (m: MembershipRow) => - m.discordUsername ? `@${m.discordUsername}` : '—', - }, - { - key: 'customField', - header: 'Custom Field', - width: '14rem', - sortValue: (m: MembershipRow) => m.customField ?? '', - render: (m: MembershipRow) => m.customField ?? '—', - }, - { - key: 'donorAddress', - header: 'Donor Address', - width: '40rem', - sortValue: (m: MembershipRow) => m.donorAddress ?? '', - render: (m: MembershipRow) => m.donorAddress ?? '—', - }, - { - key: 'userAddress', - header: 'User Address', - width: '40rem', - sortValue: (m: MembershipRow) => m.userAddress ?? '', - render: (m: MembershipRow) => { - return ( - - {m.userAddress ?? '—'} - {m.userAddress && - (m.addressConfirmed ? ( - - ) : ( - - ))} - - ) - }, - }, - { - key: 'membershipEmail', - header: 'Membership Email', - width: '13rem', - sortValue: (m: MembershipRow) => m.membershipEmail ?? '', - render: (m: MembershipRow) => m.membershipEmail ?? '—', - }, - { - key: 'donorEmail', - header: 'Donor Email', - width: '13rem', - sortValue: (m: MembershipRow) => m.donorEmail ?? '', - render: (m: MembershipRow) => m.donorEmail ?? '—', - }, - { - key: 'userEmail', - header: 'User Email', - width: '13rem', - sortValue: (m: MembershipRow) => m.userEmail ?? '', - render: (m: MembershipRow) => m.userEmail ?? '—', - }, - { - key: 'donorPhone', - header: 'Donor Phone', - width: '10rem', - sortValue: (m: MembershipRow) => m.donorPhone ?? '', - render: (m: MembershipRow) => formatPhone(m.donorPhone), - }, - { - key: 'userPhone', - header: 'User Phone', - width: '10rem', - sortValue: (m: MembershipRow) => m.userPhone ?? '', - render: (m: MembershipRow) => formatPhone(m.userPhone), - }, - { - key: 'membershipTier', - header: 'Tier', - width: '11rem', - sortValue: (m: MembershipRow) => m.membershipTier ?? '', - render: (m: MembershipRow) => m.membershipTier ?? '—', - }, - { - key: 'contributionCount', - header: 'Contributions', - width: '7rem', - sortValue: (m: MembershipRow) => m.contributionCount ?? -1, - render: (m: MembershipRow) => m.contributionCount ?? '—', - }, - { - key: 'isRecurring', - header: 'Recurring', - width: '6rem', - sortValue: (m: MembershipRow) => m.isRecurring ?? -1, - render: (m: MembershipRow) => - m.isRecurring == null ? '—' : m.isRecurring ? 'Yes' : 'No', - }, - { - key: 'duesPayingMember', - header: 'Dues Paying Member', - width: '7rem', - sortValue: (m: MembershipRow) => (m.duesPayingMember ? 1 : 0), - render: (m: MembershipRow) => ( - - {m.duesPayingMember ? 'Yes' : 'No'} - - ), - }, - { - key: 'membershipBenefitEligible', - header: 'Eligible', - width: '6rem', - sortValue: (m: MembershipRow) => m.membershipBenefitEligible ?? -1, - render: (m: MembershipRow) => - m.membershipBenefitEligible == null - ? '—' - : m.membershipBenefitEligible - ? 'Yes' - : 'No', - }, - { - key: 'membershipCardStatus', - header: 'Card Status', - width: '8rem', - sortValue: (m: MembershipRow) => m.membershipCardStatus ?? 0, - render: (m: MembershipRow) => - m.membershipCardStatus != null - ? String(m.membershipCardStatus) - : '—', - }, - { - key: 'membershipMerchStatus', - header: 'Merch Status', - width: '8rem', - sortValue: (m: MembershipRow) => m.membershipMerchStatus ?? 0, - render: (m: MembershipRow) => - m.membershipMerchStatus != null - ? String(m.membershipMerchStatus) - : '—', - }, -] - -const mapPacketToRow = ( - packet: MembershipsResponsePacket, - index: number -): MembershipRow => { - const donor = packet.donor - const user = packet.user - - const donorName = [donor.firstname, donor.lastname] - .filter(Boolean) - .join(' ') - const userName = [user?.firstName, user?.lastName].filter(Boolean).join(' ') - const discordUsername = user?.discordUsers?.[0]?.username ?? '' - const customField = packet.customField?.answer ?? '' - const membershipEmail = donor.membershipData?.donorEmail ?? '' - const donorEmail = donor.email ?? '' - const userEmail = user?.email ?? '' - const donorPhone = donor.phone ?? '' - const userPhone = user?.phone ?? '' - const donorAddress = [ - donor.addr1, - [donor.city, [donor.state, donor.zip].filter(Boolean).join(' ')] - .filter(Boolean) - .join(', '), - donor.country, - ] - .filter(Boolean) - .join(', ') - const userAddress = [ - [user?.address?.addressLine1, user?.address?.addressLine2] - .filter(Boolean) - .join(', '), - [ - user?.address?.city, - [user?.address?.state, user?.address?.zip] - .filter(Boolean) - .join(' '), - ] - .filter(Boolean) - .join(', '), - user?.address?.county, - ] - .filter(Boolean) - .join(', ') - const membershipTier = packet.customField?.label ?? '—' - const contributionCount = donor.contributions?.length - const isRecurring = donor.contributions?.some( - (contribution) => contribution.isRecurring - ) - - return { - id: `${user?.id ?? donor.userId ?? donor.email}-${index}`, - donorName: donorName || undefined, - userName: userName || undefined, - discordUsername: discordUsername || undefined, - customField: customField || undefined, - membershipEmail: membershipEmail || undefined, - donorEmail: donorEmail || undefined, - userEmail: userEmail || undefined, - donorPhone: donorPhone || undefined, - userPhone: userPhone || undefined, - donorAddress: donorAddress || undefined, - userAddress: userAddress || undefined, - membershipTier: membershipTier || undefined, - contributionCount, - isRecurring, - duesPayingMember: - donor.membershipData?.duesPayingMember ?? - user?.duesPayingMember ?? - false, - membershipBenefitEligible: - donor.membershipData?.membershipBenefitEligible ?? - user?.membershipBenefitEligible ?? - undefined, - membershipCardStatus: - donor.membershipData?.membershipCardStatus ?? - user?.membershipCardStatus ?? - undefined, - membershipMerchStatus: - donor.membershipData?.membershipMerchStatus ?? - user?.membershipMerchStatus ?? - undefined, - nameConfirmed: Boolean(user?.nameConfirmed), - addressConfirmed: Boolean(user?.addressConfirmed), - } -} - -export default function Page() { - const { ready, onGet } = useFetch() - const columns = buildColumns() - const loadMoreRef = useRef(null) - const membershipsQuery = useInfiniteQuery({ - queryKey: ['/actblue/memberships', { limit: 50 }], - queryFn: ({ pageParam, signal }) => - onGet( - '/actblue/memberships', - zPaginatedResponse(zMembershipsResponsePacket), - { - query: { page: pageParam, limit: 50 }, - signal, - } - ), - initialPageParam: 0, - getNextPageParam: (lastPage, pages) => { - const loadedCount = pages.reduce( - (total, page) => total + page.data.length, - 0 - ) - - return loadedCount < lastPage.count ? pages.length : undefined - }, - enabled: ready, - }) - - useEffect(() => { - const loadMoreElement = loadMoreRef.current - if (!loadMoreElement || !membershipsQuery.hasNextPage) return - - const observer = new IntersectionObserver( - ([entry]) => { - if ( - entry.isIntersecting && - !membershipsQuery.isFetchingNextPage - ) - void membershipsQuery.fetchNextPage() - }, - { rootMargin: '300px' } - ) - - observer.observe(loadMoreElement) - return () => observer.disconnect() - }, [ - membershipsQuery, - membershipsQuery.fetchNextPage, - membershipsQuery.hasNextPage, - membershipsQuery.isFetchingNextPage, - ]) - - const queryError = membershipsQuery.error - const rows = (membershipsQuery.data?.pages ?? []) - .flatMap((page) => page.data) - .map((packet, index) => mapPacketToRow(packet, index)) - const totalEntries = membershipsQuery.data?.pages[0]?.count - const showQueryError = membershipsQuery.isError && !membershipsQuery.data - - return ( -
-
-
- Admin - / - - Membership Test - -
- -
- Entries Loaded: {rows.length.toLocaleString()} - {totalEntries != null && - ` of ${totalEntries.toLocaleString()}`} -
-
-
-
-

Membership Test

-

- Manage membership records and details. -

-
- -
- {showQueryError && ( -
- Unable to load membership records. The API endpoint - for /actblue/memberships could not be reached. - {queryError instanceof Error && ( - <> {queryError.message} - )} -
- )} -
m.id} /> -
- {membershipsQuery.isFetchingNextPage && - 'Loading more membership records...'} -
- - - - ) -} From d88e3cc08ac2912ecd65abf10e6133f22ff2f793 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Sun, 13 Sep 2026 14:45:25 -0400 Subject: [PATCH 10/27] update use fetch --- src/util/hooks/useFetch.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/util/hooks/useFetch.ts b/src/util/hooks/useFetch.ts index 7f3e319c..761d3541 100644 --- a/src/util/hooks/useFetch.ts +++ b/src/util/hooks/useFetch.ts @@ -123,13 +123,13 @@ export function useFetch() { const text = await res.text() let error: ApiError = { message: res.statusText || 'An error occurred', - error: null, + error: '', } if (text.trim().length > 0) { try { error = JSON.parse(text) as ApiError } catch { - error = { message: text, error: null } + error = { message: text, error: '' } } } throw new FetchError(error.message, res.status, error.error) From d5d200daa764ed589baf59e5a7aca39305121fa5 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Sun, 13 Sep 2026 14:54:33 -0400 Subject: [PATCH 11/27] removing dumb comments t to remind me to do stuff i dont want to add now --- src/app/account/sections/AccountDetailsSection.tsx | 2 -- src/queries/users.queries.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/app/account/sections/AccountDetailsSection.tsx b/src/app/account/sections/AccountDetailsSection.tsx index ebe91400..4006d734 100644 --- a/src/app/account/sections/AccountDetailsSection.tsx +++ b/src/app/account/sections/AccountDetailsSection.tsx @@ -57,8 +57,6 @@ export function AccountDetailsSection({ setUpdatedUser(userData) }, [userData]) - // Mirrors the admin membership panel's "Package Shipped" values - // (Yes / No / Returned / Not Received / Canceled). const membershipDeliverableLabels: Record< MembershipDeliverableStatus, string diff --git a/src/queries/users.queries.ts b/src/queries/users.queries.ts index 6c65ab48..7cb3f6f1 100644 --- a/src/queries/users.queries.ts +++ b/src/queries/users.queries.ts @@ -100,8 +100,6 @@ export function useUpdatedUser({ query: { orderId }, }) - // Confirming the donor link also confirms their Discord identity; - // don't fail the link if the donor has no membership record. try { await onPatch( '/actblue/donors/:donorEmail/membership', From 50d08973bec25a1d8545e43924f2a6385ea717a2 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Tue, 15 Sep 2026 22:17:16 -0400 Subject: [PATCH 12/27] moving membership moved to volunteer dashboard --- .../endorsements/endorsementFilters.tsx | 2 +- src/app/volunteer_dashboard/home.tsx | 4 +- src/app/volunteer_dashboard/layout.tsx | 9 +- .../endorsements/endorsementFilters.tsx | 2 +- .../panels/fundraising/page.tsx | 5 +- .../useFundraisingDashboardController.ts | 9 + .../components/ContributionsMenu.module.css | 6 +- .../components/ContributionsMenu.tsx | 32 ++- .../components/EditableSelectTag.module.css | 0 .../components/EditableSelectTag.tsx | 2 +- .../components/FieldHistory.module.css | 6 +- .../membership/components/FieldHistory.tsx | 22 +- .../components/SourceMenu.module.css | 0 .../membership/components/SourceMenu.tsx | 3 +- .../membership/components/Tags.module.css | 0 .../panels/membership/components/Tags.tsx | 0 .../panels/membership/hooks.ts | 4 +- .../panels/membership/membership.helpers.ts | 17 +- .../panels/membership/membership.types.ts | 0 .../membership/membershipCells.module.css | 0 .../panels/membership/membershipCells.tsx | 0 .../panels/membership/membershipColumns.tsx | 2 +- .../panels/membership/page.module.css | 0 .../panels/membership/page.tsx | 259 +++++++++--------- .../common/dropdown/DropdownOverlay.tsx | 16 +- src/util/hooks/useInfiniteScroll.ts | 7 +- 26 files changed, 224 insertions(+), 183 deletions(-) rename src/app/{admin => volunteer_dashboard}/panels/membership/components/ContributionsMenu.module.css (97%) rename src/app/{admin => volunteer_dashboard}/panels/membership/components/ContributionsMenu.tsx (95%) rename src/app/{admin => volunteer_dashboard}/panels/membership/components/EditableSelectTag.module.css (100%) rename src/app/{admin => volunteer_dashboard}/panels/membership/components/EditableSelectTag.tsx (98%) rename src/app/{admin => volunteer_dashboard}/panels/membership/components/FieldHistory.module.css (95%) rename src/app/{admin => volunteer_dashboard}/panels/membership/components/FieldHistory.tsx (86%) rename src/app/{admin => volunteer_dashboard}/panels/membership/components/SourceMenu.module.css (100%) rename src/app/{admin => volunteer_dashboard}/panels/membership/components/SourceMenu.tsx (98%) rename src/app/{admin => volunteer_dashboard}/panels/membership/components/Tags.module.css (100%) rename src/app/{admin => volunteer_dashboard}/panels/membership/components/Tags.tsx (100%) rename src/app/{admin => volunteer_dashboard}/panels/membership/hooks.ts (98%) rename src/app/{admin => volunteer_dashboard}/panels/membership/membership.helpers.ts (98%) rename src/app/{admin => volunteer_dashboard}/panels/membership/membership.types.ts (100%) rename src/app/{admin => volunteer_dashboard}/panels/membership/membershipCells.module.css (100%) rename src/app/{admin => volunteer_dashboard}/panels/membership/membershipCells.tsx (100%) rename src/app/{admin => volunteer_dashboard}/panels/membership/membershipColumns.tsx (99%) rename src/app/{admin => volunteer_dashboard}/panels/membership/page.module.css (100%) rename src/app/{admin => volunteer_dashboard}/panels/membership/page.tsx (50%) diff --git a/src/app/admin/panels/endorsements/endorsementFilters.tsx b/src/app/admin/panels/endorsements/endorsementFilters.tsx index bf6d6020..2a870e9b 100644 --- a/src/app/admin/panels/endorsements/endorsementFilters.tsx +++ b/src/app/admin/panels/endorsements/endorsementFilters.tsx @@ -181,7 +181,7 @@ function optionDropdown( label="Filter by" onClose={closeDropdown} narrowLayoutMode="trigger" - style={{ left: 0, right: 'auto' }} + align="start" body={
diff --git a/src/app/volunteer_dashboard/layout.tsx b/src/app/volunteer_dashboard/layout.tsx index c0710875..0e7aa4db 100644 --- a/src/app/volunteer_dashboard/layout.tsx +++ b/src/app/volunteer_dashboard/layout.tsx @@ -16,6 +16,7 @@ import { zRole, zUser, } from '@/contracts/data' +import { zMembershipsResponsePacket } from '@/contracts/responses' import { useEndorsementQueries, usePositionQueries } from '@/queries' import { usePaginatedSearch, useCurrentUser } from '@/util/hooks' import { useQuery } from '@tanstack/react-query' @@ -83,6 +84,11 @@ function LayoutContent({ children }: { children: ReactNode }) { zActBlueDonationPacket, { search: { limit: 0 } } ) + const memberships = usePaginatedSearch( + '/actblue/memberships', + zMembershipsResponsePacket, + { search: { limit: 1 } } + ) const positionHierarchy = useQuery({ queryKey: ['positionHierarchy'], queryFn: positionQueries.getPositionHierarchy, @@ -137,7 +143,7 @@ function LayoutContent({ children }: { children: ReactNode }) { label: 'Membership', href: '/volunteer_dashboard/panels/membership', icon: FaMoneyCheckDollar, - // count: insert, + count: memberships.query.data?.count, }, ], }, @@ -321,6 +327,7 @@ function LayoutContent({ children }: { children: ReactNode }) { userCount: users.query.data?.count, donorCount: donors.query.data?.count, contributionCount: contributions.query.data?.count, + membershipCount: memberships.query.data?.count, roleCount: roles.query.data?.count, permissionCount: permissions.query.data?.count, positionCount, diff --git a/src/app/volunteer_dashboard/panels/endorsements/endorsementFilters.tsx b/src/app/volunteer_dashboard/panels/endorsements/endorsementFilters.tsx index a516a606..2dd434f6 100644 --- a/src/app/volunteer_dashboard/panels/endorsements/endorsementFilters.tsx +++ b/src/app/volunteer_dashboard/panels/endorsements/endorsementFilters.tsx @@ -181,7 +181,7 @@ function optionDropdown( label="Filter by" onClose={closeDropdown} narrowLayoutMode="trigger" - style={{ left: 0, right: 'auto' }} + align="start" body={
diff --git a/src/app/volunteer_dashboard/panels/fundraising/useFundraisingDashboardController.ts b/src/app/volunteer_dashboard/panels/fundraising/useFundraisingDashboardController.ts index 443affc2..9655d357 100644 --- a/src/app/volunteer_dashboard/panels/fundraising/useFundraisingDashboardController.ts +++ b/src/app/volunteer_dashboard/panels/fundraising/useFundraisingDashboardController.ts @@ -14,7 +14,9 @@ import { type ChartGranularityMode, } from '@/components/common/charts/timeBuckets' import { SortDirection } from '@/contracts/requests' +import { zMembershipsResponsePacket } from '@/contracts/responses' import { useActblueQueries } from '@/queries' +import { usePaginatedSearch } from '@/util/hooks' import { keepPreviousData, useQueries, useQuery } from '@tanstack/react-query' import { useEffect, useMemo, useState } from 'react' @@ -174,6 +176,12 @@ export function useFundraisingDashboardController() { enabled: actblueQueries.ready, }) + const membershipCountQuery = usePaginatedSearch( + '/actblue/memberships', + zMembershipsResponsePacket, + { search: { limit: 1 } } + ).query + const earliestContributionQuery = useQuery({ queryKey: ['/actblue/contributions', 'earliest'], queryFn: ({ signal }) => @@ -443,6 +451,7 @@ export function useFundraisingDashboardController() { canApplyCustomRange, statsQuery, allTimeStatsQuery, + membershipCountQuery, setStartDate, setEndDate, setCommittedPreset, diff --git a/src/app/admin/panels/membership/components/ContributionsMenu.module.css b/src/app/volunteer_dashboard/panels/membership/components/ContributionsMenu.module.css similarity index 97% rename from src/app/admin/panels/membership/components/ContributionsMenu.module.css rename to src/app/volunteer_dashboard/panels/membership/components/ContributionsMenu.module.css index 9ae161a1..da7e388b 100644 --- a/src/app/admin/panels/membership/components/ContributionsMenu.module.css +++ b/src/app/volunteer_dashboard/panels/membership/components/ContributionsMenu.module.css @@ -34,7 +34,7 @@ border-bottom: 1px solid rgba(17, 24, 39, 0.08); color: rgba(15, 23, 42, 0.85); font-size: 0.8rem; - font-weight: 550; + font-weight: 500; letter-spacing: -0.01em; &:last-child { @@ -56,7 +56,7 @@ .contributionDate { color: rgba(15, 23, 42, 0.9); - font-weight: 650; + font-weight: 600; } .contributionMain { @@ -69,7 +69,7 @@ .contributionForm { color: rgba(15, 23, 42, 0.62); - font-weight: 550; + font-weight: 500; } .contributionLineitems { diff --git a/src/app/admin/panels/membership/components/ContributionsMenu.tsx b/src/app/volunteer_dashboard/panels/membership/components/ContributionsMenu.tsx similarity index 95% rename from src/app/admin/panels/membership/components/ContributionsMenu.tsx rename to src/app/volunteer_dashboard/panels/membership/components/ContributionsMenu.tsx index 8021cb25..8ab972f5 100644 --- a/src/app/admin/panels/membership/components/ContributionsMenu.tsx +++ b/src/app/volunteer_dashboard/panels/membership/components/ContributionsMenu.tsx @@ -4,6 +4,7 @@ import { countMonthsWithLineitems, filterRecurringContributions, getEarliestLineitemDate, + getMembershipTierForAmount, } from '../membership.helpers' import { MemberMenuProps } from '../membership.types' import styles from './ContributionsMenu.module.css' @@ -20,16 +21,22 @@ const formatContributionDate = (value: string) => }) const recurringAmountTagClass = (amount?: number) => { - if (amount == null) return undefined - if (amount >= 100) return tags.tagInnerCircle - if (amount >= 20) return tags.tagSignature - if (amount >= 10) return tags.tagPremium - if (amount >= 5) return tags.tagMember - return undefined + switch (getMembershipTierForAmount(amount)) { + case 'Inner Circle Member': + return tags.tagInnerCircle + case 'Signature Member': + return tags.tagSignature + case 'Premium Member': + return tags.tagPremium + case 'Dues Paying Member': + return tags.tagMember + default: + return undefined + } } const formatAmount = (amount: number) => - `$${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + amount.toLocaleString('en-US', { style: 'currency', currency: 'USD' }) const monthsElapsed = (start: Date, end: Date) => (end.getUTCFullYear() - start.getUTCFullYear()) * 12 + @@ -160,7 +167,7 @@ export const ContributionsMenu = ({ ? () => { closeDropdown() router.push( - `/admin/panels/contributions?lineitemId=${record.firstLineitemId}` + `/volunteer_dashboard/panels/contributions?lineitemId=${record.firstLineitemId}` ) } : undefined @@ -185,9 +192,7 @@ export const ContributionsMenu = ({ } > {record.lineitemCount} lineitem - {record.lineitemCount === 1 - ? '' - : 's'} + {record.lineitemCount !== 1 && 's'} {record.mostRecentLineitemDate && ( {record.monthsWithLineitems} of{' '} {record.monthsSpanned} month - {record.monthsSpanned === 1 - ? '' - : 's'} + {record.monthsSpanned !== 1 && + 's'} )} diff --git a/src/app/admin/panels/membership/components/EditableSelectTag.module.css b/src/app/volunteer_dashboard/panels/membership/components/EditableSelectTag.module.css similarity index 100% rename from src/app/admin/panels/membership/components/EditableSelectTag.module.css rename to src/app/volunteer_dashboard/panels/membership/components/EditableSelectTag.module.css diff --git a/src/app/admin/panels/membership/components/EditableSelectTag.tsx b/src/app/volunteer_dashboard/panels/membership/components/EditableSelectTag.tsx similarity index 98% rename from src/app/admin/panels/membership/components/EditableSelectTag.tsx rename to src/app/volunteer_dashboard/panels/membership/components/EditableSelectTag.tsx index 7c20c670..98d93a6c 100644 --- a/src/app/admin/panels/membership/components/EditableSelectTag.tsx +++ b/src/app/volunteer_dashboard/panels/membership/components/EditableSelectTag.tsx @@ -64,7 +64,7 @@ export const EditableSelectTag = ({ onClose={closeDropdown} className={styles.editSelectOverlay} bodyClassName={styles.editSelectMenu} - style={{ left: 0, right: 'auto' }} + align="start" body={choices.map((choice) => ( - )} - ( - ( -
- + {tableMode === 'edit' ? ( + + ) : ( + + )} + ( + ( +
- {label} - - - ariaLabel={ariaLabel} - orientation="horizontal" - value={options[key]} - options={choices} - onChange={(value) => - setOption(key, value) - } - /> -
- ) - )} - /> - )} - /> + + {label} + + + ariaLabel={ariaLabel} + orientation="horizontal" + value={options[key]} + options={choices} + onChange={(value) => + setOption( + key, + value + ) + } + /> +
+ ) + )} + /> + )} + /> + - -
-
m.id} - collapsedCategories={collapsedCategories} - mode={tableMode} - footer={ - hasNextPage && ( -
- {isFetchingNextPage && - 'Loading more membership records...'} -
- ) - } - /> +
+
m.id} + collapsedCategories={collapsedCategories} + mode={tableMode} + footer={ + hasNextPage && ( +
+ {isFetchingNextPage && + 'Loading more membership records...'} +
+ ) + } + /> + - - setMemberToMatch(null)} - title="Match User" - subtitle="Search users and link one to this membership record." - searchValue={userSearch.query ?? ''} - onSearchChange={handleUserSearch} - > - setMemberToMatch(null)} + title="Match User" + subtitle="Search users and link one to this membership record." + searchValue={userSearch.query ?? ''} + onSearchChange={handleUserSearch} > - {userSearchQuery.data?.data.map((user) => ( - void handleMatchUser(user)} - > - - {user.preferredName ?? - [user.firstName, user.lastName] - .filter(Boolean) - .join(' ') ?? - user.email ?? - `User ${user.id}`} - - {user.email && {user.email}} - - ))} - - - + + {userSearchQuery.data?.data.map((user) => ( + void handleMatchUser(user)} + > + + {user.preferredName ?? + [user.firstName, user.lastName] + .filter(Boolean) + .join(' ') ?? + user.email ?? + `User ${user.id}`} + + {user.email && {user.email}} + + ))} + + + + ) } diff --git a/src/components/common/dropdown/DropdownOverlay.tsx b/src/components/common/dropdown/DropdownOverlay.tsx index f100166f..1e0be47e 100644 --- a/src/components/common/dropdown/DropdownOverlay.tsx +++ b/src/components/common/dropdown/DropdownOverlay.tsx @@ -17,6 +17,8 @@ import { FiX } from 'react-icons/fi' type DropdownOverlayNarrowLayoutMode = 'container' | 'trigger' | 'flow' +type DropdownOverlayAlign = 'auto' | 'start' | 'end' + const DROPDOWN_OVERLAY_LAYOUT_CONFIG = { viewportPadding: 12, narrowBreakpointQuery: '(max-width: 53rem)', @@ -43,6 +45,7 @@ interface RectLike { interface ComputeResponsiveOverlayStyleInput { narrowLayoutMode: DropdownOverlayNarrowLayoutMode + align: DropdownOverlayAlign isNarrowLayout: boolean viewportWidth: number viewportMaxWidth: number @@ -54,6 +57,7 @@ interface ComputeResponsiveOverlayStyleInput { function computeResponsiveOverlayStyle({ narrowLayoutMode, + align, isNarrowLayout, viewportWidth, viewportMaxWidth, @@ -111,10 +115,12 @@ function computeResponsiveOverlayStyle({ const fitWidth = Math.min(overlayScrollWidth, viewportMaxWidth) const rightAlignedStart = anchorRect.right - fitWidth const leftAlignedEnd = anchorRect.left + fitWidth - const shouldAlignLeft = + const fitsWhenAlignedLeft = rightAlignedStart < DROPDOWN_OVERLAY_LAYOUT_CONFIG.viewportPadding && leftAlignedEnd <= viewportWidth - DROPDOWN_OVERLAY_LAYOUT_CONFIG.viewportPadding + const shouldAlignLeft = + align === 'start' || (align === 'auto' && fitsWhenAlignedLeft) return { position: undefined, @@ -131,11 +137,13 @@ function computeResponsiveOverlayStyle({ interface UseDropdownOverlayResponsiveStyleInput { overlayRef: RefObject narrowLayoutMode: DropdownOverlayNarrowLayoutMode + align: DropdownOverlayAlign } function useDropdownOverlayResponsiveStyle({ overlayRef, narrowLayoutMode, + align, }: UseDropdownOverlayResponsiveStyleInput): CSSProperties { const [responsiveStyle, setResponsiveStyle] = useState( INITIAL_OVERLAY_RESPONSIVE_STYLE @@ -193,6 +201,7 @@ function useDropdownOverlayResponsiveStyle({ ? { maxWidth: `${Math.floor(viewportMaxWidth)}px` } : computeResponsiveOverlayStyle({ narrowLayoutMode, + align, isNarrowLayout: narrowLayoutMedia.matches, viewportWidth, viewportMaxWidth, @@ -241,7 +250,7 @@ function useDropdownOverlayResponsiveStyle({ window.removeEventListener('scroll', scheduleLayout, true) narrowLayoutMedia.removeEventListener('change', scheduleLayout) } - }, [overlayRef, narrowLayoutMode]) + }, [overlayRef, narrowLayoutMode, align]) return responsiveStyle } @@ -261,6 +270,7 @@ export interface DropdownOverlayProps extends React.HTMLAttributes( @@ -280,6 +290,7 @@ export const DropdownOverlay = forwardRef( footerClassName, footerButtonClassName, narrowLayoutMode = 'container', + align = 'auto', className, style, children, @@ -292,6 +303,7 @@ export const DropdownOverlay = forwardRef( const responsiveStyle = useDropdownOverlayResponsiveStyle({ overlayRef: localRef, narrowLayoutMode, + align, }) const shellClassName = [styles.shell, className] diff --git a/src/util/hooks/useInfiniteScroll.ts b/src/util/hooks/useInfiniteScroll.ts index 017d57d1..1d3ca0ee 100644 --- a/src/util/hooks/useInfiniteScroll.ts +++ b/src/util/hooks/useInfiniteScroll.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { RefCallback, useCallback, useEffect, useState } from 'react' export interface UseInfiniteScrollOptions { hasNextPage: boolean @@ -31,7 +31,10 @@ export function useInfiniteScroll({ return () => observer.disconnect() }, [sentinel, hasNextPage, isFetchingNextPage, fetchNextPage, rootMargin]) - const sentinelRef = useCallback((node: T | null) => setSentinel(node), []) + const sentinelRef: RefCallback = useCallback( + (node: T | null) => setSentinel(node), + [] + ) return { sentinelRef } } From 657bb59d9933826a41c01732f5953c538b245396 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Wed, 16 Sep 2026 10:22:11 -0400 Subject: [PATCH 13/27] d --- .../panels/membership/hooks.ts | 4 ++- .../panels/membership/membershipCells.tsx | 30 ++++++++++++------- .../panels/membership/membershipColumns.tsx | 23 ++++++++++---- .../panels/membership/page.tsx | 7 +++++ src/components/common/table/Table.module.css | 18 ++++++----- src/components/common/table/Table.tsx | 10 ++++++- 6 files changed, 67 insertions(+), 25 deletions(-) diff --git a/src/app/volunteer_dashboard/panels/membership/hooks.ts b/src/app/volunteer_dashboard/panels/membership/hooks.ts index 29f67f27..fbc38e8c 100644 --- a/src/app/volunteer_dashboard/panels/membership/hooks.ts +++ b/src/app/volunteer_dashboard/panels/membership/hooks.ts @@ -227,14 +227,16 @@ export interface MembershipTableOptions { showStatus: boolean showConfirmed: boolean showFulfilled: boolean + showZebra: boolean collapseFulfillment: boolean } const defaultTableOptions: MembershipTableOptions = { - showRowNumber: false, + showRowNumber: true, showStatus: false, showConfirmed: false, showFulfilled: true, + showZebra: false, collapseFulfillment: false, } diff --git a/src/app/volunteer_dashboard/panels/membership/membershipCells.tsx b/src/app/volunteer_dashboard/panels/membership/membershipCells.tsx index 812d9318..1a731a3b 100644 --- a/src/app/volunteer_dashboard/panels/membership/membershipCells.tsx +++ b/src/app/volunteer_dashboard/panels/membership/membershipCells.tsx @@ -34,6 +34,8 @@ const NoLinkedUser = () => ( No linked user ) +const NoData = () => No Data + export const NameValue = ({ member }: MemberValueProps) => ( {member.userName ?? member.donorName ?? '—'} @@ -114,13 +116,11 @@ export const NameMenu = ({ member, closeDropdown }: MemberMenuProps) => ( export const DiscordValue = ({ member }: MemberValueProps) => { const displayedHandle = member.discordUsername ?? member.contributionDiscord + if (!displayedHandle) return + return ( - - {displayedHandle - ? `@${normalizeDiscordHandle(displayedHandle)}` - : '—'} - + @{normalizeDiscordHandle(displayedHandle)} ( /> ) -export const PhoneValue = ({ member }: MemberValueProps) => ( - - {formatPhone(member.userPhone ?? member.donorPhone)} - -) +export const PhoneValue = ({ member }: MemberValueProps) => { + const phone = member.userPhone ?? member.donorPhone + + if (!phone) return + + return {formatPhone(phone)} +} + +export const AmountValue = ({ member }: MemberValueProps) => { + const amount = member.recurringSummary?.activeAmount + + if (amount == null) return + + return ${amount} +} export const PhoneEdit = ({ member, edit }: MemberEditProps) => { const draft = useMemberDraft(edit, member).userPhone diff --git a/src/app/volunteer_dashboard/panels/membership/membershipColumns.tsx b/src/app/volunteer_dashboard/panels/membership/membershipColumns.tsx index 189d42be..fb285ccd 100644 --- a/src/app/volunteer_dashboard/panels/membership/membershipColumns.tsx +++ b/src/app/volunteer_dashboard/panels/membership/membershipColumns.tsx @@ -17,11 +17,13 @@ import { MembershipTableMode, MembershipTier, PackageShipped, + ShirtSize, } from './membership.types' import { AddressEdit, AddressMenu, AddressValue, + AmountValue, DiscordMenu, DiscordValue, EmailEdit, @@ -92,6 +94,17 @@ const membershipTierRank: Record = { 'Dues Paying Member': 1, } +const shirtSizeRank: Record = { + XXL: 0, + XL: 1, + L: 2, + M: 3, + S: 4, + XS: 5, +} + +const NO_SHIRT_SIZE_RANK = Object.keys(shirtSizeRank).length + const DOT_COLOR_TRUE = 'rgba(112, 195, 32, 0.6)' const DOT_COLOR_FALSE = 'rgba(255, 95, 75, 0.6)' @@ -267,7 +280,7 @@ export const buildColumns = ({ ) : ( - Not Eligible + Not A Member ) }, @@ -401,7 +414,8 @@ export const buildColumns = ({ header: 'Shirt', width: '5rem', allowOverflow: true, - sortValue: (m) => m.shirtSize ?? '', + sortValue: (m) => + m.shirtSize ? shirtSizeRank[m.shirtSize] : NO_SHIRT_SIZE_RANK, render: (m) => , renderEdit: (m) => , }, @@ -410,10 +424,7 @@ export const buildColumns = ({ header: 'Amount', width: '6rem', sortValue: (m) => m.recurringSummary?.activeAmount ?? 0, - render: (m) => { - const amount = m.recurringSummary?.activeAmount - return amount != null ? `$${amount}` : '—' - }, + render: (m) => , }, { key: 'contributionsReal', diff --git a/src/app/volunteer_dashboard/panels/membership/page.tsx b/src/app/volunteer_dashboard/panels/membership/page.tsx index 82cb54f5..19f54b9b 100644 --- a/src/app/volunteer_dashboard/panels/membership/page.tsx +++ b/src/app/volunteer_dashboard/panels/membership/page.tsx @@ -61,6 +61,12 @@ const tableOptionRows: { ariaLabel: 'Tag Groups', choices: showHideChoices, }, + { + key: 'showZebra', + label: 'Zebra striping', + ariaLabel: 'Zebra Striping', + choices: showHideChoices, + }, { key: 'collapseFulfillment', label: 'Fulfillment columns', @@ -295,6 +301,7 @@ export default function Page() { rowKey={(m) => m.id} collapsedCategories={collapsedCategories} mode={tableMode} + zebra={options.showZebra} footer={ hasNextPage && (
{ rowKey: (row: T, index: number) => string | number collapsedCategories?: string[] mode?: TableMode + zebra?: boolean footer?: React.ReactNode } @@ -149,6 +150,7 @@ function TableRowInner({ visibleEntries, gridTemplateColumns, mode, + zebra, openColKey, setOpenCell, }: { @@ -159,6 +161,7 @@ function TableRowInner({ visibleEntries: VisibleEntry[] gridTemplateColumns: string mode: TableMode + zebra: boolean openColKey: string | null setOpenCell: OpenCellSetter }) { @@ -176,7 +179,10 @@ function TableRowInner({ return (
@@ -317,6 +323,7 @@ export function Table({ rowKey, collapsedCategories = [], mode = 'view', + zebra = false, footer, }: TableProps) { const [sortKey, setSortKey] = useState(null) @@ -537,6 +544,7 @@ export function Table({ visibleEntries={visibleEntries} gridTemplateColumns={gridTemplateColumns} mode={mode} + zebra={zebra} openColKey={ openCell?.startsWith(openPrefix) ? openCell.slice(openPrefix.length) From 514895639222e5e920740d2ac8819cdf462615cc Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Wed, 16 Sep 2026 10:29:45 -0400 Subject: [PATCH 14/27] table description --- src/app/volunteer_dashboard/home.tsx | 1 + src/app/volunteer_dashboard/layout.tsx | 7 ++ .../panels/experiment/page.module.css | 48 ++++++++++++ .../panels/experiment/page.tsx | 23 ++++++ src/components/common/table/Table.tsx | 78 +++++++++++++++++++ 5 files changed, 157 insertions(+) create mode 100644 src/app/volunteer_dashboard/panels/experiment/page.module.css create mode 100644 src/app/volunteer_dashboard/panels/experiment/page.tsx diff --git a/src/app/volunteer_dashboard/home.tsx b/src/app/volunteer_dashboard/home.tsx index 5e388443..57af1bae 100644 --- a/src/app/volunteer_dashboard/home.tsx +++ b/src/app/volunteer_dashboard/home.tsx @@ -9,6 +9,7 @@ import { FaCheckToSlot, FaClipboardUser, FaDollarSign, + FaFlask, FaMoneyBills, FaMoneyCheckDollar, } from 'react-icons/fa6' diff --git a/src/app/volunteer_dashboard/layout.tsx b/src/app/volunteer_dashboard/layout.tsx index 0e7aa4db..d0bb852b 100644 --- a/src/app/volunteer_dashboard/layout.tsx +++ b/src/app/volunteer_dashboard/layout.tsx @@ -28,6 +28,7 @@ import { FaClipboardUser, FaDollarSign, FaCheckToSlot, + FaFlask, FaMoneyBills, FaMoneyCheckDollar, } from 'react-icons/fa6' @@ -175,6 +176,12 @@ function LayoutContent({ children }: { children: ReactNode }) { icon: FaCheckToSlot, count: endorsements.data?.length, }, + { + key: 'experiment', + label: 'Experiment', + href: '/volunteer_dashboard/panels/experiment', + icon: FaFlask, + }, ] useEffect(() => { diff --git a/src/app/volunteer_dashboard/panels/experiment/page.module.css b/src/app/volunteer_dashboard/panels/experiment/page.module.css new file mode 100644 index 00000000..503e1ea8 --- /dev/null +++ b/src/app/volunteer_dashboard/panels/experiment/page.module.css @@ -0,0 +1,48 @@ +.panelContents { + width: 100%; + min-width: 0; + height: calc(100dvh - 100px); + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 0; +} + +.scrollView { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 0 0 1rem; +} + +.galleryHeader { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 1rem; + padding: 1.15rem 1.05rem 0.35rem; +} + +.galleryHeading { + min-width: 0; +} + +.galleryTitle { + margin: 0; + font-size: 1.45rem; + font-weight: 800; + letter-spacing: -0.02em; + color: rgba(9, 34, 58, 0.9); +} + +.gallerySubTitle { + margin: 0.35rem 0 0; + font-size: 0.95rem; + font-weight: 600; + letter-spacing: -0.01em; + color: rgba(15, 23, 42, 0.55); +} diff --git a/src/app/volunteer_dashboard/panels/experiment/page.tsx b/src/app/volunteer_dashboard/panels/experiment/page.tsx new file mode 100644 index 00000000..c2b474c2 --- /dev/null +++ b/src/app/volunteer_dashboard/panels/experiment/page.tsx @@ -0,0 +1,23 @@ +'use client' + +import styles from './page.module.css' +import Panel from '@/components/common/panel/Panel' + +export default function Page() { + return ( + +
+
+
+
+

Experiment

+

+ Sandbox for iterating on the Table component. +

+
+
+
+
+
+ ) +} diff --git a/src/components/common/table/Table.tsx b/src/components/common/table/Table.tsx index c47261bc..20fef45d 100644 --- a/src/components/common/table/Table.tsx +++ b/src/components/common/table/Table.tsx @@ -12,6 +12,84 @@ import React, { } from 'react' import { FiChevronDown, FiChevronUp } from 'react-icons/fi' +/* + * How To Use: + * + * This component is our standardized data table. You describe your data with a + * list of column definitions and hand it an array of rows; the table handles + * layout, sticky headers, sorting, per-cell dropdown menus, an inline edit + * mode, and row virtualization for large datasets. + * + * The table is generic over your row type , so every callback you provide + * receives a fully typed row. + * + * + * + * TableProps: + * - columns: Column definitions, optionally grouped into categories. + * - data: The rows to render. + * - rowKey: Returns a stable unique key for each row. + * - collapsedCategories: Labels of categories to render collapsed as dots. + * - mode: 'view' (default) or 'edit' to swap in the editable cell renderers. + * - zebra: Alternating row background colors. + * - footer: Rendered below the last row, inside the scroll container. + * + * + * Column: + * - key: Unique column identifier, also used as the sort key. + * - header: Header label text. + * - render: Cell content in view mode. + * - renderEdit: Cell content in edit mode. Omit to lock the column while editing. + * - width: CSS grid track width. Defaults to 10rem. + * - allowOverflow: Let content escape the cell instead of being ellipsized. + * - onCellClick: Called when the cell is activated by click or Enter/Space. + * - menu: Dropdown content anchored under the cell, opened on activation. + * - sortValue: Provide to make the column sortable by clicking its header. + * + * + * ColumnCategory: + * - label: Category name, used to target it via collapsedCategories. + * - columns: The columns belonging to this category. + * - collapsedWidth: Width used while collapsed. Defaults to 5rem. + * - dotColor: Dot color per column while collapsed. Return null for a bullet. + * - rowRender: Replaces the category's cells for a row with a single spanning cell. + * + * + * + * Example usage: + * + * import { Table, type ColumnEntry } from '@/components/common/table' + * + * + * const columns: ColumnEntry[] = [ + * { + * key: 'name', + * header: 'Name', + * width: '14rem', + * render: (person) => person.name, + * renderEdit: (person) => , + * sortValue: (person) => person.name, + * }, + * { + * label: 'Contact', + * columns: [ + * { key: 'email', header: 'Email', render: (p) => p.email }, + * { key: 'phone', header: 'Phone', render: (p) => p.phone }, + * ], + * }, + * ] + * + * + *
person.id} + * mode="view" + * zebra + * /> + * + */ + const DEFAULT_COLUMN_WIDTH = '10rem' const VIRTUALIZE_THRESHOLD = 60 const OVERSCAN = 10 From 5e99956f34c1e5bc34d6b95ae60453c45d0ebf54 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Wed, 16 Sep 2026 10:33:50 -0400 Subject: [PATCH 15/27] better naming scheme for example --- .../panels/experiment/page.tsx | 26 +++++++++++++++++++ src/components/common/table/Table.tsx | 16 ++++++------ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/app/volunteer_dashboard/panels/experiment/page.tsx b/src/app/volunteer_dashboard/panels/experiment/page.tsx index c2b474c2..306c30b3 100644 --- a/src/app/volunteer_dashboard/panels/experiment/page.tsx +++ b/src/app/volunteer_dashboard/panels/experiment/page.tsx @@ -2,8 +2,27 @@ import styles from './page.module.css' import Panel from '@/components/common/panel/Panel' +import { Table, type ColumnEntry } from '@/components/common/table' export default function Page() { + const columns: ColumnEntry[] = [ + { + key: 'name', + header: 'Name', + width: '14rem', + render: (person) => person.name, + renderEdit: (person) => , + sortValue: (person) => person.name, + }, + { + label: 'Contact', + columns: [ + { key: 'email', header: 'Email', render: (p) => p.email }, + { key: 'phone', header: 'Phone', render: (p) => p.phone }, + ], + }, + ] + return (
@@ -16,6 +35,13 @@ export default function Page() {

+
person.id} + mode="view" + zebra + /> diff --git a/src/components/common/table/Table.tsx b/src/components/common/table/Table.tsx index 20fef45d..142504e7 100644 --- a/src/components/common/table/Table.tsx +++ b/src/components/common/table/Table.tsx @@ -61,20 +61,20 @@ import { FiChevronDown, FiChevronUp } from 'react-icons/fi' * import { Table, type ColumnEntry } from '@/components/common/table' * * - * const columns: ColumnEntry[] = [ + * const columns: ColumnEntry[] = [ * { * key: 'name', * header: 'Name', * width: '14rem', - * render: (person) => person.name, - * renderEdit: (person) => , - * sortValue: (person) => person.name, + * render: (example) => example.name, + * renderEdit: (example) => , + * sortValue: (example) => example.name, * }, * { * label: 'Contact', * columns: [ - * { key: 'email', header: 'Email', render: (p) => p.email }, - * { key: 'phone', header: 'Phone', render: (p) => p.phone }, + * { key: 'email', header: 'Email', render: (e) => e.email }, + * { key: 'phone', header: 'Phone', render: (e) => e.phone }, * ], * }, * ] @@ -82,8 +82,8 @@ import { FiChevronDown, FiChevronUp } from 'react-icons/fi' * *
person.id} + * data={examples} + * rowKey={(example) => example.id} * mode="view" * zebra * /> From 061476a5912a7d37ebafe20ce763d49136f789c4 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Wed, 16 Sep 2026 11:29:32 -0400 Subject: [PATCH 16/27] removing expirement --- src/app/volunteer_dashboard/layout.tsx | 6 --- .../panels/experiment/page.module.css | 48 ------------------ .../panels/experiment/page.tsx | 49 ------------------- 3 files changed, 103 deletions(-) delete mode 100644 src/app/volunteer_dashboard/panels/experiment/page.module.css delete mode 100644 src/app/volunteer_dashboard/panels/experiment/page.tsx diff --git a/src/app/volunteer_dashboard/layout.tsx b/src/app/volunteer_dashboard/layout.tsx index d0bb852b..0d2115ad 100644 --- a/src/app/volunteer_dashboard/layout.tsx +++ b/src/app/volunteer_dashboard/layout.tsx @@ -176,12 +176,6 @@ function LayoutContent({ children }: { children: ReactNode }) { icon: FaCheckToSlot, count: endorsements.data?.length, }, - { - key: 'experiment', - label: 'Experiment', - href: '/volunteer_dashboard/panels/experiment', - icon: FaFlask, - }, ] useEffect(() => { diff --git a/src/app/volunteer_dashboard/panels/experiment/page.module.css b/src/app/volunteer_dashboard/panels/experiment/page.module.css deleted file mode 100644 index 503e1ea8..00000000 --- a/src/app/volunteer_dashboard/panels/experiment/page.module.css +++ /dev/null @@ -1,48 +0,0 @@ -.panelContents { - width: 100%; - min-width: 0; - height: calc(100dvh - 100px); - flex: 1 1 auto; - min-height: 0; - display: flex; - flex-direction: column; - overflow: hidden; - padding: 0; -} - -.scrollView { - flex: 1 1 auto; - min-height: 0; - display: flex; - flex-direction: column; - overflow: hidden; - padding: 0 0 1rem; -} - -.galleryHeader { - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 1rem; - padding: 1.15rem 1.05rem 0.35rem; -} - -.galleryHeading { - min-width: 0; -} - -.galleryTitle { - margin: 0; - font-size: 1.45rem; - font-weight: 800; - letter-spacing: -0.02em; - color: rgba(9, 34, 58, 0.9); -} - -.gallerySubTitle { - margin: 0.35rem 0 0; - font-size: 0.95rem; - font-weight: 600; - letter-spacing: -0.01em; - color: rgba(15, 23, 42, 0.55); -} diff --git a/src/app/volunteer_dashboard/panels/experiment/page.tsx b/src/app/volunteer_dashboard/panels/experiment/page.tsx deleted file mode 100644 index 306c30b3..00000000 --- a/src/app/volunteer_dashboard/panels/experiment/page.tsx +++ /dev/null @@ -1,49 +0,0 @@ -'use client' - -import styles from './page.module.css' -import Panel from '@/components/common/panel/Panel' -import { Table, type ColumnEntry } from '@/components/common/table' - -export default function Page() { - const columns: ColumnEntry[] = [ - { - key: 'name', - header: 'Name', - width: '14rem', - render: (person) => person.name, - renderEdit: (person) => , - sortValue: (person) => person.name, - }, - { - label: 'Contact', - columns: [ - { key: 'email', header: 'Email', render: (p) => p.email }, - { key: 'phone', header: 'Phone', render: (p) => p.phone }, - ], - }, - ] - - return ( - -
-
-
-
-

Experiment

-

- Sandbox for iterating on the Table component. -

-
-
-
person.id} - mode="view" - zebra - /> - - - - ) -} From 6ac792c277fa11e4e5cd3f557cd4488a01a13b1b Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Wed, 16 Sep 2026 14:08:17 -0400 Subject: [PATCH 17/27] lint --- src/app/volunteer_dashboard/home.tsx | 1 - src/app/volunteer_dashboard/layout.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/src/app/volunteer_dashboard/home.tsx b/src/app/volunteer_dashboard/home.tsx index 57af1bae..5e388443 100644 --- a/src/app/volunteer_dashboard/home.tsx +++ b/src/app/volunteer_dashboard/home.tsx @@ -9,7 +9,6 @@ import { FaCheckToSlot, FaClipboardUser, FaDollarSign, - FaFlask, FaMoneyBills, FaMoneyCheckDollar, } from 'react-icons/fa6' diff --git a/src/app/volunteer_dashboard/layout.tsx b/src/app/volunteer_dashboard/layout.tsx index 0d2115ad..0e7aa4db 100644 --- a/src/app/volunteer_dashboard/layout.tsx +++ b/src/app/volunteer_dashboard/layout.tsx @@ -28,7 +28,6 @@ import { FaClipboardUser, FaDollarSign, FaCheckToSlot, - FaFlask, FaMoneyBills, FaMoneyCheckDollar, } from 'react-icons/fa6' From b80c8c5be7029e4c02d09fb160b20343e37c4646 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Thu, 17 Sep 2026 18:36:31 -0400 Subject: [PATCH 18/27] implementing shit --- .../membership/components/SourceMenu.tsx | 12 +- .../components/UserMatch.module.css | 85 ++++++++ .../membership/components/UserMatch.tsx | 181 ++++++++++++++++++ .../panels/membership/hooks.ts | 155 ++++++++++++++- .../panels/membership/membership.helpers.ts | 12 +- .../panels/membership/membershipCells.tsx | 10 + .../panels/membership/page.module.css | 24 +++ .../panels/membership/page.tsx | 91 +++++---- .../requests/MembershipSearchRequest.ts | 9 + src/contracts/requests/index.ts | 1 + 10 files changed, 534 insertions(+), 46 deletions(-) create mode 100644 src/app/volunteer_dashboard/panels/membership/components/UserMatch.module.css create mode 100644 src/app/volunteer_dashboard/panels/membership/components/UserMatch.tsx create mode 100644 src/contracts/requests/MembershipSearchRequest.ts diff --git a/src/app/volunteer_dashboard/panels/membership/components/SourceMenu.tsx b/src/app/volunteer_dashboard/panels/membership/components/SourceMenu.tsx index cc26e77e..7d2556d1 100644 --- a/src/app/volunteer_dashboard/panels/membership/components/SourceMenu.tsx +++ b/src/app/volunteer_dashboard/panels/membership/components/SourceMenu.tsx @@ -6,7 +6,7 @@ import styles from './SourceMenu.module.css' import tags from './Tags.module.css' import { DropdownOverlay } from '@/components/common' import { cn } from '@/util' -import { CSSProperties } from 'react' +import { CSSProperties, ReactNode } from 'react' import { FiCheck } from 'react-icons/fi' export interface MemberSource { @@ -63,6 +63,8 @@ export interface SourceMenuProps { confirmed?: boolean confirmedSource?: string history?: Omit + /** Replaces the history section while the member has no linked user. */ + unlinked?: ReactNode } export const SourceMenu = ({ @@ -77,12 +79,15 @@ export const SourceMenu = ({ confirmed, confirmedSource, history, + unlinked, }: SourceMenuProps) => { const rows = sources.map((source) => ({ ...source, matchKey: source.value ? normalize(source.value) : undefined, })) + const showUnlinked = unlinked != null && member.userId == null + const displayedLabel = rows.find((row) => row.value)?.label const allIdentical = new Set(rows.flatMap((row) => row.matchKey ?? [])).size === 1 @@ -146,7 +151,10 @@ export const SourceMenu = ({ ) })} - {history && } + {showUnlinked && unlinked} + {!showUnlinked && history && ( + + )} } /> diff --git a/src/app/volunteer_dashboard/panels/membership/components/UserMatch.module.css b/src/app/volunteer_dashboard/panels/membership/components/UserMatch.module.css new file mode 100644 index 00000000..c3547f0b --- /dev/null +++ b/src/app/volunteer_dashboard/panels/membership/components/UserMatch.module.css @@ -0,0 +1,85 @@ +.matchSection { + display: grid; + gap: 0.35rem; + margin-top: 0.35rem; + padding-top: 0.6rem; + border-top: 1px solid rgba(17, 24, 39, 0.1); +} + +.matchSectionLabel { + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + color: rgba(15, 23, 42, 0.56); + text-transform: uppercase; +} + +.matchEmpty { + font-size: 0.8rem; + color: rgba(15, 23, 42, 0.55); +} + +.matchContainer { + display: flex; + flex-direction: column; + width: 100%; + overflow: hidden; + border: 1px solid rgba(17, 24, 39, 0.1); + border-radius: 0.75rem; + background: rgba(255, 255, 255, 0.72); +} + +.matchEntry { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.65rem; + color: rgba(15, 23, 42, 0.85); + font-size: 0.8rem; +} + +.matchEntryMain { + min-width: 0; + display: grid; + gap: 0.1rem; +} + +.matchEntryName { + color: rgba(15, 23, 42, 0.9); + font-weight: 600; +} + +.matchEntryDetail { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: rgba(15, 23, 42, 0.62); + font-weight: 500; +} + +.matchButton { + margin-left: auto; + white-space: nowrap; + padding-inline: 0.75rem; + padding-block: 0.25rem; + border: 0; + border-radius: 9999px; + font-size: 0.72rem; + font-weight: 600; + color: rgba(2, 132, 199, 1); + background: rgba(14, 165, 233, 0.14); + cursor: pointer; + transition: + background 120ms ease, + color 120ms ease; + + &:hover:not(:disabled) { + background: #e6e6e6; + color: #3d3d3d; + } + + &:disabled { + opacity: 0.6; + cursor: default; + } +} diff --git a/src/app/volunteer_dashboard/panels/membership/components/UserMatch.tsx b/src/app/volunteer_dashboard/panels/membership/components/UserMatch.tsx new file mode 100644 index 00000000..f414d5a7 --- /dev/null +++ b/src/app/volunteer_dashboard/panels/membership/components/UserMatch.tsx @@ -0,0 +1,181 @@ +'use client' + +import { + useLinkDonorToUser, + useUserByDiscord, + useUserByEmail, + useUserByName, + useUserByPhone, +} from '../hooks' +import { formatPhone } from '../membership.helpers' +import { Member } from '../membership.types' +import styles from './UserMatch.module.css' +import { UserProfile } from '@/contracts/data' + +export interface UserMatchProps { + member: Member + onLinked: () => void +} + +interface UserMatchSuggestionProps extends UserMatchProps { + match?: UserProfile + detail?: string + hasValue: boolean + isLoading: boolean + isError: boolean + missingMessage: string + emptyMessage: string +} + +const UserMatchSuggestion = ({ + member, + onLinked, + match, + detail, + hasValue, + isLoading, + isError, + missingMessage, + emptyMessage, +}: UserMatchSuggestionProps) => { + const linkMutation = useLinkDonorToUser() + const donorEmail = member.donorEmail + + const statusMessage = !hasValue + ? missingMessage + : isLoading + ? 'Searching users…' + : isError + ? 'Failed to search users' + : match == null + ? emptyMessage + : undefined + + const displayName = + match?.preferredName ?? + [match?.firstName, match?.lastName].filter(Boolean).join(' ') + + return ( +
+ Suggested User + {statusMessage ? ( +
{statusMessage}
+ ) : ( +
+
+ + + {displayName || `User ${match?.id}`} + + + {detail} + + + +
+
+ )} + {linkMutation.isError && ( +
Failed to link user
+ )} +
+ ) +} + +export const EmailUserMatch = ({ member, onLinked }: UserMatchProps) => { + const email = member.donorEmail ?? member.discordEmail + const { match, isLoading, isError } = useUserByEmail(email) + + return ( + + ) +} + +export const PhoneUserMatch = ({ member, onLinked }: UserMatchProps) => { + const phone = member.donorPhone ?? member.phone + const { match, matchedPhone, isLoading, isError } = useUserByPhone(phone) + + return ( + + ) +} + +export const DiscordUserMatch = ({ member, onLinked }: UserMatchProps) => { + const handle = member.discordUsername ?? member.contributionDiscord + const { match, matchedHandle, isLoading, isError } = + useUserByDiscord(handle) + + return ( + + ) +} + +export const NameUserMatch = ({ member, onLinked }: UserMatchProps) => { + const name = member.donorName ?? member.userName + const { match, ambiguous, isLoading, isError } = useUserByName(name) + + return ( + + ) +} diff --git a/src/app/volunteer_dashboard/panels/membership/hooks.ts b/src/app/volunteer_dashboard/panels/membership/hooks.ts index fbc38e8c..4d1068fc 100644 --- a/src/app/volunteer_dashboard/panels/membership/hooks.ts +++ b/src/app/volunteer_dashboard/panels/membership/hooks.ts @@ -5,6 +5,10 @@ import { isValidAddressDraft, isValidPhone, mapPacketToMember, + discordKey, + nameKey, + normalizeEmail, + phoneKey, } from './membership.helpers' import { DescribeChange, @@ -15,7 +19,8 @@ import { MembershipTableMode, PendingUpdate, } from './membership.types' -import { User, zUser } from '@/contracts/data' +import { User, UserProfile, zUser, zUserProfile } from '@/contracts/data' +import { ActBlueDonorLinkRequest } from '@/contracts/requests' import { zMembershipsResponsePacket, zPaginatedResponse, @@ -38,6 +43,7 @@ import { const HISTORY_STALE_TIME = 5 * 60 * 1000 const HISTORY_LIMIT = 5 +const USER_MATCH_LIMIT = 10 const PAGE_SIZE = 250 const EMPTY_DRAFT: MemberEdits = {} const noopUnsubscribe = () => undefined @@ -175,6 +181,146 @@ export function useMemberDraft(edit: EditController, member: Member) { return useSyncExternalStore(subscribe, getDraft, getDraft) } +function useUserSearch(query?: string, searchField?: string) { + const { ready, onGet } = useFetch() + + return useQuery({ + queryKey: ['/users', { query, searchField, limit: USER_MATCH_LIMIT }], + queryFn: + ready && query + ? ({ signal }: { signal: AbortSignal }) => + onGet('/users', zPaginatedResponse(zUserProfile), { + query: { + query, + searchField, + limit: USER_MATCH_LIMIT, + }, + signal, + }) + : skipToken, + staleTime: HISTORY_STALE_TIME, + }) +} + +export function useUserByEmail(email?: string) { + const normalized = email ? normalizeEmail(email) : undefined + const query = useUserSearch(normalized) + + const match = query.data?.data.find( + (user) => + user.email != null && normalizeEmail(user.email) === normalized + ) + + return { match, isLoading: query.isLoading, isError: query.isError } +} + +export function useUserByPhone(phone?: string) { + const { ready, onGet } = useFetch() + const digits = phone ? phoneKey(phone) : undefined + const search = useUserSearch(digits, 'phone') + const candidate = search.data?.data[0] + + const candidateQuery = useQuery({ + queryKey: ['/users/:userId', candidate?.id], + queryFn: + ready && candidate != null + ? ({ signal }: { signal: AbortSignal }) => + onGet('/users/:userId', zUser, { + params: { userId: candidate.id }, + signal, + }) + : skipToken, + staleTime: HISTORY_STALE_TIME, + }) + + const candidatePhone = candidateQuery.data?.phone ?? undefined + const matched = + candidatePhone != null && phoneKey(candidatePhone) === digits + + return { + match: matched ? candidate : undefined, + matchedPhone: matched ? candidatePhone : undefined, + isLoading: search.isLoading || candidateQuery.isLoading, + isError: search.isError || candidateQuery.isError, + } +} + +export function useUserByDiscord(handle?: string) { + const normalized = handle ? discordKey(handle) : undefined + const query = useUserSearch(normalized, 'discord_usernames') + + const matchedHandle = query.data?.data + .flatMap((user) => + user.discordUsers.map((discord) => ({ user, discord })) + ) + .find(({ discord }) => discordKey(discord.username) === normalized) + + return { + match: matchedHandle?.user, + matchedHandle: matchedHandle?.discord.username, + isLoading: query.isLoading, + isError: query.isError, + } +} + +const userNameKeys = (user: UserProfile) => + [ + user.preferredName, + [user.firstName, user.lastName].filter(Boolean).join(' '), + ...user.aliases, + ].flatMap((name) => (name ? [nameKey(name)] : [])) + +export function useUserByName(name?: string) { + const normalized = name ? nameKey(name) : undefined + const query = useUserSearch(normalized) + + const matches = + query.data?.data.filter((user) => + userNameKeys(user).includes(normalized ?? '') + ) ?? [] + + return { + match: matches.length === 1 ? matches[0] : undefined, + ambiguous: matches.length > 1, + isLoading: query.isLoading, + isError: query.isError, + } +} + +export function useLinkDonorToUser() { + const { onPost } = useFetch() + const loggedInUser = useCurrentUser() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + donorEmail, + userId, + }: { + donorEmail: string + userId: number + }) => + await onPost( + '/actblue/donors/:donorEmail/link', + { + userId, + metaData: { + dataSource: 'Membership Panel', + userWhoUpdatedId: loggedInUser.data?.id, + }, + } satisfies ActBlueDonorLinkRequest, + null, + { params: { donorEmail } } + ), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ['/actblue/memberships'], + }) + }, + onError: (error) => console.error(error), + }) +} + export function useSaveMemberships(onSaved: () => void) { const { onPatch } = useFetch() const loggedInUser = useCurrentUser() @@ -316,6 +462,10 @@ export function useMembershipPanel() { const { query, members, totalEntries } = useMembershipsQuery() const { fetchNextPage, hasNextPage, isFetchingNextPage } = query + const isPending: boolean = query.isPending + const error: Error | null = query.error + const refetchQuery = query.refetch + const refetch = useCallback(() => void refetchQuery(), [refetchQuery]) const { sentinelRef } = useInfiniteScroll({ hasNextPage, isFetchingNextPage, @@ -350,5 +500,8 @@ export function useMembershipPanel() { hasNextPage, isFetchingNextPage, sentinelRef, + isPending, + error, + refetch, } } diff --git a/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts b/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts index ea0f3c9c..229d3967 100644 --- a/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts +++ b/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts @@ -45,9 +45,13 @@ export const formatUserAddress = (address?: UserAddress) => export const normalizePhone = (phone: string) => phone.replace(/\D/g, '') +/** Digits without the US country code, so `+1 555…` and `555…` compare equal. */ +export const phoneKey = (phone: string) => + normalizePhone(phone).replace(/^1(?=\d{10}$)/, '') + export const formatPhone = (phone?: string) => { if (!phone) return '—' - const digits = normalizePhone(phone).replace(/^1(?=\d{10}$)/, '') + const digits = phoneKey(phone) if (digits.length === 10) { return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}` } @@ -59,6 +63,12 @@ export const normalizeEmail = (email: string) => email.trim().toLowerCase() export const normalizeDiscordHandle = (handle: string) => handle.trim().replace(/^@/, '') +export const discordKey = (handle: string) => + normalizeDiscordHandle(handle).toLowerCase() + +export const nameKey = (name: string) => + name.trim().toLowerCase().replace(/\s+/g, ' ') + export const resolveSelectDraft = ( draft: T | null | undefined, saved: T | undefined diff --git a/src/app/volunteer_dashboard/panels/membership/membershipCells.tsx b/src/app/volunteer_dashboard/panels/membership/membershipCells.tsx index 1a731a3b..4164ce27 100644 --- a/src/app/volunteer_dashboard/panels/membership/membershipCells.tsx +++ b/src/app/volunteer_dashboard/panels/membership/membershipCells.tsx @@ -4,6 +4,12 @@ import { EditableSelectTag } from './components/EditableSelectTag' import { SourceMenu } from './components/SourceMenu' import { ConfirmedBadge } from './components/Tags' import tags from './components/Tags.module.css' +import { + DiscordUserMatch, + EmailUserMatch, + NameUserMatch, + PhoneUserMatch, +} from './components/UserMatch' import { useMemberDraft } from './hooks' import { describeAddressChange, @@ -110,6 +116,7 @@ export const NameMenu = ({ member, closeDropdown }: MemberMenuProps) => ( emptyMessage: 'No name changes found', describeChange: describeNameChange, }} + unlinked={} /> ) @@ -144,6 +151,7 @@ export const DiscordMenu = ({ member, closeDropdown }: MemberMenuProps) => ( { label: 'Discord', value: member.discordUsername }, { label: 'Contribution', value: member.contributionDiscord }, ]} + unlinked={} /> ) @@ -331,6 +339,7 @@ export const PhoneMenu = ({ member, closeDropdown }: MemberMenuProps) => ( emptyMessage: 'No phone changes found', describeChange: describePhoneChange, }} + unlinked={} /> ) @@ -385,6 +394,7 @@ export const EmailMenu = ({ member, closeDropdown }: MemberMenuProps) => ( emptyMessage: 'No email changes found', describeChange: describeEmailChange, }} + unlinked={} /> ) diff --git a/src/app/volunteer_dashboard/panels/membership/page.module.css b/src/app/volunteer_dashboard/panels/membership/page.module.css index ff9769ab..dd56c6f8 100644 --- a/src/app/volunteer_dashboard/panels/membership/page.module.css +++ b/src/app/volunteer_dashboard/panels/membership/page.module.css @@ -109,6 +109,30 @@ text-align: center; } +.tableStatus { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 2.5rem 1rem; + color: rgba(15, 23, 42, 0.55); + font-size: 0.9rem; + font-weight: 600; + text-align: center; +} + +.tableStatusTitle { + margin: 0; + color: rgba(15, 23, 42, 0.8); +} + +.tableStatusDetail { + margin: 0; + max-width: 48rem; + font-weight: 500; + overflow-wrap: anywhere; +} + .tableToolbar { display: flex; align-items: center; diff --git a/src/app/volunteer_dashboard/panels/membership/page.tsx b/src/app/volunteer_dashboard/panels/membership/page.tsx index 19f54b9b..90b88472 100644 --- a/src/app/volunteer_dashboard/panels/membership/page.tsx +++ b/src/app/volunteer_dashboard/panels/membership/page.tsx @@ -1,6 +1,7 @@ 'use client' import { + useLinkDonorToUser, useMembershipPanel, usePendingUpdates, useSaveMemberships, @@ -19,10 +20,8 @@ import { import Panel from '@/components/common/panel/Panel' import { Table } from '@/components/common/table' import { UserProfile, zUserProfile } from '@/contracts/data' -import { ActBlueDonorLinkRequest } from '@/contracts/requests' import { cn } from '@/util' -import { useCurrentUser, useFetch, usePaginatedSearch } from '@/util/hooks' -import { useQueryClient } from '@tanstack/react-query' +import { usePaginatedSearch } from '@/util/hooks' import { ChangeEvent, useCallback, useMemo, useState } from 'react' import { FaEdit, FaSave, FaTrashAlt } from 'react-icons/fa' @@ -128,10 +127,8 @@ function EditToolbar({ } export default function Page() { - const { onPost } = useFetch() - const loggedInUser = useCurrentUser() - const queryClient = useQueryClient() const [memberToMatch, setMemberToMatch] = useState(null) + const linkMutation = useLinkDonorToUser() const { members, totalEntries, @@ -145,6 +142,9 @@ export default function Page() { hasNextPage, isFetchingNextPage, sentinelRef, + isPending, + error, + refetch, } = useMembershipPanel() const { @@ -158,25 +158,10 @@ export default function Page() { const donorEmail = memberToMatch?.donorEmail if (donorEmail == null) return - await onPost( - '/actblue/donors/:donorEmail/link', - { - userId: user.id, - metaData: { - dataSource: 'Membership Panel', - userWhoUpdatedId: loggedInUser.data?.id, - }, - } satisfies ActBlueDonorLinkRequest, - null, - { params: { donorEmail } } - ) - + await linkMutation.mutateAsync({ donorEmail, userId: user.id }) setMemberToMatch(null) - await queryClient.invalidateQueries({ - queryKey: ['/actblue/memberships'], - }) }, - [loggedInUser.data?.id, memberToMatch?.donorEmail, onPost, queryClient] + [linkMutation, memberToMatch?.donorEmail] ) const handleUserSearch = useCallback( @@ -295,25 +280,47 @@ export default function Page() {
-
m.id} - collapsedCategories={collapsedCategories} - mode={tableMode} - zebra={options.showZebra} - footer={ - hasNextPage && ( -
- {isFetchingNextPage && - 'Loading more membership records...'} -
- ) - } - /> + {error ? ( +
+

+ Could not load membership records. +

+

+ {error.message} +

+ +
+ ) : isPending ? ( +
+ Loading membership records... +
+ ) : ( +
m.id} + collapsedCategories={collapsedCategories} + mode={tableMode} + zebra={options.showZebra} + footer={ + hasNextPage && ( +
+ {isFetchingNextPage && + 'Loading more membership records...'} +
+ ) + } + /> + )} diff --git a/src/contracts/requests/index.ts b/src/contracts/requests/index.ts index e3b7baa7..ae0218eb 100644 --- a/src/contracts/requests/index.ts +++ b/src/contracts/requests/index.ts @@ -18,6 +18,7 @@ export * from './GetDiscordWarnsRequest' export * from './GetRolesSearchRequest' export * from './GetUsersSearchRequest' export * from './MembershipEvalBackfillRequest' +export * from './MembershipSearchRequest' export * from './CreateUserRequest' export * from './MutationRequest' export * from './SearchRequest' From 20dfc67081bd0f51496a696700d42e3e8592555c Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Thu, 17 Sep 2026 18:41:24 -0400 Subject: [PATCH 19/27] push --- .../panels/membership/components/SourceMenu.tsx | 1 - .../volunteer_dashboard/panels/membership/membership.helpers.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/app/volunteer_dashboard/panels/membership/components/SourceMenu.tsx b/src/app/volunteer_dashboard/panels/membership/components/SourceMenu.tsx index 7d2556d1..8c0e0197 100644 --- a/src/app/volunteer_dashboard/panels/membership/components/SourceMenu.tsx +++ b/src/app/volunteer_dashboard/panels/membership/components/SourceMenu.tsx @@ -63,7 +63,6 @@ export interface SourceMenuProps { confirmed?: boolean confirmedSource?: string history?: Omit - /** Replaces the history section while the member has no linked user. */ unlinked?: ReactNode } diff --git a/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts b/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts index 229d3967..9be795e0 100644 --- a/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts +++ b/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts @@ -45,7 +45,6 @@ export const formatUserAddress = (address?: UserAddress) => export const normalizePhone = (phone: string) => phone.replace(/\D/g, '') -/** Digits without the US country code, so `+1 555…` and `555…` compare equal. */ export const phoneKey = (phone: string) => normalizePhone(phone).replace(/^1(?=\d{10}$)/, '') From 40605de3ced337dad52920726b383f053320e581 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Thu, 17 Sep 2026 19:18:03 -0400 Subject: [PATCH 20/27] update --- pnpm-lock.yaml | 10 +++++----- src/app/admin/layout.tsx | 3 --- .../panels/membership/components/FieldHistory.tsx | 2 +- .../panels/membership/components/UserMatch.tsx | 2 +- .../volunteer_dashboard/panels/membership/hooks.ts | 12 ++++++------ .../panels/membership/membership.helpers.ts | 6 +++--- .../panels/membership/membership.types.ts | 12 ++++-------- .../volunteer_dashboard/panels/membership/page.tsx | 2 +- src/contracts/requests/MembershipSearchRequest.ts | 9 --------- 9 files changed, 21 insertions(+), 37 deletions(-) delete mode 100644 src/contracts/requests/MembershipSearchRequest.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 962030be..868b284d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,7 +77,7 @@ importers: version: 1.0.3 pv-contracts: specifier: github:Progressive-Victory/the-contracts#main&path:/dist - version: '@progressive-victory/the-contracts@https://codeload.github.com/Progressive-Victory/the-contracts/tar.gz/47f5d648145135bdfc0ccfb065f69d2740fba152#path:/dist' + version: '@progressive-victory/the-contracts@https://codeload.github.com/Progressive-Victory/the-contracts/tar.gz/9a9ddb9618d25463954eb3aa16f837bc4922ce8e#path:/dist' react: specifier: ^19.3.0 version: 19.3.0 @@ -1227,9 +1227,9 @@ packages: engines: {node: '>=20'} hasBin: true - '@progressive-victory/the-contracts@https://codeload.github.com/Progressive-Victory/the-contracts/tar.gz/47f5d648145135bdfc0ccfb065f69d2740fba152#path:/dist': - resolution: {path: /dist, tarball: https://codeload.github.com/Progressive-Victory/the-contracts/tar.gz/47f5d648145135bdfc0ccfb065f69d2740fba152} - version: 9.0.0 + '@progressive-victory/the-contracts@https://codeload.github.com/Progressive-Victory/the-contracts/tar.gz/9a9ddb9618d25463954eb3aa16f837bc4922ce8e#path:/dist': + resolution: {path: /dist, tarball: https://codeload.github.com/Progressive-Victory/the-contracts/tar.gz/9a9ddb9618d25463954eb3aa16f837bc4922ce8e} + version: 9.0.1 '@radix-ui/primitive@1.1.7': resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} @@ -5950,7 +5950,7 @@ snapshots: dependencies: playwright: 1.63.0 - '@progressive-victory/the-contracts@https://codeload.github.com/Progressive-Victory/the-contracts/tar.gz/47f5d648145135bdfc0ccfb065f69d2740fba152#path:/dist': + '@progressive-victory/the-contracts@https://codeload.github.com/Progressive-Victory/the-contracts/tar.gz/9a9ddb9618d25463954eb3aa16f837bc4922ce8e#path:/dist': dependencies: '@fastify/multipart': 10.1.1 zod: 4.6.5 diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 3f356c0d..e0c39dfb 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -15,9 +15,6 @@ import { zUser, } from 'pv-contracts/data' import { zMembershipsResponsePacket } from 'pv-contracts/responses' -import { usePositionQueries, useEndorsementQueries } from '@/queries' -import { usePaginatedSearch } from '@/util/hooks' -import { useQuery } from '@tanstack/react-query' export default function Layout({ children }: { children: React.ReactNode }) { const positionQueries = usePositionQueries() diff --git a/src/app/volunteer_dashboard/panels/membership/components/FieldHistory.tsx b/src/app/volunteer_dashboard/panels/membership/components/FieldHistory.tsx index 7e129755..e9c0c30b 100644 --- a/src/app/volunteer_dashboard/panels/membership/components/FieldHistory.tsx +++ b/src/app/volunteer_dashboard/panels/membership/components/FieldHistory.tsx @@ -3,7 +3,7 @@ import { useFieldHistory } from '../hooks' import { DescribeChange, Member } from '../membership.types' import styles from './FieldHistory.module.css' -import { User } from '@/contracts/data' +import { User } from 'pv-contracts/data' export const selectUserHistory = (user: User) => user.history ?? [] diff --git a/src/app/volunteer_dashboard/panels/membership/components/UserMatch.tsx b/src/app/volunteer_dashboard/panels/membership/components/UserMatch.tsx index f414d5a7..4af83bab 100644 --- a/src/app/volunteer_dashboard/panels/membership/components/UserMatch.tsx +++ b/src/app/volunteer_dashboard/panels/membership/components/UserMatch.tsx @@ -10,7 +10,7 @@ import { import { formatPhone } from '../membership.helpers' import { Member } from '../membership.types' import styles from './UserMatch.module.css' -import { UserProfile } from '@/contracts/data' +import { UserProfile } from 'pv-contracts/data' export interface UserMatchProps { member: Member diff --git a/src/app/volunteer_dashboard/panels/membership/hooks.ts b/src/app/volunteer_dashboard/panels/membership/hooks.ts index 4d1068fc..b598da34 100644 --- a/src/app/volunteer_dashboard/panels/membership/hooks.ts +++ b/src/app/volunteer_dashboard/panels/membership/hooks.ts @@ -19,12 +19,6 @@ import { MembershipTableMode, PendingUpdate, } from './membership.types' -import { User, UserProfile, zUser, zUserProfile } from '@/contracts/data' -import { ActBlueDonorLinkRequest } from '@/contracts/requests' -import { - zMembershipsResponsePacket, - zPaginatedResponse, -} from '@/contracts/responses' import { useCurrentUser, useFetch, useInfiniteScroll } from '@/util/hooks' import { skipToken, @@ -33,6 +27,12 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query' +import { User, UserProfile, zUser, zUserProfile } from 'pv-contracts/data' +import { ActBlueDonorLinkRequest } from 'pv-contracts/requests' +import { + zMembershipsResponsePacket, + zPaginatedResponse, +} from 'pv-contracts/responses' import { useCallback, useMemo, diff --git a/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts b/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts index 9be795e0..f495365c 100644 --- a/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts +++ b/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts @@ -17,13 +17,13 @@ import { MembershipDeliverableStatus, ShirtSize as ApiShirtSize, UserAddress, -} from '@/contracts/data' +} from 'pv-contracts/data' import { UpdateMembershipRequest, UpdateUserAddressRequest, UpdateUserRequest, -} from '@/contracts/requests' -import { MembershipsResponsePacket } from '@/contracts/responses' +} from 'pv-contracts/requests' +import { MembershipsResponsePacket } from 'pv-contracts/responses' export const joinName = (firstName?: string | null, lastName?: string | null) => [firstName, lastName].filter(Boolean).join(' ').trim() || undefined diff --git a/src/app/volunteer_dashboard/panels/membership/membership.types.ts b/src/app/volunteer_dashboard/panels/membership/membership.types.ts index 0275e1a8..85741d44 100644 --- a/src/app/volunteer_dashboard/panels/membership/membership.types.ts +++ b/src/app/volunteer_dashboard/panels/membership/membership.types.ts @@ -2,12 +2,12 @@ import { MembershipDeliverableStatus, User, UserAddress, -} from '@/contracts/data' +} from 'pv-contracts/data' import { UpdateMembershipRequest, UpdateUserRequest, -} from '@/contracts/requests' -import { MembershipsResponsePacket } from '@/contracts/responses' +} from 'pv-contracts/requests' +import { MembershipsResponsePacket } from 'pv-contracts/responses' export const membershipTiers = [ 'Dues Paying Member', @@ -50,11 +50,7 @@ export interface RecurringSummary { } export type PackageShipped = - | 'Yes' - | 'No' - | 'Returned' - | 'Not Received' - | 'Canceled' + 'Yes' | 'No' | 'Returned' | 'Not Received' | 'Canceled' export interface Member { packet: MembershipsResponsePacket diff --git a/src/app/volunteer_dashboard/panels/membership/page.tsx b/src/app/volunteer_dashboard/panels/membership/page.tsx index 90b88472..cc4e2f9f 100644 --- a/src/app/volunteer_dashboard/panels/membership/page.tsx +++ b/src/app/volunteer_dashboard/panels/membership/page.tsx @@ -19,9 +19,9 @@ import { } from '@/components/common' import Panel from '@/components/common/panel/Panel' import { Table } from '@/components/common/table' -import { UserProfile, zUserProfile } from '@/contracts/data' import { cn } from '@/util' import { usePaginatedSearch } from '@/util/hooks' +import { UserProfile, zUserProfile } from 'pv-contracts/data' import { ChangeEvent, useCallback, useMemo, useState } from 'react' import { FaEdit, FaSave, FaTrashAlt } from 'react-icons/fa' diff --git a/src/contracts/requests/MembershipSearchRequest.ts b/src/contracts/requests/MembershipSearchRequest.ts deleted file mode 100644 index d193e67a..00000000 --- a/src/contracts/requests/MembershipSearchRequest.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { zSearchRequest } from './SearchRequest' -import z from 'zod' - -export const zMembershipSearchRequest = zSearchRequest.extend({ - isBenefitEligible: z.boolean(), - isMember: z.boolean(), -}) - -export type MembershipSearchRequest = z.infer From dfcbf85be78c9dc64e86a88814dce795f4d6dc60 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Fri, 18 Sep 2026 12:16:11 -0400 Subject: [PATCH 21/27] additional fixes --- pnpm-lock.yaml | 10 +- .../components/TierHistoryMenu.module.css | 74 ++++++++++++++ .../membership/components/TierHistoryMenu.tsx | 96 +++++++++++++++++++ .../panels/membership/hooks.ts | 17 +++- .../panels/membership/membership.helpers.ts | 38 ++++++++ .../panels/membership/membership.types.ts | 9 ++ .../panels/membership/membershipColumns.tsx | 5 + 7 files changed, 242 insertions(+), 7 deletions(-) create mode 100644 src/app/volunteer_dashboard/panels/membership/components/TierHistoryMenu.module.css create mode 100644 src/app/volunteer_dashboard/panels/membership/components/TierHistoryMenu.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 868b284d..a2c9fb63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,7 +77,7 @@ importers: version: 1.0.3 pv-contracts: specifier: github:Progressive-Victory/the-contracts#main&path:/dist - version: '@progressive-victory/the-contracts@https://codeload.github.com/Progressive-Victory/the-contracts/tar.gz/9a9ddb9618d25463954eb3aa16f837bc4922ce8e#path:/dist' + version: '@progressive-victory/the-contracts@https://codeload.github.com/Progressive-Victory/the-contracts/tar.gz/cc566e2ffa70d9a57371ab3286481cc951f0df51#path:/dist' react: specifier: ^19.3.0 version: 19.3.0 @@ -1227,9 +1227,9 @@ packages: engines: {node: '>=20'} hasBin: true - '@progressive-victory/the-contracts@https://codeload.github.com/Progressive-Victory/the-contracts/tar.gz/9a9ddb9618d25463954eb3aa16f837bc4922ce8e#path:/dist': - resolution: {path: /dist, tarball: https://codeload.github.com/Progressive-Victory/the-contracts/tar.gz/9a9ddb9618d25463954eb3aa16f837bc4922ce8e} - version: 9.0.1 + '@progressive-victory/the-contracts@https://codeload.github.com/Progressive-Victory/the-contracts/tar.gz/cc566e2ffa70d9a57371ab3286481cc951f0df51#path:/dist': + resolution: {path: /dist, tarball: https://codeload.github.com/Progressive-Victory/the-contracts/tar.gz/cc566e2ffa70d9a57371ab3286481cc951f0df51} + version: 9.0.3 '@radix-ui/primitive@1.1.7': resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} @@ -5950,7 +5950,7 @@ snapshots: dependencies: playwright: 1.63.0 - '@progressive-victory/the-contracts@https://codeload.github.com/Progressive-Victory/the-contracts/tar.gz/9a9ddb9618d25463954eb3aa16f837bc4922ce8e#path:/dist': + '@progressive-victory/the-contracts@https://codeload.github.com/Progressive-Victory/the-contracts/tar.gz/cc566e2ffa70d9a57371ab3286481cc951f0df51#path:/dist': dependencies: '@fastify/multipart': 10.1.1 zod: 4.6.5 diff --git a/src/app/volunteer_dashboard/panels/membership/components/TierHistoryMenu.module.css b/src/app/volunteer_dashboard/panels/membership/components/TierHistoryMenu.module.css new file mode 100644 index 00000000..b42afecc --- /dev/null +++ b/src/app/volunteer_dashboard/panels/membership/components/TierHistoryMenu.module.css @@ -0,0 +1,74 @@ +.tierHistoryOverlay { + min-width: 20rem; +} + +.tierHistoryBody { + display: flex; + flex-direction: column; + gap: 0.6rem; + width: 100%; +} + +.tierHistorySummary { + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.02em; + color: rgba(15, 23, 42, 0.56); +} + +.tierHistoryEmpty { + font-size: 0.8rem; + font-weight: 500; + color: rgba(15, 23, 42, 0.5); +} + +.listBox { + display: flex; + flex-direction: column; + width: 100%; + overflow: hidden; + border: 1px solid rgba(17, 24, 39, 0.1); + border-radius: 0.75rem; + background: rgba(255, 255, 255, 0.72); +} + +.tierRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.5rem 0.65rem; + border-bottom: 1px solid rgba(17, 24, 39, 0.08); + font-size: 0.8rem; + font-weight: 500; + letter-spacing: -0.01em; + + &:last-child { + border-bottom: 0; + } +} + +.tierMeta { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; +} + +.tierRange { + color: rgba(15, 23, 42, 0.9); + font-weight: 600; +} + +.tierPayments { + color: rgba(15, 23, 42, 0.5); + font-weight: 500; + font-size: 0.72rem; +} + +.tierAmount { + color: rgba(15, 23, 42, 0.62); + font-weight: 600; + white-space: nowrap; +} diff --git a/src/app/volunteer_dashboard/panels/membership/components/TierHistoryMenu.tsx b/src/app/volunteer_dashboard/panels/membership/components/TierHistoryMenu.tsx new file mode 100644 index 00000000..11b35676 --- /dev/null +++ b/src/app/volunteer_dashboard/panels/membership/components/TierHistoryMenu.tsx @@ -0,0 +1,96 @@ +'use client' + +import { + buildTierTimeline, + filterRecurringContributions, +} from '../membership.helpers' +import { MemberMenuProps, MembershipTier } from '../membership.types' +import tags from './Tags.module.css' +import styles from './TierHistoryMenu.module.css' +import { DropdownOverlay } from '@/components/common' +import { cn } from '@/util' + +const tierTagClass: Record = { + 'Dues Paying Member': tags.tagMember, + 'Premium Member': tags.tagPremium, + 'Signature Member': tags.tagSignature, + 'Inner Circle Member': tags.tagInnerCircle, +} + +const formatDate = (value: string) => + new Date(value).toLocaleDateString([], { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + +const formatAmount = (amount: number) => + amount.toLocaleString('en-US', { style: 'currency', currency: 'USD' }) + +export const TierHistoryMenu = ({ member, closeDropdown }: MemberMenuProps) => { + const segments = buildTierTimeline( + filterRecurringContributions(member.contributionRecords) + ).reverse() + + return ( + + No recurring contributions found + + ) : ( + <> + + {segments.length} tier change + {segments.length === 1 ? '' : 's'} + +
+ {segments.map((segment) => ( +
+ + + {segment.tier ?? 'Not A Member'} + + + {formatDate(segment.from)} + {segment.to !== segment.from && + ` – ${formatDate(segment.to)}`} + + + {segment.payments} payment + {segment.payments === 1 ? '' : 's'} + + + + {formatAmount(segment.minAmount)} + {segment.maxAmount !== + segment.minAmount && + ` – ${formatAmount(segment.maxAmount)}`} + +
+ ))} +
+ + ) + } + /> + ) +} diff --git a/src/app/volunteer_dashboard/panels/membership/hooks.ts b/src/app/volunteer_dashboard/panels/membership/hooks.ts index b598da34..0d09f47f 100644 --- a/src/app/volunteer_dashboard/panels/membership/hooks.ts +++ b/src/app/volunteer_dashboard/panels/membership/hooks.ts @@ -395,10 +395,18 @@ function useMembershipsQuery() { onGet( '/actblue/memberships', zPaginatedResponse(zMembershipsResponsePacket), - { query: { page: pageParam, limit: PAGE_SIZE }, signal } + { + query: { + page: pageParam, + limit: PAGE_SIZE, + isMember: true, + }, + signal, + } ), initialPageParam: 0, getNextPageParam: (lastPage, pages) => { + if (lastPage.data.length < PAGE_SIZE) return undefined const loadedCount = pages.reduce( (total, page) => total + page.data.length, 0 @@ -416,7 +424,12 @@ function useMembershipsQuery() { [query.data] ) - return { query, members, totalEntries: query.data?.pages[0]?.count } + const totalEntries = + query.data != null && !query.hasNextPage + ? members.length + : query.data?.pages[0]?.count + + return { query, members, totalEntries } } export function usePendingUpdates(edit: EditController, members: Member[]) { diff --git a/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts b/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts index f495365c..43d9ea39 100644 --- a/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts +++ b/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts @@ -12,6 +12,7 @@ import { PendingUpdate, RecurringSummary, ShirtSize, + TierSegment, } from './membership.types' import { MembershipDeliverableStatus, @@ -271,6 +272,43 @@ export const getMembershipTierForAmount = (amount?: number) => { return tierThresholds.find(([min]) => amount >= min)?.[1] } +// collapses each payment's tier into contiguous runs, so a change of recurring amount starts a new segment +export const buildTierTimeline = ( + records: ContributionRecord[] +): TierSegment[] => { + const payments = records + .flatMap((record) => + Object.values(record.lineitemsByMonth ?? {}).flat() + ) + .sort((a, b) => a.paidAt.localeCompare(b.paidAt)) + + const segments: TierSegment[] = [] + + for (const payment of payments) { + const tier = getMembershipTierForAmount(payment.amount) + const current = segments.at(-1) + + if (current && current.tier === tier) { + current.to = payment.paidAt + current.payments += 1 + current.minAmount = Math.min(current.minAmount, payment.amount) + current.maxAmount = Math.max(current.maxAmount, payment.amount) + continue + } + + segments.push({ + tier, + from: payment.paidAt, + to: payment.paidAt, + payments: 1, + minAmount: payment.amount, + maxAmount: payment.amount, + }) + } + + return segments +} + export const computeRecurringSummary = ( records: ContributionRecord[], referenceDate: Date = new Date() diff --git a/src/app/volunteer_dashboard/panels/membership/membership.types.ts b/src/app/volunteer_dashboard/panels/membership/membership.types.ts index 85741d44..ca199bed 100644 --- a/src/app/volunteer_dashboard/panels/membership/membership.types.ts +++ b/src/app/volunteer_dashboard/panels/membership/membership.types.ts @@ -49,6 +49,15 @@ export interface RecurringSummary { tier?: MembershipTier } +export interface TierSegment { + tier?: MembershipTier + from: string + to: string + payments: number + minAmount: number + maxAmount: number +} + export type PackageShipped = 'Yes' | 'No' | 'Returned' | 'Not Received' | 'Canceled' diff --git a/src/app/volunteer_dashboard/panels/membership/membershipColumns.tsx b/src/app/volunteer_dashboard/panels/membership/membershipColumns.tsx index fb285ccd..fc69a3db 100644 --- a/src/app/volunteer_dashboard/panels/membership/membershipColumns.tsx +++ b/src/app/volunteer_dashboard/panels/membership/membershipColumns.tsx @@ -3,6 +3,7 @@ import { ContributionsMenu } from './components/ContributionsMenu' import { BoolTag, EditableBoolTag } from './components/Tags' import tags from './components/Tags.module.css' +import { TierHistoryMenu } from './components/TierHistoryMenu' import { MembershipTableOptions, useMemberDraft } from './hooks' import { hasAddressDraftChange, @@ -262,10 +263,14 @@ export const buildColumns = ({ key: 'membershipTier', header: 'Tier', width: '11rem', + allowOverflow: true, sortValue: (m) => { const tier = m.recurringSummary?.tier return tier ? membershipTierRank[tier] : 0 }, + menu: (m, { closeDropdown }) => ( + + ), render: (m) => { const tier = m.recurringSummary?.tier return tier ? ( From d16b4c6cc804f438b6d2868468c3e22996d4a334 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Fri, 18 Sep 2026 17:36:00 -0400 Subject: [PATCH 22/27] implmenting a ton of new shit --- src/app/volunteer_dashboard/layout.tsx | 8 +- .../panels/fundraising/page.tsx | 4 +- .../useFundraisingDashboardController.ts | 16 +- .../panels/members/panel_views/DonorView.tsx | 227 ++++------ .../components/ContributionsMenu.tsx | 8 +- .../components/EditableSelectTag.tsx | 2 +- .../membership/components/TierHistoryMenu.tsx | 155 ++++--- .../panels/membership/hooks.ts | 150 ++++++- .../panels/membership/membership.helpers.ts | 46 ++- .../panels/membership/membership.types.ts | 9 + .../membership/membershipCells.module.css | 11 + .../panels/membership/membershipCells.tsx | 157 ++++++- .../panels/membership/membershipColumns.tsx | 45 +- .../panels/membership/page.module.css | 150 ++++++- .../panels/membership/page.tsx | 131 +++++- src/components/common/table/Table.module.css | 66 +++ src/components/common/table/Table.tsx | 386 ++++++++++++++++-- src/components/common/table/index.ts | 9 +- 18 files changed, 1280 insertions(+), 300 deletions(-) diff --git a/src/app/volunteer_dashboard/layout.tsx b/src/app/volunteer_dashboard/layout.tsx index f411168a..29477938 100644 --- a/src/app/volunteer_dashboard/layout.tsx +++ b/src/app/volunteer_dashboard/layout.tsx @@ -20,6 +20,7 @@ import { zRole, zUser, } from 'pv-contracts/data' +import { MembershipSearchRequest } from 'pv-contracts/requests' import { zMembershipsResponsePacket } from 'pv-contracts/responses' import { Suspense, useEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' @@ -52,6 +53,11 @@ interface DashboardPanelConfigItem { groupChildren?: DashboardGroupChildConfigItem[] } +const membershipCountSearch: MembershipSearchRequest = { + limit: 1, + isMember: true, +} + export default function Layout({ children }: { children: ReactNode }) { return ( @@ -87,7 +93,7 @@ function LayoutContent({ children }: { children: ReactNode }) { const memberships = usePaginatedSearch( '/actblue/memberships', zMembershipsResponsePacket, - { search: { limit: 1 } } + { search: membershipCountSearch } ) const positionHierarchy = useQuery({ queryKey: ['positionHierarchy'], diff --git a/src/app/volunteer_dashboard/panels/fundraising/page.tsx b/src/app/volunteer_dashboard/panels/fundraising/page.tsx index 003f825a..a0d1c3ea 100644 --- a/src/app/volunteer_dashboard/panels/fundraising/page.tsx +++ b/src/app/volunteer_dashboard/panels/fundraising/page.tsx @@ -69,6 +69,7 @@ export default function Page() { statsQuery, allTimeStatsQuery, membershipCountQuery, + contributionCountQuery, setStartDate, setEndDate, setCommittedPreset, @@ -711,8 +712,7 @@ export default function Page() { href="/volunteer_dashboard/panels/contributions" icon={FaMoneyBills} tag={{ - count: allTimeStatsQuery.data - ?.totalContributionCount, + count: contributionCountQuery.data?.count, }} buttonType="card" trackPanelHistory diff --git a/src/app/volunteer_dashboard/panels/fundraising/useFundraisingDashboardController.ts b/src/app/volunteer_dashboard/panels/fundraising/useFundraisingDashboardController.ts index 13241396..1ddf5a9e 100644 --- a/src/app/volunteer_dashboard/panels/fundraising/useFundraisingDashboardController.ts +++ b/src/app/volunteer_dashboard/panels/fundraising/useFundraisingDashboardController.ts @@ -16,12 +16,19 @@ import { import { useActblueQueries } from '@/queries' import { usePaginatedSearch } from '@/util/hooks' import { keepPreviousData, useQueries, useQuery } from '@tanstack/react-query' +import { zActBlueDonationPacket } from 'pv-contracts/data' import { SortDirection } from 'pv-contracts/requests' +import type { MembershipSearchRequest } from 'pv-contracts/requests' import { zMembershipsResponsePacket } from 'pv-contracts/responses' import { useEffect, useMemo, useState } from 'react' type ChartBarDisplayMode = 'grouped' | 'stacked' +const membershipCountSearch: MembershipSearchRequest = { + limit: 1, + isMember: true, +} + function getPreviousEquivalentRange(start: Date, end: Date) { if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { return null @@ -179,7 +186,13 @@ export function useFundraisingDashboardController() { const membershipCountQuery = usePaginatedSearch( '/actblue/memberships', zMembershipsResponsePacket, - { search: { limit: 1 } } + { search: membershipCountSearch } + ).query + + const contributionCountQuery = usePaginatedSearch( + '/actblue/contributions', + zActBlueDonationPacket, + { search: { limit: 0 } } ).query const earliestContributionQuery = useQuery({ @@ -452,6 +465,7 @@ export function useFundraisingDashboardController() { statsQuery, allTimeStatsQuery, membershipCountQuery, + contributionCountQuery, setStartDate, setEndDate, setCommittedPreset, diff --git a/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx index fe05a284..e61ae6a4 100644 --- a/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx +++ b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx @@ -3,15 +3,9 @@ import styles from './DonorView.module.css' import { ListBody } from '@/app/admin/layout/List' import { SearchModal } from '@/app/admin/layout/SearchModal' -import { - DateField, - Form, - FormGroup, - TextField, -} from '@/components/common/forms' +import { Form, FormGroup, TextField } from '@/components/common/forms' import { NavigationButton } from '@/components/common/navigation_stack/navigation_button/NavigationButton' import type { UseQueryResult } from '@tanstack/react-query' -import Link from 'next/link' import { ActBlueContribution, ActBlueContributionCustomField, @@ -100,12 +94,6 @@ const calcContributionData = (donor: ActBlueDonor): ContributionData => { } } -const formatLineitemDate = (value: Date) => - Intl.DateTimeFormat('en-US', { - dateStyle: 'medium', - timeStyle: 'short', - }).format(value) - export function DonorView({ selectedId, user, @@ -196,129 +184,68 @@ export function DonorView({ const renderDonorSummaryFields = ( donor: ActBlueDonor, contributionData: ContributionData - ) => ( - <> - - - - - - - - - - - - - - - - - - label="Employer Name" - getter={(form) => form.employerData?.employer} - /> - - label="Occupation" - getter={(form) => form.employerData?.occupation} - /> - - label="Employer Street Address" - getter={(form) => form.employerData?.employerAddr1} - /> - - label="Employer City" - getter={(form) => form.employerData?.employerCity} - /> - - label="Employer State" - getter={(form) => form.employerData?.employerState} - /> - - label="Employer Zip Code" - getter={(form) => `${form.employerData?.employerZip ?? ''}`} - /> - - label="Employer Country" - getter={(form) => form.employerData?.employerCountry} - /> - - - - - label="Total Dollar Donations" - getter={() => `$${contributionData.total}`} - /> - - label="Currently Has a Recurring Donation" - getter={() => `${contributionData.hasActiveRecurring}`} - /> - - label="Total Contributions" - getter={() => `${contributionData.lineitems.length}`} - /> - - - {(contributionData.lineitems ?? []).map((lineitem) => ( - - - label="Paid At" - getter={() => lineitem.paidAt} - /> - - label="Sequence" - getter={() => `${lineitem.sequence}`} - /> - - label="Amount" - getter={() => `$${lineitem.amount}`} - /> - - label="Recurring Amount" - getter={() => `$${lineitem.recurringAmount}`} - /> - - label="Amount Less AB Fees" - getter={() => `$${lineitem.amountLessAbFees}`} - /> - - donor.contributions?.[0]?.contributionForm - } - /> - {contributionData.customFields?.map((field) => ( - field.answer} - /> - ))} - - - - ))} - - ) + ) => [ + + + + + + , + + + + + + + + , + + + + label="Employer Name" + getter={(form) => form.employerData?.employer} + /> + + label="Occupation" + getter={(form) => form.employerData?.occupation} + /> + + label="Employer Street Address" + getter={(form) => form.employerData?.employerAddr1} + /> + + label="Employer City" + getter={(form) => form.employerData?.employerCity} + /> + + label="Employer State" + getter={(form) => form.employerData?.employerState} + /> + + label="Employer Zip Code" + getter={(form) => `${form.employerData?.employerZip ?? ''}`} + /> + + label="Employer Country" + getter={(form) => form.employerData?.employerCountry} + /> + , + + + + label="Total Dollar Donations" + getter={() => `$${contributionData.total}`} + /> + + label="Currently Has a Recurring Donation" + getter={() => `${contributionData.hasActiveRecurring}`} + /> + + label="Total Contributions" + getter={() => `${contributionData.lineitems.length}`} + /> + , + ] const renderContributionList = ( contributionData: ContributionData, @@ -336,10 +263,6 @@ export function DonorView({ defaultCollapsed subGroup > - - label="Paid At" - getter={() => lineitem.paidAt} - /> label="Sequence" getter={() => `${lineitem.sequence}`} @@ -367,7 +290,7 @@ export function DonorView({ ) } /> -
+ {contributionData.customFields?.map((field) => ( field.answer} /> ))} -
- - Full Details - + /> ))}
diff --git a/src/app/volunteer_dashboard/panels/membership/components/ContributionsMenu.tsx b/src/app/volunteer_dashboard/panels/membership/components/ContributionsMenu.tsx index 8ab972f5..391c8b1d 100644 --- a/src/app/volunteer_dashboard/panels/membership/components/ContributionsMenu.tsx +++ b/src/app/volunteer_dashboard/panels/membership/components/ContributionsMenu.tsx @@ -232,9 +232,11 @@ export const ContributionsMenu = ({ )} > {record.isRecurring && - record.recurringAmount != null && - `${formatAmount(record.recurringAmount)} · `} - {formatAmount(record.amount)} + record.recurringAmount != null + ? formatAmount( + record.recurringAmount + ) + : formatAmount(record.amount)} ))} diff --git a/src/app/volunteer_dashboard/panels/membership/components/EditableSelectTag.tsx b/src/app/volunteer_dashboard/panels/membership/components/EditableSelectTag.tsx index 98d93a6c..21aa94bb 100644 --- a/src/app/volunteer_dashboard/panels/membership/components/EditableSelectTag.tsx +++ b/src/app/volunteer_dashboard/panels/membership/components/EditableSelectTag.tsx @@ -45,7 +45,7 @@ export const EditableSelectTag = ({ menuLabel, }: EditableSelectTagProps) => { const variantClasses = selectVariants[variant] - const choices: (T | null)[] = allowClear ? [null, ...options] : [...options] + const choices: (T | null)[] = allowClear ? [...options, null] : [...options] return ( const formatAmount = (amount: number) => amount.toLocaleString('en-US', { style: 'currency', currency: 'USD' }) -export const TierHistoryMenu = ({ member, closeDropdown }: MemberMenuProps) => { - const segments = buildTierTimeline( - filterRecurringContributions(member.contributionRecords) - ).reverse() - - return ( - - No recurring contributions found +const HistoryMenu = ({ + closeDropdown, + label, + changeNoun, + segments, +}: { + closeDropdown: () => void + label: string + changeNoun: string + segments: ReturnType +}) => ( + + No recurring contributions found + + ) : ( + <> + + {segments.length} {changeNoun} + {segments.length === 1 ? '' : 's'} - ) : ( - <> - - {segments.length} tier change - {segments.length === 1 ? '' : 's'} - -
- {segments.map((segment) => ( -
- - - {segment.tier ?? 'Not A Member'} - - - {formatDate(segment.from)} - {segment.to !== segment.from && - ` – ${formatDate(segment.to)}`} - - - {segment.payments} payment - {segment.payments === 1 ? '' : 's'} - +
+ {segments.map((segment) => ( +
+ + + {segment.tier ?? 'Not A Member'} - - {formatAmount(segment.minAmount)} - {segment.maxAmount !== - segment.minAmount && - ` – ${formatAmount(segment.maxAmount)}`} + + {formatDate(segment.from)} + {segment.to !== segment.from && + ` – ${formatDate(segment.to)}`} -
- ))} -
- - ) - } - /> - ) -} + + {segment.payments} payment + {segment.payments === 1 ? '' : 's'} + +
+ + {formatAmount(segment.minAmount)} + {segment.maxAmount !== segment.minAmount && + ` – ${formatAmount(segment.maxAmount)}`} + +
+ ))} +
+ + ) + } + /> +) + +export const TierHistoryMenu = ({ member, closeDropdown }: MemberMenuProps) => ( + +) + +export const AmountHistoryMenu = ({ + member, + closeDropdown, +}: MemberMenuProps) => ( + +) diff --git a/src/app/volunteer_dashboard/panels/membership/hooks.ts b/src/app/volunteer_dashboard/panels/membership/hooks.ts index 0d09f47f..79c1023c 100644 --- a/src/app/volunteer_dashboard/panels/membership/hooks.ts +++ b/src/app/volunteer_dashboard/panels/membership/hooks.ts @@ -19,7 +19,12 @@ import { MembershipTableMode, PendingUpdate, } from './membership.types' -import { useCurrentUser, useFetch, useInfiniteScroll } from '@/util/hooks' +import { + useCurrentUser, + useFetch, + useInfiniteScroll, + usePaginatedSearch, +} from '@/util/hooks' import { skipToken, useInfiniteQuery, @@ -29,12 +34,14 @@ import { } from '@tanstack/react-query' import { User, UserProfile, zUser, zUserProfile } from 'pv-contracts/data' import { ActBlueDonorLinkRequest } from 'pv-contracts/requests' +import type { MembershipSearchRequest } from 'pv-contracts/requests' import { zMembershipsResponsePacket, zPaginatedResponse, } from 'pv-contracts/responses' import { useCallback, + useEffect, useMemo, useRef, useState, @@ -45,9 +52,55 @@ const HISTORY_STALE_TIME = 5 * 60 * 1000 const HISTORY_LIMIT = 5 const USER_MATCH_LIMIT = 10 const PAGE_SIZE = 250 +// # TODO Make these part of API settings +const COLUMN_ORDER_STORAGE_KEY = 'membership.columnOrder' +const TABLE_OPTIONS_STORAGE_KEY = 'membership.tableOptions' const EMPTY_DRAFT: MemberEdits = {} const noopUnsubscribe = () => undefined +export function useColumnOrder() { + const [columnOrder, setColumnOrder] = useState([]) + + useEffect(() => { + try { + const raw = window.localStorage.getItem(COLUMN_ORDER_STORAGE_KEY) + if (raw == null) return + + const parsed: unknown = JSON.parse(raw) + if ( + Array.isArray(parsed) && + parsed.every((id) => typeof id === 'string') + ) + setColumnOrder(parsed) + } catch { + console.error('Failed to parse column order from localStorage') + } + }, []) + + const onColumnOrderChange = useCallback((order: string[]) => { + setColumnOrder(order) + try { + window.localStorage.setItem( + COLUMN_ORDER_STORAGE_KEY, + JSON.stringify(order) + ) + } catch { + console.error('Failed to save column order to localStorage') + } + }, []) + + const resetColumnOrder = useCallback(() => { + setColumnOrder([]) + try { + window.localStorage.removeItem(COLUMN_ORDER_STORAGE_KEY) + } catch { + console.error('Failed to reset column order in localStorage') + } + }, []) + + return { columnOrder, onColumnOrderChange, resetColumnOrder } +} + export function useFieldHistory({ userId, selectHistory, @@ -386,9 +439,84 @@ const defaultTableOptions: MembershipTableOptions = { collapseFulfillment: false, } +function useTableOptions() { + const [options, setOptions] = + useState(defaultTableOptions) + const hydrated = useRef(false) + + useEffect(() => { + try { + const raw = window.localStorage.getItem(TABLE_OPTIONS_STORAGE_KEY) + if (raw == null) return + + const parsed: unknown = JSON.parse(raw) + if (parsed == null || typeof parsed !== 'object') return + + const stored = parsed as Record + setOptions((current) => { + const next = { ...current } + for (const key of Object.keys( + current + ) as (keyof MembershipTableOptions)[]) + if (typeof stored[key] === 'boolean') + next[key] = stored[key] + return next + }) + } catch { + console.error('Failed to parse table options from localStorage') + } + }, []) + + useEffect(() => { + if (!hydrated.current) { + hydrated.current = true + return + } + try { + window.localStorage.setItem( + TABLE_OPTIONS_STORAGE_KEY, + JSON.stringify(options) + ) + } catch { + console.error('Failed to save table options to localStorage') + } + }, [options]) + + const setOption = useCallback( + ( + key: K, + value: MembershipTableOptions[K] + ) => setOptions((current) => ({ ...current, [key]: value })), + [] + ) + + const resetOptions = useCallback(() => { + setOptions(defaultTableOptions) + try { + window.localStorage.removeItem(TABLE_OPTIONS_STORAGE_KEY) + } catch { + console.error('Failed to remove table options from localStorage') + } + }, []) + + return { options, setOption, resetOptions } +} + +const eligibleCountSearch: MembershipSearchRequest = { + limit: 1, + isMember: true, + isBenefitEligible: true, +} + function useMembershipsQuery() { const { ready, onGet } = useFetch() + const eligibleCountQuery = usePaginatedSearch( + '/actblue/memberships', + zMembershipsResponsePacket, + { search: eligibleCountSearch } + ).query + const query = useInfiniteQuery({ queryKey: ['/actblue/memberships', { limit: PAGE_SIZE }], queryFn: ({ pageParam, signal }) => @@ -429,7 +557,9 @@ function useMembershipsQuery() { ? members.length : query.data?.pages[0]?.count - return { query, members, totalEntries } + const eligibleMemberCount = eligibleCountQuery.data?.count + + return { query, members, totalEntries, eligibleMemberCount } } export function usePendingUpdates(edit: EditController, members: Member[]) { @@ -468,11 +598,11 @@ export function usePendingUpdates(edit: EditController, members: Member[]) { } export function useMembershipPanel() { - const [options, setOptions] = - useState(defaultTableOptions) + const { options, setOption, resetOptions } = useTableOptions() const [tableMode, setTableMode] = useState('view') const { editController, clearEdits } = useMemberEdits() - const { query, members, totalEntries } = useMembershipsQuery() + const { query, members, totalEntries, eligibleMemberCount } = + useMembershipsQuery() const { fetchNextPage, hasNextPage, isFetchingNextPage } = query const isPending: boolean = query.isPending @@ -485,14 +615,6 @@ export function useMembershipPanel() { fetchNextPage, }) - const setOption = useCallback( - ( - key: K, - value: MembershipTableOptions[K] - ) => setOptions((current) => ({ ...current, [key]: value })), - [] - ) - const stopEditing = useCallback(() => { clearEdits() setTableMode('view') @@ -503,8 +625,10 @@ export function useMembershipPanel() { return { members, totalEntries, + eligibleMemberCount, options, setOption, + resetOptions, tableMode, setTableMode, editController, diff --git a/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts b/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts index 43d9ea39..47a41881 100644 --- a/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts +++ b/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts @@ -8,6 +8,7 @@ import { MemberEdits, MembershipTier, membershipTiers, + MembershipSearchField, PackageShipped, PendingUpdate, RecurringSummary, @@ -242,6 +243,27 @@ export const filterRecurringContributions = ( records: ContributionRecord[] | undefined ) => (records ?? []).filter((record) => record.isRecurring) +export const memberSearchValues = ( + member: Member, + field: MembershipSearchField +): (string | undefined)[] => { + if (field === 'discord') + return [member.discordUsername, member.contributionDiscord] + if (field === 'email') + return [member.userEmail, member.discordEmail, member.donorEmail] + return [member.userName, member.donorName] +} + +export const matchesSearchQuery = ( + member: Member, + normalizedQuery: string, + field: MembershipSearchField +) => + normalizedQuery !== '' && + memberSearchValues(member, field).some((value) => + value?.toLowerCase().includes(normalizedQuery) + ) + export const countMonthsWithLineitems = (records: ContributionRecord[]) => new Set(records.flatMap((record) => record.lineitemMonths ?? [])).size @@ -272,9 +294,9 @@ export const getMembershipTierForAmount = (amount?: number) => { return tierThresholds.find(([min]) => amount >= min)?.[1] } -// collapses each payment's tier into contiguous runs, so a change of recurring amount starts a new segment -export const buildTierTimeline = ( - records: ContributionRecord[] +const buildPaymentTimeline = ( + records: ContributionRecord[], + isSameSegment: (current: TierSegment, amount: number) => boolean ): TierSegment[] => { const payments = records .flatMap((record) => @@ -285,10 +307,9 @@ export const buildTierTimeline = ( const segments: TierSegment[] = [] for (const payment of payments) { - const tier = getMembershipTierForAmount(payment.amount) const current = segments.at(-1) - if (current && current.tier === tier) { + if (current && isSameSegment(current, payment.amount)) { current.to = payment.paidAt current.payments += 1 current.minAmount = Math.min(current.minAmount, payment.amount) @@ -297,7 +318,7 @@ export const buildTierTimeline = ( } segments.push({ - tier, + tier: getMembershipTierForAmount(payment.amount), from: payment.paidAt, to: payment.paidAt, payments: 1, @@ -309,6 +330,18 @@ export const buildTierTimeline = ( return segments } +export const buildTierTimeline = (records: ContributionRecord[]) => + buildPaymentTimeline( + records, + (current, amount) => current.tier === getMembershipTierForAmount(amount) + ) + +export const buildAmountTimeline = (records: ContributionRecord[]) => + buildPaymentTimeline( + records, + (current, amount) => current.minAmount === amount + ) + export const computeRecurringSummary = ( records: ContributionRecord[], referenceDate: Date = new Date() @@ -365,6 +398,7 @@ export const computeRecurringSummary = ( monthsWithLineitems: monthKeys.size, earliestLineitemDate, activeAmount, + totalAmount: records.reduce((sum, record) => sum + record.amount, 0), tier: getMembershipTierForAmount(activeAmount), } } diff --git a/src/app/volunteer_dashboard/panels/membership/membership.types.ts b/src/app/volunteer_dashboard/panels/membership/membership.types.ts index ca199bed..b5051e53 100644 --- a/src/app/volunteer_dashboard/panels/membership/membership.types.ts +++ b/src/app/volunteer_dashboard/panels/membership/membership.types.ts @@ -46,6 +46,7 @@ export interface RecurringSummary { monthsWithLineitems: number earliestLineitemDate?: string activeAmount?: number + totalAmount: number tier?: MembershipTier } @@ -117,6 +118,14 @@ export interface Member { export type MembershipTableMode = 'view' | 'edit' +export const membershipSearchFields = [ + { value: 'name', label: 'Name' }, + { value: 'discord', label: 'Discord' }, + { value: 'email', label: 'Email' }, +] as const + +export type MembershipSearchField = + (typeof membershipSearchFields)[number]['value'] export type MemberFlag = | 'nameConfirmed' | 'discordConfirmed' diff --git a/src/app/volunteer_dashboard/panels/membership/membershipCells.module.css b/src/app/volunteer_dashboard/panels/membership/membershipCells.module.css index be3a0cb5..546630b5 100644 --- a/src/app/volunteer_dashboard/panels/membership/membershipCells.module.css +++ b/src/app/volunteer_dashboard/panels/membership/membershipCells.module.css @@ -24,6 +24,17 @@ } } +.nameMatchText { + color: rgba(2, 132, 199, 1); +} + +.addressPlaceholderTag { + min-width: 0; + padding: 0.05rem 1rem; + font-size: 0.7rem; + font-weight: 600; +} + .editUnavailable { font-size: 0.75rem; font-style: italic; diff --git a/src/app/volunteer_dashboard/panels/membership/membershipCells.tsx b/src/app/volunteer_dashboard/panels/membership/membershipCells.tsx index 4164ce27..d458bc66 100644 --- a/src/app/volunteer_dashboard/panels/membership/membershipCells.tsx +++ b/src/app/volunteer_dashboard/panels/membership/membershipCells.tsx @@ -35,6 +35,7 @@ import { } from './membership.types' import styles from './membershipCells.module.css' import { cn } from '@/util' +import { Fragment } from 'react' const NoLinkedUser = () => ( No linked user @@ -42,9 +43,33 @@ const NoLinkedUser = () => ( const NoData = () => No Data -export const NameValue = ({ member }: MemberValueProps) => ( +const HighlightedText = ({ text, query }: { text: string; query: string }) => { + const index = query === '' ? -1 : text.toLowerCase().indexOf(query) + + if (index === -1) return <>{text} + + return ( + <> + {text.slice(0, index)} + + {text.slice(index, index + query.length)} + + {text.slice(index + query.length)} + + ) +} + +export const NameValue = ({ + member, + searchQuery = '', +}: MemberValueProps & { searchQuery?: string }) => ( - {member.userName ?? member.donorName ?? '—'} + + + ) @@ -120,14 +145,21 @@ export const NameMenu = ({ member, closeDropdown }: MemberMenuProps) => ( /> ) -export const DiscordValue = ({ member }: MemberValueProps) => { +export const DiscordValue = ({ + member, + searchQuery = '', +}: MemberValueProps & { searchQuery?: string }) => { const displayedHandle = member.discordUsername ?? member.contributionDiscord if (!displayedHandle) return + const handle = `@${normalizeDiscordHandle(displayedHandle)}` + return ( - @{normalizeDiscordHandle(displayedHandle)} + + + ( /> ) -export const AddressValue = ({ member }: MemberValueProps) => ( - - {member.userAddress ?? member.donorAddress ?? '—'} - +const AddressPlaceholder = ({ label }: { label: string }) => ( + + {label} ) +export const AddressValue = ({ member }: MemberValueProps) => { + const parts = member.userAddressParts + + if (member.userAddress == null) + return ( + + {member.donorAddress ?? '—'} + + + ) + + const segments: { + node: React.ReactNode + startFilled: boolean + endFilled: boolean + }[] = [ + { + node: parts?.addressLine1 ?? ( + + ), + startFilled: Boolean(parts?.addressLine1), + endFilled: Boolean(parts?.addressLine1), + }, + ...(parts?.addressLine2 + ? [ + { + node: parts.addressLine2, + startFilled: true, + endFilled: true, + }, + ] + : []), + { + node: parts?.city ?? , + startFilled: Boolean(parts?.city), + endFilled: Boolean(parts?.city), + }, + { + node: ( + <> + {parts?.state ?? }{' '} + {parts?.zip ?? } + + ), + startFilled: Boolean(parts?.state), + endFilled: Boolean(parts?.zip), + }, + ] + + return ( + + + {segments.map((segment, index) => { + const previous = segments[index - 1] + const separatorFilled = + previous != null && + previous.endFilled && + segment.startFilled + + return ( + + {previous != null && (separatorFilled ? ', ' : ' ')} + {segment.node} + + ) + })} + + + + ) +} + const addressInputs: { key: AddressField label: string @@ -287,6 +396,21 @@ export const AmountValue = ({ member }: MemberValueProps) => { return ${amount} } +export const TotalAmountValue = ({ member }: MemberValueProps) => { + const total = member.recurringSummary?.totalAmount + + if (!total) return + + return ( + + {total.toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + })} + + ) +} + export const PhoneEdit = ({ member, edit }: MemberEditProps) => { const draft = useMemberDraft(edit, member).userPhone @@ -343,9 +467,20 @@ export const PhoneMenu = ({ member, closeDropdown }: MemberMenuProps) => ( /> ) -export const EmailValue = ({ member }: MemberValueProps) => ( +export const EmailValue = ({ + member, + searchQuery = '', +}: MemberValueProps & { searchQuery?: string }) => ( - {member.userEmail ?? member.discordEmail ?? member.donorEmail ?? '—'} + ) @@ -398,7 +533,7 @@ export const EmailMenu = ({ member, closeDropdown }: MemberMenuProps) => ( /> ) -const shirtSizes: ShirtSize[] = ['XS', 'S', 'M', 'L', 'XL', 'XXL'] +const shirtSizes: ShirtSize[] = ['XXL', 'XL', 'L', 'M', 'S', 'XS'] const shirtSizeClass: Record = { XS: tags.tagRed, diff --git a/src/app/volunteer_dashboard/panels/membership/membershipColumns.tsx b/src/app/volunteer_dashboard/panels/membership/membershipColumns.tsx index fc69a3db..bd24ed0f 100644 --- a/src/app/volunteer_dashboard/panels/membership/membershipColumns.tsx +++ b/src/app/volunteer_dashboard/panels/membership/membershipColumns.tsx @@ -3,12 +3,16 @@ import { ContributionsMenu } from './components/ContributionsMenu' import { BoolTag, EditableBoolTag } from './components/Tags' import tags from './components/Tags.module.css' -import { TierHistoryMenu } from './components/TierHistoryMenu' +import { + TierHistoryMenu, + AmountHistoryMenu, +} from './components/TierHistoryMenu' import { MembershipTableOptions, useMemberDraft } from './hooks' import { hasAddressDraftChange, hasNameDraftChange, hasDiscordDraftChange, + matchesSearchQuery, } from './membership.helpers' import { EditController, @@ -17,6 +21,7 @@ import { MemberFlag, MembershipTableMode, MembershipTier, + MembershipSearchField, PackageShipped, ShirtSize, } from './membership.types' @@ -40,6 +45,7 @@ import { PhoneValue, ShirtSizeEdit, ShirtSizeValue, + TotalAmountValue, } from './membershipCells' import styles from './page.module.css' import { Column, ColumnEntry } from '@/components/common/table' @@ -226,6 +232,7 @@ const rowNumberColumn: Column = { key: 'rowNumber', header: '#', width: '3rem', + reorderable: false, render: (_m, index) => index + 1, } @@ -247,14 +254,28 @@ export const buildColumns = ({ tableMode, edit, onMatchUser, + searchQuery = '', + searchField = 'name', }: { options: MembershipTableOptions tableMode: MembershipTableMode edit: EditController onMatchUser: (member: Member) => void + searchQuery?: string + searchField?: MembershipSearchField }): ColumnEntry[] => { const { showConfirmed, showStatus, showRowNumber } = options const showFulfilledTag = options.showFulfilled && tableMode === 'view' + const normalizedQuery = searchQuery.trim().toLowerCase() + const nameQuery = searchField === 'name' ? normalizedQuery : '' + const discordQuery = searchField === 'discord' ? normalizedQuery : '' + const emailQuery = searchField === 'email' ? normalizedQuery : '' + const matchesSearch = (m: Member) => + matchesSearchQuery(m, normalizedQuery, searchField) + const searchCellClass = (field: MembershipSearchField) => (m: Member) => + searchField === field && matchesSearch(m) + ? styles.nameCellMatch + : undefined return [ ...(showRowNumber ? [rowNumberColumn] : []), @@ -336,7 +357,8 @@ export const buildColumns = ({ width: '11rem', allowOverflow: true, sortValue: (m) => m.userName ?? m.donorName ?? '', - render: (m) => , + render: (m) => , + cellClassName: searchCellClass('name'), renderEdit: (m) => , menu: (m, { closeDropdown }) => ( @@ -348,7 +370,10 @@ export const buildColumns = ({ width: '11rem', allowOverflow: true, sortValue: (m) => m.discordUsername ?? '', - render: (m) => , + render: (m) => ( + + ), + cellClassName: searchCellClass('discord'), menu: (m, { closeDropdown }) => ( ), @@ -408,7 +433,8 @@ export const buildColumns = ({ width: '14rem', allowOverflow: true, sortValue: (m) => m.email ?? '', - render: (m) => , + render: (m) => , + cellClassName: searchCellClass('email'), renderEdit: (m) => , menu: (m, { closeDropdown }) => ( @@ -428,8 +454,19 @@ export const buildColumns = ({ key: 'membershipAmount', header: 'Amount', width: '6rem', + allowOverflow: true, sortValue: (m) => m.recurringSummary?.activeAmount ?? 0, render: (m) => , + menu: (m, { closeDropdown }) => ( + + ), + }, + { + key: 'totalAmount', + header: 'Total Amount', + width: '7rem', + sortValue: (m) => m.recurringSummary?.totalAmount ?? 0, + render: (m) => , }, { key: 'contributionsReal', diff --git a/src/app/volunteer_dashboard/panels/membership/page.module.css b/src/app/volunteer_dashboard/panels/membership/page.module.css index dd56c6f8..3ae1dafb 100644 --- a/src/app/volunteer_dashboard/panels/membership/page.module.css +++ b/src/app/volunteer_dashboard/panels/membership/page.module.css @@ -16,7 +16,7 @@ display: flex; flex-direction: column; overflow: hidden; - padding: 0 0 1rem; + padding: 0; } .panelHeader { @@ -37,6 +37,135 @@ color: rgba(15, 23, 42, 0.45); } +.panelHeaderRight { + display: inline-flex; + align-items: center; + gap: 0.75rem; + min-width: 0; +} + +.nameCellMatch { + background: rgba(14, 165, 233, 0.14); +} + +.panelSearch { + position: relative; + display: inline-flex; + align-items: center; +} + +.panelSearchIcon { + position: absolute; + left: 0.55rem; + color: rgb(158, 162, 169); + pointer-events: none; +} + +.panelSearchCount { + position: absolute; + right: 0.8rem; + font-size: 0.82rem; + font-weight: 500; + color: rgba(15, 23, 42, 0.4); + pointer-events: none; +} + +.panelSearch:focus-within .panelSearchCount { + right: 1.7rem; +} + +.panelSearchClear { + position: absolute; + right: 0.55rem; + top: 50%; + transform: translateY(-50%); + display: none; + align-items: center; + justify-content: center; + box-sizing: content-box; + width: 0.85rem; + height: 0.85rem; + padding: 0; + border: 0; + background: none; + line-height: 0; + overflow: visible; + cursor: pointer; + + & svg { + display: block; + width: 100%; + height: 100%; + overflow: visible; + } + + & circle { + fill: #676767; + } + + & path { + fill: none; + stroke: #ffffff; + stroke-width: 1.6; + stroke-linecap: round; + } +} + +.panelSearch:focus-within .panelSearchClear { + display: inline-flex; +} + +.panelSearchFilter { + position: absolute; + right: 0.75rem; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 0; + color: rgb(158, 162, 169); + + & svg { + display: block; + } +} + +.panelSearchFilterSelect { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} + +.panelSearchInput { + width: 14rem; + max-width: 40dvw; + padding: 0.35rem 3.6rem 0.35rem 1.85rem; + border: 1px solid rgba(17, 24, 39, 0.14); + border-radius: 9999px; + background: rgba(255, 255, 255, 0.85); + font-size: 0.8rem; + font-weight: 500; + letter-spacing: -0.01em; + color: rgba(15, 23, 42, 0.85); + + &::placeholder { + color: rgba(15, 23, 42, 0.4); + font-weight: 500; + } + + &::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; + } + + &:focus-visible { + outline: none; + box-shadow: 0 0 0 3px rgba(2, 132, 199, 1); + } +} + .breadcrumbs { display: inline-flex; align-items: center; @@ -93,7 +222,7 @@ .tableWrapper { flex: 1 1 auto; min-height: 0; - padding: 0.75rem 1.05rem 1.5rem; + padding: 0.75rem 1.05rem 1rem; &:has([aria-expanded='true']) { --confirm-tooltip-opacity: 0; @@ -198,6 +327,23 @@ color: rgba(15, 23, 42, 0.58); } +.restoreDefaultsButton { + padding: 0; + border-radius: 0; + background: none; + color: rgba(15, 23, 42, 0.46); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0; + text-transform: none; + transition: color 0.15s ease; + + &:hover { + background: none; + color: rgba(15, 23, 42, 0.8); + } +} + .userMatchedLink { display: inline-flex; cursor: pointer; diff --git a/src/app/volunteer_dashboard/panels/membership/page.tsx b/src/app/volunteer_dashboard/panels/membership/page.tsx index cc4e2f9f..7e15c073 100644 --- a/src/app/volunteer_dashboard/panels/membership/page.tsx +++ b/src/app/volunteer_dashboard/panels/membership/page.tsx @@ -5,9 +5,16 @@ import { useMembershipPanel, usePendingUpdates, useSaveMemberships, + useColumnOrder, MembershipTableOptions, } from './hooks' -import { EditController, Member } from './membership.types' +import { matchesSearchQuery } from './membership.helpers' +import { + EditController, + Member, + MembershipSearchField, + membershipSearchFields, +} from './membership.types' import { buildColumns, FULFILLMENT_CATEGORY } from './membershipColumns' import styles from './page.module.css' import { ListBody, ListElement } from '@/app/admin/layout/List' @@ -24,6 +31,8 @@ import { usePaginatedSearch } from '@/util/hooks' import { UserProfile, zUserProfile } from 'pv-contracts/data' import { ChangeEvent, useCallback, useMemo, useState } from 'react' import { FaEdit, FaSave, FaTrashAlt } from 'react-icons/fa' +import { FiSearch } from 'react-icons/fi' +import { IoMdOptions } from 'react-icons/io' const showHideChoices = [ { value: true, label: 'Show' }, @@ -128,12 +137,21 @@ function EditToolbar({ export default function Page() { const [memberToMatch, setMemberToMatch] = useState(null) + // not wired to the query yet + const [searchDraft, setSearchDraft] = useState('') + const [searchField, setSearchField] = + useState('name') + const [scrollToMatchToken, setScrollToMatchToken] = useState(0) + const { columnOrder, onColumnOrderChange, resetColumnOrder } = + useColumnOrder() const linkMutation = useLinkDonorToUser() const { members, totalEntries, + eligibleMemberCount, options, setOption, + resetOptions, tableMode, setTableMode, editController, @@ -181,8 +199,10 @@ export default function Page() { tableMode, edit: editController, onMatchUser: setMemberToMatch, + searchQuery: searchDraft, + searchField, }), - [options, tableMode, editController] + [options, tableMode, editController, searchDraft, searchField] ) const collapsedCategories = useMemo( @@ -190,15 +210,99 @@ export default function Page() { [options.collapseFulfillment] ) + const isSearchMatch = useCallback( + (member: Member) => + matchesSearchQuery( + member, + searchDraft.trim().toLowerCase(), + searchField + ), + [searchDraft, searchField] + ) + + const matchCount = useMemo( + () => members.filter(isSearchMatch).length, + [members, isSearchMatch] + ) + return ( - Showing {members.length.toLocaleString()} - {totalEntries != null && - ` of ${totalEntries.toLocaleString()}`} +
+
+ {eligibleMemberCount != null && + `${eligibleMemberCount.toLocaleString()} Eligible · `} + Showing {members.length.toLocaleString()} + {totalEntries != null && + ` of ${totalEntries.toLocaleString()}`} +
+
+
} > @@ -273,6 +377,15 @@ export default function Page() { ) )} + footerButtonLabel="Restore Defaults" + footerButtonClassName={ + styles.restoreDefaultsButton + } + footerButtonOnClick={() => { + resetOptions() + resetColumnOrder() + closeDropdown() + }} /> )} /> @@ -308,6 +421,12 @@ export default function Page() { collapsedCategories={collapsedCategories} mode={tableMode} zebra={options.showZebra} + isScrollTarget={isSearchMatch} + scrollToColumnKey={searchField} + scrollToRowToken={scrollToMatchToken} + reorderable={tableMode === 'edit'} + entryOrder={columnOrder} + onEntryOrderChange={onColumnOrderChange} footer={ hasNextPage && (
: @@ -101,9 +103,11 @@ export interface Column { renderEdit?: (row: T, index: number) => React.ReactNode width?: string allowOverflow?: boolean + cellClassName?: (row: T, index: number) => string | undefined onCellClick?: (row: T, index: number) => void menu?: (row: T, controls: { closeDropdown: () => void }) => React.ReactNode sortValue?: (row: T) => string | number | boolean | null | undefined + reorderable?: boolean } export interface ColumnCategory { @@ -120,6 +124,9 @@ function isCategory(entry: ColumnEntry): entry is ColumnCategory { return 'columns' in entry && 'label' in entry } +const entryId = (entry: ColumnEntry) => + isCategory(entry) ? entry.label : entry.key + export type TableMode = 'view' | 'edit' export interface TableProps { @@ -130,6 +137,16 @@ export interface TableProps { mode?: TableMode zebra?: boolean footer?: React.ReactNode + isScrollTarget?: (row: T) => boolean + /** Column brought into horizontal view alongside the scroll target. */ + scrollToColumnKey?: string + /** Increment to scroll the first isScrollTarget row into view. */ + scrollToRowToken?: number + /** Allow header cells to be dragged to reorder top-level entries. */ + reorderable?: boolean + /** Entry ids (column key, or category label) in the desired order. */ + entryOrder?: string[] + onEntryOrderChange?: (order: string[]) => void } type SortDir = 'asc' | 'desc' @@ -151,6 +168,8 @@ function TableCellInner({ isOpen, cellId, setOpenCell, + dragging = false, + entryId: cellEntryId, }: { col: Column row: T @@ -159,6 +178,8 @@ function TableCellInner({ isOpen: boolean cellId: string setOpenCell: OpenCellSetter + dragging?: boolean + entryId?: string }) { const editing = mode === 'edit' const editable = editing && col.renderEdit != null @@ -181,9 +202,12 @@ function TableCellInner({ className={cn( col.allowOverflow ? styles.cellOverflowVisible : styles.cell, interactive && styles.clickable, - editing && !editable && styles.lockedCell + editing && !editable && styles.lockedCell, + dragging && styles.draggingCell, + col.cellClassName?.(row, index) )} data-label={col.header} + data-entry-id={cellEntryId} data-open-cell={isOpen ? 'true' : undefined} role={interactive ? 'button' : undefined} tabIndex={interactive ? 0 : undefined} @@ -231,6 +255,7 @@ function TableRowInner({ zebra, openColKey, setOpenCell, + draggingEntryId, }: { row: T index: number @@ -242,6 +267,7 @@ function TableRowInner({ zebra: boolean openColKey: string | null setOpenCell: OpenCellSetter + draggingEntryId: string | null }) { const rowOverrides = new Map() for (const entry of columns) { @@ -270,7 +296,12 @@ function TableRowInner({ return ( {category.columns.map((col) => { const color = @@ -305,7 +336,12 @@ function TableRowInner({ return ( ({ isOpen={openColKey === col.key} cellId={`${rowId}::${col.key}`} setOpenCell={setOpenCell} + entryId={entry.category?.label ?? col.key} + dragging={ + draggingEntryId === + (entry.category?.label ?? col.key) + } /> ) })} @@ -403,16 +444,32 @@ export function Table({ mode = 'view', zebra = false, footer, + isScrollTarget, + scrollToColumnKey, + scrollToRowToken, + reorderable = false, + entryOrder, + onEntryOrderChange, }: TableProps) { const [sortKey, setSortKey] = useState(null) const [sortDir, setSortDir] = useState('asc') const [openCell, setOpenCell] = useState(null) + const [draggingId, setDraggingId] = useState(null) + const [dropTarget, setDropTarget] = useState<{ + id: string + after: boolean + } | null>(null) + const [hoveredEntryId, setHoveredEntryId] = useState(null) + const [handleOffsets, setHandleOffsets] = useState>( + {} + ) const [metrics, setMetrics] = useState({ rowHeight: 0, headerHeight: 0, }) const containerRef = useRef(null) const headerRef = useRef(null) + const handledScrollToken = useRef(undefined) useEffect(() => { if (mode === 'edit') setOpenCell(null) @@ -454,12 +511,99 @@ export function Table({ } } + const orderedColumns = useMemo(() => { + if (!entryOrder?.length) return columns + const rank = new Map(entryOrder.map((id, index) => [id, index])) + return [...columns].sort( + (a, b) => + (rank.get(entryId(a)) ?? Number.MAX_SAFE_INTEGER) - + (rank.get(entryId(b)) ?? Number.MAX_SAFE_INTEGER) + ) + }, [columns, entryOrder]) + + const setColumnDragImage = ( + event: React.DragEvent, + entryKey: string + ): void => { + const container = containerRef.current + if (!container) return + + const cells = Array.from( + container.querySelectorAll( + `[data-entry-id="${entryKey}"]` + ) + ) + if (cells.length === 0) return + + const view = container.getBoundingClientRect() + const visible = cells + .map((cell) => ({ cell, rect: cell.getBoundingClientRect() })) + .filter( + ({ rect }) => + rect.bottom > view.top && + rect.top < view.bottom && + rect.right > view.left && + rect.left < view.right + ) + if (visible.length === 0) return + + const left = Math.min(...visible.map(({ rect }) => rect.left)) + const right = Math.max(...visible.map(({ rect }) => rect.right)) + const top = Math.min(...visible.map(({ rect }) => rect.top)) + const bottom = Math.min( + Math.max(...visible.map(({ rect }) => rect.bottom)), + view.bottom + ) + + const ghost = document.createElement('div') + ghost.style.cssText = `position:fixed;top:-10000px;left:-10000px;width:${right - left}px;height:${bottom - top}px;overflow:hidden;background:#ffffff;border-radius:0.5rem;box-shadow:0 8px 20px rgba(15,23,42,0.18);` + + for (const { cell, rect } of visible) { + const clone = cell.cloneNode(true) as HTMLElement + const computed = window.getComputedStyle(cell) + clone.style.fontFamily = computed.fontFamily + clone.style.fontSize = computed.fontSize + clone.style.fontWeight = computed.fontWeight + clone.style.fontStyle = computed.fontStyle + clone.style.lineHeight = computed.lineHeight + clone.style.letterSpacing = computed.letterSpacing + clone.style.color = computed.color + clone.style.position = 'absolute' + clone.style.margin = '0' + clone.style.left = `${rect.left - left}px` + clone.style.top = `${rect.top - top}px` + clone.style.width = `${rect.width}px` + clone.style.height = `${rect.height}px` + clone.style.opacity = '1' + ghost.appendChild(clone) + } + + document.body.appendChild(ghost) + event.dataTransfer.setDragImage( + ghost, + event.clientX - left, + event.clientY - top + ) + requestAnimationFrame(() => ghost.remove()) + } + + const moveEntry = (fromId: string, toId: string, after: boolean) => { + if (fromId === toId) return + const ids = orderedColumns.map(entryId) + if (!ids.includes(fromId) || !ids.includes(toId)) return + + const next = ids.filter((id) => id !== fromId) + const target = next.indexOf(toId) + next.splice(after ? target + 1 : target, 0, fromId) + onEntryOrderChange?.(next) + } + const flatColumns = useMemo( () => - columns.flatMap((entry) => + orderedColumns.flatMap((entry) => isCategory(entry) ? entry.columns : [entry] ), - [columns] + [orderedColumns] ) const sortedData = useMemo(() => { @@ -486,7 +630,7 @@ export function Table({ const visibleEntries = useMemo(() => { const entries: VisibleEntry[] = [] - for (const entry of columns) { + for (const entry of orderedColumns) { if (isCategory(entry)) { if (collapsedSet.has(entry.label)) { entries.push({ @@ -513,7 +657,7 @@ export function Table({ } } return entries - }, [columns, collapsedSet]) + }, [orderedColumns, collapsedSet]) const gridTemplateColumns = visibleEntries.map((e) => e.width).join(' ') @@ -537,6 +681,49 @@ export function Table({ ) }, [mode, visibleEntries, hasRows]) + useEffect(() => { + if ( + scrollToRowToken == null || + scrollToRowToken === handledScrollToken.current || + isScrollTarget == null + ) + return + + const container = containerRef.current + if (!container || metrics.rowHeight <= 0) return + + const index = sortedData.findIndex(isScrollTarget) + if (index === -1) return + + handledScrollToken.current = scrollToRowToken + const top = index * metrics.rowHeight + + const headerCell = headerRef.current?.querySelector( + `[data-column-key="${scrollToColumnKey ?? ''}"]` + ) + let left = container.scrollLeft + + if (headerCell) { + const cellRect = headerCell.getBoundingClientRect() + const headerRect = headerRef.current!.getBoundingClientRect() + const cellLeft = cellRect.left - headerRect.left + const cellRight = cellLeft + cellRect.width + const viewRight = left + container.clientWidth + + if (cellLeft < left) left = cellLeft + else if (cellRight > viewRight) + left = cellRight - container.clientWidth + } + + container.scrollTo({ top, left, behavior: 'smooth' }) + }, [ + scrollToRowToken, + isScrollTarget, + scrollToColumnKey, + sortedData, + metrics.rowHeight, + ]) + const { start, end } = useVirtualRange( containerRef, metrics, @@ -544,6 +731,34 @@ export function Table({ metrics.rowHeight > 0 && rowCount > VIRTUALIZE_THRESHOLD ) + useLayoutEffect(() => { + const header = headerRef.current + if (!reorderable || !header) return + + const offsets: Record = {} + for (const entry of visibleEntries) { + const label = entry.category?.label + if (entry.type !== 'column' || !label || offsets[label] != null) + continue + + const cells = header.querySelectorAll( + `[data-entry-id="${label}"]` + ) + if (cells.length < 2) continue + + const first = cells[0].getBoundingClientRect() + const last = cells[cells.length - 1].getBoundingClientRect() + offsets[label] = (last.right - first.left) / 2 + } + + setHandleOffsets((current) => { + const keys = Object.keys(offsets) + const unchanged = + keys.length === Object.keys(current).length && + keys.every((key) => current[key] === offsets[key]) + return unchanged ? current : offsets + }) + }, [reorderable, visibleEntries]) return (
({ ref={headerRef} > {visibleEntries.map((entry) => { + const dragId = entry.category?.label ?? entry.col?.key ?? '' + const groupColumns = + entry.type === 'column' && entry.category + ? entry.category.columns + : null + const isEntryStart = + !groupColumns || groupColumns[0] === entry.col + const isEntryEnd = + !groupColumns || + groupColumns[groupColumns.length - 1] === entry.col + const entryReorderable = + reorderable && + (entry.category != null || + (entry.col?.reorderable ?? true)) + const isDropTarget = + draggingId != null && + dropTarget?.id === dragId && + draggingId !== dragId + const dropsOnRightHalf = (event: React.DragEvent) => { + const cells = + headerRef.current?.querySelectorAll( + `[data-entry-id="${dragId}"]` + ) + const first = cells?.[0]?.getBoundingClientRect() + const last = + cells?.[cells.length - 1]?.getBoundingClientRect() + const rect = event.currentTarget.getBoundingClientRect() + const left = first?.left ?? rect.left + const right = last?.right ?? rect.right + return event.clientX > (left + right) / 2 + } + const dragProps = entryReorderable + ? { + draggable: true, + onMouseEnter: () => setHoveredEntryId(dragId), + onMouseLeave: () => setHoveredEntryId(null), + onDragStart: (event: React.DragEvent) => { + setColumnDragImage(event, dragId) + setDraggingId(dragId) + }, + onDragEnd: () => { + setDraggingId(null) + setDropTarget(null) + }, + onDragOver: (event: React.DragEvent) => { + event.preventDefault() + const after = dropsOnRightHalf(event) + setDropTarget((current) => + current?.id === dragId && + current.after === after + ? current + : { id: dragId, after } + ) + }, + onDrop: (event: React.DragEvent) => { + event.preventDefault() + if (draggingId) + moveEntry( + draggingId, + dragId, + dropsOnRightHalf(event) + ) + setDraggingId(null) + setDropTarget(null) + }, + } + : {} + + const dropClass = !isDropTarget + ? undefined + : dropTarget.after + ? isEntryEnd + ? styles.dropAfter + : undefined + : isEntryStart + ? styles.dropBefore + : undefined + + const sharedClasses = cn( + entryReorderable && styles.draggableHeader, + hoveredEntryId === dragId && styles.headerHovered, + draggingId === dragId && styles.draggingHeader, + dropClass + ) + if (entry.type === 'group-collapsed') { return ( + data-entry-id={dragId} + className={cn( + styles.collapsedHeader, + sharedClasses + )} + {...dragProps} + > + {entryReorderable && ( + ) } const col = entry.col! return ( handleSort(col.key) : undefined } + {...dragProps} > - {col.header} - {col.sortValue && ( - - {sortKey === col.key ? ( - sortDir === 'asc' ? ( - - ) : ( - - ) - ) : ( - - )} - + {entryReorderable && isEntryStart && ( + ) })} @@ -618,7 +947,7 @@ export function Table({ row={row} index={index} rowId={rowId} - columns={columns} + columns={orderedColumns} visibleEntries={visibleEntries} gridTemplateColumns={gridTemplateColumns} mode={mode} @@ -629,6 +958,7 @@ export function Table({ : null } setOpenCell={setOpenCell} + draggingEntryId={draggingId} /> ) })} diff --git a/src/components/common/table/index.ts b/src/components/common/table/index.ts index 6d77550e..a86149cb 100644 --- a/src/components/common/table/index.ts +++ b/src/components/common/table/index.ts @@ -1,8 +1 @@ -export { Table } from './Table' -export type { - Column, - ColumnCategory, - ColumnEntry, - TableMode, - TableProps, -} from './Table' +export * from './Table' From 3f69e63ff629e18456f975cc4969c0a54d47a663 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Fri, 18 Sep 2026 17:39:46 -0400 Subject: [PATCH 23/27] lint --- src/components/common/dropdown/DropdownButton.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/components/common/dropdown/DropdownButton.tsx b/src/components/common/dropdown/DropdownButton.tsx index 930f5e88..ddeeca70 100644 --- a/src/components/common/dropdown/DropdownButton.tsx +++ b/src/components/common/dropdown/DropdownButton.tsx @@ -53,11 +53,7 @@ import { IoClose } from 'react-icons/io5' */ export type DropdownButtonVariant = - | 'long' - | 'short' - | 'minimal' - | 'icon' - | 'plain' + 'long' | 'short' | 'minimal' | 'icon' | 'plain' interface DropdownVariantConfig { ariaHasPopup: 'menu' | 'dialog' From c9ba98e5174a6453e994c2f94095ce92e1c84b8a Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Fri, 18 Sep 2026 20:55:01 -0400 Subject: [PATCH 24/27] implementing the depricated admin panel --- .../panels/membership/page.module.css | 53 ++++++++++++++ .../panels/membership/page.tsx | 69 ++++++++++++------- 2 files changed, 99 insertions(+), 23 deletions(-) diff --git a/src/app/volunteer_dashboard/panels/membership/page.module.css b/src/app/volunteer_dashboard/panels/membership/page.module.css index 3ae1dafb..6385673b 100644 --- a/src/app/volunteer_dashboard/panels/membership/page.module.css +++ b/src/app/volunteer_dashboard/panels/membership/page.module.css @@ -269,6 +269,59 @@ gap: 0.5rem; } +.pickerStatus { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; + padding: 0.9rem 1rem 1rem; + + &:not(:has(button)) { + align-items: center; + justify-content: center; + padding: 2rem 1rem; + text-align: center; + color: #64748b; + font-weight: 500; + } + + &.error { + color: #ef4444; + } +} + +.userPickerItem { + display: flex; + width: calc(100% - 0.5rem); + flex-direction: column; + align-items: flex-start; + gap: 0.15rem; + margin: 0.25rem; + padding: 1rem; + border: none; + border-radius: 1rem; + background: none; + font: inherit; + text-align: left; + cursor: pointer; + + &:hover { + background-color: #e5e7eb; + } +} + +.userPickerName { + font-size: 0.92rem; + font-weight: 550; + color: rgba(15, 23, 42, 0.85); +} + +.userPickerSub { + font-size: 0.82rem; + font-weight: 500; + color: #6b7280; +} + .toolbarButton { display: flex; cursor: pointer; diff --git a/src/app/volunteer_dashboard/panels/membership/page.tsx b/src/app/volunteer_dashboard/panels/membership/page.tsx index 7e15c073..d36a109d 100644 --- a/src/app/volunteer_dashboard/panels/membership/page.tsx +++ b/src/app/volunteer_dashboard/panels/membership/page.tsx @@ -17,7 +17,6 @@ import { } from './membership.types' import { buildColumns, FULFILLMENT_CATEGORY } from './membershipColumns' import styles from './page.module.css' -import { ListBody, ListElement } from '@/app/admin/layout/List' import { SearchModal } from '@/app/volunteer_dashboard/layout/SearchModal' import { DropdownButton, @@ -39,6 +38,16 @@ const showHideChoices = [ { value: false, label: 'Hide' }, ] +function getUserDisplayName(user: UserProfile): string { + if (user.preferredName) return user.preferredName + + const fullName = [user.firstName, user.lastName].filter(Boolean).join(' ') + if (fullName) return fullName + + if (user.email) return user.email + return `User ${user.id}` +} + const tableOptionRows: { key: keyof MembershipTableOptions label: string @@ -225,6 +234,41 @@ export default function Page() { [members, isSearchMatch] ) + const renderUserPickerResults = () => { + const { isPending, error, data } = userSearchQuery + const results = data?.data ?? [] + + const renderContent = () => { + if (isPending) return 'Loading...' + if (error) return `Error: ${error.message}` + if (results.length === 0) return 'No results found' + + return results.map((user) => ( + + )) + } + + return ( +
+ {renderContent()} +
+ ) + } + return ( - - {userSearchQuery.data?.data.map((user) => ( - void handleMatchUser(user)} - > - - {user.preferredName ?? - [user.firstName, user.lastName] - .filter(Boolean) - .join(' ') ?? - user.email ?? - `User ${user.id}`} - - {user.email && {user.email}} - - ))} - + {renderUserPickerResults()}
From 124d62098f6fc0558920c2625f8734b8de5c87a6 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Sat, 19 Sep 2026 17:47:36 -0400 Subject: [PATCH 25/27] bito that took forever --- src/app/volunteer_dashboard/page.module.css | 6 +- .../panels/fundraising/page.module.css | 2 +- .../members/panel_views/DonorView.module.css | 8 +- .../panel_views/HistoryView.module.css | 8 +- .../components/ContributionsMenu.tsx | 277 ++++++++---------- .../components/EditableSelectTag.tsx | 12 +- .../membership/components/SourceMenu.tsx | 2 +- .../membership/components/Tags.module.css | 2 +- .../panels/membership/components/Tags.tsx | 4 +- .../panels/membership/hooks.ts | 9 +- .../panels/membership/membership.helpers.ts | 27 +- .../panels/membership/membership.types.ts | 16 +- .../panels/membership/membershipCells.tsx | 31 +- .../panels/membership/membershipColumns.tsx | 50 ++-- .../panels/membership/page.module.css | 4 +- .../panels/membership/page.tsx | 163 ++++++----- .../panels/positions/page.module.css | 6 +- .../common/buttons/Button.module.css | 2 +- .../common/dropdown/DropdownButton.module.css | 2 +- .../common/icons/CloseCircleIcon.tsx | 11 + .../navigation_stack/detail/Detail.module.css | 2 +- .../sidebar/Sidebar.module.css | 2 +- src/components/common/panel/Panel.module.css | 2 +- .../common/tab_bar/TabBar.module.css | 2 +- src/components/common/table/Table.module.css | 21 +- src/components/common/table/Table.tsx | 262 +++++++++-------- .../common/widget/DashboardWidget.module.css | 4 +- .../common/widget/ProgressBar.module.css | 2 +- 28 files changed, 485 insertions(+), 454 deletions(-) create mode 100644 src/components/common/icons/CloseCircleIcon.tsx diff --git a/src/app/volunteer_dashboard/page.module.css b/src/app/volunteer_dashboard/page.module.css index c579d1fb..96e4b10c 100644 --- a/src/app/volunteer_dashboard/page.module.css +++ b/src/app/volunteer_dashboard/page.module.css @@ -121,7 +121,7 @@ align-items: center; justify-content: center; font-size: clamp(1.65rem, 2.6vw, 2.3rem); - font-weight: 750; + font-weight: 700; letter-spacing: -0.03em; line-height: 1.08; color: rgba(9, 34, 58, 0.96); @@ -144,7 +144,7 @@ .unselectedProfileName { margin: 0; font-size: clamp(1.65rem, 2.6vw, 2.3rem); - font-weight: 750; + font-weight: 700; letter-spacing: -0.03em; line-height: 1.08; color: rgba(9, 34, 58, 0.96); @@ -176,7 +176,7 @@ .unselectedProfileHandle { margin: 0; font-size: 0.98rem; - font-weight: 540; + font-weight: 500; letter-spacing: -0.01em; color: rgba(15, 23, 42, 0.55); animation: nameIn 0.79s cubic-bezier(0.25, 0.46, 0.45, 0.94) 3.275s both; diff --git a/src/app/volunteer_dashboard/panels/fundraising/page.module.css b/src/app/volunteer_dashboard/panels/fundraising/page.module.css index 83c91253..de196402 100644 --- a/src/app/volunteer_dashboard/panels/fundraising/page.module.css +++ b/src/app/volunteer_dashboard/panels/fundraising/page.module.css @@ -208,7 +208,7 @@ } .panelBreadcrumb { - font-weight: 650; + font-weight: 600; color: rgba(15, 23, 42, 0.55); } diff --git a/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.module.css b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.module.css index a31c8946..10282083 100644 --- a/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.module.css +++ b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.module.css @@ -61,7 +61,7 @@ .emptyTitle { font-size: 1.05rem; - font-weight: 650; + font-weight: 600; letter-spacing: -0.01em; color: #0f172a; } @@ -109,7 +109,7 @@ .linkedTitle { font-size: 1.05rem; - font-weight: 650; + font-weight: 600; letter-spacing: -0.01em; color: #0f172a; } @@ -172,7 +172,7 @@ padding: 0.5rem 0.75rem; background: rgba(239, 68, 68, 0.1); color: #7f1d1d; - font-weight: 650; + font-weight: 600; cursor: pointer; transition: transform 120ms ease, @@ -269,7 +269,7 @@ .modalTitle { font-size: 1.05rem; - font-weight: 650; + font-weight: 600; letter-spacing: -0.01em; color: #0f172a; } diff --git a/src/app/volunteer_dashboard/panels/members/panel_views/HistoryView.module.css b/src/app/volunteer_dashboard/panels/members/panel_views/HistoryView.module.css index 4c16b1cc..3b1aad1f 100644 --- a/src/app/volunteer_dashboard/panels/members/panel_views/HistoryView.module.css +++ b/src/app/volunteer_dashboard/panels/members/panel_views/HistoryView.module.css @@ -38,7 +38,7 @@ border-bottom: 1px solid rgba(17, 24, 39, 0.08); color: rgba(15, 23, 42, 0.85); - font-weight: 550; + font-weight: 500; letter-spacing: -0.01em; cursor: pointer; @@ -74,16 +74,16 @@ .historyEntryPrefix { color: rgba(15, 23, 42, 0.62); - font-weight: 550; + font-weight: 500; } .historyEntryActor { color: rgba(9, 34, 58, 0.8); - font-weight: 650; + font-weight: 600; } .historyEntryDate { - font-weight: 650; + font-weight: 600; color: rgba(15, 23, 42, 0.85); } diff --git a/src/app/volunteer_dashboard/panels/membership/components/ContributionsMenu.tsx b/src/app/volunteer_dashboard/panels/membership/components/ContributionsMenu.tsx index 391c8b1d..553467f7 100644 --- a/src/app/volunteer_dashboard/panels/membership/components/ContributionsMenu.tsx +++ b/src/app/volunteer_dashboard/panels/membership/components/ContributionsMenu.tsx @@ -6,7 +6,7 @@ import { getEarliestLineitemDate, getMembershipTierForAmount, } from '../membership.helpers' -import { MemberMenuProps } from '../membership.types' +import { ContributionRecord, MemberMenuProps } from '../membership.types' import styles from './ContributionsMenu.module.css' import tags from './Tags.module.css' import { DropdownOverlay } from '@/components/common' @@ -73,6 +73,114 @@ const buildMonthRange = (start: Date, end: Date) => { return months } +type MonthLineitem = NonNullable< + ContributionRecord['lineitemsByMonth'] +>[string][number] + +interface MonthItemProps { + item: MonthLineitem + onlyRecurring?: boolean +} + +const MonthItem = ({ item, onlyRecurring }: MonthItemProps) => ( + + + {item.orderNumber} · {formatContributionDate(item.paidAt)} + + + {formatAmount(item.amount)} + + +) + +interface MonthRowProps { + label: string + items: MonthLineitem[] + onlyRecurring?: boolean +} + +const MonthRow = ({ label, items, onlyRecurring }: MonthRowProps) => ( +
+ {label} + {items.length === 0 ? ( + + 0 lineitems + + ) : ( + + {items.map((item) => ( + + ))} + + )} +
+) + +interface ContributionRowProps { + record: ContributionRecord + onlyRecurring?: boolean + onSelect?: () => void +} + +const ContributionRow = ({ + record, + onlyRecurring, + onSelect, +}: ContributionRowProps) => ( +
+ + + {formatContributionDate(record.createdAt)} + + + {record.contributionForm} + + + {record.lineitemCount} lineitem + {record.lineitemCount !== 1 && 's'} + + {record.mostRecentLineitemDate && ( + + last paid{' '} + {formatContributionDate(record.mostRecentLineitemDate)} + + )} + {record.monthsSpanned != null && ( + + {record.monthsWithLineitems} of {record.monthsSpanned} month + {record.monthsSpanned !== 1 && 's'} + + )} + + + {record.isRecurring && record.recurringAmount != null + ? formatAmount(record.recurringAmount) + : formatAmount(record.amount)} + +
+) + export const ContributionsMenu = ({ member, closeDropdown, @@ -153,15 +261,11 @@ export const ContributionsMenu = ({ ) : (
{records.map((record) => ( -
{ @@ -172,73 +276,7 @@ export const ContributionsMenu = ({ } : undefined } - > - - - {formatContributionDate( - record.createdAt - )} - - - {record.contributionForm} - - - {record.lineitemCount} lineitem - {record.lineitemCount !== 1 && 's'} - - {record.mostRecentLineitemDate && ( - - last paid{' '} - {formatContributionDate( - record.mostRecentLineitemDate - )} - - )} - {record.monthsSpanned != null && ( - - {record.monthsWithLineitems} of{' '} - {record.monthsSpanned} month - {record.monthsSpanned !== 1 && - 's'} - - )} - - - {record.isRecurring && - record.recurringAmount != null - ? formatAmount( - record.recurringAmount - ) - : formatAmount(record.amount)} - -
+ /> ))}
)} @@ -248,83 +286,16 @@ export const ContributionsMenu = ({ Months
- {monthRows.map((monthRow) => { - const items = - lineitemsByMonth[monthRow.key] ?? [] - - return ( -
- - {monthRow.label} - - {items.length === 0 ? ( - - 0 lineitems - - ) : ( - - {items.map((item) => ( - - - { - item.orderNumber - }{' '} - ·{' '} - {formatContributionDate( - item.paidAt - )} - - - {formatAmount( - item.amount - )} - - - ))} - - )} -
- ) - })} + {monthRows.map((monthRow) => ( + + ))}
)} diff --git a/src/app/volunteer_dashboard/panels/membership/components/EditableSelectTag.tsx b/src/app/volunteer_dashboard/panels/membership/components/EditableSelectTag.tsx index 21aa94bb..7119ed46 100644 --- a/src/app/volunteer_dashboard/panels/membership/components/EditableSelectTag.tsx +++ b/src/app/volunteer_dashboard/panels/membership/components/EditableSelectTag.tsx @@ -31,6 +31,8 @@ export interface EditableSelectTagProps { onSelect: (value: T | null) => void allowClear?: boolean menuLabel?: string + fallback?: string + optionLabel?: (value: T) => string } export const EditableSelectTag = ({ @@ -43,6 +45,8 @@ export const EditableSelectTag = ({ onSelect, allowClear = false, menuLabel, + fallback = 'N/A', + optionLabel = (option: T) => option, }: EditableSelectTagProps) => { const variantClasses = selectVariants[variant] const choices: (T | null)[] = allowClear ? [...options, null] : [...options] @@ -85,14 +89,18 @@ export const EditableSelectTag = ({ : optionClass[choice] )} > - {choice ?? 'N/A'} + {choice == null + ? fallback + : optionLabel(choice)}
))} /> )} > - {value ?? 'N/A'} + + {value == null ? fallback : optionLabel(value)} + ( - {source ? `Identical To ${source}` : 'Identical'} + {source ? `Identical to ${source}` : 'Identical'} ) diff --git a/src/app/volunteer_dashboard/panels/membership/components/Tags.module.css b/src/app/volunteer_dashboard/panels/membership/components/Tags.module.css index c7f84c09..69819158 100644 --- a/src/app/volunteer_dashboard/panels/membership/components/Tags.module.css +++ b/src/app/volunteer_dashboard/panels/membership/components/Tags.module.css @@ -132,7 +132,7 @@ color: rgba(241, 245, 249, 0.95); background: rgba(15, 23, 42, 0.92); font-size: 0.72rem; - font-weight: 560; + font-weight: 500; line-height: 1.35; box-shadow: 0 8px 20px rgba(2, 6, 23, 0.28); diff --git a/src/app/volunteer_dashboard/panels/membership/components/Tags.tsx b/src/app/volunteer_dashboard/panels/membership/components/Tags.tsx index 132cb00e..30984070 100644 --- a/src/app/volunteer_dashboard/panels/membership/components/Tags.tsx +++ b/src/app/volunteer_dashboard/panels/membership/components/Tags.tsx @@ -29,7 +29,7 @@ export const EditableBoolTag = ({ styles.editableBoolTag )} aria-pressed={value === true} - aria-label={value ? `${label} confirmed` : `${label} not confirmed`} + aria-label={`${label}${value ? '' : ' not'} confirmed`} role="button" tabIndex={0} onClick={onToggle} @@ -53,7 +53,7 @@ export const ConfirmedBadge = ({ }) => ( {confirmed ? ( diff --git a/src/app/volunteer_dashboard/panels/membership/hooks.ts b/src/app/volunteer_dashboard/panels/membership/hooks.ts index 79c1023c..f3fb0a47 100644 --- a/src/app/volunteer_dashboard/panels/membership/hooks.ts +++ b/src/app/volunteer_dashboard/panels/membership/hooks.ts @@ -16,7 +16,6 @@ import { HistoryEntry, Member, MemberEdits, - MembershipTableMode, PendingUpdate, } from './membership.types' import { @@ -599,7 +598,7 @@ export function usePendingUpdates(edit: EditController, members: Member[]) { export function useMembershipPanel() { const { options, setOption, resetOptions } = useTableOptions() - const [tableMode, setTableMode] = useState('view') + const [isEditing, setIsEditing] = useState(false) const { editController, clearEdits } = useMemberEdits() const { query, members, totalEntries, eligibleMemberCount } = useMembershipsQuery() @@ -617,7 +616,7 @@ export function useMembershipPanel() { const stopEditing = useCallback(() => { clearEdits() - setTableMode('view') + setIsEditing(false) }, [clearEdits]) const saveMutation = useSaveMemberships(stopEditing) @@ -629,8 +628,8 @@ export function useMembershipPanel() { options, setOption, resetOptions, - tableMode, - setTableMode, + isEditing, + setIsEditing, editController, saveMutation, discardEdits: stopEditing, diff --git a/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts b/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts index 47a41881..5b0a5199 100644 --- a/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts +++ b/src/app/volunteer_dashboard/panels/membership/membership.helpers.ts @@ -12,12 +12,11 @@ import { PackageShipped, PendingUpdate, RecurringSummary, - ShirtSize, TierSegment, } from './membership.types' import { MembershipDeliverableStatus, - ShirtSize as ApiShirtSize, + ShirtSize, UserAddress, } from 'pv-contracts/data' import { @@ -449,10 +448,7 @@ export const mapPacketToMember = ( userAddress: formatUserAddress(user?.address), userAddressParts: user?.address, donorAddress: formatDonorAddress(donor), - shirtSize: - membership?.shirtSize === '2XL' - ? 'XXL' - : (membership?.shirtSize ?? undefined), + shirtSize: membership?.shirtSize ?? undefined, isMember: membership?.duesPayingMember, eligibleForBenefits: membership?.membershipBenefitEligible, membershipTier: membershipTiers.find( @@ -467,13 +463,13 @@ export const mapPacketToMember = ( discordConfirmed: membership?.discordConfirmed ?? Boolean(user?.discordUsers?.length), nameConfirmed: - membership?.nameConfirmed ?? user?.nameConfirmed ?? undefined, + membership?.nameConfirmed ?? user?.nameConfirmed ?? false, addressConfirmed: - membership?.addressConfirmed ?? user?.addressConfirmed ?? undefined, - cardPrinted: membership?.cardPrinted ?? undefined, - labelPrinted: membership?.labelPrinted ?? undefined, - cardPacked: membership?.itemsPackaged ?? undefined, - benefitShipped: membership?.benefitsShipped ?? undefined, + membership?.addressConfirmed ?? user?.addressConfirmed ?? false, + cardPrinted: membership?.cardPrinted ?? false, + labelPrinted: membership?.labelPrinted ?? false, + cardPacked: membership?.itemsPackaged ?? false, + benefitShipped: membership?.benefitsShipped ?? false, membershipCardStatus: cardStatus, membershipMerchStatus: merchStatus, packageShipped: packageShippedFromStatus(cardStatus), @@ -481,9 +477,6 @@ export const mapPacketToMember = ( } } -const toApiShirtSize = (size: ShirtSize): ApiShirtSize => - size === 'XXL' ? ApiShirtSize.DoubleExtraLarge : (size as ApiShirtSize) - const packageShippedStatus: Record< PackageShipped, MembershipDeliverableStatus @@ -540,10 +533,10 @@ const resolveConfirmation = ( const resolveShirtSize = ( member: Member, draft: MemberEdits -): FieldChange => { +): FieldChange => { if (draft.shirtSize === undefined) return undefined if ((draft.shirtSize ?? undefined) === member.shirtSize) return undefined - return draft.shirtSize == null ? null : toApiShirtSize(draft.shirtSize) + return draft.shirtSize ?? null } const resolveCardStatus = (member: Member, draft: MemberEdits) => { diff --git a/src/app/volunteer_dashboard/panels/membership/membership.types.ts b/src/app/volunteer_dashboard/panels/membership/membership.types.ts index b5051e53..aae76f1f 100644 --- a/src/app/volunteer_dashboard/panels/membership/membership.types.ts +++ b/src/app/volunteer_dashboard/panels/membership/membership.types.ts @@ -1,5 +1,6 @@ import { MembershipDeliverableStatus, + ShirtSize, User, UserAddress, } from 'pv-contracts/data' @@ -18,8 +19,6 @@ export const membershipTiers = [ export type MembershipTier = (typeof membershipTiers)[number] -export type ShirtSize = 'XS' | 'S' | 'M' | 'L' | 'XL' | 'XXL' - export interface ContributionRecord { orderNumber: string createdAt: string @@ -59,8 +58,15 @@ export interface TierSegment { maxAmount: number } -export type PackageShipped = - 'Yes' | 'No' | 'Returned' | 'Not Received' | 'Canceled' +export const packageShippedOptions = [ + 'Yes', + 'No', + 'Returned', + 'Not Received', + 'Canceled', +] as const + +export type PackageShipped = (typeof packageShippedOptions)[number] export interface Member { packet: MembershipsResponsePacket @@ -116,8 +122,6 @@ export interface Member { userMatched?: boolean } -export type MembershipTableMode = 'view' | 'edit' - export const membershipSearchFields = [ { value: 'name', label: 'Name' }, { value: 'discord', label: 'Discord' }, diff --git a/src/app/volunteer_dashboard/panels/membership/membershipCells.tsx b/src/app/volunteer_dashboard/panels/membership/membershipCells.tsx index d458bc66..21b17006 100644 --- a/src/app/volunteer_dashboard/panels/membership/membershipCells.tsx +++ b/src/app/volunteer_dashboard/panels/membership/membershipCells.tsx @@ -31,10 +31,11 @@ import { MemberMenuProps, MemberValueProps, PackageShipped, - ShirtSize, + packageShippedOptions, } from './membership.types' import styles from './membershipCells.module.css' import { cn } from '@/util' +import { ShirtSize } from 'pv-contracts/data' import { Fragment } from 'react' const NoLinkedUser = () => ( @@ -533,21 +534,24 @@ export const EmailMenu = ({ member, closeDropdown }: MemberMenuProps) => ( /> ) -const shirtSizes: ShirtSize[] = ['XXL', 'XL', 'L', 'M', 'S', 'XS'] +const shirtSizes = Object.values(ShirtSize).reverse() const shirtSizeClass: Record = { - XS: tags.tagRed, - S: tags.tagOrange, - M: tags.tagYellow, - L: tags.tagGreen, - XL: tags.tagBlue, - XXL: tags.tagPurple, + [ShirtSize.ExtraSmall]: tags.tagRed, + [ShirtSize.Small]: tags.tagOrange, + [ShirtSize.Medium]: tags.tagYellow, + [ShirtSize.Large]: tags.tagGreen, + [ShirtSize.ExtraLarge]: tags.tagBlue, + [ShirtSize.DoubleExtraLarge]: tags.tagPurple, } +const shirtSizeLabel = (size: ShirtSize) => + size === ShirtSize.DoubleExtraLarge ? 'XXL' : size + export const ShirtSizeValue = ({ member }: MemberValueProps) => member.shirtSize ? ( - {member.shirtSize} + {shirtSizeLabel(member.shirtSize)} ) : ( N/A @@ -566,6 +570,7 @@ export const ShirtSizeEdit = ({ member, edit }: MemberEditProps) => { value={value} options={shirtSizes} optionClass={shirtSizeClass} + optionLabel={shirtSizeLabel} dirty={dirty} allowClear onSelect={(shirtSize) => edit.update(member, { shirtSize })} @@ -573,14 +578,6 @@ export const ShirtSizeEdit = ({ member, edit }: MemberEditProps) => { ) } -const packageShippedOptions: PackageShipped[] = [ - 'Yes', - 'No', - 'Returned', - 'Not Received', - 'Canceled', -] - const packageShippedClass: Record = { Yes: tags.tagGreen, No: tags.tagRed, diff --git a/src/app/volunteer_dashboard/panels/membership/membershipColumns.tsx b/src/app/volunteer_dashboard/panels/membership/membershipColumns.tsx index bd24ed0f..194bb1cb 100644 --- a/src/app/volunteer_dashboard/panels/membership/membershipColumns.tsx +++ b/src/app/volunteer_dashboard/panels/membership/membershipColumns.tsx @@ -18,12 +18,11 @@ import { EditController, Member, MemberEdits, + MemberEditProps, MemberFlag, - MembershipTableMode, MembershipTier, MembershipSearchField, PackageShipped, - ShirtSize, } from './membership.types' import { AddressEdit, @@ -51,6 +50,7 @@ import styles from './page.module.css' import { Column, ColumnEntry } from '@/components/common/table' import { cn } from '@/util' import Link from 'next/link' +import { ShirtSize } from 'pv-contracts/data' const monthAbbreviations = [ 'Jan', @@ -102,12 +102,12 @@ const membershipTierRank: Record = { } const shirtSizeRank: Record = { - XXL: 0, - XL: 1, - L: 2, - M: 3, - S: 4, - XS: 5, + [ShirtSize.DoubleExtraLarge]: 0, + [ShirtSize.ExtraLarge]: 1, + [ShirtSize.Large]: 2, + [ShirtSize.Medium]: 3, + [ShirtSize.Small]: 4, + [ShirtSize.ExtraSmall]: 5, } const NO_SHIRT_SIZE_RANK = Object.keys(shirtSizeRank).length @@ -140,15 +140,15 @@ interface FlagColumnConfig { ) => React.ReactNode } +interface FlagEditProps extends MemberEditProps { + config: FlagColumnConfig +} + const FlagEdit = ({ member, edit, config: { key, header, resolveDraft }, -}: { - member: Member - edit: EditController - config: FlagColumnConfig -}) => { +}: FlagEditProps) => { const draft = useMemberDraft(edit, member) const value = resolveDraft ? resolveDraft(member, draft) @@ -249,23 +249,25 @@ const isFullyFulfilled = (m: Member, requireConfirmations: boolean) => export const FULFILLMENT_CATEGORY = 'Fulfillment' +export interface BuildColumnsArgs { + options: MembershipTableOptions + isEditing: boolean + edit: EditController + onMatchUser: (member: Member) => void + searchQuery?: string + searchField?: MembershipSearchField +} + export const buildColumns = ({ options, - tableMode, + isEditing, edit, onMatchUser, searchQuery = '', searchField = 'name', -}: { - options: MembershipTableOptions - tableMode: MembershipTableMode - edit: EditController - onMatchUser: (member: Member) => void - searchQuery?: string - searchField?: MembershipSearchField -}): ColumnEntry[] => { +}: BuildColumnsArgs): ColumnEntry[] => { const { showConfirmed, showStatus, showRowNumber } = options - const showFulfilledTag = options.showFulfilled && tableMode === 'view' + const showFulfilledTag = options.showFulfilled && !isEditing const normalizedQuery = searchQuery.trim().toLowerCase() const nameQuery = searchField === 'name' ? normalizedQuery : '' const discordQuery = searchField === 'discord' ? normalizedQuery : '' @@ -312,7 +314,7 @@ export const buildColumns = ({ }, }, { - label: FULFILLMENT_CATEGORY, + key: FULFILLMENT_CATEGORY, collapsedWidth: '6rem', dotColor: statusDotColor, rowRender: showFulfilledTag diff --git a/src/app/volunteer_dashboard/panels/membership/page.module.css b/src/app/volunteer_dashboard/panels/membership/page.module.css index 6385673b..7afd6e0a 100644 --- a/src/app/volunteer_dashboard/panels/membership/page.module.css +++ b/src/app/volunteer_dashboard/panels/membership/page.module.css @@ -187,7 +187,7 @@ } .panelBreadcrumb { - font-weight: 650; + font-weight: 600; color: rgba(15, 23, 42, 0.55); } @@ -312,7 +312,7 @@ .userPickerName { font-size: 0.92rem; - font-weight: 550; + font-weight: 500; color: rgba(15, 23, 42, 0.85); } diff --git a/src/app/volunteer_dashboard/panels/membership/page.tsx b/src/app/volunteer_dashboard/panels/membership/page.tsx index d36a109d..6bf7a4d6 100644 --- a/src/app/volunteer_dashboard/panels/membership/page.tsx +++ b/src/app/volunteer_dashboard/panels/membership/page.tsx @@ -23,6 +23,7 @@ import { DropdownOverlay, ToggleGroup, } from '@/components/common' +import { CloseCircleIcon } from '@/components/common/icons/CloseCircleIcon' import Panel from '@/components/common/panel/Panel' import { Table } from '@/components/common/table' import { cn } from '@/util' @@ -144,6 +145,77 @@ function EditToolbar({ ) } +type TableOptionRowConfig = (typeof tableOptionRows)[number] + +interface TableOptionRowProps { + config: TableOptionRowConfig + value: boolean + onChange: (value: boolean) => void +} + +function TableOptionRow({ + config: { label, ariaLabel, choices }, + value, + onChange, +}: TableOptionRowProps) { + return ( +
+ {label} + + ariaLabel={ariaLabel} + orientation="horizontal" + value={value} + options={choices} + onChange={onChange} + /> +
+ ) +} + +interface TableOptionsMenuProps { + options: MembershipTableOptions + setOption: ( + key: K, + value: MembershipTableOptions[K] + ) => void + onRestoreDefaults: () => void +} + +function TableOptionsMenu({ + options, + setOption, + onRestoreDefaults, +}: TableOptionsMenuProps) { + return ( + ( + ( + setOption(config.key, value)} + /> + ))} + footerButtonLabel="Restore Defaults" + footerButtonClassName={styles.restoreDefaultsButton} + footerButtonOnClick={() => { + onRestoreDefaults() + closeDropdown() + }} + /> + )} + /> + ) +} + export default function Page() { const [memberToMatch, setMemberToMatch] = useState(null) // not wired to the query yet @@ -161,8 +233,8 @@ export default function Page() { options, setOption, resetOptions, - tableMode, - setTableMode, + isEditing, + setIsEditing, editController, saveMutation, discardEdits, @@ -205,13 +277,13 @@ export default function Page() { () => buildColumns({ options, - tableMode, + isEditing, edit: editController, onMatchUser: setMemberToMatch, searchQuery: searchDraft, searchField, }), - [options, tableMode, editController, searchDraft, searchField] + [options, isEditing, editController, searchDraft, searchField] ) const collapsedCategories = useMemo( @@ -316,10 +388,7 @@ export default function Page() { onMouseDown={(event) => event.preventDefault()} onClick={() => setSearchDraft('')} > - + ) : ( @@ -361,7 +430,7 @@ export default function Page() {
- {tableMode === 'edit' ? ( + {isEditing ? ( setTableMode('edit')} + onClick={() => setIsEditing(true)} > Edit )} - ( - ( -
- - {label} - - - ariaLabel={ariaLabel} - orientation="horizontal" - value={options[key]} - options={choices} - onChange={(value) => - setOption( - key, - value - ) - } - /> -
- ) - )} - footerButtonLabel="Restore Defaults" - footerButtonClassName={ - styles.restoreDefaultsButton - } - footerButtonOnClick={() => { - resetOptions() - resetColumnOrder() - closeDropdown() - }} - /> - )} + { + resetOptions() + resetColumnOrder() + }} />
@@ -463,14 +484,16 @@ export default function Page() { data={members} rowKey={(m) => m.id} collapsedCategories={collapsedCategories} - mode={tableMode} - zebra={options.showZebra} + options={{ + editing: isEditing, + zebra: options.showZebra, + reorderable: isEditing, + entryOrder: columnOrder, + onEntryOrderChange: onColumnOrderChange, + }} isScrollTarget={isSearchMatch} scrollToColumnKey={searchField} scrollToRowToken={scrollToMatchToken} - reorderable={tableMode === 'edit'} - entryOrder={columnOrder} - onEntryOrderChange={onColumnOrderChange} footer={ hasNextPage && (
): ReactElement { + return ( + + ) +} diff --git a/src/components/common/navigation_stack/detail/Detail.module.css b/src/components/common/navigation_stack/detail/Detail.module.css index a9595cfb..39b25af1 100644 --- a/src/components/common/navigation_stack/detail/Detail.module.css +++ b/src/components/common/navigation_stack/detail/Detail.module.css @@ -70,7 +70,7 @@ } .panelBreadcrumb { - font-weight: 650; + font-weight: 600; color: rgba(15, 23, 42, 0.55); } diff --git a/src/components/common/navigation_stack/sidebar/Sidebar.module.css b/src/components/common/navigation_stack/sidebar/Sidebar.module.css index 22fe63f9..cf5e8aeb 100644 --- a/src/components/common/navigation_stack/sidebar/Sidebar.module.css +++ b/src/components/common/navigation_stack/sidebar/Sidebar.module.css @@ -425,7 +425,7 @@ } .panelBreadcrumb { - font-weight: 650; + font-weight: 600; color: rgba(15, 23, 42, 0.55); } diff --git a/src/components/common/panel/Panel.module.css b/src/components/common/panel/Panel.module.css index f428333f..dc7d6a6d 100644 --- a/src/components/common/panel/Panel.module.css +++ b/src/components/common/panel/Panel.module.css @@ -96,7 +96,7 @@ } .panelBreadcrumb { - font-weight: 650; + font-weight: 600; color: rgba(15, 23, 42, 0.55); } diff --git a/src/components/common/tab_bar/TabBar.module.css b/src/components/common/tab_bar/TabBar.module.css index 2f77abcb..25f3d830 100644 --- a/src/components/common/tab_bar/TabBar.module.css +++ b/src/components/common/tab_bar/TabBar.module.css @@ -77,7 +77,7 @@ color: rgba(15, 23, 42, 0.7); font-size: 0.82rem; - font-weight: 650; + font-weight: 600; letter-spacing: -0.01em; white-space: nowrap; diff --git a/src/components/common/table/Table.module.css b/src/components/common/table/Table.module.css index eb67ea0e..1be1c7d1 100644 --- a/src/components/common/table/Table.module.css +++ b/src/components/common/table/Table.module.css @@ -152,7 +152,6 @@ } .cell, -.cellOverflowVisible, .dotCell { position: relative; align-self: stretch; @@ -203,3 +202,23 @@ height: 0.55rem; border-radius: 50%; } + +.dragGhost { + position: fixed; + top: -10000px; + left: -10000px; + overflow: hidden; + background: #ffffff; + border-radius: 0.5rem; + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.18); +} + +.dragGhostCell { + position: absolute; + margin: 0; + opacity: 1; +} + +.spacer { + flex-shrink: 0; +} diff --git a/src/components/common/table/Table.tsx b/src/components/common/table/Table.tsx index 19fffbf5..e6a061a9 100644 --- a/src/components/common/table/Table.tsx +++ b/src/components/common/table/Table.tsx @@ -2,6 +2,7 @@ import styles from './Table.module.css' import { cn } from '@/util' +import { SortDirection } from 'pv-contracts/requests' import React, { useCallback, useEffect, @@ -30,12 +31,19 @@ import { MdDragIndicator } from 'react-icons/md' * - columns: Column definitions, optionally grouped into categories. * - data: The rows to render. * - rowKey: Returns a stable unique key for each row. - * - collapsedCategories: Labels of categories to render collapsed as dots. - * - mode: 'view' (default) or 'edit' to swap in the editable cell renderers. - * - zebra: Alternating row background colors. + * - collapsedCategories: Keys of categories to render collapsed as dots. + * - options: Feature toggles and their companion props. See TableOptions. * - footer: Rendered below the last row, inside the scroll container. * * + * TableOptions: + * - editing: Swap in the editable cell renderers. + * - zebra: Alternating row background colors. + * - reorderable: Let header cells be dragged to reorder top-level entries. + * - entryOrder: Entry ids (column key, or category label) in the desired order. + * - onEntryOrderChange: Receives the new entry order after a drag. + * + * * Column: * - key: Unique column identifier, also used as the sort key. * - header: Header label text. @@ -50,11 +58,12 @@ import { MdDragIndicator } from 'react-icons/md' * * * ColumnCategory: - * - label: Category name, used to target it via collapsedCategories. + * - key: Category identifier, unique across all columns and categories. * - columns: The columns belonging to this category. * - collapsedWidth: Width used while collapsed. Defaults to 5rem. * - dotColor: Dot color per column while collapsed. Return null for a bullet. * - rowRender: Replaces the category's cells for a row with a single spanning cell. + * Return anything falsy to leave the row's cells alone. * * * @@ -73,7 +82,7 @@ import { MdDragIndicator } from 'react-icons/md' * sortValue: (example) => example.name, * }, * { - * label: 'Contact', + * key: 'contact', * columns: [ * { key: 'email', header: 'Email', render: (e) => e.email }, * { key: 'phone', header: 'Phone', render: (e) => e.phone }, @@ -86,8 +95,7 @@ import { MdDragIndicator } from 'react-icons/md' * columns={columns} * data={examples} * rowKey={(example) => example.id} - * mode="view" - * zebra + * options={{ zebra: true }} * /> * */ @@ -95,6 +103,7 @@ import { MdDragIndicator } from 'react-icons/md' const DEFAULT_COLUMN_WIDTH = '10rem' const VIRTUALIZE_THRESHOLD = 60 const OVERSCAN = 10 +const NO_COLLAPSED_CATEGORIES: string[] = [] export interface Column { key: string @@ -111,46 +120,47 @@ export interface Column { } export interface ColumnCategory { - label: string + key: string columns: Column[] collapsedWidth?: string dotColor?: (row: T, col: Column) => string | null - rowRender?: (row: T) => React.ReactNode | null + rowRender?: (row: T) => React.ReactNode } export type ColumnEntry = Column | ColumnCategory function isCategory(entry: ColumnEntry): entry is ColumnCategory { - return 'columns' in entry && 'label' in entry + return 'columns' in entry } -const entryId = (entry: ColumnEntry) => - isCategory(entry) ? entry.label : entry.key +const entryCells = (root: HTMLElement | null | undefined, entryKey: string) => + Array.from( + root?.querySelectorAll( + `[data-entry-id="${CSS.escape(entryKey)}"]` + ) ?? [] + ) -export type TableMode = 'view' | 'edit' +export interface TableOptions { + editing?: boolean + zebra?: boolean + /** Distinct from Column.reorderable, which pins an individual column. */ + reorderable?: boolean + entryOrder?: string[] + onEntryOrderChange?: (order: string[]) => void +} export interface TableProps { columns: ColumnEntry[] data: T[] rowKey: (row: T, index: number) => string | number collapsedCategories?: string[] - mode?: TableMode - zebra?: boolean + options?: TableOptions footer?: React.ReactNode isScrollTarget?: (row: T) => boolean - /** Column brought into horizontal view alongside the scroll target. */ scrollToColumnKey?: string - /** Increment to scroll the first isScrollTarget row into view. */ scrollToRowToken?: number - /** Allow header cells to be dragged to reorder top-level entries. */ - reorderable?: boolean - /** Entry ids (column key, or category label) in the desired order. */ - entryOrder?: string[] - onEntryOrderChange?: (order: string[]) => void } -type SortDir = 'asc' | 'desc' - type OpenCellSetter = React.Dispatch> interface VisibleEntry { @@ -160,28 +170,29 @@ interface VisibleEntry { width: string } +interface TableCellProps { + col: Column + row: T + index: number + editing: boolean + isOpen: boolean + cellId: string + setOpenCell: OpenCellSetter + dragging?: boolean + entryId?: string +} + function TableCellInner({ col, row, index, - mode, + editing, isOpen, cellId, setOpenCell, dragging = false, entryId: cellEntryId, -}: { - col: Column - row: T - index: number - mode: TableMode - isOpen: boolean - cellId: string - setOpenCell: OpenCellSetter - dragging?: boolean - entryId?: string -}) { - const editing = mode === 'edit' +}: TableCellProps) { const editable = editing && col.renderEdit != null const interactive = !editing && Boolean(col.menu ?? col.onCellClick) @@ -200,7 +211,7 @@ function TableCellInner({ return ( ({ const TableCell = React.memo(TableCellInner) as typeof TableCellInner +interface TableRowProps { + row: T + index: number + rowId: string | number + columns: ColumnEntry[] + visibleEntries: VisibleEntry[] + gridTemplateColumns: string + editing: boolean + zebra: boolean + openColKey: string | null + setOpenCell: OpenCellSetter + draggingEntryId: string | null +} + function TableRowInner({ row, index, @@ -251,32 +276,17 @@ function TableRowInner({ columns, visibleEntries, gridTemplateColumns, - mode, + editing, zebra, openColKey, setOpenCell, draggingEntryId, -}: { - row: T - index: number - rowId: string | number - columns: ColumnEntry[] - visibleEntries: VisibleEntry[] - gridTemplateColumns: string - mode: TableMode - zebra: boolean - openColKey: string | null - setOpenCell: OpenCellSetter - draggingEntryId: string | null -}) { +}: TableRowProps) { const rowOverrides = new Map() for (const entry of columns) { - if (isCategory(entry) && entry.rowRender) { - const result = entry.rowRender(row) - if (result != null) { - rowOverrides.set(entry.label, result) - } - } + if (!isCategory(entry)) continue + const override = entry.rowRender?.(row) + if (override) rowOverrides.set(entry.key, override) } const rendered = new Set() @@ -295,11 +305,11 @@ function TableRowInner({ const category = entry.category! return ( @@ -320,29 +330,27 @@ function TableRowInner({ key={col.key} className={styles.bullet} > - · + · ) })} ) } - if (entry.category && rowOverrides.has(entry.category.label)) { - if (rendered.has(entry.category.label)) { - return null - } - rendered.add(entry.category.label) - const span = entry.category.columns.length + if (entry.category && rowOverrides.has(entry.category.key)) { + if (rendered.has(entry.category.key)) return null + rendered.add(entry.category.key) + const columnSpan = entry.category.columns.length return ( ({ styles.overrideContent )} > - {rowOverrides.get(entry.category.label)} + {rowOverrides.get(entry.category.key)} ) @@ -362,14 +370,13 @@ function TableRowInner({ col={col} row={row} index={index} - mode={mode} + editing={editing} isOpen={openColKey === col.key} cellId={`${rowId}::${col.key}`} setOpenCell={setOpenCell} - entryId={entry.category?.label ?? col.key} + entryId={entry.category?.key ?? col.key} dragging={ - draggingEntryId === - (entry.category?.label ?? col.key) + draggingEntryId === (entry.category?.key ?? col.key) } /> ) @@ -440,19 +447,23 @@ export function Table({ columns, data, rowKey, - collapsedCategories = [], - mode = 'view', - zebra = false, + collapsedCategories = NO_COLLAPSED_CATEGORIES, + options, footer, isScrollTarget, scrollToColumnKey, scrollToRowToken, - reorderable = false, - entryOrder, - onEntryOrderChange, }: TableProps) { + const { + editing = false, + zebra = false, + reorderable = false, + entryOrder, + onEntryOrderChange, + } = options ?? {} + const [sortKey, setSortKey] = useState(null) - const [sortDir, setSortDir] = useState('asc') + const [sortDir, setSortDir] = useState(SortDirection.ASC) const [openCell, setOpenCell] = useState(null) const [draggingId, setDraggingId] = useState(null) const [dropTarget, setDropTarget] = useState<{ @@ -472,8 +483,8 @@ export function Table({ const handledScrollToken = useRef(undefined) useEffect(() => { - if (mode === 'edit') setOpenCell(null) - }, [mode]) + if (editing) setOpenCell(null) + }, [editing]) useEffect(() => { if (!openCell) return @@ -504,10 +515,12 @@ export function Table({ const handleSort = (key: string) => { if (sortKey === key) { - setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')) + setSortDir((d) => + d === SortDirection.ASC ? SortDirection.DESC : SortDirection.ASC + ) } else { setSortKey(key) - setSortDir('asc') + setSortDir(SortDirection.ASC) } } @@ -516,8 +529,8 @@ export function Table({ const rank = new Map(entryOrder.map((id, index) => [id, index])) return [...columns].sort( (a, b) => - (rank.get(entryId(a)) ?? Number.MAX_SAFE_INTEGER) - - (rank.get(entryId(b)) ?? Number.MAX_SAFE_INTEGER) + (rank.get(a.key) ?? Number.MAX_SAFE_INTEGER) - + (rank.get(b.key) ?? Number.MAX_SAFE_INTEGER) ) }, [columns, entryOrder]) @@ -528,11 +541,7 @@ export function Table({ const container = containerRef.current if (!container) return - const cells = Array.from( - container.querySelectorAll( - `[data-entry-id="${entryKey}"]` - ) - ) + const cells = entryCells(container, entryKey) if (cells.length === 0) return const view = container.getBoundingClientRect() @@ -556,11 +565,14 @@ export function Table({ ) const ghost = document.createElement('div') - ghost.style.cssText = `position:fixed;top:-10000px;left:-10000px;width:${right - left}px;height:${bottom - top}px;overflow:hidden;background:#ffffff;border-radius:0.5rem;box-shadow:0 8px 20px rgba(15,23,42,0.18);` + ghost.className = styles.dragGhost + ghost.style.width = `${right - left}px` + ghost.style.height = `${bottom - top}px` for (const { cell, rect } of visible) { const clone = cell.cloneNode(true) as HTMLElement const computed = window.getComputedStyle(cell) + clone.classList.add(styles.dragGhostCell) clone.style.fontFamily = computed.fontFamily clone.style.fontSize = computed.fontSize clone.style.fontWeight = computed.fontWeight @@ -568,13 +580,10 @@ export function Table({ clone.style.lineHeight = computed.lineHeight clone.style.letterSpacing = computed.letterSpacing clone.style.color = computed.color - clone.style.position = 'absolute' - clone.style.margin = '0' clone.style.left = `${rect.left - left}px` clone.style.top = `${rect.top - top}px` clone.style.width = `${rect.width}px` clone.style.height = `${rect.height}px` - clone.style.opacity = '1' ghost.appendChild(clone) } @@ -589,7 +598,7 @@ export function Table({ const moveEntry = (fromId: string, toId: string, after: boolean) => { if (fromId === toId) return - const ids = orderedColumns.map(entryId) + const ids = orderedColumns.map((entry) => entry.key) if (!ids.includes(fromId) || !ids.includes(toId)) return const next = ids.filter((id) => id !== fromId) @@ -625,14 +634,14 @@ export function Table({ return 0 }) - return sortDir === 'desc' ? sorted.reverse() : sorted + return sortDir === SortDirection.DESC ? sorted.reverse() : sorted }, [data, flatColumns, sortKey, sortDir]) const visibleEntries = useMemo(() => { const entries: VisibleEntry[] = [] for (const entry of orderedColumns) { if (isCategory(entry)) { - if (collapsedSet.has(entry.label)) { + if (collapsedSet.has(entry.key)) { entries.push({ type: 'group-collapsed', category: entry, @@ -679,7 +688,7 @@ export function Table({ ? current : { rowHeight, headerHeight } ) - }, [mode, visibleEntries, hasRows]) + }, [editing, visibleEntries, hasRows]) useEffect(() => { if ( @@ -699,7 +708,7 @@ export function Table({ const top = index * metrics.rowHeight const headerCell = headerRef.current?.querySelector( - `[data-column-key="${scrollToColumnKey ?? ''}"]` + `[data-column-key="${CSS.escape(scrollToColumnKey ?? '')}"]` ) let left = container.scrollLeft @@ -737,18 +746,20 @@ export function Table({ const offsets: Record = {} for (const entry of visibleEntries) { - const label = entry.category?.label - if (entry.type !== 'column' || !label || offsets[label] != null) + const categoryKey = entry.category?.key + if ( + entry.type !== 'column' || + !categoryKey || + offsets[categoryKey] != null + ) continue - const cells = header.querySelectorAll( - `[data-entry-id="${label}"]` - ) + const cells = entryCells(header, categoryKey) if (cells.length < 2) continue const first = cells[0].getBoundingClientRect() const last = cells[cells.length - 1].getBoundingClientRect() - offsets[label] = (last.right - first.left) / 2 + offsets[categoryKey] = (last.right - first.left) / 2 } setHandleOffsets((current) => { @@ -762,7 +773,7 @@ export function Table({ return (
({ ref={headerRef} > {visibleEntries.map((entry) => { - const dragId = entry.category?.label ?? entry.col?.key ?? '' + const dragId = entry.category?.key ?? entry.col?.key ?? '' const groupColumns = entry.type === 'column' && entry.category ? entry.category.columns @@ -790,13 +801,10 @@ export function Table({ dropTarget?.id === dragId && draggingId !== dragId const dropsOnRightHalf = (event: React.DragEvent) => { - const cells = - headerRef.current?.querySelectorAll( - `[data-entry-id="${dragId}"]` - ) - const first = cells?.[0]?.getBoundingClientRect() + const cells = entryCells(headerRef.current, dragId) + const first = cells[0]?.getBoundingClientRect() const last = - cells?.[cells.length - 1]?.getBoundingClientRect() + cells[cells.length - 1]?.getBoundingClientRect() const rect = event.currentTarget.getBoundingClientRect() const left = first?.left ?? rect.left const right = last?.right ?? rect.right @@ -859,7 +867,7 @@ export function Table({ if (entry.type === 'group-collapsed') { return ( ({ {col.sortValue && ( {sortKey === col.key ? ( - sortDir === 'asc' ? ( + sortDir === SortDirection.ASC ? ( ) : ( ({ {start > 0 && (
)} @@ -950,7 +956,7 @@ export function Table({ columns={orderedColumns} visibleEntries={visibleEntries} gridTemplateColumns={gridTemplateColumns} - mode={mode} + editing={editing} zebra={zebra} openColKey={ openCell?.startsWith(openPrefix) @@ -965,10 +971,8 @@ export function Table({ {end < rowCount && (
)} {footer} diff --git a/src/components/common/widget/DashboardWidget.module.css b/src/components/common/widget/DashboardWidget.module.css index 7bda1afb..672bba95 100644 --- a/src/components/common/widget/DashboardWidget.module.css +++ b/src/components/common/widget/DashboardWidget.module.css @@ -23,7 +23,7 @@ .value { font-size: 1.16rem; - font-weight: 850; + font-weight: 800; letter-spacing: -0.025em; color: rgba(9, 34, 58, 0.9); } @@ -41,7 +41,7 @@ flex-wrap: wrap; gap: 0.25rem; font-size: 0.73rem; - font-weight: 650; + font-weight: 600; color: rgba(15, 23, 42, 0.48); } diff --git a/src/components/common/widget/ProgressBar.module.css b/src/components/common/widget/ProgressBar.module.css index 90dcf2d6..f8947d17 100644 --- a/src/components/common/widget/ProgressBar.module.css +++ b/src/components/common/widget/ProgressBar.module.css @@ -9,7 +9,7 @@ align-items: center; justify-content: space-between; font-size: 0.74rem; - font-weight: 760; + font-weight: 700; letter-spacing: 0.01em; color: rgba(15, 23, 42, 0.65); } From bb5887cdf99cf335a3c84278a1899a009ed6adb7 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Sat, 19 Sep 2026 17:52:19 -0400 Subject: [PATCH 26/27] bito --- src/util/hooks/useFetch.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/util/hooks/useFetch.ts b/src/util/hooks/useFetch.ts index 761d3541..2284c235 100644 --- a/src/util/hooks/useFetch.ts +++ b/src/util/hooks/useFetch.ts @@ -119,8 +119,9 @@ export function useFetch() { await onLogout() } + const text = await res.text() + if (!res.ok) { - const text = await res.text() let error: ApiError = { message: res.statusText || 'An error occurred', error: '', @@ -135,7 +136,6 @@ export function useFetch() { throw new FetchError(error.message, res.status, error.error) } - const text = await res.text() const content = res.status === 204 || text.trim().length === 0 ? undefined From 04c2472eb96d8b4bb8f7339d1a039ed587df9aed Mon Sep 17 00:00:00 2001 From: zoe2276 Date: Sat, 19 Sep 2026 23:24:31 +0000 Subject: [PATCH 27/27] dot separator no longer utf8 --- src/app/endorsements/components/FilterButtonRow.tsx | 4 ++-- .../panels/endorsements/components/EndorsementBanner.tsx | 5 +++-- .../panels/endorsements/endorsementFilters.tsx | 3 ++- .../panels/members/panel_views/DonorView.tsx | 4 ++-- .../panels/members/panel_views/HistoryView.tsx | 8 ++++---- src/app/volunteer_dashboard/panels/membership/page.tsx | 4 ++-- src/components/common/widget/DashboardWidget.tsx | 6 ++++-- src/util/helpers.ts | 2 ++ 8 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/app/endorsements/components/FilterButtonRow.tsx b/src/app/endorsements/components/FilterButtonRow.tsx index 40f6b2bf..cbbf9c44 100644 --- a/src/app/endorsements/components/FilterButtonRow.tsx +++ b/src/app/endorsements/components/FilterButtonRow.tsx @@ -11,7 +11,7 @@ import { type SectionGroupingMode, type SectionSortOrder, } from '../endorsements.types' -import { cn } from '@/util' +import { cn, DOT_SEPARATOR } from '@/util' import { AnimatePresence, LayoutGroup, motion } from 'motion/react' import { type FocusEvent, @@ -839,7 +839,7 @@ function TagsDropdownGroup({ ? getOptionLabel(requiredSection.options, requiredSection.value) : null const selectedLabel = selectedRequiredLabel - ? `${selectedTagLabel} · ${selectedRequiredLabel}` + ? `${selectedTagLabel} ${DOT_SEPARATOR} ${selectedRequiredLabel}` : selectedTagLabel const handleRequiredSectionPress = (optionValue: string) => { diff --git a/src/app/volunteer_dashboard/panels/endorsements/components/EndorsementBanner.tsx b/src/app/volunteer_dashboard/panels/endorsements/components/EndorsementBanner.tsx index 82790cf3..b872a41a 100644 --- a/src/app/volunteer_dashboard/panels/endorsements/components/EndorsementBanner.tsx +++ b/src/app/volunteer_dashboard/panels/endorsements/components/EndorsementBanner.tsx @@ -8,7 +8,7 @@ import { import { TabBar, TabSpec } from '@/components/common/tab_bar/TabBar' import { HStack, Spacer, VStack, ZStack } from '@/components/layout' import { stateOptions } from '@/models' -import { cn, parseErrorMessage } from '@/util' +import { cn, DOT_SEPARATOR, parseErrorMessage } from '@/util' import { Endorsement, EndorsementType, InitiativeType } from 'pv-contracts/data' import { ChangeEvent, useState } from 'react' import { FaCamera } from 'react-icons/fa' @@ -135,7 +135,8 @@ export function EndorsementBanner({ endorsement.jurisdiction, ] .filter(Boolean) - .join(' · ') || 'No state selected'} + .join(` ${DOT_SEPARATOR} `) || + 'No state selected'} {uploadError && ( diff --git a/src/app/volunteer_dashboard/panels/endorsements/endorsementFilters.tsx b/src/app/volunteer_dashboard/panels/endorsements/endorsementFilters.tsx index 684d5c59..d8ef201d 100644 --- a/src/app/volunteer_dashboard/panels/endorsements/endorsementFilters.tsx +++ b/src/app/volunteer_dashboard/panels/endorsements/endorsementFilters.tsx @@ -4,6 +4,7 @@ import styles from './endorsementFilters.module.css' import { FilterTag } from '@/app/volunteer_dashboard/layout/FilterTags' import { DropdownOverlay, DropdownOverlayButton } from '@/components/common' import { stateOptions } from '@/models' +import { DOT_SEPARATOR } from '@/util' import { ElectionStatus, Endorsement, @@ -260,7 +261,7 @@ export function useEndorsementFilters(endorsements: Endorsement[]) { selectedIncumbentLabel, ] .filter(Boolean) - .join(' · ') || 'More' + .join(` ${DOT_SEPARATOR} `) || 'More' let selectedFilterIcon = if (selectedState) selectedFilterIcon = diff --git a/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx index 36297ee8..d31de893 100644 --- a/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx +++ b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx @@ -4,7 +4,7 @@ import styles from './DonorView.module.css' import { SearchModal } from '@/app/volunteer_dashboard/layout/SearchModal' import { Form, FormGroup, TextField } from '@/components/common/forms' import { NavigationButton } from '@/components/common/navigation_stack/navigation_button/NavigationButton' -import { cn } from '@/util' +import { cn, DOT_SEPARATOR } from '@/util' import type { UseQueryResult } from '@tanstack/react-query' import { ActBlueContribution, @@ -276,7 +276,7 @@ export function DonorView({ .map((lineitem) => (
{eligibleMemberCount != null && - `${eligibleMemberCount.toLocaleString()} Eligible · `} + `${eligibleMemberCount.toLocaleString()} Eligible ${DOT_SEPARATOR} `} Showing {members.length.toLocaleString()} {totalEntries != null && ` of ${totalEntries.toLocaleString()}`} diff --git a/src/components/common/widget/DashboardWidget.tsx b/src/components/common/widget/DashboardWidget.tsx index 5786b16e..781c3975 100644 --- a/src/components/common/widget/DashboardWidget.tsx +++ b/src/components/common/widget/DashboardWidget.tsx @@ -1,7 +1,7 @@ 'use client' import styles from './DashboardWidget.module.css' -import { cn } from '@/util' +import { cn, DOT_SEPARATOR } from '@/util' import { forwardRef } from 'react' export interface DashboardWidgetProps extends React.HTMLAttributes { @@ -40,7 +40,9 @@ export const DashboardWidget = forwardRef(
{hasStat1 && {stat1}} {hasStat1 && hasStat2 && ( - · + + {DOT_SEPARATOR} + )} {hasStat2 && {stat2}}
diff --git a/src/util/helpers.ts b/src/util/helpers.ts index bca29f1f..44fa2ab8 100644 --- a/src/util/helpers.ts +++ b/src/util/helpers.ts @@ -71,3 +71,5 @@ export function cn(...classes: unknown[]) { .map((className) => String(className)) .join(' ') } + +export const DOT_SEPARATOR = '\u00b7'