diff --git a/apps/condo/domains/billing/access/BillingReceipt.js b/apps/condo/domains/billing/access/BillingReceipt.js index 2b9c379b700..f397e74452b 100644 --- a/apps/condo/domains/billing/access/BillingReceipt.js +++ b/apps/condo/domains/billing/access/BillingReceipt.js @@ -3,11 +3,15 @@ */ const { throwAuthenticationError } = require('@open-condo/keystone/apolloErrorFormatter') +const { find } = require('@open-condo/keystone/schema') const { canManageBillingEntityWithContext } = require('@condo/domains/billing/utils/accessSchema') const { canReadObjectsAsB2BAppServiceUser } = require('@condo/domains/miniapp/utils/b2bAppServiceUserAccess') +const { + checkPermissionsInEmployedOrganizations, +} = require('@condo/domains/organization/utils/accessSchema') const { getUserResidents, getUserServiceConsumers } = require('@condo/domains/resident/utils/accessSchema') -const { RESIDENT, SERVICE } = require('@condo/domains/user/constants/common') +const { RESIDENT, SERVICE, STAFF } = require('@condo/domains/user/constants/common') const { canDirectlyReadSchemaObjects } = require('@condo/domains/user/utils/directAccess') async function canReadBillingReceipts (args) { @@ -61,8 +65,57 @@ async function canReadSensitiveBillingReceiptData ({ authentication: { item: use return user.type !== RESIDENT } +const SOFT_DELETE_ALLOWED_UPDATE_FIELDS = new Set(['dv', 'sender', 'deletedAt']) + +const isSoftDeleteInput = (data) => { + const target = data?.data || data + if (typeof target !== 'object' || target === null) return false + + return !!target?.deletedAt && + Object.keys(target).every(key => SOFT_DELETE_ALLOWED_UPDATE_FIELDS.has(key)) +} + +function isSoftDeleteUpdateRequest (originalInput) { + if (Array.isArray(originalInput)) { + return originalInput.every((itemInput) => isSoftDeleteInput(itemInput)) + } + return isSoftDeleteInput(originalInput) +} + +async function getOrganizationIdsFromReceiptIds (ids) { + if (!ids.length) return [] + const receipts = await find('BillingReceipt', { id_in: ids, deletedAt: null }) + // some receipts were already deleted + if (receipts.length !== ids.length) return [] + const contextIds = Array.from(new Set(receipts.map(({ context }) => context))) + const contexts = await find('BillingIntegrationOrganizationContext', { + id_in: contextIds, + deletedAt: null, + organization: { deletedAt: null }, + }) + if (!contexts.length || contexts.length !== contextIds.length) { + return [] + } + return Array.from(new Set(contexts.map(({ organization }) => organization))) +} + async function canManageBillingReceipts (args) { - return await canManageBillingEntityWithContext(args) + const { authentication: { item: user }, operation, context, itemId, itemIds, originalInput } = args + if (!user) return throwAuthenticationError() + if (user.deletedAt) return false + if (user.isAdmin) return true + if (user.type === STAFF && operation === 'update') { + if (!isSoftDeleteUpdateRequest(originalInput)) { + return false + } + const receiptIds = itemIds?.length ? itemIds : [itemId] + const organizationIds = await getOrganizationIdsFromReceiptIds(receiptIds) + if (!organizationIds.length) { + return false + } + return checkPermissionsInEmployedOrganizations(context, user, organizationIds, 'canImportBillingReceipts') + } + return canManageBillingEntityWithContext(args) } /* diff --git a/apps/condo/domains/billing/components/BillingPageContent/ReceiptsTable.tsx b/apps/condo/domains/billing/components/BillingPageContent/ReceiptsTable.tsx index 82c849ad773..0a5b123fa15 100644 --- a/apps/condo/domains/billing/components/BillingPageContent/ReceiptsTable.tsx +++ b/apps/condo/domains/billing/components/BillingPageContent/ReceiptsTable.tsx @@ -1,33 +1,41 @@ -import { SortBillingReceiptsBy, BillingReceipt as BillingReceiptType, TourStepTypeType } from '@app/condo/schema' -import { Row, Col, Typography, Space, type RowProps } from 'antd' -import dayjs from 'dayjs' +import { BillingReceipt as BillingReceiptType, BillingReceiptWhereInput, SortBillingReceiptsBy, TourStepTypeType } from '@app/condo/schema' +import { Col, Row, Space, type RowProps } from 'antd' +import dayjs, { Dayjs } from 'dayjs' import get from 'lodash/get' +import isEqual from 'lodash/isEqual' import getConfig from 'next/config' import { useRouter } from 'next/router' -import React, { useCallback, useMemo, useState, CSSProperties, useEffect } from 'react' +import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react' import bridge from '@open-condo/bridge' -import { useDeepCompareEffect } from '@open-condo/codegen/utils/useDeepCompareEffect' +import { useLazyQuery } from '@open-condo/next/apollo' import { useIntl } from '@open-condo/next/intl' +import { useOrganization } from '@open-condo/next/organization' +import { + ActionBar, + ActionBarProps, + Button, + FullTableState, + GetTableData, + Input, + RowSelectionState, + Table, + TableRef, +} from '@open-condo/ui' import { ServicesModal } from '@condo/domains/billing/components/BillingPageContent/ServicesModal' +import { BillingReceiptForOrganization as BillingReceiptForOrganizationGQL } from '@condo/domains/billing/gql' import { useReceiptTableColumns } from '@condo/domains/billing/hooks/useReceiptTableColumns' import { useReceiptTableFilters } from '@condo/domains/billing/hooks/useReceiptTableFilters' import { BillingReceiptForOrganization } from '@condo/domains/billing/utils/clientSchema' -import Input from '@condo/domains/common/components/antd/Input' -import { BasicEmptyListView } from '@condo/domains/common/components/EmptyListView' +import { DeleteButtonWithConfirmModal } from '@condo/domains/common/components/DeleteButtonWithConfirmModal' import DatePicker from '@condo/domains/common/components/Pickers/DatePicker' -import { Table, DEFAULT_PAGE_SIZE } from '@condo/domains/common/components/Table/Index' +import { DEFAULT_PAGE_SIZE } from '@condo/domains/common/components/Table/Index' import { TableFiltersContainer } from '@condo/domains/common/components/TableFiltersContainer' import { useQueryMappers } from '@condo/domains/common/hooks/useQueryMappers' -import { useSearch } from '@condo/domains/common/hooks/useSearch' -import { getFiltersQueryData } from '@condo/domains/common/utils/filters.utils' -import { getFiltersFromQuery } from '@condo/domains/common/utils/helpers' -import { updateQuery } from '@condo/domains/common/utils/helpers' -import { - getPageIndexFromOffset, - parseQuery, -} from '@condo/domains/common/utils/tables.utils' +import { useTableSearch } from '@condo/domains/common/hooks/useSearch' +import { useTableTranslations } from '@condo/domains/common/hooks/useTableTranslations' +import { defaultParseUrlQuery } from '@condo/domains/common/utils/tableUrls' import { useTourContext } from '@condo/domains/onboarding/contexts/TourContext' import { useBillingAndAcquiringContexts } from './ContextProvider' @@ -38,86 +46,351 @@ const SORTABLE_PROPERTIES = ['toPay'] const INPUT_STYLE: CSSProperties = { width: '18em' } const ITEMS_GUTTER: RowProps['gutter'] = [0, 24] const FILTERS_GUTTER: RowProps['gutter'] = [16, 20] +const ASC = 'ASC' +const DESC = 'DESC' +const PERIOD_FILTER_KEY = 'period' + +const getSortQueryValue = (sortState: FullTableState['sortState']): string | undefined => { + if (!sortState?.length) return undefined + + const firstSort = sortState[0] + const sortOrder = firstSort.desc ? DESC : ASC + return `${firstSort.id}_${sortOrder}` +} + +const getPeriodDate = (period: unknown): Dayjs | null => { + return typeof period === 'string' && period ? dayjs(period, 'YYYY-MM-DD') : null +} + +const getEffectivePeriod = (period: unknown, defaultPeriod?: string | null): string | undefined => { + if (typeof period === 'string' && period) return period + return defaultPeriod || undefined +} + +const getFilterStateWithDefaultPeriod = ( + filterState: FullTableState['filterState'], + defaultPeriod?: string | null +): FullTableState['filterState'] => { + const period = getEffectivePeriod(filterState?.[PERIOD_FILTER_KEY], defaultPeriod) + if (!period) return filterState + + return { + ...filterState, + [PERIOD_FILTER_KEY]: period, + } +} + +const getTableStateWithDefaultPeriod = (tableState: FullTableState, defaultPeriod?: string | null): FullTableState => { + return { + ...tableState, + filterState: getFilterStateWithDefaultPeriod(tableState.filterState, defaultPeriod), + } +} + +const getFiltersWithGlobalSearch = ( + filterState: FullTableState['filterState'], + globalFilter?: string, + defaultPeriod?: string | null +): FullTableState['filterState'] => { + const nextFilters = { ...filterState } + + if (globalFilter && globalFilter.trim() !== '') { + nextFilters.search = globalFilter + } else { + delete nextFilters.search + } + + if (defaultPeriod && nextFilters[PERIOD_FILTER_KEY] === defaultPeriod) { + delete nextFilters[PERIOD_FILTER_KEY] + } + + return nextFilters +} + +const setQueryParam = (query: Record, key: string, value: string | undefined): void => { + if (value) { + query[key] = value + return + } + + delete query[key] +} + +const buildNextTableQuery = ( + currentQuery: Record, + params: FullTableState, + defaultPeriod?: string | null +): Record => { + const { startRow, filterState, sortState, rowSelectionState, globalFilter } = params + const nextFilters = getFiltersWithGlobalSearch(filterState, globalFilter, defaultPeriod) + const nextQuery = { ...currentQuery } + + const nextOffset = startRow > 0 ? String(startRow) : undefined + const nextFiltersValue = Object.keys(nextFilters).length > 0 ? JSON.stringify(nextFilters) : undefined + const nextSortValue = getSortQueryValue(sortState) + const nextSelectedRows = rowSelectionState?.length > 0 ? JSON.stringify(rowSelectionState) : undefined + + setQueryParam(nextQuery, 'offset', nextOffset) + setQueryParam(nextQuery, 'filters', nextFiltersValue) + setQueryParam(nextQuery, 'sort', nextSortValue) + setQueryParam(nextQuery, 'selectedRows', nextSelectedRows) + + return nextQuery +} export const ReceiptsTable: React.FC = () => { const intl = useIntl() const SearchPlaceholder = intl.formatMessage({ id: 'filters.FullSearch' }) - const LoadingErrorMessage = intl.formatMessage({ id: 'errors.LoadingError' }) + const CancelSelectionMessage = intl.formatMessage({ id: 'global.cancelSelection' }) + const DeleteMessage = intl.formatMessage({ id: 'Delete' }) + const DontDeleteMessage = intl.formatMessage({ id: 'DontDelete' }) + const ImpossibleToRestoreMessage = intl.formatMessage({ id: 'global.ImpossibleToRestore' }) + const userOrganization = useOrganization() const { billingContexts } = useBillingAndAcquiringContexts() const billingContext = billingContexts.length > 0 ? billingContexts[0] : null const currencyCode = get(billingContext, ['integration', 'currencyCode'], defaultCurrencyCode) - const reportPeriod = get(billingContexts.find(({ lastReport }) => !!lastReport), ['lastReport', 'period'], null) - const contextIds = billingContexts.map(({ id }) => id) - const hasToPayDetails = get(billingContext, ['integration', 'dataFormat', 'hasToPayDetails'], false) + const reportPeriod = get(billingContexts.find(({ lastReport }) => !!lastReport), ['lastReport', 'period'], null) as string | null + const contextIds = useMemo( + () => billingContexts.map(({ id }) => id).sort((a, b) => a.localeCompare(b)), + [billingContexts] + ) const hasServices = get(billingContext, ['integration', 'dataFormat', 'hasServices'], false) const hasServicesDetails = get(billingContext, ['integration', 'dataFormat', 'hasServicesDetails'], false) + const canManageReceipts = get(userOrganization, ['link', 'role', 'canImportBillingReceipts'], false) const router = useRouter() - const { filters, sorters, offset } = parseQuery(router.query) - const currentPageIndex = getPageIndexFromOffset(offset, DEFAULT_PAGE_SIZE) - const filtersFromQuery: Record = useMemo(() => getFiltersFromQuery(router.query), [router.query]) - - const [search, handleSearchChange] = useSearch() - const filterMetas = useReceiptTableFilters(reportPeriod, search) - const { filtersToWhere, sortersToSortBy } = useQueryMappers(filterMetas, SORTABLE_PROPERTIES) - - const onPeriodChange = useMemo(() => async (periodString) => { - setPeriod(periodString) - const newParameters = getFiltersQueryData({ ...filtersFromQuery, period: periodString ? dayjs(periodString).format( 'YYYY-MM-01') : null }) - await updateQuery(router, { newParameters }, { shallow: true, resetOldParameters: false }) - }, [router, filtersFromQuery]) - - const { - loading, - count: total, - objs: receipts, - error, - refetch, - } = BillingReceiptForOrganization.useObjects({ - where: { ...filtersToWhere(filters), context: { id_in: contextIds } }, - sortBy: sortersToSortBy(sorters) as SortBillingReceiptsBy[], - first: DEFAULT_PAGE_SIZE, - skip: (currentPageIndex - 1) * DEFAULT_PAGE_SIZE, - }) + const tableRef = useRef(null) + const isTableReadyRef = useRef(false) + const [search, handleSearchChange, setSearch] = useTableSearch(tableRef) + const [selectedRowsCount, setSelectedRowsCount] = useState(0) + const [selectedRowIds, setSelectedRowIds] = useState([]) + const [period, setPeriod] = useState(() => reportPeriod ? dayjs(reportPeriod, 'YYYY-MM-DD') : null) - const { updateStepIfNotCompleted } = useTourContext() - useDeepCompareEffect(() => { - if (receipts.length > 0) { - updateStepIfNotCompleted(TourStepTypeType.UploadReceipts) - } - }, [receipts]) + const filterMetas = useReceiptTableFilters(reportPeriod, search, contextIds) + const { filtersToWhere, sortersToSortBy } = useQueryMappers(filterMetas, SORTABLE_PROPERTIES) + const mainTableColumns = useReceiptTableColumns(filterMetas, currencyCode) + const columnLabels = useTableTranslations() + const initialTableState = useMemo( + () => getTableStateWithDefaultPeriod(defaultParseUrlQuery(router.query, DEFAULT_PAGE_SIZE), reportPeriod), + [reportPeriod, router.query] + ) + const setPeriodFromFilterState = useCallback((filterState: FullTableState['filterState']) => { + const nextPeriod = getEffectivePeriod(filterState?.[PERIOD_FILTER_KEY], reportPeriod) + setPeriod((prevPeriod) => { + if (prevPeriod?.format('YYYY-MM-DD') === nextPeriod) return prevPeriod - const mainTableColumns = useReceiptTableColumns(filterMetas, hasToPayDetails, currencyCode) + return getPeriodDate(nextPeriod) + }) + }, [reportPeriod]) + const updateUrlQuery = useCallback((params: FullTableState) => { + if (!isTableReadyRef.current) return + + setPeriodFromFilterState(params.filterState) + + const nextQuery = buildNextTableQuery(router.query as Record, params, reportPeriod) + if (isEqual(router.query, nextQuery)) return + + router.replace({ + pathname: router.pathname, + query: nextQuery, + }, undefined, { shallow: true }).catch((error) => { + console.error('Failed to update billing receipts table query params', error) + }) + }, [reportPeriod, router, setPeriodFromFilterState]) + + const { updateStepIfNotCompleted } = useTourContext() + const [fetchReceipts] = useLazyQuery(BillingReceiptForOrganizationGQL.GET_ALL_OBJS_WITH_COUNT_QUERY) + const updateReceipt = BillingReceiptForOrganization.useUpdate({}) const [modalIsVisible, setModalIsVisible] = useState(false) const [detailedReceipt, setDetailedReceipt] = useState(null) - const [period, setPeriod] = useState(dayjs(reportPeriod, 'YYYY-MM-DD')) - const showServiceModal = (receipt: BillingReceiptType) => { + + const showServiceModal = useCallback((receipt: BillingReceiptType) => { setModalIsVisible(true) setDetailedReceipt(receipt || null) - return - } - const hideServiceModal = () => { + }, []) + + const hideServiceModal = useCallback(() => { setModalIsVisible(false) - } - - useDeepCompareEffect(()=>{ - const filtersPeriod = get(filters, 'period') - if (filtersPeriod) { - setPeriod(dayjs(filtersPeriod as string)) + }, []) + + const onPeriodChange = useCallback((value: Dayjs | null, dateString: string) => { + const currentFilterState = tableRef.current?.api?.getFilterState() || {} + const nextFilterState = { ...currentFilterState } + let nextPeriod = reportPeriod || undefined + + if (value && dateString) { + nextPeriod = value.startOf('month').format('YYYY-MM-01') + } + + if (nextPeriod) { + nextFilterState[PERIOD_FILTER_KEY] = nextPeriod + } else { + delete nextFilterState[PERIOD_FILTER_KEY] + } + + setPeriod(getPeriodDate(nextPeriod)) + tableRef.current?.api?.setFilterState(nextFilterState) + }, [reportPeriod]) + + const onRowClick = useCallback((record: BillingReceiptType) => { + const hasSelectedText = globalThis.window?.getSelection?.()?.toString().trim() + if (hasSelectedText) return + + if (hasServices) { + showServiceModal(record) + } + }, [hasServices, showServiceModal]) + + const dataSource: GetTableData = useCallback(async ({ + filterState, + sortState, + startRow, + endRow, + globalFilter, + }) => { + const sortBy = sortersToSortBy(sortState) as SortBillingReceiptsBy[] + const effectiveFilterState = getFilterStateWithDefaultPeriod(filterState, reportPeriod) + const where = { + ...filtersToWhere({ + ...effectiveFilterState, + search: globalFilter, + }), + context: { id_in: contextIds }, + } + try { + const { data } = await fetchReceipts({ + variables: { + where, + sortBy, + first: endRow - startRow, + skip: startRow, + }, + fetchPolicy: 'network-only', + }) + + const rowData: BillingReceiptType[] = data?.objs?.filter(Boolean) ?? [] + const rowCount = data?.meta?.count ?? 0 + + if (rowData.length > 0) { + updateStepIfNotCompleted(TourStepTypeType.UploadReceipts) + } + + return { + rowData, + rowCount, + } + } catch (error) { + console.error('Failed to fetch billing receipts', error) + return { + rowData: [], + rowCount: 0, + } + } + }, [contextIds, fetchReceipts, filtersToWhere, reportPeriod, sortersToSortBy, updateStepIfNotCompleted]) + + const softDeleteSelectedReceipts = useCallback(async () => { + if (!selectedRowIds.length) return + + const deletedAt = new Date().toISOString() + + for (const id of selectedRowIds) { + await updateReceipt({ deletedAt }, { id }) } - }, [filters]) + + tableRef.current?.api?.resetRowSelection() + setSelectedRowsCount(0) + setSelectedRowIds([]) + tableRef.current?.api?.setPagination({ startRow: 0, endRow: DEFAULT_PAGE_SIZE }) + await tableRef.current?.api?.refetchData() + }, [selectedRowIds, updateReceipt]) + + const selectedReceiptsActionBarButtons: ActionBarProps['actions'] = useMemo(() => [ + , + , + ], [ + CancelSelectionMessage, + DeleteMessage, + DontDeleteMessage, + ImpossibleToRestoreMessage, + softDeleteSelectedReceipts, + ]) + + const rowSelectionOptions = useMemo(() => ({ + enableRowSelection: canManageReceipts, + onRowSelectionChange: (rowSelectionState: RowSelectionState) => { + setSelectedRowsCount(rowSelectionState.length) + setSelectedRowIds(rowSelectionState) + }, + }), [canManageReceipts]) + + const getRowId = useCallback((row: BillingReceiptType) => row.id, []) + const onTableReady = useCallback((nextTableRef: TableRef) => { + isTableReadyRef.current = true + + const tableSearch = nextTableRef.api.getGlobalFilter() + setSearch(String(tableSearch || '')) + setSelectedRowsCount(initialTableState.rowSelectionState.length) + setSelectedRowIds(initialTableState.rowSelectionState) + + const currentFilterState = nextTableRef.api.getFilterState() + const initialFilterState = getFilterStateWithDefaultPeriod(initialTableState.filterState, reportPeriod) + setPeriodFromFilterState(initialFilterState) + if (!isEqual(currentFilterState, initialFilterState)) { + nextTableRef.api.setFilterState(initialFilterState) + } + }, [initialTableState.filterState, initialTableState.rowSelectionState, reportPeriod, setPeriodFromFilterState, setSearch]) + + useEffect(() => { + if (!reportPeriod || !tableRef.current) return + + const currentFilterState = tableRef.current.api.getFilterState() + if (currentFilterState?.[PERIOD_FILTER_KEY]) return + + const nextFilterState = getFilterStateWithDefaultPeriod(currentFilterState, reportPeriod) + if (isEqual(currentFilterState, nextFilterState)) return + + setPeriodFromFilterState(nextFilterState) + tableRef.current.api.setFilterState(nextFilterState) + }, [reportPeriod, setPeriodFromFilterState]) useEffect(() => { const handleRedirect = async (event) => { - if (get(event, 'type') === 'condo-bridge') refetch() + if (get(event, 'type') === 'condo-bridge') { + await tableRef.current?.api?.refetchData() + } } bridge.subscribe(handleRedirect) return () => { bridge.unsubscribe(handleRedirect) } - }, [refetch]) + }, []) + + const SelectedItemsMessage = useMemo(() => { + return intl.formatMessage({ id: 'ItemsSelectedCount' }, { count: selectedRowsCount }) + }, [intl, selectedRowsCount]) const periodMetaSelect = useMemo(() => { return ( @@ -131,27 +404,7 @@ export const ReceiptsTable: React.FC = () => { /> ) - }, [period, onPeriodChange]) - - const onRow = useCallback((record: BillingReceiptType) => { - return { - onClick: () => { - if (hasServices) { - showServiceModal(record) - } - }, - } - }, [hasServices]) - - if (error) { - return ( - - - {LoadingErrorMessage} - - - ) - } + }, [onPeriodChange, period]) return ( <> @@ -170,17 +423,31 @@ export const ReceiptsTable: React.FC = () => { {periodMetaSelect} - - + id='billing-receipts-table' + dataSource={dataSource} columns={mainTableColumns} - onRow={onRow} + pageSize={DEFAULT_PAGE_SIZE} + onTableStateChange={updateUrlQuery} + initialTableState={initialTableState} + columnLabels={columnLabels} + rowSelectionOptions={rowSelectionOptions} + getRowId={getRowId} + onTableReady={onTableReady} + onRowClick={onRowClick} + ref={tableRef} /> + {canManageReceipts && selectedRowsCount > 0 && ( + + + + )} = ({ const [expanded, setExpanded] = useState(false) const handleRowExpand = () => setExpanded(!expanded) + const handleClose = useCallback(() => { + setExpanded(false) + onCancel() + }, [onCancel]) const ModalFooter = () => { return ( @@ -134,7 +138,7 @@ export const ServicesModal: React.FC = ({ ) } - if (!services || !services.length) return null + if (!visible || !services?.length) return null // TODO (savelevMatthew): Move modal to common width-expandable component? return ( @@ -142,10 +146,8 @@ export const ServicesModal: React.FC = ({ {isDetailed && } { - setExpanded(false) - onCancel() - }} + onCancel={handleClose} + destroyOnClose footer={} className='services-modal' title={modalTitleMessage} @@ -209,4 +211,4 @@ export const ServicesModal: React.FC = ({ ) -} \ No newline at end of file +} diff --git a/apps/condo/domains/billing/gql.js b/apps/condo/domains/billing/gql.js index b65459e254e..5eb060145a6 100644 --- a/apps/condo/domains/billing/gql.js +++ b/apps/condo/domains/billing/gql.js @@ -58,7 +58,7 @@ const ResidentBillingVirtualReceipt = generateGqlQueries('ResidentBillingVirtual const BillingReceiptForOrganization = generateGqlQueries('BillingReceipt', `{ id period toPay file { file { id publicUrl } } - property { id address addressKey } + property { id address addressKey addressMeta { ${ADDRESS_META_SUBFIELDS_QUERY_LIST} } } account { id number unitType unitName fullName ownerType globalId isClosed } toPayDetails { ${BILLING_RECEIPT_TO_PAY_DETAILS_FIELDS} } ${BILLING_RECEIPT_SERVICE_FIELDS} diff --git a/apps/condo/domains/billing/hooks/useReceiptTableColumns.ts b/apps/condo/domains/billing/hooks/useReceiptTableColumns.ts index f1ca05bb38e..1e90c5804ae 100644 --- a/apps/condo/domains/billing/hooks/useReceiptTableColumns.ts +++ b/apps/condo/domains/billing/hooks/useReceiptTableColumns.ts @@ -1,18 +1,24 @@ +import { BillingReceipt, BillingReceiptWhereInput, BuildingUnitSubType } from '@app/condo/schema' import get from 'lodash/get' -import { useRouter } from 'next/router' import { useMemo } from 'react' -import { Sheet } from '@open-condo/icons' import { useIntl } from '@open-condo/next/intl' +import { TableColumn } from '@open-condo/ui' -import { IFilters } from '@condo/domains/billing/utils/helpers' -import { getFilterIcon, getTextFilterDropdown } from '@condo/domains/common/components/Table/Filters' -import { getIconRender, getMoneyRender, getTextRender } from '@condo/domains/common/components/Table/Renders' -import { FiltersMeta, getFilterDropdownByKey } from '@condo/domains/common/utils/filters.utils' -import { getFilteredValue } from '@condo/domains/common/utils/helpers' -import { getSorterMap, parseQuery } from '@condo/domains/common/utils/tables.utils' +import { getMoneyRender, getTableCellRenderer } from '@condo/domains/common/components/Table/Renders' +import { getFilterComponentByKey, TableFiltersMeta } from '@condo/domains/common/utils/filters.utils' +import { getAddressDetails } from '@condo/domains/common/utils/helpers' -export const useReceiptTableColumns = (filterMetas: Array>, detailed: boolean, currencyCode: string) => { +type ReceiptTableRow = BillingReceipt +type ReceiptColumn = TableColumn +type ReceiptDataKey = Extract['dataKey'] + +const receiptDataKey = (value: TKey): TKey => value + +export const useReceiptTableColumns = ( + filterMetas: Array>, + currencyCode: string +): ReceiptColumn[] => { const intl = useIntl() const AddressTitle = intl.formatMessage({ id: 'field.Address' }) const UnitNameTitle = intl.formatMessage({ id: 'field.UnitName' }) @@ -23,149 +29,143 @@ export const useReceiptTableColumns = (filterMetas: Array>, de const ToPayTitle = intl.formatMessage({ id: 'field.TotalPayment' }) const PenaltyTitle = intl.formatMessage({ id: 'PaymentPenalty' }) const ChargeTitle = intl.formatMessage({ id: 'Charged' }) - const ShortFlatNumber = intl.formatMessage({ id: 'field.ShortFlatNumber' }) const PaidTitle = intl.formatMessage({ id: 'PaymentPaid' }) - const TooltipPDF = intl.formatMessage({ id: 'pages.billing.ReceiptsTable.PDFTooltip' }) - const router = useRouter() - const { filters, sorters } = parseQuery(router.query) - const sorterMap = getSorterMap(sorters) - return useMemo(() => { - let search = get(filters, 'search') - search = Array.isArray(search) ? null : search + const renderAddressCell = (value: string, record: ReceiptTableRow, _index: number, globalFilter?: string) => { + const property = get(record, 'property', {}) + const { streetPart, renderPostfix } = getAddressDetails(property) + const addressLine = streetPart || get(property, 'address') || value + + return getTableCellRenderer({ + search: globalFilter, + ellipsis: true, + postfix: renderPostfix, + extraPostfixProps: { + type: 'secondary', + style: { whiteSpace: 'pre-line' }, + }, + })(addressLine) + } + const renderTextCellWithoutTooltip = (value: string, _record: ReceiptTableRow, _index: number, globalFilter?: string) => { + if (!value) return null + + return getTableCellRenderer({ search: globalFilter, ellipsis: true })(value) + } + const renderUnitNameCell = (value: string, record: ReceiptTableRow, _index: number, globalFilter?: string) => { + const unitType = get(record, 'account.unitType', BuildingUnitSubType.Flat) + const unitTypeMessage = unitType + ? intl.formatMessage({ id: `pages.condo.ticket.field.unitType.${unitType}` as FormatjsIntl.Message['ids'] }) + : null - const columns = { + return getTableCellRenderer({ search: globalFilter, ellipsis: true, extraTitle: unitTypeMessage + ' ' + value })(value) + } + const renderMoneyCell = getMoneyRender(intl, currencyCode) + + const allColumns: Record = { address: { - title: AddressTitle, - key: 'address', - dataIndex: ['property', 'address'], - sorter: false, - filteredValue: get(filters, 'address'), - width: detailed ? '25%' : '50%', - filterIcon: getFilterIcon, - filterDropdown: getTextFilterDropdown({ inputProps: { placeholder: AddressTitle } }), - render: getTextRender(search), + header: AddressTitle, + id: 'address', + dataKey: receiptDataKey('property.address'), + enableSorting: false, + filterComponent: getFilterComponentByKey(filterMetas, 'address'), + initialSize: '21%', + render: renderAddressCell, }, unitName: { - title: UnitNameTitle, - key: 'unitName', - dataIndex: ['account', 'unitName'], - sorter: false, - filteredValue: get(filters, 'unitName'), - filterIcon: getFilterIcon, - filterDropdown: getTextFilterDropdown({ inputProps: { placeholder: UnitNameTitle } }), - width: '17%', - render: getTextRender(search), + header: UnitNameTitle, + id: 'unitName', + dataKey: receiptDataKey('account.unitName'), + enableSorting: false, + filterComponent: getFilterComponentByKey(filterMetas, 'unitName'), + initialSize: '8%', + render: renderUnitNameCell, }, fullName: { - title: FullNameTitle, - key: 'fullName', - dataIndex: ['account', 'fullName'], - sorter: false, - filteredValue: get(filters, 'fullName'), - width: '18%', - filterIcon: getFilterIcon, - filterDropdown: getTextFilterDropdown({ inputProps: { placeholder: FullNameTitle } }), - render: getTextRender(search), + header: FullNameTitle, + id: 'fullName', + dataKey: receiptDataKey('account.fullName'), + enableSorting: false, + filterComponent: getFilterComponentByKey(filterMetas, 'fullName'), + initialSize: '18%', + render: renderTextCellWithoutTooltip, }, category: { - title: CategoryTitle, - key: 'category', - dataIndex: ['category', 'name'], - sorter: false, - filteredValue: getFilteredValue(filters, 'category'), - width: '16%', - filterIcon: getFilterIcon, - filterDropdown: getFilterDropdownByKey(filterMetas, 'category'), - render: getTextRender(search), + header: CategoryTitle, + id: 'category', + dataKey: receiptDataKey('category.name'), + enableSorting: false, + filterComponent: getFilterComponentByKey(filterMetas, 'category'), + initialSize: '10%', + render: renderTextCellWithoutTooltip, }, account: { - title: AccountTitle, - key: 'account', - dataIndex: ['account', 'number'], - sorter: false, - filteredValue: get(filters, 'account'), - width: detailed ? '20%' : '30%', - filterIcon: getFilterIcon, - filterDropdown: getTextFilterDropdown({ inputProps: { placeholder: AccountTitle } }), - render: getTextRender(search), + header: AccountTitle, + id: 'account', + dataKey: receiptDataKey('account.number'), + enableSorting: false, + filterComponent: getFilterComponentByKey(filterMetas, 'account'), + initialSize: '14%', + render: renderTextCellWithoutTooltip, }, balance: { - title: DebtTitle, - key: 'balance', - dataIndex: ['toPayDetails', 'balance'], - sorter: false, - width: '14%', - align: 'right', - render: getMoneyRender(intl, currencyCode), + header: DebtTitle, + id: 'balance', + dataKey: receiptDataKey('toPayDetails.balance'), + enableSorting: false, + initialVisibility: false, + initialSize: '14%', + render: renderMoneyCell, }, penalty: { - title: PenaltyTitle, - key: 'penalty', - dataIndex: ['toPayDetails', 'penalty'], - sorter: false, - width: '13%', - align: 'right', - render: getMoneyRender(intl, currencyCode), + header: PenaltyTitle, + id: 'penalty', + dataKey: receiptDataKey('toPayDetails.penalty'), + enableSorting: false, + initialVisibility: false, + initialSize: '13%', + render: renderMoneyCell, }, charge: { - title: ChargeTitle, - key: 'charge', - dataIndex: ['toPayDetails', 'charge'], - sorter: false, - width: '14%', - align: 'right', - render: getMoneyRender(intl, currencyCode), + header: ChargeTitle, + id: 'charge', + dataKey: receiptDataKey('toPayDetails.charge'), + enableSorting: false, + initialSize: '12%', + render: renderMoneyCell, }, paid: { - title: PaidTitle, - key: 'paid', - dataIndex: ['toPayDetails', 'paid'], - sorter: false, - width: '14%', - align: 'right', - render: getMoneyRender(intl, currencyCode), + header: PaidTitle, + id: 'paid', + dataKey: receiptDataKey('toPayDetails.paid'), + enableSorting: false, + initialVisibility: false, + initialSize: '12%', + render: renderMoneyCell, }, toPay: { - title: ToPayTitle, - key: 'toPay', - dataIndex: ['toPay'], - sorter: true, - sortOrder: get(sorterMap, 'toPay'), - width: detailed ? '13%' : '20%', - align: 'right', - render: getMoneyRender(intl, currencyCode), - }, - pdf: { - title: 'PDF', - key: 'pdf', - dataIndex: '', - width: detailed ? '8%' : '10%', - align: 'center', - render: getIconRender(Sheet, '', TooltipPDF), + header: ToPayTitle, + id: 'toPay', + dataKey: receiptDataKey('toPay'), + enableSorting: true, + initialSize: '12%', + render: renderMoneyCell, }, } - - return detailed - ? [columns.address, columns.unitName, columns.fullName, columns.account, columns.category, columns.balance, columns.penalty, columns.charge, columns.paid, columns.toPay] - : [columns.address, columns.unitName, columns.account, columns.toPay] + return Object.values(allColumns) }, [ - AddressTitle, - FullNameTitle, - UnitNameTitle, AccountTitle, - ToPayTitle, + AddressTitle, + CategoryTitle, + ChargeTitle, DebtTitle, + FullNameTitle, PaidTitle, PenaltyTitle, - ChargeTitle, - CategoryTitle, - filters, - sorterMap, - ShortFlatNumber, - TooltipPDF, - detailed, + ToPayTitle, + UnitNameTitle, currencyCode, + filterMetas, + intl, ]) } diff --git a/apps/condo/domains/billing/hooks/useReceiptTableFilters.tsx b/apps/condo/domains/billing/hooks/useReceiptTableFilters.tsx index 006b8bd0630..c19de69321c 100644 --- a/apps/condo/domains/billing/hooks/useReceiptTableFilters.tsx +++ b/apps/condo/domains/billing/hooks/useReceiptTableFilters.tsx @@ -1,36 +1,162 @@ import { BillingReceiptWhereInput } from '@app/condo/schema' +import isEmpty from 'lodash/isEmpty' import { useMemo } from 'react' import { useIntl } from '@open-condo/next/intl' +import { BillingProperty as BillingPropertyGQL } from '@condo/domains/billing/gql' import { BillingCategory } from '@condo/domains/billing/utils/clientSchema' -import { ComponentType, convertToOptions, FilterComponentSize, FiltersMeta } from '@condo/domains/common/utils/filters.utils' -import { categoryToSearchQuery, getFilter, getStringContainsFilter } from '@condo/domains/common/utils/tables.utils' +import { ISearchInputProps } from '@condo/domains/common/components/GraphQlSearchInput' +import { ComponentType, convertToOptions, FilterComponentSize, TableFiltersMeta } from '@condo/domains/common/utils/filters.utils' +import { getAddressDetails, ObjectWithAddressInfo } from '@condo/domains/common/utils/helpers' +import { categoryToSearchQuery, getFilter, getStringContainsFilter, QueryArgType, WhereType } from '@condo/domains/common/utils/tables.utils' -const addressFilter = getStringContainsFilter(['property', 'address']) +const addressFilter = (search: QueryArgType) => { + if (!search) return + + const addressKeys = (Array.isArray(search) ? search : [search]) + .map((value) => String(value).trim()) + .filter(Boolean) + + if (addressKeys.length === 0) return + if (addressKeys.length === 1) return { property: { addressKey: addressKeys[0] } } + + return { + OR: addressKeys.map((addressKey) => ({ property: { addressKey } })), + } +} +const addressStringContainsFilter = getStringContainsFilter(['property', 'address']) const unitNameFilter = getStringContainsFilter(['account', 'unitName']) const accountFilter = getStringContainsFilter(['account', 'number']) const fullNameFilter = getStringContainsFilter(['account', 'fullName']) const categoryFilter = getFilter(['category', 'id'], 'array', 'string', 'in') const periodFilter = (period: string) => ({ period }) -export function useReceiptTableFilters (defaultPeriod: string, search: string): Array> { +const formatAddressOptionText = (property: ObjectWithAddressInfo): string => { + const { streetPart, cityPart } = getAddressDetails(property) + if (streetPart && cityPart) return `${streetPart}, ${cityPart}` + return streetPart || property?.address || '' +} + +const searchBillingPropertyByContextIds = (contextIds: string[]): ISearchInputProps['search'] => { + return async function (client, searchText, query = {}, first = 10, skip = 0) { + if (!contextIds?.length) return [] + + const addressSearchFilter = isEmpty(searchText) ? {} : { address_contains_i: searchText } + const where = { + ...addressSearchFilter, + ...query, + context: { id_in: contextIds }, + } + const sortBy = ['address_ASC'] + + const { data = {}, error } = await client.query({ + query: BillingPropertyGQL.GET_ALL_OBJS_QUERY, + variables: { where, sortBy, first, skip }, + fetchPolicy: 'network-only', + }) + + if (error) console.warn(error) + + const uniqueByAddressKey = new Map() + for (const property of (data?.objs || []).filter(Boolean)) { + const addressKey = property?.addressKey + if (!addressKey || uniqueByAddressKey.has(addressKey)) continue + + uniqueByAddressKey.set(addressKey, { + text: formatAddressOptionText({ address: property.address, addressMeta: property.addressMeta }), + value: addressKey, + }) + } + + return Array.from(uniqueByAddressKey.values()) + } +} + +const getAddressKeyInitialValueQuery = (initialValue: string | string[]): WhereType => { + const addressKeys = (Array.isArray(initialValue) ? initialValue : [initialValue]).filter(Boolean) + if (addressKeys.length === 0) return {} + return { addressKey_in: addressKeys } +} + +export function useReceiptTableFilters (defaultPeriod: string | null, search: string, contextIds: string[] = []): Array> { const intl = useIntl() - // const contextPeriod = get(context, ['lastReport', 'period'], null) const SelectMessage = intl.formatMessage({ id: 'Select' }) + const EnterAddressMessage = intl.formatMessage({ id: 'pages.condo.meter.EnterAddress' }) + const EnterUnitNameMessage = intl.formatMessage({ id: 'pages.condo.ticket.filters.EnterUnitName' }) + const EnterAccountNumberMessage = intl.formatMessage({ id: 'pages.condo.meter.EnterAccountNumber' }) + const EnterHolderMessage = intl.formatMessage({ id: 'field.Holder' }) const StatusMessage = intl.formatMessage({ id: 'Status' }) - const categorySearchFilter = categoryToSearchQuery(search, intl.messages) + const categorySearchFilter = useMemo( + () => categoryToSearchQuery(search, intl.messages), + [search, intl.messages] + ) + const billingPropertySearch = useMemo(() => searchBillingPropertyByContextIds(contextIds), [contextIds]) const { objs: categories } = BillingCategory.useObjects({}) - const categoryOptions = useMemo(() => convertToOptions(categories, 'name', 'id'), [categories]) + const categoryOptionsKey = useMemo( + () => categories.map(({ id, name }) => `${id}:${name}`).sort().join('|'), + [categories] + ) + // NOTE: Keep options reference stable while data content is unchanged, to avoid filter component remounts. + // eslint-disable-next-line react-hooks/exhaustive-deps + const categoryOptions = useMemo(() => convertToOptions(categories, 'name', 'id'), [categoryOptionsKey]) return useMemo(() => { return [ - { keyword: 'period', filters: [periodFilter], defaultValue: defaultPeriod }, - { keyword: 'search', filters: [addressFilter, unitNameFilter, accountFilter, fullNameFilter, categorySearchFilter], combineType: 'OR' }, - { keyword: 'address', filters: [addressFilter] }, - { keyword: 'unitName', filters: [unitNameFilter] }, - { keyword: 'account', filters: [accountFilter] }, - { keyword: 'fullName', filters: [fullNameFilter] }, + { keyword: 'period', filters: [periodFilter], defaultValue: defaultPeriod || undefined }, + { keyword: 'search', filters: [addressStringContainsFilter, unitNameFilter, accountFilter, fullNameFilter, categorySearchFilter], combineType: 'OR' }, + { + keyword: 'address', + filters: [addressFilter], + component: { + type: ComponentType.GQLSelect, + props: { + search: billingPropertySearch, + getInitialValueQuery: getAddressKeyInitialValueQuery, + mode: 'multiple', + showArrow: true, + placeholder: EnterAddressMessage, + infinityScroll: true, + }, + modalFilterComponentWrapper: { + label: EnterAddressMessage, + size: FilterComponentSize.Large, + }, + columnFilterComponentWrapper: { + width: '400px', + }, + }, + }, + { + keyword: 'unitName', + filters: [unitNameFilter], + component: { + type: ComponentType.Input, + props: { + placeholder: EnterUnitNameMessage, + }, + }, + }, + { + keyword: 'account', + filters: [accountFilter], + component: { + type: ComponentType.Input, + props: { + placeholder: EnterAccountNumberMessage, + }, + }, + }, + { + keyword: 'fullName', + filters: [fullNameFilter], + component: { + type: ComponentType.Input, + props: { + placeholder: EnterHolderMessage, + }, + }, + }, { keyword: 'category', filters: [categoryFilter], @@ -49,6 +175,17 @@ export function useReceiptTableFilters (defaultPeriod: string, search: string): }, }, ] - }, [SelectMessage, StatusMessage, categoryOptions, defaultPeriod, categorySearchFilter]) + }, [ + EnterAccountNumberMessage, + EnterAddressMessage, + EnterHolderMessage, + EnterUnitNameMessage, + SelectMessage, + StatusMessage, + billingPropertySearch, + categoryOptions, + defaultPeriod, + categorySearchFilter, + ]) } diff --git a/apps/condo/domains/billing/schema/BillingReceipt.test.js b/apps/condo/domains/billing/schema/BillingReceipt.test.js index a826e7f0bba..3a85e6bbb27 100644 --- a/apps/condo/domains/billing/schema/BillingReceipt.test.js +++ b/apps/condo/domains/billing/schema/BillingReceipt.test.js @@ -83,6 +83,7 @@ describe('BillingReceipt', () => { let account let integrationUser let integrationManager + let billingReceiptsImporter let anotherContext let anotherProperty let anotherAccount @@ -113,6 +114,14 @@ describe('BillingReceipt', () => { user = await makeClientWithNewRegisteredAndLoggedInUser() const { managerUserClient } = await makeOrganizationIntegrationManager({ context }) integrationManager = managerUserClient + const { managerUserClient: importerUserClient } = await makeOrganizationIntegrationManager({ + context, + employeeRoleArgs: { + canImportBillingReceipts: true, + canReadBillingReceipts: true, + }, + }) + billingReceiptsImporter = importerUserClient }) describe('CRUD', () => { describe('Create', () => { @@ -278,9 +287,23 @@ describe('BillingReceipt', () => { }) }) }) - test('Integration manager cannot', async () => { + test('Employee with canImportBillingReceipts cannot update non-deletedAt fields', async () => { await expectToThrowAccessDeniedErrorToObj(async () => { - await updateTestBillingReceipt(integrationManager, receipt.id, payload) + await updateTestBillingReceipt(billingReceiptsImporter, receipt.id, payload) + }) + }) + test('Employee with canImportBillingReceipts can delete receipt', async () => { + const [receiptToDelete] = await createTestBillingReceipt(admin, context, property, account) + const [deletedReceipt] = await updateTestBillingReceipt(billingReceiptsImporter, receiptToDelete.id, { + deletedAt: new Date().toISOString(), + }) + expect(deletedReceipt).toBeDefined() + expect(deletedReceipt.deletedAt).not.toBeNull() + }) + test('Employee with canImportBillingReceipts cannot update receipt', async () => { + const [receiptToDelete] = await createTestBillingReceipt(admin, context, property, account) + await expectToThrowAccessDeniedErrorToObj(async () => { + await updateTestBillingReceipt(billingReceiptsImporter, receiptToDelete.id, { toPay: '0' }) }) }) test('Other users cannot', async () => { @@ -356,6 +379,18 @@ describe('BillingReceipt', () => { await updateTestBillingReceipts(integrationManager, [payload]) }) }) + test('Employee with canImportBillingReceipts can delete receipts', async () => { + const [receiptToDelete] = await createTestBillingReceipt(admin, context, property, account) + const [oneMoreReceiptToDelete] = await createTestBillingReceipt(admin, context, property, account) + const [deletedReceipts] = await updateTestBillingReceipts(billingReceiptsImporter, [ + { id: receiptToDelete.id, data: { deletedAt: new Date().toISOString() } }, + { id: oneMoreReceiptToDelete.id, data: { deletedAt: new Date().toISOString() } }, + ]) + expect(deletedReceipts).toEqual([ + expect.objectContaining({ deletedAt: expect.any(String) }), + expect.objectContaining({ deletedAt: expect.any(String) }), + ]) + }) test('Other users cannot', async () => { await expectToThrowAccessDeniedErrorToObjects(async () => { await updateTestBillingReceipts(user, [payload]) @@ -665,9 +700,17 @@ describe('BillingReceipt', () => { }) }) }) - test('Integration manager cannot', async () => { + test('Employee with canImportBillingReceipts can delete receipt for same organization context', async () => { + const [updatedReceipt] = await updateTestBillingReceipt(billingReceiptsImporter, receipt.id, payload) + + expect(updatedReceipt).toBeDefined() + expect(updatedReceipt).toHaveProperty('deletedAt') + expect(updatedReceipt.deletedAt).not.toBeNull() + }) + test('Employee with canImportBillingReceipts cannot for another organization context', async () => { + const [anotherReceipt] = await createTestBillingReceipt(admin, anotherContext, anotherProperty, anotherAccount) await expectToThrowAccessDeniedErrorToObj(async () => { - await updateTestBillingReceipt(integrationManager, receipt.id, payload) + await updateTestBillingReceipt(billingReceiptsImporter, anotherReceipt.id, payload) }) }) test('Other users cannot', async () => {