From 38616b3d3154e3b885dead05f137f015c2b831e3 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Thu, 14 May 2026 13:51:45 +0530 Subject: [PATCH 1/4] PM-5071 Enhance SFDC views --- .../styles.module.scss | 2 +- .../src/pages/reports/ReportsPage.module.scss | 37 +- .../reports/src/pages/reports/ReportsPage.tsx | 478 +++++++++++++----- 3 files changed, 376 insertions(+), 141 deletions(-) diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss b/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss index 36bee9379..456661485 100644 --- a/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss @@ -10,7 +10,7 @@ justify-content: center; text-transform: uppercase; font-family: $font-barlow-condensed; - font-size: 40px; + font-size: 50px; font-weight: $font-weight-medium; margin-top: $sp-2; padding: $sp-6 0; diff --git a/src/apps/reports/src/pages/reports/ReportsPage.module.scss b/src/apps/reports/src/pages/reports/ReportsPage.module.scss index 53cb25e50..4cfbe966b 100644 --- a/src/apps/reports/src/pages/reports/ReportsPage.module.scss +++ b/src/apps/reports/src/pages/reports/ReportsPage.module.scss @@ -1,3 +1,4 @@ +@import '@libs/ui/styles/includes'; .page { display: flex; flex-direction: column; @@ -176,17 +177,35 @@ color: #6b6f75; } -.billingSummary { - margin-top: 8px; - padding: 16px; - border: 1px solid #e4e6e9; - border-radius: 6px; - background: #fafbfc; +.billingAccountIdLink { + margin: 0; + padding: 0; + border: 0; + background: none; + color: $link-blue-dark; + cursor: pointer; + font: inherit; + text-align: inherit; } -.billingSummaryTitle { - font-weight: 600; - margin-bottom: 12px; +.billingAccountIdLink:hover { + color: #0a58ca; +} + +.billingModalBody { + min-width: 280px; + padding-top: 4px; +} + +.billingModalMeta { + font-size: 13px; + color: #6b6f75; + margin-bottom: 14px; +} + +.billingModalLoading { + padding: 16px 0; + color: #494f55; } .billingDetailGrid { diff --git a/src/apps/reports/src/pages/reports/ReportsPage.tsx b/src/apps/reports/src/pages/reports/ReportsPage.tsx index 6263c0038..77542c0a2 100644 --- a/src/apps/reports/src/pages/reports/ReportsPage.tsx +++ b/src/apps/reports/src/pages/reports/ReportsPage.tsx @@ -1,9 +1,21 @@ -import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react' +import { + ChangeEvent, + Dispatch, + FC, + SetStateAction, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' +import { format as formatIsoDate, isValid, parseISO } from 'date-fns' import { NavigateFunction, useNavigate } from 'react-router-dom' import { + BaseModal, Button, IconOutline, + InputDatePicker, InputSelect, InputSelectOption, InputText, @@ -16,6 +28,7 @@ import { Pagination } from '~/apps/admin/src/lib' import { bulkMemberLookupRouteId } from '../../config/routes.config' import { handleError } from '../../lib/utils' import { + BillingAccountDetail, BillingAccountProfileResponse, BillingAccountsViewData, downloadBlobFile, @@ -30,7 +43,7 @@ import { SfdcBillingAccountPaymentRow, } from '../../lib/services' -import { getReportParameterValidationError } from './reports-page.validation' +import { getReportParameterValidationError, isValidReportDateValue } from './reports-page.validation' import styles from './ReportsPage.module.scss' const pageTitle = 'Reports' @@ -38,15 +51,16 @@ const bulkMembersByHandlesPath = '/identity/users-by-handles' const BILLING_ACCOUNTS_REPORT_PATH = '/sfdc/billing-accounts' const SFDC_PAYMENTS_REPORT_PATH = '/sfdc/payments' const BILLING_ACCOUNTS_REPORT_DEFINITION: ReportDefinition = { - description: 'View billing-account details and SFDC payments by billing account ID.', + description: + 'View SFDC payments across all billing accounts by default. Optionally filter by billing account ID and dates.', method: 'GET', name: 'Billing Accounts', parameters: [ { - description: 'Billing account ID', + description: 'Optional billing account ID to narrow payments to a single account.', location: 'query', name: 'billingAccountId', - required: true, + required: false, type: 'string', }, { @@ -68,12 +82,17 @@ const BILLING_ACCOUNTS_REPORT_DEFINITION: ReportDefinition = { type ReportsPageTab = 'reports' | 'billingAccounts' const buildSfdcPaymentsQueryPath = ( - billingAccountId: string, + billingAccountId: string | undefined, startDate?: string, endDate?: string, ): string => { const query = new URLSearchParams() - query.append('billingAccountIds', billingAccountId.trim()) + const trimmedBa = billingAccountId?.trim() + + if (trimmedBa) { + query.append('billingAccountIds', trimmedBa) + } + const start = startDate?.trim() const end = endDate?.trim() @@ -85,7 +104,8 @@ const buildSfdcPaymentsQueryPath = ( query.append('endDate', end) } - return `${SFDC_PAYMENTS_REPORT_PATH}?${query.toString()}` + const queryString = query.toString() + return queryString ? `${SFDC_PAYMENTS_REPORT_PATH}?${queryString}` : SFDC_PAYMENTS_REPORT_PATH } const formatReportCell = (value: unknown): string => { @@ -130,13 +150,177 @@ const PAYMENT_TABLE_COLUMNS: { key: keyof SfdcBillingAccountPaymentRow; label: s ] const PAYMENT_ROWS_PER_PAGE_OPTIONS = [10, 25, 50] +type BillingAccountDateParamInputProps = { + label: string + parameterErrors: Record + parameterName: 'startDate' | 'endDate' + parameterValues: Record + setParameterValues: Dispatch>> +} + +function billingAccountDatePickerBounds( + parameterName: 'startDate' | 'endDate', + parsedStart: Date | undefined, + parsedEnd: Date | undefined, +): { maxDate?: Date; minDate?: Date } { + const startOk = !!parsedStart && isValid(parsedStart) + const endOk = !!parsedEnd && isValid(parsedEnd) + + if (parameterName === 'endDate' && startOk) { + return { maxDate: undefined, minDate: parsedStart } + } + + if (parameterName === 'startDate' && endOk) { + return { maxDate: parsedEnd, minDate: undefined } + } + + return {} +} + +const BillingAccountDateParamInput: FC = ( + props: BillingAccountDateParamInputProps, +) => { + const startRaw = props.parameterValues.startDate?.trim() + const endRaw = props.parameterValues.endDate?.trim() + const parsedStart = startRaw && isValidReportDateValue(startRaw) ? parseISO(startRaw) : undefined + const parsedEnd = endRaw && isValidReportDateValue(endRaw) ? parseISO(endRaw) : undefined + const rawValue = props.parameterValues[props.parameterName]?.trim() + const selectedDate = rawValue && isValidReportDateValue(rawValue) ? parseISO(rawValue) : undefined + const dateBounds = billingAccountDatePickerBounds( + props.parameterName, + parsedStart, + parsedEnd, + ) + + function handleDateChange(date: Date | null): void { + props.setParameterValues(previous => ({ + ...previous, + [props.parameterName]: date && isValid(date) ? formatIsoDate(date, 'yyyy-MM-dd') : '', + })) + } + + return ( + + ) +} + +type BillingAccountIdCellProps = { + rawId: unknown + onOpen: (id: string) => void +} + +const BillingAccountIdCell: FC = (props: BillingAccountIdCellProps) => { + const displayed = formatReportCell(props.rawId) + + function handleClick(): void { + props.onOpen(String(props.rawId)) + } + + if (displayed === '—') { + return <>{displayed} + } + + return ( + + ) +} + +const BillingAccountSummaryBody = (props: { + billingAccount: BillingAccountDetail | undefined + billingAccountIdLabel: string +}): JSX.Element => ( + <> +
+ {`Billing account ID: ${props.billingAccountIdLabel}`} +
+ {props.billingAccount ? ( +
+
+ Name + {props.billingAccount.name} +
+
+ Description + + {formatReportCell(props.billingAccount.description)} + +
+
+ Subcontracting end customer + + {formatReportCell(props.billingAccount.subcontractingEndCustomer)} + +
+
+ Status + {props.billingAccount.status} +
+
+ Start date + + {props.billingAccount.startDate + ? formatPaymentDate(String(props.billingAccount.startDate)) + : '—'} + +
+
+ End date + + {props.billingAccount.endDate + ? formatPaymentDate(String(props.billingAccount.endDate)) + : '—'} + +
+
+ Budget + + {formatReportCell(props.billingAccount.budget)} + +
+
+ Markup + + {formatReportCell(props.billingAccount.markup)} + +
+
+ ) : ( +
+ No billing account profile was found for this ID. +
+ )} + +) + const BillingAccountReportResults = ( props: { data: BillingAccountsViewData }, ): JSX.Element => { - const billingAccount: BillingAccountsViewData['billingAccount'] = props.data.billingAccount const payments: BillingAccountsViewData['payments'] = props.data.payments const [currentPage, setCurrentPage] = useState(1) const [rowsPerPage, setRowsPerPage] = useState(PAYMENT_ROWS_PER_PAGE_OPTIONS[0]) + const [modalBaId, setModalBaId] = useState(undefined) + const [modalProfile, setModalProfile] = useState(undefined) + const [modalLoading, setModalLoading] = useState(false) + const openBillingProfileModal = useCallback((id: string) => { + setModalBaId(id) + }, []) const total = payments.length const totalPages = Math.max(1, Math.ceil(total / rowsPerPage)) const currentSliceStart = (currentPage - 1) * rowsPerPage @@ -148,73 +332,94 @@ const BillingAccountReportResults = ( setCurrentPage(1) }, [payments]) + useEffect(() => { + if (!modalBaId) { + setModalProfile(undefined) + setModalLoading(false) + return undefined + } + + let cancelled = false + setModalLoading(true) + setModalProfile(undefined) + + const profileQuery = new URLSearchParams({ billingAccountId: modalBaId }) + const profilePath = `${BILLING_ACCOUNTS_REPORT_PATH}?${profileQuery.toString()}` + + fetchReportJson(profilePath) + .then(response => { + if (!cancelled) { + setModalProfile(response.billingAccount) + } + }) + .catch(() => { + if (!cancelled) { + setModalProfile(undefined) + } + }) + .finally(() => { + if (!cancelled) { + setModalLoading(false) + } + }) + + return () => { + cancelled = true + } + }, [modalBaId]) + function handleRowsPerPageChange(event: ChangeEvent): void { setRowsPerPage(Number(event.target.value)) setCurrentPage(1) } + function handleCloseBillingModal(): void { + setModalBaId(undefined) + } + + function renderPaymentCell( + row: SfdcBillingAccountPaymentRow, + colKey: keyof SfdcBillingAccountPaymentRow, + ): JSX.Element | string { + const value = row[colKey] + + if (colKey === 'paymentDate') { + return formatPaymentDate(String(value)) + } + + if (colKey === 'billingAccountId') { + return + } + + return formatReportCell(value) + } + return (
-
-
Billing account
- {billingAccount ? ( -
-
- Name - {billingAccount.name} -
-
- Description - - {formatReportCell(billingAccount.description)} - -
-
- Subcontracting end customer - - {formatReportCell(billingAccount.subcontractingEndCustomer)} - -
-
- Status - {billingAccount.status} -
-
- Start date - - {billingAccount.startDate - ? formatPaymentDate(String(billingAccount.startDate)) - : '—'} - -
-
- End date - - {billingAccount.endDate - ? formatPaymentDate(String(billingAccount.endDate)) - : '—'} - -
-
- Budget - - {formatReportCell(billingAccount.budget)} - -
-
- Markup - - {formatReportCell(billingAccount.markup)} - -
-
- ) : ( -
- No billing account profile was found for this ID. Payments for this account may still - appear below. + + Close + + )} + > + {modalBaId === undefined ? undefined : ( +
+ {modalLoading ? ( +
Loading billing account…
+ ) : ( + + )}
)} -
+
Payments
@@ -235,11 +440,7 @@ const BillingAccountReportResults = ( {paginatedPayments.map(row => ( {PAYMENT_TABLE_COLUMNS.map(col => ( - - {col.key === 'paymentDate' - ? formatPaymentDate(String(row[col.key])) - : formatReportCell(row[col.key])} - + {renderPaymentCell(row, col.key)} ))} ))} @@ -317,10 +518,6 @@ const buildParameterTooltipContent = (parameter: ReportParameter): JSX.Element = ) -const EMPTY_BILLING_ACCOUNT_PROFILE_RESPONSE: BillingAccountProfileResponse = { - billingAccount: undefined, -} - type ReportActionsProps = { handleCsvDownload: () => void handleJsonDownload: () => void @@ -610,67 +807,67 @@ const ReportsPageContent: FC = props => { Object.keys(parameterErrors).length > 0 ), [parameterErrors]) - const handleBillingAccountView = useCallback(async () => { - if (activeTab !== 'billingAccounts' || hasInvalidParameterValues) { - return - } - - const billingAccountId = parameterValues.billingAccountId?.trim() - - if (!billingAccountId) { - return - } - + const fetchBillingPaymentsForParams = useCallback(async (params: Record) => { try { setIsBillingAccountViewLoading(true) - const profileQuery = new URLSearchParams({ billingAccountId }) - const profilePath = `${BILLING_ACCOUNTS_REPORT_PATH}?${profileQuery.toString()}` + const billingAccountId = params.billingAccountId?.trim() const paymentsPath = buildSfdcPaymentsQueryPath( - billingAccountId, - parameterValues.startDate, - parameterValues.endDate, + billingAccountId || undefined, + params.startDate, + params.endDate, ) - - const paymentsPromise = fetchReportJson(paymentsPath) - const profilePromise = fetchReportJson(profilePath) - .catch(() => EMPTY_BILLING_ACCOUNT_PROFILE_RESPONSE) - const [profile, payments] = await Promise.all([profilePromise, paymentsPromise]) - - setBillingAccountViewData({ - billingAccount: profile.billingAccount, - payments, - }) + const payments = await fetchReportJson(paymentsPath) + setBillingAccountViewData({ payments }) } catch (error) { handleError(error) } finally { setIsBillingAccountViewLoading(false) } + }, []) + + useEffect(() => { + if (activeTab !== 'billingAccounts') { + return undefined + } + + fetchBillingPaymentsForParams({}) + .catch(handleError) + + return undefined + }, [activeTab, fetchBillingPaymentsForParams]) + + const handleBillingAccountView = useCallback(() => { + if (activeTab !== 'billingAccounts' || hasInvalidParameterValues) { + return + } + + fetchBillingPaymentsForParams(parameterValues) + .catch(handleError) }, [ activeTab, + fetchBillingPaymentsForParams, hasInvalidParameterValues, - parameterValues.billingAccountId, - parameterValues.endDate, - parameterValues.startDate, + parameterValues, ]) - const handleDownload = useCallback(async (format: 'json' | 'csv') => { + const handleDownload = useCallback(async (downloadFormat: 'json' | 'csv') => { if (!selectedReport || hasInvalidParameterValues) { return } try { - setDownloadingFormat(format) + setDownloadingFormat(downloadFormat) const requestPath = buildReportPathWithParams(selectedReport) - const blob = format === 'json' + const blob = downloadFormat === 'json' ? await downloadReportAsJson(requestPath) : await downloadReportAsCsv(requestPath) const challengeIdSuffix = parameterValues.challengeId?.trim() const fileName = buildDownloadName( selectedReport.name, - format, + downloadFormat, challengeIdSuffix, ) downloadBlobFile(blob, fileName) @@ -687,12 +884,18 @@ const ReportsPageContent: FC = props => { const handleResetFilters = useCallback(() => { setParameterValues({}) + + if (activeTab === 'billingAccounts') { + fetchBillingPaymentsForParams({}) + .catch(handleError) + return + } + setBillingAccountViewData(undefined) - }, []) + }, [activeTab, fetchBillingPaymentsForParams]) const handleBillingAccountViewClick = useCallback(() => { handleBillingAccountView() - .catch(handleError) }, [handleBillingAccountView]) const isDownloading = downloadingFormat !== undefined @@ -721,9 +924,8 @@ const ReportsPageContent: FC = props => { const billingAccountViewDisabled = !selectedReportForForm || isDownloading || isBillingAccountViewLoading - || requiredParamsMissing || hasInvalidParameterValues - || hasUnresolvedPathParams + const isResetDisabled = Object.keys(parameterValues).length === 0 const handleJsonDownload = useCallback(() => { @@ -766,6 +968,7 @@ const ReportsPageContent: FC = props => { /> ) + // eslint-disable-next-line complexity -- mirrors report parameter types (text, select, billing dates) const renderParameterInput = useCallback((parameter: ReportParameter) => { const commonProps = { label: formatParameterLabel(parameter.name), @@ -775,27 +978,32 @@ const ReportsPageContent: FC = props => { : (parameter.type.endsWith('[]') ? 'Comma-separated values' : 'Enter value'), } - if (parameter.type === 'boolean') { - const options: InputSelectOption[] = [ - { label: 'True', value: 'true' }, - { label: 'False', value: 'false' }, - ] + const isBillingDateField = selectedReportForForm?.path === BILLING_ACCOUNTS_REPORT_PATH + && parameter.type === 'date' + && (parameter.name === 'startDate' || parameter.name === 'endDate') + if (isBillingDateField) { return ( - ) } - if (parameter.type === 'enum') { - const options: InputSelectOption[] = (parameter.options ?? []).map(option => ({ - label: option, - value: option, - })) + if (parameter.type === 'boolean' || parameter.type === 'enum') { + const options: InputSelectOption[] = parameter.type === 'boolean' + ? [ + { label: 'True', value: 'true' }, + { label: 'False', value: 'false' }, + ] + : (parameter.options ?? []).map(option => ({ + label: option, + value: option, + })) return ( = props => { hint={parameter.type === 'date' ? 'Use ISO 8601 format (e.g. 2024-01-31)' : undefined} /> ) - }, [createSelectParamChange, handleParameterChange, parameterErrors, parameterValues]) + }, [ + createSelectParamChange, + handleParameterChange, + parameterErrors, + parameterValues, + selectedReportForForm?.path, + setParameterValues, + ]) return ( <> @@ -837,8 +1052,9 @@ const ReportsPageContent: FC = props => { + 'fill required parameters, and download JSON or CSV from the reports API.' : ( <> - {'Enter a billing account ID and optional start/end dates, then click View ' - + 'to load billing account payment data. '} + {'Payments load for all billing accounts by default. Optionally narrow by billing ' + + 'account ID and dates, then click View. Open a billing account profile from ' + + 'the Billing account ID column in the table. '} If no dates are specified, records from the past 45 days are displayed by default. From a2af777a7cef5942ef8522ab37a22328d28a86fe Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Thu, 14 May 2026 14:46:48 +0530 Subject: [PATCH 2/4] Review app css --- .../src/lib/components/AiReviewsTable/AiReviewsTable.module.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss index bcd7b1eb5..8e48c1306 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss @@ -34,6 +34,7 @@ max-width: 100%; word-break: break-word; overflow-wrap: break-word; + white-space: pre-line; } .reRunIcon { From afeb522483d38ac9a94b7a526c52cdf2ba8fd801 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 15 May 2026 17:58:45 +0530 Subject: [PATCH 3/4] Add categories filter --- .../reports/src/pages/reports/ReportsPage.tsx | 66 ++- .../home/tabs/payments/Payments.module.scss | 29 + .../tabs/payments/PaymentsListView.spec.tsx | 58 +- .../home/tabs/payments/PaymentsListView.tsx | 494 +++++++++++------- .../filter-bar/FilterBar.module.scss | 44 +- .../lib/components/filter-bar/FilterBar.tsx | 207 ++++++-- .../wallet-admin/src/lib/services/wallet.ts | 20 +- .../home/tabs/winnings/PaymentsListView.tsx | 4 +- 8 files changed, 666 insertions(+), 256 deletions(-) diff --git a/src/apps/reports/src/pages/reports/ReportsPage.tsx b/src/apps/reports/src/pages/reports/ReportsPage.tsx index 77542c0a2..2011aafb8 100644 --- a/src/apps/reports/src/pages/reports/ReportsPage.tsx +++ b/src/apps/reports/src/pages/reports/ReportsPage.tsx @@ -75,12 +75,55 @@ const BILLING_ACCOUNTS_REPORT_DEFINITION: ReportDefinition = { name: 'endDate', type: 'date', }, + { + description: 'Optional payment category group', + location: 'query', + name: 'paymentCategory', + options: ['TAAS_PAYMENT', 'TOPGEAR_PAYMENT', 'POINTS_AWARD', 'TOPCODER'], + type: 'enum', + }, ], path: BILLING_ACCOUNTS_REPORT_PATH, } type ReportsPageTab = 'reports' | 'billingAccounts' +/** API category values excluded from the Topcoder filter group. */ +const BILLING_TOPCODER_EXCLUDED_CATEGORIES = [ + 'TAAS_PAYMENT', + 'TOPGEAR_PAYMENT', + 'POINTS_AWARD', +] as const + +/** UI-only value for payments whose category is not TaaS, Topgear, or Points. */ +const BILLING_TOPCODER_CATEGORY_FILTER = 'TOPCODER' + +const BILLING_PAYMENT_CATEGORY_OPTIONS: InputSelectOption[] = [ + { label: 'All categories', value: '' }, + { label: 'TaaS', value: 'TAAS_PAYMENT' }, + { label: 'Topgear', value: 'TOPGEAR_PAYMENT' }, + { label: 'Points', value: 'POINTS_AWARD' }, + { label: 'Topcoder', value: BILLING_TOPCODER_CATEGORY_FILTER }, +] + +const filterBillingPaymentsByCategory = ( + payments: SfdcBillingAccountPaymentRow[], + paymentCategory?: string, +): SfdcBillingAccountPaymentRow[] => { + const filter = paymentCategory?.trim() + + if (!filter) { + return payments + } + + if (filter === BILLING_TOPCODER_CATEGORY_FILTER) { + const excludedCategories = new Set(BILLING_TOPCODER_EXCLUDED_CATEGORIES) + return payments.filter(row => !excludedCategories.has(row.category)) + } + + return payments.filter(row => row.category === filter) +} + const buildSfdcPaymentsQueryPath = ( billingAccountId: string | undefined, startDate?: string, @@ -817,7 +860,9 @@ const ReportsPageContent: FC = props => { params.endDate, ) const payments = await fetchReportJson(paymentsPath) - setBillingAccountViewData({ payments }) + setBillingAccountViewData({ + payments: filterBillingPaymentsByCategory(payments, params.paymentCategory), + }) } catch (error) { handleError(error) } finally { @@ -978,10 +1023,23 @@ const ReportsPageContent: FC = props => { : (parameter.type.endsWith('[]') ? 'Comma-separated values' : 'Enter value'), } - const isBillingDateField = selectedReportForForm?.path === BILLING_ACCOUNTS_REPORT_PATH + const isBillingForm = selectedReportForForm?.path === BILLING_ACCOUNTS_REPORT_PATH + + const isBillingDateField = isBillingForm && parameter.type === 'date' && (parameter.name === 'startDate' || parameter.name === 'endDate') + if (isBillingForm && parameter.name === 'paymentCategory') { + return ( + + ) + } + if (isBillingDateField) { return ( = props => { : ( <> {'Payments load for all billing accounts by default. Optionally narrow by billing ' - + 'account ID and dates, then click View. Open a billing account profile from ' - + 'the Billing account ID column in the table. '} + + 'account ID, dates, or category, then click View. Open a billing account profile ' + + 'from the Billing account ID column in the table. '} If no dates are specified, records from the past 45 days are displayed by default. diff --git a/src/apps/wallet-admin/src/home/tabs/payments/Payments.module.scss b/src/apps/wallet-admin/src/home/tabs/payments/Payments.module.scss index 25d4e4412..59b259e1c 100644 --- a/src/apps/wallet-admin/src/home/tabs/payments/Payments.module.scss +++ b/src/apps/wallet-admin/src/home/tabs/payments/Payments.module.scss @@ -62,6 +62,35 @@ color: white; } + .paymentListingTabs { + display: flex; + align-items: flex-end; + gap: $sp-6; + margin-bottom: $sp-4; + border-bottom: 1px solid $black-20; + } + + .paymentListingTab { + border: none; + background: transparent; + padding: $sp-2 0; + margin-bottom: -1px; + cursor: pointer; + color: $black-60; + font-weight: 600; + border-bottom: 2px solid transparent; + + &:focus-visible { + outline: 2px solid $turq-160; + outline-offset: 2px; + } + } + + .paymentListingTabActive { + color: $turq-160; + border-bottom-color: $turq-160; + } + .centered { height: 200px; display: flex; diff --git a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.spec.tsx b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.spec.tsx index b73f60a1d..9a8a016bf 100644 --- a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.spec.tsx +++ b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.spec.tsx @@ -209,6 +209,14 @@ const paymentsResponse = { ], } +const TOPCODER_TAB_CATEGORIES = [ + 'TASK_PAYMENT', + 'CONTEST_PAYMENT', + 'COPILOT_PAYMENT', + 'REVIEW_BOARD_PAYMENT', + 'ENGAGEMENT_PAYMENT', +] + describe('PaymentsListView', () => { beforeEach(() => { mockFilterBar.mockClear() @@ -240,7 +248,9 @@ describe('PaymentsListView', () => { .toHaveBeenCalled() expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides) .toEqual(expect.objectContaining({ - status: 'ON_HOLD_ADMIN', + status: ['ON_HOLD_ADMIN'], + category: ['TASK_PAYMENT', 'ENGAGEMENT_PAYMENT'], + date: 'last30days', })) }) @@ -273,7 +283,9 @@ describe('PaymentsListView', () => { await screen.findByText('Member earnings will appear here.') expect(mockedGetPayments) - .toHaveBeenLastCalledWith(10, 0, {}) + .toHaveBeenLastCalledWith(10, 0, { + categories: TOPCODER_TAB_CATEGORIES, + }) fireEvent.click(screen.getByRole('button', { name: 'Approver View' })) @@ -288,7 +300,9 @@ describe('PaymentsListView', () => { expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides) .toEqual(expect.objectContaining({ - status: 'ON_HOLD_ADMIN', + status: ['ON_HOLD_ADMIN'], + category: ['TASK_PAYMENT', 'ENGAGEMENT_PAYMENT'], + date: 'last30days', })) }) @@ -302,13 +316,13 @@ describe('PaymentsListView', () => { await screen.findByText('Member earnings will appear here.') expect(mockedGetPayments) - .toHaveBeenLastCalledWith(10, 0, {}) - expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides) - .toEqual({ - category: 'all', - date: 'all', - status: 'all', + .toHaveBeenLastCalledWith(10, 0, { + categories: TOPCODER_TAB_CATEGORIES, }) + expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides) + .toEqual(expect.objectContaining({ + category: TOPCODER_TAB_CATEGORIES, + })) }) it('lets an explicit status filter override the default approver status', async () => { @@ -335,7 +349,9 @@ describe('PaymentsListView', () => { expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides) .toEqual(expect.objectContaining({ - status: 'PAID', + status: ['PAID'], + category: ['TASK_PAYMENT', 'ENGAGEMENT_PAYMENT'], + date: 'last30days', })) }) @@ -363,7 +379,7 @@ describe('PaymentsListView', () => { expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides) .toEqual(expect.objectContaining({ category: 'TAAS_PAYMENT', - status: 'PAID', + status: ['PAID'], })) }) @@ -431,7 +447,7 @@ describe('PaymentsListView', () => { }) }) - it('includes the topgear payment type in the category filter options', async () => { + it('scopes the type filter to topcoder categories and lists Topgear winnings on its own tab', async () => { render( { const filterProps = mockFilterBar.mock.calls.at(-1)?.[0] const typeFilter = filterProps.filters.find((filter: any) => filter.key === 'category') - expect(typeFilter.options.some((option: any) => ( - option.value === 'TOPGEAR_PAYMENT' && option.label === 'Topgear Payment' - ))) - .toBe(true) + expect(typeFilter.options.some((option: any) => option.value === 'TOPGEAR_PAYMENT')) + .toBe(false) + + expect(screen.getByRole('tab', { name: 'Topgear' })) + .toBeTruthy() + + fireEvent.click(screen.getByRole('tab', { name: 'Topgear' })) + + await waitFor(() => { + expect(mockedGetPayments) + .toHaveBeenLastCalledWith(10, 0, { + category: ['TOPGEAR_PAYMENT'], + }) + }) }) }) diff --git a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx index ece7f1f47..c8677c243 100644 --- a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx +++ b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx @@ -31,6 +31,35 @@ const topgearPaymentCategory = 'TOPGEAR_PAYMENT' const defaultPageSize = 10 const approverDefaultDateFilter = 'last30days' +type PaymentListingTab = 'topcoder' | 'topgear' | 'taas' + +const TOPCODER_PAYMENT_CATEGORIES: ReadonlyArray = [ + 'TASK_PAYMENT', + 'CONTEST_PAYMENT', + 'COPILOT_PAYMENT', + 'REVIEW_BOARD_PAYMENT', + 'ENGAGEMENT_PAYMENT', +] + +const STATUS_FILTER_OPTIONS: { label: string, value: string }[] = [ + { label: 'Owed', value: 'OWED' }, + { label: 'On Hold (Admin)', value: 'ON_HOLD_ADMIN' }, + { label: 'On Hold (Member)', value: 'ON_HOLD' }, + { label: 'Paid', value: 'PAID' }, + { label: 'Cancelled', value: 'CANCELLED' }, + { label: 'Processing', value: 'PROCESSING' }, + { label: 'Failed', value: 'FAILED' }, + { label: 'Returned', value: 'RETURNED' }, +] + +const TOPCODER_TYPE_FILTER_OPTIONS: { label: string, value: string }[] = [ + { label: 'Task Payment', value: 'TASK_PAYMENT' }, + { label: 'Contest Payment', value: 'CONTEST_PAYMENT' }, + { label: 'Copilot Payment', value: 'COPILOT_PAYMENT' }, + { label: 'Review Board Payment', value: 'REVIEW_BOARD_PAYMENT' }, + { label: 'Engagement Payment', value: 'ENGAGEMENT_PAYMENT' }, +] + interface PaymentsListViewProps { profile: UserProfile isCollapsed?: boolean @@ -213,20 +242,28 @@ const PaymentsListView: FC = (props: PaymentsListViewProp const restrictedDefaultStatus = isApproverView ? restrictedRoleDefaultStatus : undefined const isRestrictedApproverView = isApproverView const [filters, setFilters] = React.useState>({}) + const [paymentListingTab, setPaymentListingTab] = React.useState('topcoder') + + const showPaymentListingTabs = !isApproverView + && !(restrictedCategory && !hasPaymentAdminRole) + + React.useEffect(() => { + if (restrictedCategory && !hasPaymentAdminRole) { + setPaymentListingTab('taas') + } + }, [restrictedCategory, hasPaymentAdminRole]) // eslint-disable-next-line complexity const appliedFilters = React.useMemo>(() => { - // Strip 'all' sentinel values — never forward them to the API const activeFilters = Object.fromEntries( Object.entries(filters) - .filter(([, v]) => v.length > 0 && v[0] !== 'all'), + .filter(([, v]) => v.length > 0 && !(v.length === 1 && v[0] === 'all')), ) if (restrictedCategory) { - // WiproTaasAdmin scoped to a single category let statusFilter: Record = {} - if (filters.status && filters.status[0] !== 'all') { - statusFilter = { status: activeFilters.status } + if (filters.status?.length) { + statusFilter = { status: filters.status } } return { @@ -237,85 +274,166 @@ const PaymentsListView: FC = (props: PaymentsListViewProp } if (isApproverView) { - // Payment Approver: restrict to allowed categories, default status ON_HOLD_ADMIN let statusFilter: Record = {} - if (filters.status && filters.status[0] !== 'all') { - statusFilter = { status: activeFilters.status } - } else if (!filters.status && restrictedDefaultStatus) { + if (filters.status?.length) { + statusFilter = { status: filters.status } + } else if (restrictedDefaultStatus) { statusFilter = { status: [restrictedDefaultStatus] } } let dateFilter: Record = {} - if (filters.date && filters.date[0] !== 'all') { + if (filters.date?.length && filters.date[0] !== 'all') { dateFilter = { date: activeFilters.date } - } else if (!filters.date) { + } else if (!filters.date?.length) { dateFilter = { date: [approverDefaultDateFilter] } } - let categoryFilter: Record = {} - if ( - activeFilters.category - && approverAllowedCategories.includes(activeFilters.category[0]) - ) { - categoryFilter = { category: activeFilters.category } - } else if (!filters.category || filters.category[0] === 'all') { - categoryFilter = { categories: ([] as string[]).concat(approverAllowedCategories) } - } + const pickedApproverTypes = (filters.category ?? []) + .filter(c => approverAllowedCategories.includes(c)) + const categories = pickedApproverTypes.length > 0 + ? pickedApproverTypes + : [...approverAllowedCategories] const rest = { ...activeFilters } delete rest.category + delete rest.date + delete rest.status return { ...rest, - ...categoryFilter, + categories, ...statusFilter, ...dateFilter, } } - return activeFilters - }, [filters, restrictedCategory, restrictedDefaultStatus, isApproverView]) + const base = { ...activeFilters } + delete base.category + + const allStatusesCount = STATUS_FILTER_OPTIONS.length + const statusSelection = filters.status ?? [] + const statusIsFullSelection = statusSelection.length === 0 + || statusSelection.length === allStatusesCount + + if (statusIsFullSelection) { + delete base.status + } + + if (paymentListingTab === 'topgear') { + return { + ...base, + category: [topgearPaymentCategory], + } + } + + if (paymentListingTab === 'taas') { + return { + ...base, + category: [taasPaymentCategory], + } + } + + const pickedTopcoderTypes = (filters.category ?? []) + .filter(c => TOPCODER_PAYMENT_CATEGORIES.includes(c)) + const categories = pickedTopcoderTypes.length > 0 + ? pickedTopcoderTypes + : [...TOPCODER_PAYMENT_CATEGORIES] + + return { + ...base, + categories, + } + }, [ + filters, + restrictedCategory, + restrictedDefaultStatus, + isApproverView, + paymentListingTab, + ]) const hasActiveFilters = React.useMemo( () => Object.entries(appliedFilters) .some(([key, value]) => key !== 'category' && key !== 'categories' && value.length > 0), [appliedFilters], ) - const selectedValueOverrides = React.useMemo>(() => { + const selectedValueOverrides = React.useMemo>(() => { if (restrictedCategory) { - const statusOverride = filters.status?.[0] !== 'all' ? filters.status?.[0] : undefined - return { category: restrictedCategory, - ...(statusOverride ? { status: statusOverride } : {}), + ...(filters.status?.length ? { status: filters.status } : {}), } } if (isApproverView) { - const statusOverride = filters.status?.[0] !== 'all' ? filters.status?.[0] : undefined - return { - ...(statusOverride ? { status: statusOverride } : {}), + ...(filters.status?.length ? { status: filters.status } : { status: [restrictedDefaultStatus ?? 'ON_HOLD_ADMIN'] }), + ...(filters.category?.length + ? { category: filters.category } + : { category: [...approverAllowedCategories] }), } } - return {} as Record - }, [filters.status, restrictedCategory, isApproverView]) + const overrides: Record = {} - const defaultDropdownValues = React.useMemo>(() => { - const defaults: Record = {} + if (filters.status?.length) { + overrides.status = filters.status + } - if (!restrictedCategory) { - defaults.category = filters.category?.[0] ?? 'all' + if (filters.category?.length) { + overrides.category = filters.category } - defaults.date = filters.date?.[0] ?? (isApproverView ? approverDefaultDateFilter : 'all') + if (filters.dateFrom?.[0]) { + overrides.dateFrom = filters.dateFrom[0] + } - // Fall back to the restricted default if no filter is applied - defaults.status = filters.status?.[0] ?? (restrictedDefaultStatus || 'all') + if (filters.dateTo?.[0]) { + overrides.dateTo = filters.dateTo[0] + } + + return overrides + }, [ + filters.status, + filters.category, + filters.dateFrom, + filters.dateTo, + restrictedCategory, + isApproverView, + restrictedDefaultStatus, + ]) + + const defaultDropdownValues = React.useMemo((): Record => { + if (restrictedCategory) { + return {} + } - return defaults - }, [filters.category, filters.date, filters.status, restrictedCategory, restrictedDefaultStatus, isApproverView]) + if (isApproverView) { + const approverDefaults: Record = { + category: filters.category?.length + ? filters.category + : [...approverAllowedCategories], + date: filters.date?.[0] ?? approverDefaultDateFilter, + status: filters.status?.length + ? filters.status + : [restrictedDefaultStatus ?? 'ON_HOLD_ADMIN'], + } + return approverDefaults + } + + const topcoderDefaults: Record = { + category: filters.category?.length + ? filters.category + : [...TOPCODER_PAYMENT_CATEGORIES], + } + return topcoderDefaults + }, [ + filters.category, + filters.date, + filters.status, + restrictedCategory, + restrictedDefaultStatus, + isApproverView, + ]) const [pagination, setPagination] = React.useState({ currentPage: 1, pageSize: defaultPageSize, @@ -502,6 +620,7 @@ const PaymentsListView: FC = (props: PaymentsListViewProp } setPaymentRoleView(nextView) + setPaymentListingTab('topcoder') setBulkAuditNote('') setSelectedPaymentAction(undefined) setSelectedPayments({}) @@ -517,6 +636,23 @@ const PaymentsListView: FC = (props: PaymentsListViewProp }) }, [paymentRoleView]) + const onPaymentListingTabChange = useCallback((tab: PaymentListingTab) => { + setPaymentListingTab(tab) + setBulkAuditNote('') + setSelectedPaymentAction(undefined) + setSelectedPayments({}) + setPagination(prev => ({ + ...prev, + currentPage: 1, + })) + setFilters(prev => { + const nextFilters = { ...prev } + delete nextFilters.category + + return nextFilters + }) + }, []) + /** * Applies the selected approver action to the current payment selection. * @@ -578,6 +714,112 @@ const PaymentsListView: FC = (props: PaymentsListViewProp await fetchWinnings() }, [selectedPayments, fetchWinnings]) + const listingFilters = React.useMemo((): Filter[] => { + const pageSizeFilter: Filter = { + key: 'pageSize', + label: 'Payments per page', + options: [ + { label: '10', value: '10' }, + { label: '50', value: '50' }, + { label: '100', value: '100' }, + ], + type: 'dropdown', + } + + const handleFilter: Filter = { + key: 'winnerIds', + label: 'Username/Handle', + type: 'member_autocomplete', + } + + const statusFilter: Filter = { + key: 'status', + label: 'Status', + options: STATUS_FILTER_OPTIONS, + type: 'multi_dropdown', + } + + if (isWiproTaasAdmin && !hasPaymentAdminRole) { + return [ + handleFilter, + statusFilter, + { + key: 'dateFrom', + label: 'Date from', + type: 'date', + }, + { + key: 'dateTo', + label: 'Date to', + type: 'date', + }, + pageSizeFilter, + ] + } + + if (isApproverView) { + return [ + handleFilter, + statusFilter, + { + key: 'category', + label: 'Payment Type', + options: [ + { label: 'Task Payments', value: taskPaymentCategory }, + { label: 'Engagement Payments', value: engagementPaymentCategory }, + ], + type: 'multi_dropdown', + }, + { + key: 'date', + label: 'Date', + options: [ + { label: 'Last 7 days', value: 'last7days' }, + { label: 'Last 30 days', value: 'last30days' }, + { label: 'All', value: 'all' }, + ], + type: 'dropdown', + }, + pageSizeFilter, + ] + } + + const filtersOut: Filter[] = [ + handleFilter, + statusFilter, + ] + + if (paymentListingTab === 'topcoder') { + filtersOut.push({ + key: 'category', + label: 'Type', + options: TOPCODER_TYPE_FILTER_OPTIONS, + type: 'multi_dropdown', + }) + } + + filtersOut.push( + { + key: 'dateFrom', + label: 'Date from', + type: 'date', + }, + { + key: 'dateTo', + label: 'Date to', + type: 'date', + }, + pageSizeFilter, + ) + + return filtersOut + }, [ + hasPaymentAdminRole, + isApproverView, + isWiproTaasAdmin, + paymentListingTab, + ]) + const selectedPaymentActions = selectedPaymentsCount > 0 ? [ { @@ -628,6 +870,26 @@ const PaymentsListView: FC = (props: PaymentsListViewProp
)} + {showPaymentListingTabs && ( +
+ {([ + { id: 'topcoder' as const, label: 'Topcoder' }, + { id: 'topgear' as const, label: 'Topgear' }, + { id: 'taas' as const, label: 'TaaS' }, + ]).map(tab => ( + + ))} +
+ )} = (props: PaymentsListViewProp toast.success('Download complete', { position: toast.POSITION.BOTTOM_RIGHT }) }} selectedValueOverrides={{ ...defaultDropdownValues, ...selectedValueOverrides }} - filters={[ - { - key: 'winnerIds', - label: 'Username/Handle', - type: 'member_autocomplete', - }, - { - key: 'status', - label: 'Status', - options: [ - { - label: 'All', - value: 'all', - }, - { - label: 'Owed', - value: 'OWED', - }, - { - label: 'On Hold (Admin)', - value: 'ON_HOLD_ADMIN', - }, - { - label: 'On Hold (Member)', - value: 'ON_HOLD', - }, - { - label: 'Paid', - value: 'PAID', - }, - { - label: 'Cancelled', - value: 'CANCELLED', - }, - { - label: 'Processing', - value: 'PROCESSING', - }, - { - label: 'Failed', - value: 'FAILED', - }, - { - label: 'Returned', - value: 'RETURNED', - }, - ], - type: 'dropdown', - }, - ...(isWiproTaasAdmin && !hasPaymentAdminRole ? [] : isApproverView ? [ - { - key: 'category', - label: 'Payment Type', - options: [ - { - label: 'All', - value: 'all', - }, - { - label: 'Task Payments', - value: taskPaymentCategory, - }, - { - label: 'Engagement Payments', - value: engagementPaymentCategory, - }, - ], - type: 'dropdown', - }, - ] as Filter[] : [ - { - key: 'category', - label: 'Type', - options: [ - { - label: 'All', - value: 'all', - }, - { - label: 'Task Payment', - value: 'TASK_PAYMENT', - }, - { - label: 'Contest Payment', - value: 'CONTEST_PAYMENT', - }, - { - label: 'Copilot Payment', - value: 'COPILOT_PAYMENT', - }, - { - label: 'Review Board Payment', - value: 'REVIEW_BOARD_PAYMENT', - }, - { - label: 'Engagement Payment', - value: 'ENGAGEMENT_PAYMENT', - }, - { - label: 'TaaS Payment', - value: 'TAAS_PAYMENT', - }, - { - label: 'Topgear Payment', - value: topgearPaymentCategory, - }, - ], - type: 'dropdown', - }, - ] as Filter[]), - { - key: 'date', - label: 'Date', - options: [ - { - label: 'Last 7 days', - value: 'last7days', - }, - { - label: 'Last 30 days', - value: 'last30days', - }, - { - label: 'All', - value: 'all', - }, - ], - type: 'dropdown', - }, - { - key: 'pageSize', - label: 'Payments per page', - options: [ - { - label: '10', - value: '10', - }, - { - label: '50', - value: '50', - }, - { - label: '100', - value: '100', - }, - ], - type: 'dropdown', - }, - ]} + filters={listingFilters} onFilterChange={(key: string, value: string[]) => { const newPagination = { ...pagination, diff --git a/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.module.scss b/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.module.scss index c836fe226..a452da020 100644 --- a/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.module.scss +++ b/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.module.scss @@ -37,7 +37,49 @@ .filter { width: 165px; max-width: 100%; - height: 47px; + min-height: 47px; + } + + .multiSelectWrap { + width: 200px; + max-width: 100%; + display: flex; + flex-direction: column; + gap: 2px; + min-height: 47px; + } + + .multiSelectLabel { + line-height: 1.2; + } + + .dateFilterWrap { + width: 175px; + max-width: 100%; + min-height: 47px; + + :global(.container) { + margin-bottom: 0; + } + } + + :global(.wallet-admin-ms__control) { + min-height: 47px; + border-radius: 4px; + border: 1px solid #aaa7a7; + box-shadow: none; + } + + :global(.wallet-admin-ms__value-container) { + padding: 2px 8px; + } + + :global(.wallet-admin-ms__placeholder) { + color: #6b6b6b; + } + + :global(.wallet-admin-ms__menu-portal) { + z-index: 20; } .firstFilterElement { diff --git a/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.tsx b/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.tsx index c575a8d6a..ec298d5b2 100644 --- a/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.tsx +++ b/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.tsx @@ -1,7 +1,9 @@ /* eslint-disable react/jsx-no-bind */ -import React, { ChangeEvent, useRef } from 'react' +import React, { ChangeEvent, useEffect, useRef } from 'react' +import Select, { MultiValue } from 'react-select' +import classNames from 'classnames' -import { Button, IconOutline, InputSelect, InputText } from '~/libs/ui' +import { Button, IconOutline, InputDatePicker, InputSelect, InputText } from '~/libs/ui' import { InputHandleAutocomplete, MembersAutocompeteResult, @@ -14,10 +16,12 @@ type FilterOptions = { value: string; }; +const SELECT_ALL_SENTINEL = '__select_all__' + export type Filter = { key: string; label: string; - type: 'input' | 'dropdown' | 'member_autocomplete'; + type: 'input' | 'dropdown' | 'member_autocomplete' | 'multi_dropdown' | 'date'; options?: FilterOptions[]; }; @@ -44,10 +48,38 @@ interface FilterBarProps { selectedCount?: number; onBulkClick?: () => void; selectionActions?: FilterBarSelectionAction[]; - selectedValueOverrides?: Record; + selectedValueOverrides?: Record; hasActiveFilters?: boolean; } +function parseIsoDateOnly(value: string | undefined): Date | undefined { + if (!value) { + return undefined + } + + const [y, m, d] = value.split('-') + .map(part => parseInt(part, 10)) + if (!y || !m || !d) { + return undefined + } + + return new Date(y, m - 1, d) +} + +function formatIsoDateOnly(date: Date | null): string[] { + if (!date) { + return [] + } + + const y = date.getFullYear() + const mo = String(date.getMonth() + 1) + .padStart(2, '0') + const day = String(date.getDate()) + .padStart(2, '0') + + return [`${y}-${mo}-${day}`] +} + const FilterBar: React.FC = (props: FilterBarProps) => { const [selectedValue, setSelectedValue] = React.useState>(new Map()) const selectedMembers = useRef([]) @@ -62,11 +94,33 @@ const FilterBar: React.FC = (props: FilterBarProps) => { }] : []) + useEffect(() => { + props.filters.forEach(filter => { + if (filter.type !== 'multi_dropdown') { + return + } + + const override = props.selectedValueOverrides?.[filter.key] + const next = Array.isArray(override) + ? override + : (override ? [override] : []) + setSelectedValue(prev => { + const cur = (prev.get(filter.key) as string[] | undefined) ?? [] + if (cur.length === next.length && cur.every((v, i) => v === next[i])) { + return prev + } + + return new Map(prev.set(filter.key, next)) + }) + }) + }, [props.filters, props.selectedValueOverrides]) + const renderDropdown = (index: number, filter: Filter): JSX.Element => ( ) => { @@ -80,6 +134,74 @@ const FilterBar: React.FC = (props: FilterBarProps) => { /> ) + const renderMultiDropdown = (index: number, filter: Filter): JSX.Element => { + const baseOptions = filter.options ?? [] + const menuOptions = [ + { label: 'Select All', value: SELECT_ALL_SENTINEL }, + ...baseOptions, + ] + const override = props.selectedValueOverrides?.[filter.key] + const valuesFromParent: string[] = Array.isArray(override) + ? override as string[] + : (override ? [override as string] : (selectedValue.get(filter.key) as string[] | undefined) ?? []) + const selectedOptions = baseOptions.filter(o => valuesFromParent.includes(o.value)) + + return ( +
+ + {filter.label} + +