diff --git a/.github/workflows/code_reviewer.yml b/.github/workflows/code_reviewer.yml deleted file mode 100644 index 02f198a18..000000000 --- a/.github/workflows/code_reviewer.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: AI PR Reviewer - -on: - pull_request: - types: - - opened - - synchronize -permissions: - pull-requests: write -jobs: - tc-ai-pr-review: - runs-on: ubuntu-latest - steps: - - name: Checkout Repo - uses: actions/checkout@v3 - - - name: TC AI PR Reviewer - uses: topcoder-platform/tc-ai-pr-reviewer@master - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # The GITHUB_TOKEN is there by default so you just need to keep it like it is and not necessarily need to add it as secret as it will throw an error. [More Details](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#about-the-github_token-secret) - LAB45_API_KEY: ${{ secrets.LAB45_API_KEY }} - exclude: '**/*.json, **/*.md, **/*.jpg, **/*.png, **/*.jpeg, **/*.bmp, **/*.webp' # Optional: exclude patterns separated by commas diff --git a/src/apps/admin/src/platform/skill-management/lib/components/skill-modals/SkillModal.tsx b/src/apps/admin/src/platform/skill-management/lib/components/skill-modals/SkillModal.tsx index 816d37863..271a4dca0 100644 --- a/src/apps/admin/src/platform/skill-management/lib/components/skill-modals/SkillModal.tsx +++ b/src/apps/admin/src/platform/skill-management/lib/components/skill-modals/SkillModal.tsx @@ -91,7 +91,7 @@ const SkillModal: FC = props => { setIsLoading(true) // eslint-disable-next-line unicorn/no-null - return restoreArchivedStandardizedSkill({ ...props.skill, deleted_at: null }) + return restoreArchivedStandardizedSkill({ ...props.skill, deletedAt: null }) .then(() => { refetchSkills() setEditSkill() diff --git a/src/apps/admin/src/platform/skill-management/lib/context/skills-manager.context.tsx b/src/apps/admin/src/platform/skill-management/lib/context/skills-manager.context.tsx index 180262d3d..d47e9a620 100644 --- a/src/apps/admin/src/platform/skill-management/lib/context/skills-manager.context.tsx +++ b/src/apps/admin/src/platform/skill-management/lib/context/skills-manager.context.tsx @@ -48,7 +48,7 @@ export const SkillsManagerContext: FC = props => { }: SWRResponse = useFetchCategories() const filteredSkills = useMemo(() => ( - showArchivedSkills ? allSkills : allSkills.filter(s => !s.deleted_at) + showArchivedSkills ? allSkills : allSkills.filter(s => !s.deletedAt) ), [allSkills, showArchivedSkills]) const skills = useMemo(() => findSkillsMatches(filteredSkills, skillsFilter), [filteredSkills, skillsFilter]) diff --git a/src/apps/admin/src/platform/skill-management/lib/lib/skills.utils.ts b/src/apps/admin/src/platform/skill-management/lib/lib/skills.utils.ts index be0f2a43f..ed0b7fe7a 100644 --- a/src/apps/admin/src/platform/skill-management/lib/lib/skills.utils.ts +++ b/src/apps/admin/src/platform/skill-management/lib/lib/skills.utils.ts @@ -9,7 +9,7 @@ export interface GroupedSkills { } export const isSkillArchived = (skill: StandardizedSkill): boolean => ( - !!skill.deleted_at + !!skill.deletedAt ) export const groupSkillsByCategory = (skills: StandardizedSkill[]): GroupedSkills => { diff --git a/src/apps/admin/src/platform/skill-management/lib/services/skills.service.ts b/src/apps/admin/src/platform/skill-management/lib/services/skills.service.ts index f506942a8..78089f763 100644 --- a/src/apps/admin/src/platform/skill-management/lib/services/skills.service.ts +++ b/src/apps/admin/src/platform/skill-management/lib/services/skills.service.ts @@ -8,7 +8,7 @@ import { UserSkill, xhrDeleteAsync, xhrGetAsync, xhrPostAsync, xhrPutAsync } fro const baseUrl = `${EnvironmentConfig.STANDARDIZED_SKILLS_API}/skills` export interface StandardizedSkill extends UserSkill { - deleted_at: string | null + deletedAt: string | null categoryId?: string } diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index b3b4fee54..b45a923d7 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -46,6 +46,7 @@ export const TalentSearchPage: FC = () => { const [totalResults, setTotalResults] = useState(0) const [currentPage, setCurrentPage] = useState(1) const [lastAppliedSearchSignature, setLastAppliedSearchSignature] = useState('') + const [showSkillMatchOnCards, setShowSkillMatchOnCards] = useState(false) const countryNameByCode = useMemo((): Map => new Map( (countryLookup || []) .filter(country => country.countryCode && country.country) @@ -71,7 +72,6 @@ export const TalentSearchPage: FC = () => { [selectedCountries], ) - const hasSkillSearch = selectedSkills.length > 0 const shouldShowIntroState = !hasSearched const currentSearchSignature = useMemo( (): string => JSON.stringify({ @@ -312,6 +312,7 @@ export const TalentSearchPage: FC = () => { } setHasSearched(true) + const hadSkills = selectedSkills.length > 0 const searchSucceeded = await runMemberSearch(selectedSkills, { countries: selectedCountryCodesList, openToWork: onlyOpenToWork, @@ -321,6 +322,7 @@ export const TalentSearchPage: FC = () => { }) if (searchSucceeded) { setLastAppliedSearchSignature(currentSearchSignature) + setShowSkillMatchOnCards(hadSkills) } }, [ currentSearchSignature, @@ -547,7 +549,7 @@ export const TalentSearchPage: FC = () => { ))} diff --git a/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx index b2f47e90f..2b37a9c8b 100644 --- a/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx @@ -46,6 +46,19 @@ function getUniqueMatchedSkills(talent: TalentResultCardTalent): TalentResultCar }) } +function matchedSkillStatsLabel(skill: MatchedSkill): string { + const parts: string[] = [] + if (skill.wins > 0) { + parts.push(`${skill.wins} wins`) + } + + if (skill.submitted > 0) { + parts.push(`${skill.submitted} submissions`) + } + + return parts.length > 0 ? `: ${parts.join(', ')}` : '' +} + function buildMatchedSkillsTooltipContent( count: number, skills: MatchedSkill[], @@ -59,7 +72,7 @@ function buildMatchedSkillsTooltipContent( {skills.map((skill: MatchedSkill) => (
  • {skill.name} - {`: ${skill.wins} wins, ${skill.submitted} submissions`} + {matchedSkillStatsLabel(skill)}
  • ))} diff --git a/src/apps/reports/src/config/routes.config.ts b/src/apps/reports/src/config/routes.config.ts index fb7d07091..914a95cd9 100644 --- a/src/apps/reports/src/config/routes.config.ts +++ b/src/apps/reports/src/config/routes.config.ts @@ -10,3 +10,4 @@ export const rootRoute: string export const reportsPageRouteId = 'reports' export const bulkMemberLookupRouteId = 'bulk-member-lookup' +export const billingAccountsPageRouteId = 'billing-accounts' diff --git a/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx b/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx index cb0c2e34a..2a73aa4fe 100644 --- a/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx +++ b/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx @@ -15,7 +15,11 @@ import classNames from 'classnames' import { useClickOutside } from '~/libs/shared/lib/hooks' import { TabsNavItem } from '~/libs/ui' -import { bulkMemberLookupRouteId, reportsPageRouteId } from '../../../config/routes.config' +import { + billingAccountsPageRouteId, + bulkMemberLookupRouteId, + reportsPageRouteId, +} from '../../../config/routes.config' import styles from './NavTabs.module.scss' @@ -34,6 +38,10 @@ const NavTabs: FC = () => { id: bulkMemberLookupRouteId, title: 'Bulk Member Lookup', }, + { + id: billingAccountsPageRouteId, + title: 'Billing Accounts', + }, ], []) const activeTabPathName: string = useMemo(() => { diff --git a/src/apps/reports/src/lib/services/index.ts b/src/apps/reports/src/lib/services/index.ts index b2c6dc18b..567bbcf6a 100644 --- a/src/apps/reports/src/lib/services/index.ts +++ b/src/apps/reports/src/lib/services/index.ts @@ -2,6 +2,7 @@ export { downloadBlobFile, downloadReportAsCsv, downloadReportAsJson, + fetchReportJson, fetchReportsIndex, postReportAsCsv, postReportAsJson, @@ -10,8 +11,12 @@ export { } from './reports.service' export type { + BillingAccountDetail, + BillingAccountProfileResponse, + BillingAccountsViewData, ReportDefinition, ReportGroup, ReportParameter, ReportsIndexResponse, + SfdcBillingAccountPaymentRow, } from './reports.service' diff --git a/src/apps/reports/src/lib/services/reports.service.ts b/src/apps/reports/src/lib/services/reports.service.ts index d752087c8..a6c4ed649 100644 --- a/src/apps/reports/src/lib/services/reports.service.ts +++ b/src/apps/reports/src/lib/services/reports.service.ts @@ -28,6 +28,46 @@ export type ReportGroup = { export type ReportsIndexResponse = Record +export type BillingAccountDetail = { + name: string + description: string | null + subcontractingEndCustomer: string | null + status: string + startDate: string | null + endDate: string | null + budget: string | number + markup: string | number +} + +export type SfdcBillingAccountPaymentRow = { + paymentId: string + paymentDate: string + billingAccountId: string + paymentStatus: string + challengeFee: string | number + paymentAmount: string | number + challengeId: string + category: string + isTask: boolean + challengeName: string | null + challengeStatus: string | null + winnerHandle: string + winnerId: string + winnerFirstName: string + winnerLastName: string +} + +/** Response from GET /sfdc/billing-accounts */ +export type BillingAccountProfileResponse = { + billingAccount?: BillingAccountDetail +} + +/** Billing Accounts in-app view: profile + rows from GET /sfdc/payments */ +export type BillingAccountsViewData = { + billingAccount?: BillingAccountDetail + payments: SfdcBillingAccountPaymentRow[] +} + const reportsDownloadClient: AxiosInstance = xhrCreateInstance() const buildReportUrl = (path: string): string => { @@ -137,6 +177,16 @@ export const downloadReportAsJson = (path: string): Promise => ( downloadReportBlob(path, 'application/json') ) +export const fetchReportJson = async (path: string): Promise => { + if (!path) { + throw new Error('Report path is required') + } + + const normalizedPath = path.startsWith('/') ? path : `/${path}` + const url = `${EnvironmentConfig.API.V6}/reports${normalizedPath}` + return xhrGetAsync(url, reportsDownloadClient) +} + export const downloadReportAsCsv = (path: string): Promise => ( downloadReportBlob(path, 'text/csv') ) diff --git a/src/apps/reports/src/pages/reports/BillingAccountsPage.tsx b/src/apps/reports/src/pages/reports/BillingAccountsPage.tsx new file mode 100644 index 000000000..fc399e312 --- /dev/null +++ b/src/apps/reports/src/pages/reports/BillingAccountsPage.tsx @@ -0,0 +1,3 @@ +import { BillingAccountsPage } from './ReportsPage' + +export default BillingAccountsPage diff --git a/src/apps/reports/src/pages/reports/ReportsPage.module.scss b/src/apps/reports/src/pages/reports/ReportsPage.module.scss index e804f2221..53cb25e50 100644 --- a/src/apps/reports/src/pages/reports/ReportsPage.module.scss +++ b/src/apps/reports/src/pages/reports/ReportsPage.module.scss @@ -9,6 +9,16 @@ max-width: 720px; } +.billingDefaultWindowNote { + display: inline-block; + margin-top: 6px; + padding: 2px 8px; + border-radius: 4px; + background: #fff4d6; + color: #5f4a00; + font-weight: 500; +} + .selectors { display: flex; flex-wrap: wrap; @@ -26,26 +36,97 @@ gap: 4px; } +.filtersPanel { + margin-top: 12px; + padding: 16px; + border: 1px solid #e4e6e9; + border-radius: 8px; + background: #fcfcfd; +} + .params { display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 16px; - margin-top: 12px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 30px 50px; + margin-top: 0; + align-items: start; +} + +@media (max-width: 1200px) { + .params { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .params { + grid-template-columns: 1fr; + } +} + +.paramCard { + display: grid; + grid-template-rows: auto auto; + row-gap: 8px; + align-content: start; +} + +.paramHeader { + min-height: 28px; +} + +.paramTitleRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.paramHeaderActions { + display: flex; + align-items: center; + gap: 8px; } .paramLabel { font-weight: 600; + color: #2f3338; } -.paramMeta { - color: #6b6f75; - font-size: 12px; +.paramTypePill { + font-size: 11px; + color: #5e6369; + background: #f3f4f6; + border-radius: 999px; + padding: 2px 8px; + white-space: nowrap; +} + +.actionsBar { + display: flex; + justify-content: flex-start; + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid #eceef1; } -.paramHint { +.paramInfoButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border: 0; + padding: 0; + border-radius: 50%; + background: transparent; color: #6b6f75; - font-size: 12px; - font-style: italic; + cursor: pointer; + + svg { + width: 16px; + height: 16px; + } } .reportTitle { @@ -94,3 +175,133 @@ font-style: italic; color: #6b6f75; } + +.billingSummary { + margin-top: 8px; + padding: 16px; + border: 1px solid #e4e6e9; + border-radius: 6px; + background: #fafbfc; +} + +.billingSummaryTitle { + font-weight: 600; + margin-bottom: 12px; +} + +.billingDetailGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px 24px; +} + +.billingDetailItem { + display: flex; + flex-direction: column; + gap: 2px; +} + +.billingDetailLabel { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #6b6f75; +} + +.billingDetailValue { + font-size: 14px; + color: #1a1d21; + word-break: break-word; +} + +.billingMissingNotice { + margin-top: 8px; + padding: 12px; + border-radius: 4px; + background: #f3f4f6; + color: #494f55; + font-size: 14px; +} + +.paymentsSection { + margin-top: 24px; +} + +.paymentsResults { + display: flex; + flex-direction: column; + gap: 12px; +} + +.paymentsSectionTitle { + font-weight: 600; + margin-bottom: 8px; +} + +.tableWrap { + overflow-x: auto; + border: 1px solid #e4e6e9; + border-radius: 6px; +} + +.paymentsPagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.perPageControl { + display: flex; + align-items: center; + gap: 8px; +} + +.perPageControl label { + color: #565a5f; + font-size: 13px; +} + +.perPageControl select { + min-width: 72px; + padding: 5px 8px; + border: 1px solid #d7d9dd; + border-radius: 6px; + background: #fff; +} + +.paginationMeta { + color: #565a5f; + font-size: 13px; +} + +.paymentsTable { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.paymentsTable th, +.paymentsTable td { + padding: 8px 10px; + text-align: left; + border-bottom: 1px solid #e4e6e9; + vertical-align: top; +} + +.paymentsTable th { + background: #f3f4f6; + font-weight: 600; + white-space: nowrap; +} + +.paymentsTable tbody tr:last-child td { + border-bottom: none; +} + +.paymentsEmpty { + margin-top: 8px; + color: #6b6f75; + font-style: italic; +} diff --git a/src/apps/reports/src/pages/reports/ReportsPage.tsx b/src/apps/reports/src/pages/reports/ReportsPage.tsx index b00f5cf2f..6263c0038 100644 --- a/src/apps/reports/src/pages/reports/ReportsPage.tsx +++ b/src/apps/reports/src/pages/reports/ReportsPage.tsx @@ -1,19 +1,33 @@ import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react' import { NavigateFunction, useNavigate } from 'react-router-dom' -import { Button, InputSelect, InputSelectOption, InputText, LoadingSpinner, PageTitle } from '~/libs/ui' +import { + Button, + IconOutline, + InputSelect, + InputSelectOption, + InputText, + LoadingSpinner, + PageTitle, + Tooltip, +} from '~/libs/ui' +import { Pagination } from '~/apps/admin/src/lib' import { bulkMemberLookupRouteId } from '../../config/routes.config' import { handleError } from '../../lib/utils' import { + BillingAccountProfileResponse, + BillingAccountsViewData, downloadBlobFile, downloadReportAsCsv, downloadReportAsJson, + fetchReportJson, fetchReportsIndex, ReportDefinition, ReportGroup, ReportParameter, ReportsIndexResponse, + SfdcBillingAccountPaymentRow, } from '../../lib/services' import { getReportParameterValidationError } from './reports-page.validation' @@ -21,6 +35,245 @@ import styles from './ReportsPage.module.scss' const pageTitle = 'Reports' 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.', + method: 'GET', + name: 'Billing Accounts', + parameters: [ + { + description: 'Billing account ID', + location: 'query', + name: 'billingAccountId', + required: true, + type: 'string', + }, + { + description: 'Optional start date for payment filtering (ISO 8601)', + location: 'query', + name: 'startDate', + type: 'date', + }, + { + description: 'Optional end date for payment filtering (ISO 8601)', + location: 'query', + name: 'endDate', + type: 'date', + }, + ], + path: BILLING_ACCOUNTS_REPORT_PATH, +} + +type ReportsPageTab = 'reports' | 'billingAccounts' + +const buildSfdcPaymentsQueryPath = ( + billingAccountId: string, + startDate?: string, + endDate?: string, +): string => { + const query = new URLSearchParams() + query.append('billingAccountIds', billingAccountId.trim()) + const start = startDate?.trim() + const end = endDate?.trim() + + if (start) { + query.append('startDate', start) + } + + if (end) { + query.append('endDate', end) + } + + return `${SFDC_PAYMENTS_REPORT_PATH}?${query.toString()}` +} + +const formatReportCell = (value: unknown): string => { + if (value === null || value === undefined || value === '') { + return '—' + } + + if (typeof value === 'boolean') { + return value ? 'Yes' : 'No' + } + + return String(value) +} + +const formatPaymentDate = (iso: string): string => { + const parsed = Date.parse(iso) + + if (Number.isNaN(parsed)) { + return iso + } + + return new Date(parsed) + .toLocaleString() +} + +const PAYMENT_TABLE_COLUMNS: { key: keyof SfdcBillingAccountPaymentRow; label: string }[] = [ + { key: 'paymentId', label: 'Payment ID' }, + { key: 'paymentDate', label: 'Payment date' }, + { key: 'billingAccountId', label: 'Billing account ID' }, + { key: 'paymentStatus', label: 'Status' }, + { key: 'challengeFee', label: 'Challenge fee' }, + { key: 'paymentAmount', label: 'Payment amount' }, + { key: 'challengeId', label: 'Challenge ID' }, + { key: 'category', label: 'Category' }, + { key: 'isTask', label: 'Task' }, + { key: 'challengeName', label: 'Challenge name' }, + { key: 'challengeStatus', label: 'Challenge status' }, + { key: 'winnerHandle', label: 'Winner handle' }, + { key: 'winnerId', label: 'Winner ID' }, + { key: 'winnerFirstName', label: 'Winner first name' }, + { key: 'winnerLastName', label: 'Winner last name' }, +] +const PAYMENT_ROWS_PER_PAGE_OPTIONS = [10, 25, 50] + +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 total = payments.length + const totalPages = Math.max(1, Math.ceil(total / rowsPerPage)) + const currentSliceStart = (currentPage - 1) * rowsPerPage + const paginatedPayments = payments.slice(currentSliceStart, currentSliceStart + rowsPerPage) + const showingStart = total === 0 ? 0 : ((currentPage - 1) * rowsPerPage) + 1 + const showingEnd = Math.min(currentPage * rowsPerPage, total) + + useEffect(() => { + setCurrentPage(1) + }, [payments]) + + function handleRowsPerPageChange(event: ChangeEvent): void { + setRowsPerPage(Number(event.target.value)) + setCurrentPage(1) + } + + 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. +
    + )} +
    + +
    +
    Payments
    + {total === 0 ? ( +
    No payments matched the selected filters.
    + ) : ( +
    +
    + + + + {PAYMENT_TABLE_COLUMNS.map(col => ( + + ))} + + + + {paginatedPayments.map(row => ( + + {PAYMENT_TABLE_COLUMNS.map(col => ( + + ))} + + ))} + +
    {col.label}
    + {col.key === 'paymentDate' + ? formatPaymentDate(String(row[col.key])) + : formatReportCell(row[col.key])} +
    +
    +
    +
    + + +
    +
    + {`Showing ${showingStart}-${showingEnd} of ${total} payments`} +
    + +
    +
    + )} +
    +
    + ) +} const buildDownloadName = ( name: string, @@ -48,12 +301,34 @@ const formatMethod = (method?: string): string => ( method ? method.toUpperCase() : 'GET' ) +const formatParameterLabel = (name: string): string => ( + name + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/Ids\b/g, 'IDs') + .replace(/^./, char => char.toUpperCase()) +) + +const buildParameterTooltipContent = (parameter: ReportParameter): JSX.Element => ( + <> +
    {parameter.description?.trim() || 'No description available.'}
    +
    + {`Location: ${parameter.location || 'query'} (${parameter.name})`} +
    + +) + +const EMPTY_BILLING_ACCOUNT_PROFILE_RESPONSE: BillingAccountProfileResponse = { + billingAccount: undefined, +} + type ReportActionsProps = { handleCsvDownload: () => void handleJsonDownload: () => void + handleResetFilters: () => void handleOpenBulkMemberLookup: () => void isDownloadDisabled: boolean isHandleLookupPostReport: boolean + isResetDisabled: boolean isPostReport: boolean } @@ -94,6 +369,13 @@ const ReportActions = (props: ReportActionsProps): JSX.Element => { > Download as CSV + ) } @@ -125,51 +407,67 @@ const SelectedReportSection = (props: SelectedReportSectionProps): JSX.Element = - {(props.selectedReport.parameters?.length ?? 0) > 0 && ( -
    - {props.selectedReport.parameters?.map(parameter => ( -
    -
    - {parameter.name} - {parameter.required ? ' *' : ''} -
    - {parameter.description && ( -
    {parameter.description}
    - )} -
    - Location: - {' '} - {parameter.location || 'query'} - {' '} - • Type: - {' '} - {parameter.type} -
    - {parameter.type.endsWith('[]') && ( -
    - Use comma-separated values for lists. + {(props.selectedReport.parameters?.length ?? 0) > 0 ? ( +
    +
    + {props.selectedReport.parameters?.map(parameter => ( +
    +
    +
    +
    + {formatParameterLabel(parameter.name)} + {parameter.required ? ' *' : ''} +
    +
    +
    {parameter.type}
    + + + +
    +
    - )} - {props.renderParameterInput(parameter)} -
    - ))} + {props.renderParameterInput(parameter)} +
    + ))} +
    +
    + {props.reportActions} +
    + ) : ( + props.reportActions )} - - {props.reportActions} ) } -export const ReportsPage: FC = () => { +type ReportsPageContentProps = { + initialTab: ReportsPageTab +} + +// eslint-disable-next-line complexity +const ReportsPageContent: FC = props => { const navigate: NavigateFunction = useNavigate() + const [activeTab] = useState(props.initialTab) const [reportsIndex, setReportsIndex] = useState({}) const [selectedBasePath, setSelectedBasePath] = useState('') const [selectedReportPath, setSelectedReportPath] = useState('') const [isLoading, setIsLoading] = useState(false) const [downloadingFormat, setDownloadingFormat] = useState<'json' | 'csv' | undefined>(undefined) const [parameterValues, setParameterValues] = useState>({}) - + const [billingAccountViewData, setBillingAccountViewData] = useState< + BillingAccountsViewData | undefined + >(undefined) + const [isBillingAccountViewLoading, setIsBillingAccountViewLoading] = useState(false) useEffect(() => { let isMounted = true setIsLoading(true) @@ -230,15 +528,21 @@ export const ReportsPage: FC = () => { selectedGroup?.reports?.find(report => report.path === selectedReportPath) ), [selectedGroup, selectedReportPath]) + const selectedReportForForm = activeTab === 'billingAccounts' + ? BILLING_ACCOUNTS_REPORT_DEFINITION + : selectedReport + const handleBasePathChange = useCallback((event: ChangeEvent) => { setSelectedBasePath(event.target.value) setSelectedReportPath('') setParameterValues({}) + setBillingAccountViewData(undefined) }, []) const handleReportChange = useCallback((event: ChangeEvent) => { setSelectedReportPath(event.target.value) setParameterValues({}) + setBillingAccountViewData(undefined) }, []) const handleParameterChange = useCallback((event: ChangeEvent) => { @@ -291,7 +595,7 @@ export const ReportsPage: FC = () => { }, [parameterValues]) const parameterErrors = useMemo>(() => ( - (selectedReport?.parameters ?? []).reduce>((errors, parameter) => { + (selectedReportForForm?.parameters ?? []).reduce>((errors, parameter) => { const error = getReportParameterValidationError(parameter, parameterValues[parameter.name]) if (error) { @@ -300,12 +604,55 @@ export const ReportsPage: FC = () => { return errors }, {}) - ), [parameterValues, selectedReport]) + ), [parameterValues, selectedReportForForm]) const hasInvalidParameterValues = useMemo(() => ( Object.keys(parameterErrors).length > 0 ), [parameterErrors]) + const handleBillingAccountView = useCallback(async () => { + if (activeTab !== 'billingAccounts' || hasInvalidParameterValues) { + return + } + + const billingAccountId = parameterValues.billingAccountId?.trim() + + if (!billingAccountId) { + return + } + + try { + setIsBillingAccountViewLoading(true) + const profileQuery = new URLSearchParams({ billingAccountId }) + const profilePath = `${BILLING_ACCOUNTS_REPORT_PATH}?${profileQuery.toString()}` + const paymentsPath = buildSfdcPaymentsQueryPath( + billingAccountId, + parameterValues.startDate, + parameterValues.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, + }) + } catch (error) { + handleError(error) + } finally { + setIsBillingAccountViewLoading(false) + } + }, [ + activeTab, + hasInvalidParameterValues, + parameterValues.billingAccountId, + parameterValues.endDate, + parameterValues.startDate, + ]) + const handleDownload = useCallback(async (format: 'json' | 'csv') => { if (!selectedReport || hasInvalidParameterValues) { return @@ -338,18 +685,29 @@ export const ReportsPage: FC = () => { navigate(bulkMemberLookupRouteId) }, [navigate]) + const handleResetFilters = useCallback(() => { + setParameterValues({}) + setBillingAccountViewData(undefined) + }, []) + + const handleBillingAccountViewClick = useCallback(() => { + handleBillingAccountView() + .catch(handleError) + }, [handleBillingAccountView]) + const isDownloading = downloadingFormat !== undefined + const isBusy = isDownloading || isBillingAccountViewLoading const requiredParamsMissing = useMemo(() => { - const params = selectedReport?.parameters ?? [] + const params = selectedReportForForm?.parameters ?? [] return params.some(param => param.required && !(parameterValues[param.name]?.trim())) - }, [parameterValues, selectedReport]) + }, [parameterValues, selectedReportForForm]) const hasUnresolvedPathParams = useMemo(() => ( - (selectedReport?.parameters ?? []) + (selectedReportForForm?.parameters ?? []) .filter(param => param.location === 'path') .some(param => !parameterValues[param.name]?.trim()) - ), [parameterValues, selectedReport]) + ), [parameterValues, selectedReportForForm]) const isPostReport = selectedReport?.method?.toUpperCase() === 'POST' const isHandleLookupPostReport = isPostReport && selectedReport.path === bulkMembersByHandlesPath @@ -360,6 +718,14 @@ export const ReportsPage: FC = () => { || hasInvalidParameterValues || hasUnresolvedPathParams + const billingAccountViewDisabled = !selectedReportForForm + || isDownloading + || isBillingAccountViewLoading + || requiredParamsMissing + || hasInvalidParameterValues + || hasUnresolvedPathParams + const isResetDisabled = Object.keys(parameterValues).length === 0 + const handleJsonDownload = useCallback(() => { handleDownload('json') }, [handleDownload]) @@ -368,20 +734,41 @@ export const ReportsPage: FC = () => { handleDownload('csv') }, [handleDownload]) + const billingAccountReportActions = ( +
    + + +
    + ) + const reportActions = ( ) const renderParameterInput = useCallback((parameter: ReportParameter) => { const commonProps = { - label: parameter.name, + label: formatParameterLabel(parameter.name), name: parameter.name, placeholder: parameter.type === 'date' ? 'YYYY-MM-DD' @@ -423,6 +810,7 @@ export const ReportsPage: FC = () => { return ( { return ( <> - {isDownloading && ( - + {isBusy && ( + )}
    {pageTitle}

    - Select a base path to view the available reports. After choosing a report, provide any - required parameters and download the data as JSON or CSV directly from the reports API. + {activeTab === 'reports' + ? 'Select a base path to view available reports. Choose a report, ' + + '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. '} + + If no dates are specified, records from the past 45 days are displayed + by default. + + + )}

    {isLoading ? ( @@ -451,42 +853,56 @@ export const ReportsPage: FC = () => {
    ) : ( <> - {basePathOptions.length ? ( -
    - - - {selectedGroup && ( - + {activeTab === 'reports' ? ( + <> + {basePathOptions.length ? ( +
    + + + {selectedGroup && ( + + )} +
    + ) : ( +
    + No reports are currently available. +
    )} -
    + + + ) : ( -
    - No reports are currently available. -
    + )} - + {activeTab === 'billingAccounts' && billingAccountViewData ? ( + + ) : undefined} )}
    @@ -494,4 +910,12 @@ export const ReportsPage: FC = () => { ) } +export const ReportsPage: FC = () => ( + +) + +export const BillingAccountsPage: FC = () => ( + +) + export default ReportsPage diff --git a/src/apps/reports/src/reports-app.routes.tsx b/src/apps/reports/src/reports-app.routes.tsx index df4b56ecd..01c720710 100644 --- a/src/apps/reports/src/reports-app.routes.tsx +++ b/src/apps/reports/src/reports-app.routes.tsx @@ -11,6 +11,7 @@ import { } from '~/libs/core' import { + billingAccountsPageRouteId, bulkMemberLookupRouteId, reportsPageRouteId, rootRoute, @@ -21,6 +22,9 @@ const ReportsPage: LazyLoadedComponent = lazyLoad( () => import('./pages/reports/ReportsPage'), 'ReportsPage', ) +const BillingAccountsPage: LazyLoadedComponent = lazyLoad( + () => import('./pages/reports/BillingAccountsPage'), +) const BulkMemberLookupPage: LazyLoadedComponent = lazyLoad( () => import('./pages/bulk-member-lookup/BulkMemberLookupPage'), 'BulkMemberLookupPage', @@ -43,6 +47,11 @@ export const reportsRoutes: ReadonlyArray = [ element: , route: reportsPageRouteId, }, + { + authRequired: true, + element: , + route: billingAccountsPageRouteId, + }, { authRequired: true, element: , 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 e09b31d55..bcd7b1eb5 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss @@ -15,6 +15,9 @@ display: flex; gap: $sp-2; + + flex-wrap: wrap; + align-items: flex-start; } .lockedTitle { @@ -28,8 +31,9 @@ .lockedMessage { margin-top: $sp-1; font-size: 14px; - max-width: 720px; - white-space: normal; + max-width: 100%; + word-break: break-word; + overflow-wrap: break-word; } .reRunIcon { @@ -146,3 +150,38 @@ } } + +@media (max-width: 768px) { + .wrap { + overflow: visible; + } + + .lockedBanner { + flex-direction: column; + margin-left: $sp-1; + margin-right: $sp-1; + padding: $sp-3 $sp-2; + gap: $sp-1; + } + + .lockedTitle { + width: 100%; + } + + .lockedMessage { + width: 100%; + margin-top: $sp-2; + } + + .mobileRow { + flex-direction: column; + padding-left: $sp-2; + padding-right: $sp-2; + + > * { + flex: 0 0 auto; + width: 100%; + margin-bottom: $sp-1; + } + } +} \ No newline at end of file diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx index e91f51241..087c45651 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-len */ import { FC, MouseEvent as ReactMouseEvent, useCallback, useContext, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import { toast } from 'react-toastify' @@ -274,10 +275,10 @@ const AiReviewsTable: FC = props => { } const failedReviewersText = failedGatingReviewers.length - ? `Gating Reviewers failed: ${failedGatingReviewers.join(', ')}. - This submission is automatically failed regardless of Overall Score.` + ? `This submission failed regardless of Overall Score because it failed one or more of the AI Gating Reviews. + Gating Reviewers failed: ${failedGatingReviewers.join(', ')}.` : `This submission is failed because ${hasSubmitterRole ? 'your' : 'the'} - Overall Score is bellow minimum threshold.` + Overall Score is below minimum threshold.` // Message text varies by role const roleBasedText = hasSubmitterRole @@ -293,10 +294,10 @@ const AiReviewsTable: FC = props => { } if (hasSubmitterRole) { - return 'Submission Locked - Your submission will not be reviewed in the Review Phase.' + return 'Submission Locked - Your submission won\'t be reviewed during the Review Phase.' } - return 'Submission Locked - This submission doesn\'t have to be reviewed in Review Phase.' + return 'Submission Locked - This submission won\'t be reviewed during the Review Phase.' }, [currentDecision?.submissionLocked, hasSubmitterRole]) if (isTablet) { 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 127bd1134..b73f60a1d 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 @@ -233,6 +233,7 @@ describe('PaymentsListView', () => { expect(mockedGetPayments) .toHaveBeenLastCalledWith(10, 0, { categories: ['TASK_PAYMENT', 'ENGAGEMENT_PAYMENT'], + date: ['last30days'], status: ['ON_HOLD_ADMIN'], }) expect(mockFilterBar) @@ -281,6 +282,7 @@ describe('PaymentsListView', () => { expect(mockedGetPayments) .toHaveBeenLastCalledWith(10, 0, { categories: ['TASK_PAYMENT', 'ENGAGEMENT_PAYMENT'], + date: ['last30days'], status: ['ON_HOLD_ADMIN'], }) @@ -326,6 +328,7 @@ describe('PaymentsListView', () => { expect(mockedGetPayments) .toHaveBeenLastCalledWith(10, 0, { categories: ['TASK_PAYMENT', 'ENGAGEMENT_PAYMENT'], + date: ['last30days'], status: ['PAID'], }) }) 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 7a7430579..ece7f1f47 100644 --- a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx +++ b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx @@ -29,6 +29,7 @@ const approverAllowedCategories = [taskPaymentCategory, engagementPaymentCategor const taasPaymentCategory = 'TAAS_PAYMENT' const topgearPaymentCategory = 'TOPGEAR_PAYMENT' const defaultPageSize = 10 +const approverDefaultDateFilter = 'last30days' interface PaymentsListViewProps { profile: UserProfile @@ -59,6 +60,23 @@ function formatStatus(status: string): string { } } +function isIdVerificationComplete(paymentStatus?: WinningDetail['paymentStatus']): boolean { + if (!paymentStatus) { + return false + } + + const statusWithAliases = paymentStatus as WinningDetail['paymentStatus'] & { + idVerificationPassed?: boolean + identityVerificationComplete?: boolean + } + + return Boolean( + statusWithAliases.idVerificationComplete + || statusWithAliases.idVerificationPassed + || statusWithAliases.identityVerificationComplete, + ) +} + const formatCurrency = (amountStr: string, currency: string): string => { let amount: number try { @@ -130,9 +148,11 @@ function convertPaymentToWinning(payment: WinningDetail, handleMap: Map = (props: PaymentsListViewProp const isRestrictedApproverView = isApproverView const [filters, setFilters] = React.useState>({}) + // eslint-disable-next-line complexity const appliedFilters = React.useMemo>(() => { // Strip 'all' sentinel values — never forward them to the API const activeFilters = Object.fromEntries( @@ -224,6 +245,13 @@ const PaymentsListView: FC = (props: PaymentsListViewProp statusFilter = { status: [restrictedDefaultStatus] } } + let dateFilter: Record = {} + if (filters.date && filters.date[0] !== 'all') { + dateFilter = { date: activeFilters.date } + } else if (!filters.date) { + dateFilter = { date: [approverDefaultDateFilter] } + } + let categoryFilter: Record = {} if ( activeFilters.category @@ -241,6 +269,7 @@ const PaymentsListView: FC = (props: PaymentsListViewProp ...rest, ...categoryFilter, ...statusFilter, + ...dateFilter, } } @@ -280,13 +309,13 @@ const PaymentsListView: FC = (props: PaymentsListViewProp defaults.category = filters.category?.[0] ?? 'all' } - defaults.date = filters.date?.[0] ?? 'all' + defaults.date = filters.date?.[0] ?? (isApproverView ? approverDefaultDateFilter : 'all') // Fall back to the restricted default if no filter is applied defaults.status = filters.status?.[0] ?? (restrictedDefaultStatus || 'all') return defaults - }, [filters.category, filters.date, filters.status, restrictedCategory, restrictedDefaultStatus]) + }, [filters.category, filters.date, filters.status, restrictedCategory, restrictedDefaultStatus, isApproverView]) const [pagination, setPagination] = React.useState({ currentPage: 1, pageSize: defaultPageSize, diff --git a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsTab.tsx b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsTab.tsx index 6764653c4..eaa0676c7 100644 --- a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsTab.tsx +++ b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsTab.tsx @@ -14,7 +14,7 @@ interface ListViewProps { const ListView: FC = (props: ListViewProps) => { const [paymentsCollapsed, setPaymentsCollapsed] = useState(false) - const [pointsCollapsed, setPointsCollapsed] = useState(false) + const [pointsCollapsed, setPointsCollapsed] = useState(true) return (
    diff --git a/src/apps/wallet-admin/src/lib/components/payment-edit/PaymentEdit.tsx b/src/apps/wallet-admin/src/lib/components/payment-edit/PaymentEdit.tsx index 515e481bc..24dade06f 100644 --- a/src/apps/wallet-admin/src/lib/components/payment-edit/PaymentEdit.tsx +++ b/src/apps/wallet-admin/src/lib/components/payment-edit/PaymentEdit.tsx @@ -189,7 +189,8 @@ const PaymentEdit: React.FC = (props: PaymentEditFormProps const isMemberHold = [ 'On Hold (Member)', 'On Hold (Tax Form)', - 'On Hold (Payment Provider)', + 'On Hold (Payment provider)', + 'On Hold (ID Verification)', ].includes(props.payment.status) return [ diff --git a/src/apps/wallet-admin/src/lib/models/WinningDetail.ts b/src/apps/wallet-admin/src/lib/models/WinningDetail.ts index f549db11c..3a5881f22 100644 --- a/src/apps/wallet-admin/src/lib/models/WinningDetail.ts +++ b/src/apps/wallet-admin/src/lib/models/WinningDetail.ts @@ -17,6 +17,7 @@ export interface PaymentDetail { export interface PayoutStatus { payoutSetupComplete: boolean; taxFormSetupComplete: boolean; + idVerificationComplete?: boolean; } export interface PaymentEngagementDetails { diff --git a/src/apps/wallet/src/home/tabs/winnings/PaymentsListView.tsx b/src/apps/wallet/src/home/tabs/winnings/PaymentsListView.tsx index 14ad8e27a..850d83202 100644 --- a/src/apps/wallet/src/home/tabs/winnings/PaymentsListView.tsx +++ b/src/apps/wallet/src/home/tabs/winnings/PaymentsListView.tsx @@ -59,6 +59,23 @@ function formatStatus(status: string): string { } } +function isIdVerificationComplete(paymentStatus?: WinningDetail['paymentStatus']): boolean { + if (!paymentStatus) { + return false + } + + const statusWithAliases = paymentStatus as WinningDetail['paymentStatus'] & { + idVerificationPassed?: boolean + identityVerificationComplete?: boolean + } + + return Boolean( + statusWithAliases.idVerificationComplete + || statusWithAliases.idVerificationPassed + || statusWithAliases.identityVerificationComplete, + ) +} + export const formatCurrency = (amountStr: string, currency: string): string => { let amount: number try { @@ -123,7 +140,19 @@ const PaymentsListView: FC = (props: PaymentsListViewProp grossPayment: formatCurrency(payment.details[0].totalAmount, payment.details[0].currency), id: payment.id, releaseDate: formattedReleaseDate, - status: formatStatus(payment.details[0].status), + status: ( + payment.details[0].status === 'ON_HOLD' + ? ( + !payment.paymentStatus?.payoutSetupComplete + ? 'On Hold (Payment provider)' + : !payment.paymentStatus?.taxFormSetupComplete + ? 'On Hold (Tax Form)' + : !isIdVerificationComplete(payment.paymentStatus) + ? 'On Hold (ID Verification)' + : 'On Hold' + ) + : formatStatus(payment.details[0].status) + ), type: payment.category.replaceAll('_', ' ') .toLowerCase(), } diff --git a/src/apps/wallet/src/lib/models/WinningDetail.ts b/src/apps/wallet/src/lib/models/WinningDetail.ts index f32def6e0..67d55ec2a 100644 --- a/src/apps/wallet/src/lib/models/WinningDetail.ts +++ b/src/apps/wallet/src/lib/models/WinningDetail.ts @@ -44,4 +44,9 @@ export interface WinningDetail { createdAt: string releaseDate: string datePaid: string + paymentStatus?: { + payoutSetupComplete?: boolean + taxFormSetupComplete?: boolean + idVerificationComplete?: boolean + } } diff --git a/src/apps/work/src/lib/assets/icons/IconRunnerLogs.svg b/src/apps/work/src/lib/assets/icons/IconRunnerLogs.svg new file mode 100644 index 000000000..bbfde614a --- /dev/null +++ b/src/apps/work/src/lib/assets/icons/IconRunnerLogs.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.module.scss b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.module.scss index cfd9739b2..01a4d183c 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.module.scss +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.module.scss @@ -216,6 +216,7 @@ font-weight: 600; gap: 4px; padding: 0; + text-align: left; } .sortButton:hover { diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx index e784634f4..1725414f7 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx @@ -125,33 +125,60 @@ describe('BillingAccountLineItemsModal', () => { .toBe('/work/challenges/challenge%20%2F%20100') }) - it('shows member payments and challenge fees for non-copilot users', () => { + it('shows challenge member payments without removing markup from the stored subtotal', () => { renderModal({ ...baseBillingAccountDetails, lockedAmounts: [ { - amount: '125.25', - date: '2026-02-10T00:00:00.000Z', - externalId: 'challenge-100', - externalName: 'Markup Challenge', + amount: '28.6', + date: '2026-05-12T00:00:00.000Z', + externalId: '5fdf48d2-811f-4914-b713-9e5f423c907d', + externalName: 'Copilot and Admin with reviews', externalType: 'CHALLENGE', }, ], - lockedBudget: 125.25, - markup: 0.25, - totalBudgetRemaining: 874.75, + lockedBudget: 28.6, + markup: 0.33, + totalBudgetRemaining: 971.4, }) expect(screen.getByText('Member Payments')) .toBeTruthy() expect(screen.getByText('Challenge Fee')) .toBeTruthy() - expect(screen.getByText('$100.20')) + expect(screen.getAllByText('$28.60')) + .toHaveLength(2) + expect(screen.getByText('$9.44')) + .toBeTruthy() + expect(screen.queryByText('$21.50')) + .toBeNull() + expect(screen.queryByText('$7.10')) + .toBeNull() + }) + + it('removes markup once from consumed challenge charges before showing member payments', () => { + renderModal({ + ...baseBillingAccountDetails, + consumedAmounts: [ + { + amount: '33.25', + date: '2026-05-12T00:00:00.000Z', + externalId: '2864601d-320a-45e2-85b4-a14f9f19785e', + externalName: 'May 12 challenge', + externalType: 'CHALLENGE', + }, + ], + consumedBudget: 33.25, + markup: 0.33, + totalBudgetRemaining: 966.75, + }) + + expect(screen.getByText('$25.00')) .toBeTruthy() - expect(screen.getByText('$25.05')) + expect(screen.getByText('$8.25')) .toBeTruthy() - expect(screen.getAllByText('$125.25')) - .toHaveLength(1) + expect(screen.queryByText('$10.97')) + .toBeNull() }) it('builds engagement links from assignment-backed billing rows', () => { @@ -226,6 +253,10 @@ describe('BillingAccountLineItemsModal', () => { expect(engagementLink.getAttribute('href')) .toBe('/work/projects/project%20200/engagements/engagement-300') + expect(screen.getByText('$100.00')) + .toBeTruthy() + expect(screen.getByText('$20.00')) + .toBeTruthy() expect(mockedUseFetchEngagements) .toHaveBeenLastCalledWith( 'project 200', @@ -359,31 +390,32 @@ describe('BillingAccountLineItemsModal', () => { .toBeTruthy() }) - it('shows only remaining member payments and member-payment row amounts for copilots', () => { + it('shows only remaining member payments and derived engagement row amounts for copilots', () => { renderModal({ ...baseBillingAccountDetails, consumedBudget: 500, lockedAmounts: [ { - amount: '125.25', + amount: '50', date: '2026-02-10T00:00:00.000Z', - externalId: 'challenge-100', - externalName: 'Markup Challenge', - externalType: 'CHALLENGE', + externalId: 'engagement-100', + externalName: 'Markup Engagement', + externalType: 'ENGAGEMENT', }, ], - lockedBudget: 250, - markup: 0.25, - totalBudgetRemaining: 250, + lockedBudget: 66.5, + markup: 0.33, + memberPaymentsRemaining: 200, + totalBudgetRemaining: 433.5, }, true) expect(screen.getByText('Remaining member payments')) .toBeTruthy() expect(screen.getByText('$200.00')) .toBeTruthy() - expect(screen.getByText('$100.20')) + expect(screen.getByText('$37.59')) .toBeTruthy() - expect(screen.queryByText('$125.25')) + expect(screen.queryByText('$50.00')) .toBeNull() expect(screen.queryByText('Consumed')) .toBeNull() @@ -393,16 +425,67 @@ describe('BillingAccountLineItemsModal', () => { .toBeNull() }) - it('uses API-provided member-payment row amounts for copilot responses without markup', () => { + it('shows challenge row amounts for copilots when API member-payment aliases include markup math', () => { + renderModal({ + ...baseBillingAccountDetails, + lockedAmounts: [ + { + amount: '9.15', + date: '2026-05-08T00:00:00.000Z', + externalId: 'challenge-100', + externalName: 'Test', + externalType: 'CHALLENGE', + memberPaymentAmount: '6.88', + }, + ], + lockedBudget: 9.15, + markup: 0.33, + memberPaymentsRemaining: 273413.64, + totalBudgetRemaining: 273413.64, + }, true) + + expect(screen.getByText('$9.15')) + .toBeTruthy() + expect(screen.queryByText('$6.88')) + .toBeNull() + }) + + it('shows consumed challenge member payments without challenge fees for copilots', () => { + renderModal({ + ...baseBillingAccountDetails, + consumedAmounts: [ + { + amount: '33.25', + date: '2026-05-12T00:00:00.000Z', + externalId: '2864601d-320a-45e2-85b4-a14f9f19785e', + externalName: 'May 12 challenge', + externalType: 'CHALLENGE', + }, + ], + consumedBudget: 33.25, + markup: 0.33, + memberPaymentsRemaining: 200, + totalBudgetRemaining: 966.75, + }, true) + + expect(screen.getByText('$25.00')) + .toBeTruthy() + expect(screen.queryByText('$33.25')) + .toBeNull() + expect(screen.queryByText('Challenge Fee')) + .toBeNull() + }) + + it('uses API-provided engagement member-payment row amounts for copilot responses without markup', () => { renderModal({ ...baseBillingAccountDetails, consumedAmounts: [ { amount: '125.25', date: '2026-02-10T00:00:00.000Z', - externalId: 'challenge-100', - externalName: 'Consumed Markup Challenge', - externalType: 'CHALLENGE', + externalId: 'engagement-100', + externalName: 'Consumed Markup Engagement', + externalType: 'ENGAGEMENT', memberPaymentAmount: '100.20', }, ], diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx index 0447b7034..9141da438 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx @@ -192,8 +192,8 @@ function buildEngagementUrl(projectId: string, engagementId: string): string { * @param item Line item already mapped for the current caller role. * @returns Formatted currency or `-` when a copilot-safe amount is unavailable. * @remarks Copilot rows use member payment amounts without exposing markup. - * Other roles use the member payment amount derived from billing markup and - * show the fee separately. + * Manager/admin rows derive the payment amount from the billing ledger total + * when markup is available. */ function formatLineItemAmount(item: BillingAccountModalLineItem): string { return item.displayAmount === undefined @@ -215,31 +215,68 @@ function formatLineItemChallengeFee(item: BillingAccountModalLineItem): string { : formatCurrency(item.challengeFeeAmount) } +/** + * Resolves the challenge member-payment amount that should be visible in the row. + * + * @param item Raw locked or consumed billing-account challenge line item. + * @param billingAccountDetails Billing account detail payload containing markup when available. + * @returns Member payment amount for the challenge row. + * @remarks Locked challenge rows store member payments directly. Consumed + * challenge rows store the final billing-account charge, so the billing markup + * is removed once to recover the member-payment subtotal. + */ +function getChallengeMemberPaymentAmount( + item: BillingAccountLineItem, + billingAccountDetails: BillingAccountDetails, +): number { + if (item.status === 'locked') { + return item.amount + } + + return calculateMemberPaymentAmount( + item.amount, + billingAccountDetails.markup, + ) ?? item.amount +} + +/** + * Resolves the engagement member-payment amount that should be visible in the row. + * + * @param item Raw locked or consumed billing-account engagement line item. + * @param billingAccountDetails Billing account detail payload containing markup when available. + * @returns Member payment amount when it can be derived. + * @remarks Engagement rows prefer API-provided member-payment amounts. When + * only the billing-account charge is available, markup is removed once. + */ +function getEngagementMemberPaymentAmount( + item: BillingAccountLineItem, + billingAccountDetails: BillingAccountDetails, +): number | undefined { + if (item.memberPaymentAmount !== undefined) { + return item.memberPaymentAmount + } + + return calculateMemberPaymentAmount( + item.amount, + billingAccountDetails.markup, + ) +} + /** * Resolves the member-payment amount that should be visible in the row. * * @param item Raw locked or consumed billing-account line item. * @param billingAccountDetails Billing account detail payload containing markup when available. - * @param showMemberPaymentsRemaining Whether the caller needs the copilot-safe view. - * @returns Member payment amount, or `undefined` for copilot rows when it cannot - * be safely calculated. - * @remarks Non-copilot rows fall back to the raw amount if markup is missing so - * legacy billing-account payloads keep rendering an amount. + * @returns Member payment amount when it can be derived. + * @remarks This is the only row amount used for display and fee calculation. */ function getLineItemMemberPaymentAmount( item: BillingAccountLineItem, billingAccountDetails: BillingAccountDetails, - showMemberPaymentsRemaining: boolean | undefined, ): number | undefined { - const memberPaymentAmount = item.memberPaymentAmount - ?? calculateMemberPaymentAmount( - item.amount, - billingAccountDetails.markup, - ) - - return memberPaymentAmount !== undefined || showMemberPaymentsRemaining - ? memberPaymentAmount - : item.amount + return item.externalType === 'CHALLENGE' + ? getChallengeMemberPaymentAmount(item, billingAccountDetails) + : getEngagementMemberPaymentAmount(item, billingAccountDetails) } /** @@ -247,27 +284,23 @@ function getLineItemMemberPaymentAmount( * * @param item Raw locked or consumed billing-account line item. * @param billingAccountDetails Billing account detail payload containing hidden markup when available. - * @param showMemberPaymentsRemaining Whether the caller needs the copilot-safe view. + * @param showChallengeFee Whether the caller can see billing challenge fees. * @returns A line item with `displayAmount` set to the visible member-payment * amount and, for non-copilots, `challengeFeeAmount` set to the billing markup fee. - * @remarks Copilot rows prefer the API-provided member payment amount because - * their response intentionally omits markup. Non-copilot rows derive member - * payments from the raw amount and billing-account markup, then render the fee - * in its own column. + * @remarks Copilots receive the same member-payment amount but no fee value. */ function getDisplayLineItem( item: BillingAccountLineItem, billingAccountDetails: BillingAccountDetails, - showMemberPaymentsRemaining: boolean | undefined, + showChallengeFee: boolean, ): BillingAccountModalLineItem { const displayAmount = getLineItemMemberPaymentAmount( item, billingAccountDetails, - showMemberPaymentsRemaining, ) - const challengeFeeAmount = showMemberPaymentsRemaining - ? undefined - : calculatePaymentChallengeFee(displayAmount, billingAccountDetails.markup) + const challengeFeeAmount = showChallengeFee + ? calculatePaymentChallengeFee(displayAmount, billingAccountDetails.markup) + : undefined return { ...item, @@ -345,15 +378,16 @@ export const BillingAccountLineItemsModal: FC ) => { const [sortBy, setSortBy] = useState('date') const [sortOrder, setSortOrder] = useState('desc') + const showChallengeFeeColumn = !props.showMemberPaymentsRemaining const lineItems = useMemo( () => combineBillingAccountLineItems(props.billingAccountDetails) .map(item => getDisplayLineItem( item, props.billingAccountDetails, - props.showMemberPaymentsRemaining, + showChallengeFeeColumn, )), - [props.billingAccountDetails, props.showMemberPaymentsRemaining], + [props.billingAccountDetails, showChallengeFeeColumn], ) const normalizedProjectId = useMemo( () => normalizeRouteId(props.projectId), @@ -363,7 +397,6 @@ export const BillingAccountLineItemsModal: FC () => lineItems.some(item => item.externalType === 'ENGAGEMENT' && !!item.externalId), [lineItems], ) - const showChallengeFeeColumn = !props.showMemberPaymentsRemaining const engagementResult = useFetchEngagements( normalizedProjectId, ENGAGEMENT_ASSIGNMENT_FILTERS, diff --git a/src/apps/work/src/lib/components/ConfirmationModal/ConfirmationModal.tsx b/src/apps/work/src/lib/components/ConfirmationModal/ConfirmationModal.tsx index 5a98df2d9..47242d4d5 100644 --- a/src/apps/work/src/lib/components/ConfirmationModal/ConfirmationModal.tsx +++ b/src/apps/work/src/lib/components/ConfirmationModal/ConfirmationModal.tsx @@ -1,6 +1,7 @@ import { FC, MouseEvent, + ReactNode, useCallback, } from 'react' @@ -10,6 +11,7 @@ import styles from './ConfirmationModal.module.scss' export interface ConfirmationModalProps { cancelText?: string + children?: ReactNode confirmButtonDanger?: boolean confirmDisabled?: boolean confirmText?: string @@ -48,6 +50,7 @@ export const ConfirmationModal: FC = (

    {props.message}

    + {props.children}