diff --git a/client/src/common/classes.js b/client/src/common/classes.js index 52928fa1e..7a3af3936 100644 --- a/client/src/common/classes.js +++ b/client/src/common/classes.js @@ -74,6 +74,8 @@ const CLASSES = { UNIVERSAL_SEARCH_OVERLAY: 'universal-search-overlay', UNIVERSAL_SEARCH_INPUT: 'universal-search__input', UNIVERSAL_SEARCH_INPUT_RIGHT_ELEMENTS: 'universal-search-input-right-elements', + UNIVERSAL_SEARCH_TYPE_SELECT_OVERLAY: 'universal-search__type-select-overlay', + UNIVERSAL_SEARCH_TYPE_SELECT_BTN: 'universal-search__type-select-btn', UNIVERSAL_SEARCH_FOOTER: 'universal-search__footer', UNIVERSAL_SEARCH_ACTIONS: 'universal-search__actions', diff --git a/client/src/common/resourcesTypes.js b/client/src/common/resourcesTypes.js new file mode 100644 index 000000000..12ec85401 --- /dev/null +++ b/client/src/common/resourcesTypes.js @@ -0,0 +1,14 @@ +export const RESOURCES_TYPES = { + INVOICE: 'invoice', + ESTIMATE: 'estimate', + RECEIPT: 'receipt', + PAYMENT_RECEIVE: 'payment_receive', + PAYMENT_MADE: 'payment_made', + CUSTOMER: 'customer', + VENDOR: 'vendor', + ITEM: 'item', + BILL: 'bill', + EXPENSE: 'expense', + MANUAL_JOURNAL: 'manual_journal', + ACCOUNT: 'account', +}; diff --git a/client/src/common/universalSearchOptions.js b/client/src/common/universalSearchOptions.js deleted file mode 100644 index 97c7ce50c..000000000 --- a/client/src/common/universalSearchOptions.js +++ /dev/null @@ -1,12 +0,0 @@ -import intl from 'react-intl-universal'; - -export default [ - { - name: 'Type1', - placeholder: 'Id tempor anim culpa esse id laboris.', - }, - { - name: 'Type2', - placeholder: 'Laborum aliqua eiusmod voluptate aliqua', - }, -]; diff --git a/client/src/components/Dashboard/Dashboard.js b/client/src/components/Dashboard/Dashboard.js index 879fb77e3..4f43bb287 100644 --- a/client/src/components/Dashboard/Dashboard.js +++ b/client/src/components/Dashboard/Dashboard.js @@ -7,7 +7,7 @@ import Sidebar from 'components/Sidebar/Sidebar'; import DashboardContent from 'components/Dashboard/DashboardContent'; import DialogsContainer from 'components/DialogsContainer'; import PreferencesPage from 'components/Preferences/PreferencesPage'; -import Search from 'containers/GeneralSearch/Search'; +import DashboardUniversalSearch from 'containers/UniversalSearch/DashboardUniversalSearch'; import DashboardSplitPane from 'components/Dashboard/DashboardSplitePane'; import GlobalHotkeys from './GlobalHotkeys'; import DashboardProvider from './DashboardProvider'; @@ -35,7 +35,7 @@ export default function Dashboard() { - + diff --git a/client/src/components/Dashboard/DashboardActionViewsList.js b/client/src/components/Dashboard/DashboardActionViewsList.js index 42c2bcbe7..ea8b36bf5 100644 --- a/client/src/components/Dashboard/DashboardActionViewsList.js +++ b/client/src/components/Dashboard/DashboardActionViewsList.js @@ -8,6 +8,7 @@ import { Popover, PopoverInteractionKind, Position, + Divider, } from '@blueprintjs/core'; import { FormattedMessage as T } from 'components'; import { Icon } from 'components'; @@ -17,6 +18,8 @@ import { Icon } from 'components'; */ export default function DashboardActionViewsList({ resourceName, + allMenuItem, + allMenuItemText, views, onChange, }) { @@ -28,9 +31,28 @@ export default function DashboardActionViewsList({ handleClickViewItem(view)} text={view.name} /> )); + const handleAllTabClick = () => { + handleClickViewItem(null); + }; + + const content = ( + + {allMenuItem && ( + <> + + + + )} + {viewsMenuItems} + + ); + return ( {viewsMenuItems}} + content={content} minimal={true} interactionKind={PopoverInteractionKind.CLICK} position={Position.BOTTOM_LEFT} diff --git a/client/src/components/Dashboard/DashboardContentRoute.js b/client/src/components/Dashboard/DashboardContentRoute.js index 08ca63f54..c214699c5 100644 --- a/client/src/components/Dashboard/DashboardContentRoute.js +++ b/client/src/components/Dashboard/DashboardContentRoute.js @@ -27,6 +27,7 @@ export default function DashboardContentRoute() { hint={route.hint} sidebarExpand={route.sidebarExpand} pageType={route.pageType} + defaultSearchResource={route.defaultSearchResource} /> ))} diff --git a/client/src/components/Dashboard/DashboardPage.js b/client/src/components/Dashboard/DashboardPage.js index 215daff68..6e850e659 100644 --- a/client/src/components/Dashboard/DashboardPage.js +++ b/client/src/components/Dashboard/DashboardPage.js @@ -1,10 +1,13 @@ import React, { useEffect, Suspense } from 'react'; -import { isUndefined } from 'lodash'; +// import { isUndefined } from 'lodash'; import { CLASSES } from 'common/classes'; import withDashboardActions from 'containers/Dashboard/withDashboardActions'; import { compose } from 'utils'; import { Spinner } from '@blueprintjs/core'; +// import withUniversalSearch from '../../containers/UniversalSearch/withUniversalSearch'; +import withUniversalSearchActions from '../../containers/UniversalSearch/withUniversalSearchActions'; + /** * Dashboard pages wrapper. */ @@ -16,12 +19,17 @@ function DashboardPage({ Component, name, hint, + defaultSearchResource, // #withDashboardActions changePageTitle, setDashboardBackLink, changePageHint, - toggleSidebarExpand + toggleSidebarExpand, + + // #withUniversalSearch + setResourceTypeUniversalSearch, + resetResourceTypeUniversalSearch, }) { // Hydrate the given page title. useEffect(() => { @@ -38,7 +46,7 @@ function DashboardPage({ return () => { hint && changePageHint(''); - } + }; }, [hint, changePageHint]); // Hydrate the dashboard back link status. @@ -53,7 +61,7 @@ function DashboardPage({ useEffect(() => { const className = `page-${name}`; name && document.body.classList.add(className); - + return () => { name && document.body.classList.remove(className); }; @@ -61,15 +69,30 @@ function DashboardPage({ useEffect(() => { toggleSidebarExpand(sidebarExpand); - }, [toggleSidebarExpand, sidebarExpand]) + }, [toggleSidebarExpand, sidebarExpand]); + + useEffect(() => { + if (defaultSearchResource) { + setResourceTypeUniversalSearch(defaultSearchResource); + } + return () => { + resetResourceTypeUniversalSearch(); + }; + }, [ + defaultSearchResource, + resetResourceTypeUniversalSearch, + setResourceTypeUniversalSearch, + ]); return (
- - -
- }> + + + + } + > @@ -78,4 +101,6 @@ function DashboardPage({ export default compose( withDashboardActions, + // withUniversalSearch, + withUniversalSearchActions, )(DashboardPage); diff --git a/client/src/components/Dashboard/DashboardTopbar.js b/client/src/components/Dashboard/DashboardTopbar.js index bba610b9b..74126d6db 100644 --- a/client/src/components/Dashboard/DashboardTopbar.js +++ b/client/src/components/Dashboard/DashboardTopbar.js @@ -16,7 +16,7 @@ import DashboardBreadcrumbs from 'components/Dashboard/DashboardBreadcrumbs'; import DashboardBackLink from 'components/Dashboard/DashboardBackLink'; import { Icon, Hint, If } from 'components'; -import withUniversalSearch from 'containers/UniversalSearch/withUniversalSearch'; +import withUniversalSearchActions from 'containers/UniversalSearch/withUniversalSearchActions'; import withDashboardActions from 'containers/Dashboard/withDashboardActions'; import withDashboard from 'containers/Dashboard/withDashboard'; import withSettings from 'containers/Settings/withSettings'; @@ -155,7 +155,7 @@ function DashboardTopbar({ } export default compose( - withUniversalSearch, + withUniversalSearchActions, withDashboard(({ pageTitle, pageHint, editViewId, sidebarExpended }) => ({ pageTitle, editViewId, diff --git a/client/src/components/UniversalSearch.js b/client/src/components/UniversalSearch.js deleted file mode 100644 index 733a3f0f3..000000000 --- a/client/src/components/UniversalSearch.js +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react'; - -import { Omnibar } from '@blueprintjs/select'; -import { MenuItem, Spinner } from '@blueprintjs/core'; -import { FormattedMessage as T, Icon, ListSelect } from 'components'; - -import withSearch from 'containers/GeneralSearch/withSearch'; - -import { compose } from 'utils'; - -function UniversalSearch({ - results, - onClose, - - // withSearch - globalSearchShow, - closeGlobalSearch, - ...props -}) { - const SearchRenderer = ( - { name, code, amount }, - { handleClick, modifiers, query }, - ) => { - return ( - - ); - }; - - const handleClose = () => { - closeGlobalSearch(false); - }; - - return ( - } />} - resetOnSelect={true} - onClose={handleClose} - {...props} - /> - ); -} - -export default compose(withSearch)(UniversalSearch); diff --git a/client/src/components/UniversalSearch/UniversalSearch.js b/client/src/components/UniversalSearch/UniversalSearch.js new file mode 100644 index 000000000..a0c054ba7 --- /dev/null +++ b/client/src/components/UniversalSearch/UniversalSearch.js @@ -0,0 +1,227 @@ +import React from 'react'; +import intl from 'react-intl-universal'; +import classNames from 'classnames'; +import { isUndefined } from 'lodash'; +import { + Overlay, + InputGroup, + Tag, + MenuItem, + Spinner, + Intent, +} from '@blueprintjs/core'; +import { QueryList } from '@blueprintjs/select'; +import { CLASSES } from 'common/classes'; + +import { Icon, If, ListSelect, FormattedMessage as T } from 'components'; +import { + UniversalSearchProvider, + useUniversalSearchContext, +} from './UniversalSearchProvider'; +import { filterItemsByResourceType } from './utils'; +import { RESOURCES_TYPES } from '../../common/resourcesTypes'; + +/** + * Universal search input action. + */ +function UniversalSearchInputRightElements({ onSearchTypeChange }) { + const { isLoading, searchType, defaultSearchResource, searchTypeOptions } = + useUniversalSearchContext(); + + // Handle search type option change. + const handleSearchTypeChange = (option) => { + onSearchTypeChange && onSearchTypeChange(option); + }; + + return ( +
+ + + + + +
+ ); +} + +/** + * Universal search query list. + */ +function UniversalSearchQueryList(props) { + const { isOpen, isLoading, onSearchTypeChange, searchType, ...restProps } = + props; + + return ( + ( + + )} + noResults={ + !isLoading ? ( + } /> + ) : ( + } + /> + ) + } + /> + ); +} + +/** + * Universal query search actions. + */ +function UniversalQuerySearchActions() { + return ( +
+
+ ENTER + {intl.get('universal_search.enter_text')} +
+ +
+ ESC{' '} + {intl.get('universal_search.close_text')} +
+ +
+ + + + + + + {intl.get('universal_seach.navigate_text')} +
+
+ ); +} + +/** + * Universal search input bar with items list. + */ +function UniversalSearchBar({ isOpen, onSearchTypeChange, ...listProps }) { + const { handleKeyDown, handleKeyUp } = listProps; + const handlers = isOpen + ? { onKeyDown: handleKeyDown, onKeyUp: handleKeyUp } + : {}; + + return ( +
+ } + placeholder={intl.get('universal_search.placeholder')} + onChange={listProps.handleQueryChange} + value={listProps.query} + rightElement={ + + } + /> + {listProps.itemList} +
+ ); +} + +/** + * Universal search. + */ +export function UniversalSearch({ + defaultSearchResource, + searchResource, + + overlayProps, + isOpen, + isLoading, + onSearchTypeChange, + items, + searchTypeOptions, + ...queryListProps +}) { + // Search type state. + const [searchType, setSearchType] = React.useState( + defaultSearchResource || RESOURCES_TYPES.CUSTOMER, + ); + // Handle search resource type controlled mode. + React.useEffect(() => { + if ( + !isUndefined(searchResource) && + searchResource !== defaultSearchResource + ) { + setSearchType(searchResource); + } + }, [searchResource, defaultSearchResource]); + + // Handle search type change. + const handleSearchTypeChange = (searchTypeResource) => { + setSearchType(searchTypeResource.key); + onSearchTypeChange && onSearchTypeChange(searchTypeResource); + }; + // Filters query list items based on the given search type. + const filteredItems = filterItemsByResourceType(items, searchType); + + return ( + + +
+ +
+ +
+
+
+
+ ); +} diff --git a/client/src/components/UniversalSearch/UniversalSearchProvider.js b/client/src/components/UniversalSearch/UniversalSearchProvider.js new file mode 100644 index 000000000..39a90ddd7 --- /dev/null +++ b/client/src/components/UniversalSearch/UniversalSearchProvider.js @@ -0,0 +1,29 @@ +import React, { createContext } from 'react'; + +const UniversalSearchContext = createContext(); + +/** + * Universal search data provider. + */ +function UniversalSearchProvider({ + isLoading, + defaultSearchResource, + searchType, + searchTypeOptions, + ...props +}) { + // Provider payload. + const provider = { + isLoading, + searchType, + defaultSearchResource, + searchTypeOptions, + }; + + return ; +} + +const useUniversalSearchContext = () => + React.useContext(UniversalSearchContext); + +export { UniversalSearchProvider, useUniversalSearchContext }; diff --git a/client/src/components/UniversalSearch/utils.js b/client/src/components/UniversalSearch/utils.js new file mode 100644 index 000000000..d18d31a99 --- /dev/null +++ b/client/src/components/UniversalSearch/utils.js @@ -0,0 +1,4 @@ + +export const filterItemsByResourceType = (items, type) => { + return items.filter((item) => item._type === type); +} \ No newline at end of file diff --git a/client/src/components/index.js b/client/src/components/index.js index 3718c0ae8..bcffc5cbd 100644 --- a/client/src/components/index.js +++ b/client/src/components/index.js @@ -58,7 +58,6 @@ import AccountsSuggestField from './AccountsSuggestField'; import MaterialProgressBar from './MaterialProgressBar'; import { MoneyFieldCell } from './DataTableCells'; import Card from './Card'; -import UniversalSearch from './UniversalSearch'; import { ItemsMultiSelect } from './Items'; @@ -139,5 +138,4 @@ export { MoneyFieldCell, ItemsMultiSelect, Card, - UniversalSearch, }; diff --git a/client/src/containers/Accounting/JournalsLanding/ManualJournalActionsBar.js b/client/src/containers/Accounting/JournalsLanding/ManualJournalActionsBar.js index 023be03b9..be2265a75 100644 --- a/client/src/containers/Accounting/JournalsLanding/ManualJournalActionsBar.js +++ b/client/src/containers/Accounting/JournalsLanding/ManualJournalActionsBar.js @@ -54,19 +54,20 @@ function ManualJournalActionsBar({ const handleBulkDelete = () => {}; // Handle tab change. - const handleTabChange = (customView) => { - setManualJournalsTableState({ customViewId: customView.id || null }); + const handleTabChange = (view) => { + setManualJournalsTableState({ viewSlug: view ? view.slig : null }); }; // Handle click a refresh Journals - const handleRefreshBtnClick = () => { refresh(); }; - - console.log(manualJournalsFilterConditions, fields, 'XXX'); + const handleRefreshBtnClick = () => { + refresh(); + }; return ( @@ -135,5 +136,5 @@ export default compose( withManualJournalsActions, withManualJournals(({ manualJournalsTableState }) => ({ manualJournalsFilterConditions: manualJournalsTableState.filterRoles, - })) + })), )(ManualJournalActionsBar); diff --git a/client/src/containers/Accounting/ManualJournalUniversalSearch.js b/client/src/containers/Accounting/ManualJournalUniversalSearch.js new file mode 100644 index 000000000..9ece94bf0 --- /dev/null +++ b/client/src/containers/Accounting/ManualJournalUniversalSearch.js @@ -0,0 +1,43 @@ +import { RESOURCES_TYPES } from 'common/resourcesTypes'; +import withDrawerActions from '../Drawer/withDrawerActions'; + +/** + * Universal search manual journal item select action. + */ +function JournalUniversalSearchSelectComponent({ + // #ownProps + resourceType, + resourceId, + + // #withDrawerActions + openDrawer, +}) { + if (resourceType === RESOURCES_TYPES.MANUAL_JOURNAL) { + openDrawer('journal-drawer', { manualJournalId: resourceId }); + } + return null; +} + +export const JournalUniversalSearchSelectAction = withDrawerActions( + JournalUniversalSearchSelectComponent, +); + +/** + * Mappes the manual journal item to search item. + */ +const manualJournalsToSearch = (manualJournal) => ({ + text: manualJournal.journal_number, + subText: manualJournal.formatted_date, + label: manualJournal.formatted_amount, + reference: manualJournal, +}); + +/** + * Binds universal search invoice configure. + */ +export const universalSearchJournalBind = () => ({ + resourceType: RESOURCES_TYPES.MANUAL_JOURNAL, + optionItemLabel: 'Manual journal', + selectItemAction: JournalUniversalSearchSelectAction, + itemSelect: manualJournalsToSearch, +}); diff --git a/client/src/containers/Accounts/AccountUniversalSearch.js b/client/src/containers/Accounts/AccountUniversalSearch.js new file mode 100644 index 000000000..13a484ede --- /dev/null +++ b/client/src/containers/Accounts/AccountUniversalSearch.js @@ -0,0 +1,41 @@ +import { RESOURCES_TYPES } from '../../common/resourcesTypes'; +import withDrawerActions from '../Drawer/withDrawerActions'; + +function AccountUniversalSearchItemSelectComponent({ + // #ownProps + resourceType, + resourceId, + + // #withDrawerActions + openDrawer, +}) { + if (resourceType === RESOURCES_TYPES.ACCOUNT) { + openDrawer('account-drawer', { accountId: resourceId }); + } + return null; +} + +export const AccountUniversalSearchItemSelect = withDrawerActions( + AccountUniversalSearchItemSelectComponent, +); + +/** + * Transformes account item to search item. + * @param {*} account + * @returns + */ +const accountToSearch = (account) => ({ + text: `${account.name} - ${account.code}`, + label: account.formatted_amount, + reference: account, +}); + +/** + * Binds universal search account configure. + */ +export const universalSearchAccountBind = () => ({ + resourceType: RESOURCES_TYPES.ACCOUNT, + optionItemLabel: 'Account', + selectItemAction: AccountUniversalSearchItemSelect, + itemSelect: accountToSearch, +}); diff --git a/client/src/containers/Accounts/AccountsActionsBar.js b/client/src/containers/Accounts/AccountsActionsBar.js index 80781d57d..5798c2a62 100644 --- a/client/src/containers/Accounts/AccountsActionsBar.js +++ b/client/src/containers/Accounts/AccountsActionsBar.js @@ -78,8 +78,8 @@ function AccountsActionsBar({ }; // Handle tab changing. - const handleTabChange = (customView) => { - setAccountsTableState({ customViewId: customView.id || null }); + const handleTabChange = (view) => { + setAccountsTableState({ viewSlug: view ? view.slug : null }); }; // Handle inactive switch changing. @@ -98,6 +98,8 @@ function AccountsActionsBar({ } views={resourceViews} onChange={handleTabChange} /> diff --git a/client/src/containers/Accounts/components.js b/client/src/containers/Accounts/components.js index d45fd20cb..61ad5b294 100644 --- a/client/src/containers/Accounts/components.js +++ b/client/src/containers/Accounts/components.js @@ -1,4 +1,4 @@ -import React, { memo } from 'react'; +import React from 'react'; import { Position, Classes, @@ -6,14 +6,13 @@ import { MenuItem, Menu, MenuDivider, - Intent, - Popover, - Button, + Intent } from '@blueprintjs/core'; import { Icon, Money, If } from 'components'; import intl from 'react-intl-universal'; import { safeCallback } from 'utils'; + /** * Accounts table actions menu. */ diff --git a/client/src/containers/Customers/CustomersLanding/CustomersActionsBar.js b/client/src/containers/Customers/CustomersLanding/CustomersActionsBar.js index 17696c68f..d8ef13c27 100644 --- a/client/src/containers/Customers/CustomersLanding/CustomersActionsBar.js +++ b/client/src/containers/Customers/CustomersLanding/CustomersActionsBar.js @@ -62,9 +62,9 @@ function CustomerActionsBar({ openAlert('customers-bulk-delete', { customersIds: customersSelectedRows }); }; - const handleTabChange = (viewId) => { + const handleTabChange = (view) => { setCustomersTableState({ - customViewId: viewId.id || null, + viewSlug: view ? view.slug : null, }); }; // Handle inactive switch changing. @@ -82,6 +82,8 @@ function CustomerActionsBar({ } onChange={handleTabChange} /> diff --git a/client/src/containers/Customers/CustomersUniversalSearch.js b/client/src/containers/Customers/CustomersUniversalSearch.js new file mode 100644 index 000000000..c1669d6a8 --- /dev/null +++ b/client/src/containers/Customers/CustomersUniversalSearch.js @@ -0,0 +1,33 @@ +import { RESOURCES_TYPES } from '../../common/resourcesTypes'; +import withDrawerActions from '../Drawer/withDrawerActions'; + +function CustomerUniversalSearchSelectComponent({ resourceType, resourceId }) { + if (resourceType === RESOURCES_TYPES.CUSTOMER) { + } + return null; +} + +const CustomerUniversalSearchSelectAction = withDrawerActions( + CustomerUniversalSearchSelectComponent +); + +/** + * Transformes customers to search. + * @param {*} contact + * @returns + */ +const customersToSearch = (contact) => ({ + text: contact.display_name, + label: contact.formatted_balance, + reference: contact, +}); + +/** + * Binds universal search invoice configure. + */ +export const universalSearchCustomerBind = () => ({ + resourceType: RESOURCES_TYPES.CUSTOMER, + optionItemLabel: 'Customers', + selectItemAction: CustomerUniversalSearchSelectAction, + itemSelect: customersToSearch, +}); diff --git a/client/src/containers/Expenses/ExpenseUniversalSearch.js b/client/src/containers/Expenses/ExpenseUniversalSearch.js new file mode 100644 index 000000000..78942049f --- /dev/null +++ b/client/src/containers/Expenses/ExpenseUniversalSearch.js @@ -0,0 +1,24 @@ +import { RESOURCES_TYPES } from 'common/resourcesTypes'; +import withDrawerActions from '../Drawer/withDrawerActions'; + + +/** + * Universal search bill item select action. + */ +function ExpenseUniversalSearchItemSelectComponent({ + // #ownProps + resourceType, + resourceId, + + // #withDrawerActions + openDrawer, +}) { + if (resourceType === RESOURCES_TYPES.EXPENSE) { + openDrawer('expense-drawer', { expenseId: resourceId }); + } + return null; +} + +export const ExpenseUniversalSearchItemSelect = withDrawerActions( + ExpenseUniversalSearchItemSelectComponent, +); diff --git a/client/src/containers/Expenses/ExpensesLanding/ExpenseActionsBar.js b/client/src/containers/Expenses/ExpensesLanding/ExpenseActionsBar.js index 955d0a9d8..dc44b0f49 100644 --- a/client/src/containers/Expenses/ExpensesLanding/ExpenseActionsBar.js +++ b/client/src/containers/Expenses/ExpensesLanding/ExpenseActionsBar.js @@ -37,7 +37,7 @@ function ExpensesActionsBar({ setExpensesTableState, // #withExpenses - expensesFilterConditions + expensesFilterConditions, }) { // History context. const history = useHistory(); @@ -57,9 +57,9 @@ function ExpensesActionsBar({ const handleBulkDelete = () => {}; // Handles the tab chaning. - const handleTabChange = (viewId) => { + const handleTabChange = (view) => { setExpensesTableState({ - customViewId: viewId.id || null, + viewSlug: view ? view.slug : null, }); }; @@ -73,6 +73,7 @@ function ExpensesActionsBar({ @@ -140,5 +141,5 @@ export default compose( withExpensesActions, withExpenses(({ expensesTableState }) => ({ expensesFilterConditions: expensesTableState.filterRoles, - })) + })), )(ExpensesActionsBar); diff --git a/client/src/containers/GeneralSearch/Search.js b/client/src/containers/GeneralSearch/Search.js deleted file mode 100644 index 1c51b0e77..000000000 --- a/client/src/containers/GeneralSearch/Search.js +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react'; -import { Intent, Spinner } from '@blueprintjs/core'; -import { - UniversalSearch, - ListSelect, - If, - FormattedMessage as T, -} from 'components'; -import { defaultTo } from 'lodash'; -import intl from 'react-intl-universal'; -import { useAccounts } from 'hooks/query'; -import UniversalSearchOptions from 'common/universalSearchOptions'; - -import { compose } from 'utils'; - -import withSearch from 'containers/GeneralSearch/withSearch'; - -function Search({ globalSearchShow }) { - const [query, setQuery] = React.useState(); - const [labelState, setLabelState] = React.useState(); - - const { data: accounts, isFetching: isAccountsFetching } = useAccounts({ - search_keyword: query, - }); - - const handleClick = (placeholder) => { - setLabelState(placeholder); - }; - - const MenuSelectType = ( -
- - - - handleClick(holder)} - filterable={false} - selectedItem={labelState?.name} - selectedItemProp={'name'} - textProp={'name'} - defaultText={intl.get('type')} - popoverProps={{ minimal: false, captureDismiss: true }} - buttonProps={{ - minimal: true, - }} - /> -
- ); - - return ( - setQuery(q)} - inputProps={{ - rightElement: MenuSelectType, - placeholder: `${defaultTo(labelState?.placeholder, '')}`, - }} - /> - ); -} - -export default compose(withSearch)(Search); diff --git a/client/src/containers/GeneralSearch/withSearch.js b/client/src/containers/GeneralSearch/withSearch.js deleted file mode 100644 index a70dab997..000000000 --- a/client/src/containers/GeneralSearch/withSearch.js +++ /dev/null @@ -1,15 +0,0 @@ -import { connect } from 'react-redux'; -import t from 'store/types'; - -export const mapStateToProps = (state, props) => ({ - resultSearch: state.globalSearch.searches, - globalSearchShow: state.globalSearch.isOpen, -}); - -export const mapDispatchToProps = (dispatch) => ({ - openGlobalSearch: (result) => dispatch({ type: t.OPEN_SEARCH, }), - closeGlobalSearch: (result) => dispatch({ type: t.CLOSE_SEARCH }), -}); - -export default connect(mapStateToProps, mapDispatchToProps); - diff --git a/client/src/containers/Items/ItemsActionsBar.js b/client/src/containers/Items/ItemsActionsBar.js index fa3a4c409..12e502aa4 100644 --- a/client/src/containers/Items/ItemsActionsBar.js +++ b/client/src/containers/Items/ItemsActionsBar.js @@ -58,8 +58,8 @@ function ItemsActionsBar({ }; // Handle tab changing. - const handleTabChange = (viewId) => { - setItemsTableState({ customViewId: viewId.id || null }); + const handleTabChange = (view) => { + setItemsTableState({ viewSlug: view ? view.slug : null }); }; // Handle cancel/confirm items bulk. @@ -82,6 +82,8 @@ function ItemsActionsBar({ } views={itemsViews} onChange={handleTabChange} /> diff --git a/client/src/containers/Items/ItemsUniversalSearch.js b/client/src/containers/Items/ItemsUniversalSearch.js new file mode 100644 index 000000000..acb32a976 --- /dev/null +++ b/client/src/containers/Items/ItemsUniversalSearch.js @@ -0,0 +1,45 @@ +import { RESOURCES_TYPES } from '../../common/resourcesTypes'; +import withDrawerActions from '../Drawer/withDrawerActions'; + +/** + * Item univrsal search item select action. + */ +function ItemUniversalSearchSelectComponent({ + // #ownProps + resourceType, + resourceId, + + // #withDrawerActions + openDrawer, +}) { + if (resourceType === RESOURCES_TYPES.ITEM) { + + } + return null; +} + +export const ItemUniversalSearchSelectAction = withDrawerActions( + ItemUniversalSearchSelectComponent, +); + +/** + * Transformes items to search. + * @param {*} item + * @returns + */ +const transfromItemsToSearch = (item) => ({ + text: item.name, + subText: item.code, + label: item.type, + reference: item, +}); + +/** + * Binds universal search invoice configure. + */ +export const universalSearchItemBind = () => ({ + resourceType: RESOURCES_TYPES.ITEM, + optionItemLabel: 'Items', + selectItemAction: ItemUniversalSearchSelectAction, + itemSelect: transfromItemsToSearch, +}); diff --git a/client/src/containers/Items/utils.js b/client/src/containers/Items/utils.js index 0ebdda3dc..1c5a61adb 100644 --- a/client/src/containers/Items/utils.js +++ b/client/src/containers/Items/utils.js @@ -130,4 +130,4 @@ export function transformItemsTableState(tableState) { ...transformTableStateToQuery(tableState), inactive_mode: tableState.inactiveMode, }; -} +} \ No newline at end of file diff --git a/client/src/containers/Purchases/Bills/BillUniversalSearch.js b/client/src/containers/Purchases/Bills/BillUniversalSearch.js new file mode 100644 index 000000000..44e50630e --- /dev/null +++ b/client/src/containers/Purchases/Bills/BillUniversalSearch.js @@ -0,0 +1,116 @@ +import React from 'react'; +import { MenuItem } from '@blueprintjs/core'; + +import { formattedAmount } from 'utils'; +import { T, Icon, Choose, If } from 'components'; +import intl from 'react-intl-universal'; + +import { RESOURCES_TYPES } from 'common/resourcesTypes'; +import withDrawerActions from '../../Drawer/withDrawerActions'; + +/** + * Universal search bill item select action. + */ +function BillUniversalSearchSelectComponent({ + // #ownProps + resourceType, + resourceId, + + // #withDrawerActions + openDrawer, +}) { + if (resourceType === RESOURCES_TYPES.INVOICE) { + openDrawer('bill-drawer', { billId: resourceId }); + } + return null; +} + +export const BillUniversalSearchSelect = withDrawerActions( + BillUniversalSearchSelectComponent, +); + +/** + * Status accessor. + */ +export function BillStatus({ bill }) { + return ( + + + + + + + + + + + {intl.get('overdue_by', { overdue: bill.overdue_days })} + + + + + {intl.get('due_in', { due: bill.remaining_days })} + + + + + + {intl.get('day_partially_paid', { + due: formattedAmount(bill.due_amount, bill.currency_code), + })} + + + + + + + + + + ); +} + +/** + * Bill universal search item. + */ +export function BillUniversalSearchItem( + item, + { handleClick, modifiers, query }, +) { + return ( + +
{item.text}
+ + {item.reference.bill_number}{' '} + + {item.reference.formatted_bill_date} + + + } + label={ + <> +
{item.reference.formatted_amount}
+ + + } + onClick={handleClick} + className={'universal-search__item--bill'} + /> + ); +} + +const billsToSearch = (bill) => ({ + text: bill.vendor.display_name, + reference: bill, +}); + +export const universalSearchBillBind = () => ({ + resourceType: RESOURCES_TYPES.BILL, + optionItemLabel: 'Bills', + selectItemAction: BillUniversalSearchSelect, + itemRenderer: BillUniversalSearchItem, + itemSelect: billsToSearch, +}); diff --git a/client/src/containers/Purchases/Bills/BillsLanding/BillsActionsBar.js b/client/src/containers/Purchases/Bills/BillsLanding/BillsActionsBar.js index 684fba3d9..385378ce0 100644 --- a/client/src/containers/Purchases/Bills/BillsLanding/BillsActionsBar.js +++ b/client/src/containers/Purchases/Bills/BillsLanding/BillsActionsBar.js @@ -34,7 +34,7 @@ function BillActionsBar({ setBillsTableState, // #withBills - billsConditionsRoles + billsConditionsRoles, }) { const history = useHistory(); @@ -50,13 +50,15 @@ function BillActionsBar({ }; // Handle tab change. - const handleTabChange = (customView) => { + const handleTabChange = (view) => { setBillsTableState({ - customViewId: customView.id || null, + viewSlug: view ? view.slug : null, }); }; // Handle click a refresh bills - const handleRefreshBtnClick = () => { refresh(); }; + const handleRefreshBtnClick = () => { + refresh(); + }; return ( @@ -64,6 +66,8 @@ function BillActionsBar({ } onChange={handleTabChange} /> @@ -127,6 +131,6 @@ function BillActionsBar({ export default compose( withBillsActions, withBills(({ billsTableState }) => ({ - billsConditionsRoles: billsTableState.filterRoles - })) + billsConditionsRoles: billsTableState.filterRoles, + })), )(BillActionsBar); diff --git a/client/src/containers/Purchases/PaymentMades/PaymentMadeUniversalSearch.js b/client/src/containers/Purchases/PaymentMades/PaymentMadeUniversalSearch.js new file mode 100644 index 000000000..cabd30fcd --- /dev/null +++ b/client/src/containers/Purchases/PaymentMades/PaymentMadeUniversalSearch.js @@ -0,0 +1,79 @@ +import React from 'react'; +import { MenuItem } from '@blueprintjs/core'; + +import { Icon } from 'components'; + +import { RESOURCES_TYPES } from 'common/resourcesTypes'; +import withDrawerActions from '../../Drawer/withDrawerActions'; + +import { highlightText } from 'utils'; + +/** + * Universal search bill item select action. + */ +function PaymentMadeUniversalSearchSelectComponent({ + // #ownProps + resourceType, + resourceId, + + // #withDrawerActions + openDrawer, +}) { + if (resourceType === RESOURCES_TYPES.PAYMENT_MADE) { + openDrawer('payment-made-detail-drawer', { paymentMadeId: resourceId }); + } + return null; +} + +export const PaymentMadeUniversalSearchSelect = withDrawerActions( + PaymentMadeUniversalSearchSelectComponent, +); + +/** + * Payment made universal search item. + */ + export function PaymentMadeUniversalSearchItem( + item, + { handleClick, modifiers, query }, +) { + return ( + +
{highlightText(item.text, query)}
+ + + {highlightText(item.reference.payment_number, query)}{' '} + + {highlightText(item.reference.formatted_payment_date, query)} + + + } + label={
{item.reference.formatted_amount}
} + onClick={handleClick} + className={'universal-search__item--payment-made'} + /> + ); +} + +/** + * Payment made resource item to search item. + */ +const paymentMadeToSearch = (payment) => ({ + text: payment.vendor.display_name, + subText: payment.formatted_payment_date, + label: payment.formatted_amount, + reference: payment, +}); + +/** + * Binds universal search payment made configure. + */ + export const universalSearchPaymentMadeBind = () => ({ + resourceType: RESOURCES_TYPES.PAYMENT_MADE, + optionItemLabel: 'Payment made', + selectItemAction: PaymentMadeUniversalSearchSelect, + itemRenderer: PaymentMadeUniversalSearchItem, + itemSelect: paymentMadeToSearch, +}); diff --git a/client/src/containers/Sales/Estimates/EstimatesLanding/EstimateUniversalSearch.js b/client/src/containers/Sales/Estimates/EstimatesLanding/EstimateUniversalSearch.js new file mode 100644 index 000000000..8486958e0 --- /dev/null +++ b/client/src/containers/Sales/Estimates/EstimatesLanding/EstimateUniversalSearch.js @@ -0,0 +1,106 @@ +import React from 'react'; +import { MenuItem } from '@blueprintjs/core'; + +import { Choose, T, Icon } from 'components'; + +import { RESOURCES_TYPES } from "../../../../common/resourcesTypes"; +import withDrawerActions from "../../../Drawer/withDrawerActions"; + +/** + * Estimate universal search item select action. + */ +function EstimateUniversalSearchSelectComponent({ + // #ownProps + resourceType, + resourceId, + + // #withDrawerActions + openDrawer, +}) { + if (resourceType === RESOURCES_TYPES.ESTIMATE) { + openDrawer('estimate-drawer', { estimateId: resourceId }); + } + return null; +} + +export const EstimateUniversalSearchSelect = withDrawerActions( + EstimateUniversalSearchSelectComponent, +); + +/** + * Status accessor. + */ +export const EstimateStatus = ({ estimate }) => ( + + + + + + + + + + + + + + + + + + + + + + +); + +/** + * Estimate universal search item. + */ +export function EstimateUniversalSearchItem( + item, + { handleClick, modifiers, query }, +) { + return ( + +
{item.text}
+ + {item.reference.estimate_number}{' '} + + {item.reference.formatted_estimate_date} + + + } + label={ + <> +
{item.reference.formatted_amount}
+ + + } + onClick={handleClick} + className={'universal-search__item--estimate'} + /> + ); +} + + +const transformEstimatesToSearch = (estimate) => ({ + text: estimate.customer.display_name, + label: estimate.formatted_balance, + reference: estimate, +}); + +export const universalSearchEstimateBind = () => ({ + resourceType: RESOURCES_TYPES.ESTIMATE, + optionItemLabel: 'Estimates', + selectItemAction: EstimateUniversalSearchSelect, + itemRenderer: EstimateUniversalSearchItem, + itemSelect: transformEstimatesToSearch +}); diff --git a/client/src/containers/Sales/Estimates/EstimatesLanding/EstimatesActionsBar.js b/client/src/containers/Sales/Estimates/EstimatesLanding/EstimatesActionsBar.js index 3cca7dff5..f89760072 100644 --- a/client/src/containers/Sales/Estimates/EstimatesLanding/EstimatesActionsBar.js +++ b/client/src/containers/Sales/Estimates/EstimatesLanding/EstimatesActionsBar.js @@ -35,7 +35,7 @@ function EstimateActionsBar({ setEstimatesTableState, // #withEstimates - estimatesFilterRoles + estimatesFilterRoles, }) { const history = useHistory(); @@ -51,20 +51,24 @@ function EstimateActionsBar({ const { refresh } = useRefreshEstimates(); // Handle tab change. - const handleTabChange = (customView) => { + const handleTabChange = (view) => { setEstimatesTableState({ - customViewId: customView.id || null, + viewSlug: view ? view.slug : null, }); }; // Handle click a refresh sale estimates - const handleRefreshBtnClick = () => { refresh(); }; + const handleRefreshBtnClick = () => { + refresh(); + }; return ( } views={estimatesViews} onChange={handleTabChange} /> diff --git a/client/src/containers/Sales/Invoices/InvoiceUniversalSearch.js b/client/src/containers/Sales/Invoices/InvoiceUniversalSearch.js new file mode 100644 index 000000000..cb0b784ab --- /dev/null +++ b/client/src/containers/Sales/Invoices/InvoiceUniversalSearch.js @@ -0,0 +1,121 @@ +import React from 'react'; +import intl from 'react-intl-universal'; +import { MenuItem } from '@blueprintjs/core'; + +import { T, Choose, Icon } from 'components'; + +import { highlightText } from 'utils'; + +import { RESOURCES_TYPES } from 'common/resourcesTypes'; +import withDrawerActions from '../../Drawer/withDrawerActions'; + +/** + * Universal search invoice item select action. + */ +function InvoiceUniversalSearchSelectComponent({ + // #ownProps + resourceType, + resourceId, + + // #withDrawerActions + openDrawer, +}) { + if (resourceType === RESOURCES_TYPES.INVOICE) { + openDrawer('invoice-drawer', { invoiceId: resourceId }); + } + return null; +} + +export const InvoiceUniversalSearchSelect = withDrawerActions( + InvoiceUniversalSearchSelectComponent, +); + +/** + * Invoice status. + */ +function InvoiceStatus(customer) { + return ( + + + + + + + + + + + + {intl.get('overdue_by', { overdue: customer.overdue_days })} + + + + + {intl.get('due_in', { due: customer.remaining_days })} + + + + + + + + + + + ); +} + +/** + * Universal search invoice item. + */ +export function InvoiceUniversalSearchItem( + item, + { handleClick, modifiers, query }, +) { + return ( + +
{highlightText(item.text, query)}
+ + {highlightText(item.reference.invoice_no, query)}{' '} + + {highlightText(item.reference.formatted_invoice_date, query)} + + + } + label={ + <> +
${item.reference.balance}
+ + + } + onClick={handleClick} + className={'universal-search__item--invoice'} + /> + ); +} + +/** + * Transformes invoices to search. + * @param {*} invoice + * @returns + */ + const transformInvoicesToSearch = (invoice) => ({ + id: invoice.id, + text: invoice.customer.display_name, + label: invoice.formatted_balance, + reference: invoice, +}); + +/** + * Binds universal search invoice configure. + */ +export const universalSearchInvoiceBind = () => ({ + resourceType: RESOURCES_TYPES.INVOICE, + optionItemLabel: 'Invoices', + selectItemAction: InvoiceUniversalSearchSelect, + itemRenderer: InvoiceUniversalSearchItem, + itemSelect: transformInvoicesToSearch, +}); diff --git a/client/src/containers/Sales/Invoices/InvoicesLanding/InvoicesActionsBar.js b/client/src/containers/Sales/Invoices/InvoicesLanding/InvoicesActionsBar.js index 7f4f8577d..4df9c507a 100644 --- a/client/src/containers/Sales/Invoices/InvoicesLanding/InvoicesActionsBar.js +++ b/client/src/containers/Sales/Invoices/InvoicesLanding/InvoicesActionsBar.js @@ -51,8 +51,8 @@ function InvoiceActionsBar({ const { refresh } = useRefreshInvoices(); // Handle views tab change. - const handleTabChange = (customView) => { - setInvoicesTableState({ customViewId: customView.id || null }); + const handleTabChange = (view) => { + setInvoicesTableState({ viewSlug: view ? view.slug : null }); }; // Handle click a refresh sale invoices @@ -64,6 +64,7 @@ function InvoiceActionsBar({ +
{highlightText(item.text, query)}
+ + + {highlightText(item.reference.payment_receive_no, query)}{' '} + + {highlightText(item.reference.formatted_payment_date, query)} + + + } + label={
{item.reference.formatted_amount}
} + onClick={handleClick} + className={'universal-search__item--invoice'} + /> + ); +} + +/** + * Transformes payment receives to search. + * @param {*} payment + * @returns + */ + const paymentReceivesToSearch = (payment) => ({ + text: payment.customer.display_name, + subText: payment.formatted_payment_date, + label: payment.formatted_amount, + reference: payment, +}); + +/** + * Binds universal search payment receive configure. + */ + export const universalSearchPaymentReceiveBind = () => ({ + resourceType: RESOURCES_TYPES.PAYMENT_RECEIVE, + optionItemLabel: 'Payment receive', + selectItemAction: PaymentReceiveUniversalSearchSelect, + itemRenderer: PaymentReceiveUniversalSearchItem, + itemSelect: paymentReceivesToSearch, +}); diff --git a/client/src/containers/Sales/Receipts/ReceiptUniversalSearch.js b/client/src/containers/Sales/Receipts/ReceiptUniversalSearch.js new file mode 100644 index 000000000..408aad6af --- /dev/null +++ b/client/src/containers/Sales/Receipts/ReceiptUniversalSearch.js @@ -0,0 +1,100 @@ + +import React from 'react'; +import { MenuItem } from '@blueprintjs/core'; + +import { Icon, Choose, T } from 'components'; + +import { RESOURCES_TYPES } from "../../../common/resourcesTypes"; +import withDrawerActions from "../../Drawer/withDrawerActions"; + + +/** + * Receipt universal search item select action. + */ +function ReceiptUniversalSearchSelectComponent({ + // #ownProps + resourceType, + resourceId, + onAction, + + // #withDrawerActions + openDrawer, +}) { + if (resourceType === RESOURCES_TYPES.RECEIPT) { + openDrawer('receipt-drawer', { estimateId: resourceId }); + } + return null; +} + +export const ReceiptUniversalSearchSelect = withDrawerActions( + ReceiptUniversalSearchSelectComponent, +); + +/** + * Status accessor. + */ +function ReceiptStatus({ receipt }) { + return ( + + + + + + + + + + ); +} + +/** + * Receipt universal search item. + */ +export function ReceiptUniversalSearchItem( + item, + { handleClick, modifiers, query }, +) { + return ( + +
{item.text}
+ + {item.reference.receipt_number}{' '} + + {item.reference.formatted_receipt_date} + + + } + label={ + <> +
${item.reference.amount}
+ + + } + onClick={handleClick} + className={'universal-search__item--receipt'} + /> + ); +} + +/** + * Transformes receipt resource item to search item. + */ +const transformReceiptsToSearch = (receipt) => ({ + text: receipt.customer.display_name, + label: receipt.formatted_amount, + reference: receipt, +}); + +/** + * Receipt universal search bind configuration. + */ +export const universalSearchReceiptBind = () => ({ + resourceType: RESOURCES_TYPES.RECEIPT, + optionItemLabel: 'Receipts', + selectItemAction: ReceiptUniversalSearchSelect, + itemRenderer: ReceiptUniversalSearchItem, + itemSelect: transformReceiptsToSearch, +}); diff --git a/client/src/containers/Sales/Receipts/ReceiptsLanding/ReceiptActionsBar.js b/client/src/containers/Sales/Receipts/ReceiptsLanding/ReceiptActionsBar.js index 91f974565..51867c83c 100644 --- a/client/src/containers/Sales/Receipts/ReceiptsLanding/ReceiptActionsBar.js +++ b/client/src/containers/Sales/Receipts/ReceiptsLanding/ReceiptActionsBar.js @@ -49,22 +49,24 @@ function ReceiptActionsBar({ // Sale receipt refresh action. const { refresh } = useRefreshReceipts(); - const handleTabChange = (customView) => { + const handleTabChange = (view) => { setReceiptsTableState({ - customViewId: customView.id || null, + viewSlug: view ? view.slug : null, }); }; // Handle click a refresh sale estimates - const handleRefreshBtnClick = () => { refresh(); }; - - console.log(receiptsFilterConditions, fields, 'XXX'); + const handleRefreshBtnClick = () => { + refresh(); + }; return ( } views={receiptsViews} onChange={handleTabChange} /> diff --git a/client/src/containers/UniversalSearch/DashboardUniversalSearch.js b/client/src/containers/UniversalSearch/DashboardUniversalSearch.js new file mode 100644 index 000000000..56f4453b8 --- /dev/null +++ b/client/src/containers/UniversalSearch/DashboardUniversalSearch.js @@ -0,0 +1,140 @@ +import React from 'react'; +import { debounce } from 'lodash'; +import { isUndefined } from 'lodash'; + +import { useUniversalSearch } from 'hooks/query'; +import { UniversalSearch } from 'components'; + +import { RESOURCES_TYPES } from 'common/resourcesTypes'; +import { compose } from 'utils'; +import withUniversalSearchActions from './withUniversalSearchActions'; +import withUniversalSearch from './withUniversalSearch'; + +import DashboardUniversalSearchItemActions from './DashboardUniversalSearchItemActions'; +import { DashboardUniversalSearchItem } from './components'; + +import DashboardUniversalSearchHotkeys from './DashboardUniversalSearchHotkeys'; +import { getUniversalSearchTypeOptions } from './utils'; + +/** + * Dashboard universal search. + */ +function DashboardUniversalSearch({ + // #withUniversalSearchActions + setSelectedItemUniversalSearch, + + // #withUniversalSearch + globalSearchShow, + closeGlobalSearch, + defaultUniversalResourceType, +}) { + // Search keyword. + const [searchKeyword, setSearchKeyword] = React.useState(''); + + // Default search type. + const [defaultSearchType, setDefaultSearchType] = React.useState( + defaultUniversalResourceType || RESOURCES_TYPES.CUSTOMR, + ); + // Search type. + const [searchType, setSearchType] = React.useState(defaultSearchType); + + // Sync default search type with default universal resource type. + React.useEffect(() => { + if ( + !isUndefined(defaultUniversalResourceType) && + defaultSearchType !== defaultUniversalResourceType + ) { + setSearchType(defaultUniversalResourceType); + setDefaultSearchType(defaultUniversalResourceType); + } + }, [defaultSearchType, defaultUniversalResourceType]); + + // Fetch accounts list according to the given custom view id. + const { + data, + remove, + isFetching: isSearchFetching, + isLoading: isSearchLoading, + refetch, + } = useUniversalSearch(searchType, searchKeyword, { + keepPreviousData: true, + enabled: false, + }); + + // Handle query change. + const handleQueryChange = (query) => { + setSearchKeyword(query); + }; + // Handle search type change. + const handleSearchTypeChange = (searchType) => { + remove(); + setSearchType(searchType.key); + + if (searchKeyword && searchType) { + refetch(); + } + }; + // Handle overlay of universal search close. + const handleClose = () => { + closeGlobalSearch(); + }; + + // Handle universal search item select. + const handleItemSelect = (item) => { + setSelectedItemUniversalSearch(searchType, item.id); + closeGlobalSearch(); + setSearchKeyword(''); + }; + const debounceFetch = React.useRef( + debounce(() => { + refetch(); + }, 200), + ); + + React.useEffect(() => { + if (searchKeyword) { + debounceFetch.current(); + } + }, [searchKeyword]); + + // Handles the overlay once be closed. + const handleOverlayClosed = () => { + setSearchKeyword(''); + }; + + const searchTypeOptions = React.useMemo( + () => getUniversalSearchTypeOptions(), + [], + ); + + return ( + + ); +} + +export default compose( + withUniversalSearchActions, + withUniversalSearch(({ globalSearchShow, defaultUniversalResourceType }) => ({ + globalSearchShow, + defaultUniversalResourceType, + })), +)(DashboardUniversalSearch); diff --git a/client/src/containers/UniversalSearch/DashboardUniversalSearchBinds.js b/client/src/containers/UniversalSearch/DashboardUniversalSearchBinds.js new file mode 100644 index 000000000..ef28e4dd1 --- /dev/null +++ b/client/src/containers/UniversalSearch/DashboardUniversalSearchBinds.js @@ -0,0 +1,26 @@ +import { universalSearchInvoiceBind } from '../Sales/Invoices/InvoiceUniversalSearch'; +import { universalSearchReceiptBind } from '../Sales/Receipts/ReceiptUniversalSearch'; +import { universalSearchBillBind } from '../Purchases/Bills/BillUniversalSearch'; +import { universalSearchEstimateBind } from '../Sales/Estimates/EstimatesLanding/EstimateUniversalSearch'; +import { universalSearchPaymentReceiveBind } from '../Sales/PaymentReceives/PaymentReceiveUniversalSearch'; +import { universalSearchPaymentMadeBind } from '../Purchases/PaymentMades/PaymentMadeUniversalSearch'; +import { universalSearchItemBind } from '../Items/ItemsUniversalSearch'; +import { universalSearchCustomerBind } from '../Customers/CustomersUniversalSearch'; +import { universalSearchJournalBind } from '../Accounting/ManualJournalUniversalSearch'; +import { universalSearchAccountBind } from '../Accounts/AccountUniversalSearch'; +import { universalSearchVendorBind } from '../Vendors/VendorsUniversalSearch'; + +// Universal search binds. +export const universalSearchBinds = [ + universalSearchItemBind, + universalSearchAccountBind, + universalSearchInvoiceBind, + universalSearchReceiptBind, + universalSearchEstimateBind, + universalSearchBillBind, + universalSearchPaymentReceiveBind, + universalSearchPaymentMadeBind, + universalSearchCustomerBind, + universalSearchVendorBind, + universalSearchJournalBind, +]; diff --git a/client/src/containers/UniversalSearch/DashboardUniversalSearchHotkeys.js b/client/src/containers/UniversalSearch/DashboardUniversalSearchHotkeys.js new file mode 100644 index 000000000..ab6d5611b --- /dev/null +++ b/client/src/containers/UniversalSearch/DashboardUniversalSearchHotkeys.js @@ -0,0 +1,21 @@ +import * as R from 'ramda'; +import { useHotkeys } from 'react-hotkeys-hook'; + +import withUniversalSearchActions from './withUniversalSearchActions'; + +/** + * Universal search hotkey. + */ +function DashboardUniversalSearchHotkey({ + openGlobalSearch, +}) { + useHotkeys('ctrl+o', (event, handle) => { + openGlobalSearch(); + }); + + return null; +} + +export default R.compose( + withUniversalSearchActions +)(DashboardUniversalSearchHotkey); diff --git a/client/src/containers/UniversalSearch/DashboardUniversalSearchItemActions.js b/client/src/containers/UniversalSearch/DashboardUniversalSearchItemActions.js new file mode 100644 index 000000000..12b15c2f8 --- /dev/null +++ b/client/src/containers/UniversalSearch/DashboardUniversalSearchItemActions.js @@ -0,0 +1,32 @@ +import React from 'react'; +import * as R from 'ramda'; + +import withUniversalSearch from './withUniversalSearch'; + +import { getUniversalSearchItemsActions } from './utils'; + +/** + * Universal search selected item action based on each resource type. + */ +function DashboardUniversalSearchItemActions({ + searchSelectedResourceType, + searchSelectedResourceId, +}) { + const components = getUniversalSearchItemsActions(); + + return components.map((COMPONENT) => ( + + )); +} + +export default R.compose( + withUniversalSearch( + ({ searchSelectedResourceType, searchSelectedResourceId }) => ({ + searchSelectedResourceType, + searchSelectedResourceId, + }), + ), +)(DashboardUniversalSearchItemActions); diff --git a/client/src/containers/UniversalSearch/components.js b/client/src/containers/UniversalSearch/components.js new file mode 100644 index 000000000..f3084494c --- /dev/null +++ b/client/src/containers/UniversalSearch/components.js @@ -0,0 +1,44 @@ +import React from 'react'; +import { MenuItem } from '@blueprintjs/core'; + +import { highlightText } from 'utils'; +import { getUniversalSearchBind } from './utils'; + +/** + * Default univesal search item component. + */ +function UniversalSearchItemDetail(item, { handleClick, modifiers, query }) { + return ( + +
{highlightText(item.text, query)}
+ + {item.subText && ( + + {highlightText(item.subText, query)} + + )} + + } + label={item.label ? highlightText(item.label, query) : ''} + onClick={handleClick} + /> + ); +} + +/** + * + * @param {*} props + * @param {*} actions + * @returns + */ +export const DashboardUniversalSearchItem = (props, actions) => { + const itemRenderer = getUniversalSearchBind(props._type, 'itemRenderer'); + + return typeof itemRenderer !== 'undefined' + ? itemRenderer(props, actions) + : UniversalSearchItemDetail(props, actions); + }; diff --git a/client/src/containers/UniversalSearch/utils.js b/client/src/containers/UniversalSearch/utils.js new file mode 100644 index 000000000..e1edb7c84 --- /dev/null +++ b/client/src/containers/UniversalSearch/utils.js @@ -0,0 +1,44 @@ +import { get } from 'lodash'; +import { universalSearchBinds } from './DashboardUniversalSearchBinds'; + +/** + * + * @returns + */ +export const getUniversalSearchBinds = () => { + return universalSearchBinds.map((binder) => binder()); +}; + +/** + * + * @param {*} resourceType + * @param {*} key + * @returns + */ +export const getUniversalSearchBind = (resourceType, key) => { + const resourceConfig = getUniversalSearchBinds().find( + (meta) => meta.resourceType === resourceType, + ); + return key ? get(resourceConfig, key) : resourceConfig; +}; + +/** + * + * @returns + */ +export const getUniversalSearchTypeOptions = () => { + return getUniversalSearchBinds().map((bind) => ({ + key: bind.resourceType, + label: bind.optionItemLabel, + })) +} + +/** + * + * @returns + */ +export const getUniversalSearchItemsActions = () => { + return getUniversalSearchBinds() + .filter((bind) => bind.selectItemAction) + .map((bind) => bind.selectItemAction); +} \ No newline at end of file diff --git a/client/src/containers/UniversalSearch/withUniversalSearch.js b/client/src/containers/UniversalSearch/withUniversalSearch.js new file mode 100644 index 000000000..b8fbba8b5 --- /dev/null +++ b/client/src/containers/UniversalSearch/withUniversalSearch.js @@ -0,0 +1,18 @@ +import { connect } from 'react-redux'; + +export default (mapState) => { + const mapStateToProps = (state, props) => { + const { globalSearch } = state; + + const mapped = { + globalSearchShow: globalSearch.isOpen, + defaultUniversalResourceType: globalSearch.defaultResourceType, + + searchSelectedResourceType: globalSearch.selectedItem.resourceType, + searchSelectedResourceId: globalSearch.selectedItem.resourceId, + }; + return mapState ? mapState(mapped, state, props) : mapped; + }; + + return connect(mapStateToProps); +}; diff --git a/client/src/containers/UniversalSearch/withUniversalSearchActions.js b/client/src/containers/UniversalSearch/withUniversalSearchActions.js new file mode 100644 index 000000000..97c209211 --- /dev/null +++ b/client/src/containers/UniversalSearch/withUniversalSearchActions.js @@ -0,0 +1,27 @@ +import { connect } from 'react-redux'; +import t from 'store/types'; +import { + universalSearchResetResourceType, + universalSearchSetResourceType, + universalSearchSetSelectedItem, + universalSearchResetSelectedItem +} from '../../store/search/search.actions'; + +export const mapDispatchToProps = (dispatch) => ({ + openGlobalSearch: () => dispatch({ type: t.OPEN_SEARCH }), + closeGlobalSearch: () => dispatch({ type: t.CLOSE_SEARCH }), + + setResourceTypeUniversalSearch: (resourceType) => + dispatch(universalSearchSetResourceType(resourceType)), + + resetResourceTypeUniversalSearch: () => + dispatch(universalSearchResetResourceType()), + + setSelectedItemUniversalSearch: (resourceType, resourceId) => + dispatch(universalSearchSetSelectedItem(resourceType, resourceId)), + + resetSelectedItemUniversalSearch: () => + dispatch(universalSearchResetSelectedItem()), +}); + +export default connect(null, mapDispatchToProps); diff --git a/client/src/containers/Vendors/VendorsUniversalSearch.js b/client/src/containers/Vendors/VendorsUniversalSearch.js new file mode 100644 index 000000000..525f541d8 --- /dev/null +++ b/client/src/containers/Vendors/VendorsUniversalSearch.js @@ -0,0 +1,31 @@ +import { RESOURCES_TYPES } from '../../common/resourcesTypes'; +import withDrawerActions from '../Drawer/withDrawerActions'; + +function VendorUniversalSearchSelectComponent({ resourceType, resourceId }) { + if (resourceType === RESOURCES_TYPES.VENDOR) { + } + return null; +} + +const VendorUniversalSearchSelectAction = withDrawerActions( + VendorUniversalSearchSelectComponent +); + +/** + * Transformes vendor resource item to search. + */ +const vendorToSearch = (contact) => ({ + text: contact.display_name, + label: contact.balance > 0 ? contact.formatted_balance + '' : '', + reference: contact, +}); + +/** + * Binds universal search invoice configure. + */ +export const universalSearchVendorBind = () => ({ + resourceType: RESOURCES_TYPES.VENDOR, + optionItemLabel: 'Vendor', + selectItemAction: VendorUniversalSearchSelectAction, + itemSelect: vendorToSearch, +}); diff --git a/client/src/hooks/query/GenericResource/index.js b/client/src/hooks/query/GenericResource/index.js new file mode 100644 index 000000000..27580ce9f --- /dev/null +++ b/client/src/hooks/query/GenericResource/index.js @@ -0,0 +1,131 @@ +import { useRequestQuery } from '../../useQueryRequest'; +import { RESOURCES_TYPES } from 'common/resourcesTypes'; + +/** + * + * @param {string} type + * @param {string} searchKeyword + * @param {*} query + * @returns + */ +export function useResourceData(type, query, props) { + const url = getResourceUrlFromType(type); + + return useRequestQuery( + ['UNIVERSAL_SEARCH', type, query], + { method: 'get', url, params: query }, + { + select: transformResourceData(type), + defaultData: { + items: [], + }, + ...props, + }, + ); +} + +/** + * Retrieve the resource url by the given resource type. + * @param {string} type + * @returns {string} + */ +function getResourceUrlFromType(type) { + const config = { + [RESOURCES_TYPES.INVOICE]: '/sales/invoices', + [RESOURCES_TYPES.ESTIMATE]: '/sales/estimates', + [RESOURCES_TYPES.ITEM]: '/items', + [RESOURCES_TYPES.RECEIPT]: '/sales/receipts', + [RESOURCES_TYPES.BILL]: '/purchases/bills', + [RESOURCES_TYPES.PAYMENT_RECEIVE]: '/sales/payment_receives', + [RESOURCES_TYPES.PAYMENT_MADE]: '/purchases/bill_payments', + [RESOURCES_TYPES.CUSTOMER]: '/customers', + [RESOURCES_TYPES.VENDOR]: '/vendors', + [RESOURCES_TYPES.MANUAL_JOURNAL]: '/manual-journals', + [RESOURCES_TYPES.ACCOUNT]: '/accounts', + }; + return config[type] || ''; +} + +/** + * Transformes invoices to resource data. + */ +const transformInvoices = (response) => ({ + items: response.data.sales_invoices, +}); + +/** + * Transformes items to resource data. + */ +const transformItems = (response) => ({ + items: response.data.items, +}); + +/** + * Transformes payment receives to resource data. + */ +const transformPaymentReceives = (response) => ({ + items: response.data.payment_receives, +}); + +/** + * Transformes customers to resoruce data. + */ +const transformCustomers = (response) => ({ + items: response.data.customers, +}); + +/** + * Transformes customers to resoruce data. + */ +const transformVendors = (response) => ({ + items: response.data.vendors, +}); + + +const transformPaymentMades = (response) => ({ + items: response.data.bill_payments, +}); + +const transformSaleReceipts = (response) => ({ + items: response.data.data, +}); + +const transformBills = (response) => ({ + items: response.data.bills, +}); + +const transformManualJournals = (response) => ({ + items: response.data.manual_journals, +}); + +const transformsEstimates = (response) => ({ + items: response.data.sales_estimates, +}); + +const transformAccounts = (response) => ({ + items: response.data.accounts, +}) + +/** + * Detarmines the transformer based on the given resource type. + * @param {string} type - Resource type. + */ +const transformResourceData = (type) => (response) => { + const pairs = { + [RESOURCES_TYPES.ESTIMATE]: transformsEstimates, + [RESOURCES_TYPES.INVOICE]: transformInvoices, + [RESOURCES_TYPES.RECEIPT]: transformSaleReceipts, + [RESOURCES_TYPES.ITEM]: transformItems, + [RESOURCES_TYPES.PAYMENT_RECEIVE]: transformPaymentReceives, + [RESOURCES_TYPES.PAYMENT_MADE]: transformPaymentMades, + [RESOURCES_TYPES.CUSTOMER]: transformCustomers, + [RESOURCES_TYPES.VENDOR]: transformVendors, + [RESOURCES_TYPES.BILL]: transformBills, + [RESOURCES_TYPES.MANUAL_JOURNAL]: transformManualJournals, + [RESOURCES_TYPES.ACCOUNT]: transformAccounts + }; + return { + ...pairs[type](response), + _type: type, + }; +}; diff --git a/client/src/hooks/query/UniversalSearch/UniversalSearch.js b/client/src/hooks/query/UniversalSearch/UniversalSearch.js new file mode 100644 index 000000000..94f3957dd --- /dev/null +++ b/client/src/hooks/query/UniversalSearch/UniversalSearch.js @@ -0,0 +1,41 @@ +import { getUniversalSearchBind } from '../../../containers/UniversalSearch/utils'; +import { useResourceData } from '../GenericResource'; + +/** + * Transformes the resource data to search entries based on + * the given resource type. + * @param {string} type + * @param {any} resource + * @returns + */ +function transfromResourceDataToSearch(resource) { + const selectItem = getUniversalSearchBind(resource._type, 'itemSelect'); + + return resource.items + .map((item) => ({ + ...selectItem ? selectItem(item) : {}, + _type: resource._type, + })); +} + +/** + * + * @param {*} type + * @param {*} searchKeyword + * @returns + */ +export function useUniversalSearch(type, searchKeyword, props) { + const { data, ...restProps } = useResourceData( + type, + { + search_keyword: searchKeyword, + }, + props, + ); + const searchData = transfromResourceDataToSearch(data); + + return { + data: searchData, + ...restProps, + }; +} diff --git a/client/src/hooks/query/index.js b/client/src/hooks/query/index.js index cb9f8301c..ea66b8661 100644 --- a/client/src/hooks/query/index.js +++ b/client/src/hooks/query/index.js @@ -24,3 +24,5 @@ export * from './contacts'; export * from './subscriptions'; export * from './organization'; export * from './landedCost'; +export * from './UniversalSearch/UniversalSearch'; +export * from './GenericResource'; diff --git a/client/src/hooks/query/receipts.js b/client/src/hooks/query/receipts.js index 7cb2be123..3ad0eeac1 100644 --- a/client/src/hooks/query/receipts.js +++ b/client/src/hooks/query/receipts.js @@ -99,7 +99,7 @@ export function useCloseReceipt(props) { } const transformReceipts = (res) => ({ - receipts: res.data.sale_receipts, + receipts: res.data.data, pagination: transformPagination(res.data.pagination), filterMeta: res.data.filter_meta, }); diff --git a/client/src/lang/en/index.json b/client/src/lang/en/index.json index 112ab2089..c9c29ded6 100644 --- a/client/src/lang/en/index.json +++ b/client/src/lang/en/index.json @@ -1202,6 +1202,7 @@ "universal_search.enter_text": "To select", "universal_search.close_text": "To close", "universal_seach.navigate_text": "To navigate", + "universal_search.loading": "Loading...", "pdf_preview.dialog.title": "PDF Preview", "pdf_preview.download.button": "Download", "pdf_preview.preview.button": "Preview", diff --git a/client/src/routes/dashboard.js b/client/src/routes/dashboard.js index aaf0d03ad..89b1b1a5a 100644 --- a/client/src/routes/dashboard.js +++ b/client/src/routes/dashboard.js @@ -1,6 +1,7 @@ -import React, { lazy } from 'react'; +import { lazy } from 'react'; import intl from 'react-intl-universal'; +import { RESOURCES_TYPES } from '../common/resourcesTypes'; // const BASE_URL = '/dashboard'; @@ -12,6 +13,7 @@ export const getDashboardRoutes = () => [ breadcrumb: intl.get('accounts_chart'), hotkey: 'shift+a', pageTitle: intl.get('accounts_chart'), + defaultSearchResource: RESOURCES_TYPES.ACCOUNT, }, // Accounting. { @@ -24,6 +26,7 @@ export const getDashboardRoutes = () => [ pageTitle: intl.get('new_journal'), sidebarExpand: false, backLink: true, + defaultSearchResource: RESOURCES_TYPES.MANUAL_JOURNAL, }, { path: `/manual-journals/:id/edit`, @@ -34,6 +37,7 @@ export const getDashboardRoutes = () => [ pageTitle: intl.get('edit_journal'), sidebarExpand: false, backLink: true, + defaultSearchResource: RESOURCES_TYPES.MANUAL_JOURNAL, }, { path: `/manual-journals`, @@ -43,6 +47,7 @@ export const getDashboardRoutes = () => [ breadcrumb: intl.get('manual_journals'), hotkey: 'shift+m', pageTitle: intl.get('manual_journals'), + defaultSearchResource: RESOURCES_TYPES.MANUAL_JOURNAL, }, { path: `/items/categories`, @@ -51,6 +56,7 @@ export const getDashboardRoutes = () => [ ), breadcrumb: intl.get('categories'), pageTitle: intl.get('category_list'), + defaultSearchResource: RESOURCES_TYPES.ITEM, }, // Items. { @@ -60,6 +66,7 @@ export const getDashboardRoutes = () => [ breadcrumb: intl.get('edit_item'), pageTitle: intl.get('edit_item'), backLink: true, + defaultSearchResource: RESOURCES_TYPES.ITEM, }, { path: `/items/new?duplicate=/:id`, @@ -67,6 +74,7 @@ export const getDashboardRoutes = () => [ loader: () => import('containers/Items/ItemFormPage'), }), breadcrumb: intl.get('duplicate_item'), + defaultSearchResource: RESOURCES_TYPES.ITEM, }, { path: `/items/new`, @@ -76,6 +84,7 @@ export const getDashboardRoutes = () => [ hotkey: 'ctrl+shift+w', pageTitle: intl.get('new_item'), backLink: true, + defaultSearchResource: RESOURCES_TYPES.ITEM, }, { path: `/items`, @@ -83,6 +92,7 @@ export const getDashboardRoutes = () => [ breadcrumb: intl.get('items'), hotkey: 'shift+w', pageTitle: intl.get('items_list'), + defaultSearchResource: RESOURCES_TYPES.ITEM, }, // Inventory adjustments. @@ -93,6 +103,7 @@ export const getDashboardRoutes = () => [ ), breadcrumb: intl.get('inventory_adjustments'), pageTitle: intl.get('inventory_adjustment_list'), + defaultSearchResource: RESOURCES_TYPES.ITEM, }, // Financial Reports. @@ -107,6 +118,7 @@ export const getDashboardRoutes = () => [ pageTitle: intl.get('general_ledger'), backLink: true, sidebarExpand: false, + defaultSearchResource: RESOURCES_TYPES.INVENTORY_ADJUSTMENT, }, { path: `/financial-reports/balance-sheet`, @@ -323,6 +335,7 @@ export const getDashboardRoutes = () => [ pageTitle: intl.get('new_expense'), sidebarExpand: false, backLink: true, + defaultSearchResource: RESOURCES_TYPES.EXPENSE, }, { path: `/expenses/:id/edit`, @@ -333,6 +346,7 @@ export const getDashboardRoutes = () => [ pageTitle: intl.get('edit_expense'), sidebarExpand: false, backLink: true, + defaultSearchResource: RESOURCES_TYPES.EXPENSE, }, { path: `/expenses`, @@ -342,6 +356,7 @@ export const getDashboardRoutes = () => [ breadcrumb: intl.get('expenses_list'), pageTitle: intl.get('expenses_list'), hotkey: 'shift+x', + defaultSearchResource: RESOURCES_TYPES.EXPENSE, }, // Customers @@ -354,6 +369,7 @@ export const getDashboardRoutes = () => [ breadcrumb: intl.get('edit_customer'), pageTitle: intl.get('edit_customer'), backLink: true, + defaultSearchResource: RESOURCES_TYPES.CUSTOMER, }, { path: `/customers/new`, @@ -365,6 +381,7 @@ export const getDashboardRoutes = () => [ hotkey: 'ctrl+shift+c', pageTitle: intl.get('new_customer'), backLink: true, + defaultSearchResource: RESOURCES_TYPES.CUSTOMER, }, { path: `/customers`, @@ -374,6 +391,7 @@ export const getDashboardRoutes = () => [ breadcrumb: intl.get('customers'), hotkey: 'shift+c', pageTitle: intl.get('customers_list'), + defaultSearchResource: RESOURCES_TYPES.CUSTOMER, }, { path: `/customers/contact_duplicate=/:id`, @@ -384,6 +402,7 @@ export const getDashboardRoutes = () => [ breadcrumb: intl.get('duplicate_customer'), pageTitle: intl.get('new_customer'), backLink: true, + defaultSearchResource: RESOURCES_TYPES.CUSTOMER, }, // Vendors @@ -396,6 +415,7 @@ export const getDashboardRoutes = () => [ breadcrumb: intl.get('edit_vendor'), pageTitle: intl.get('edit_vendor'), backLink: true, + defaultSearchResource: RESOURCES_TYPES.VENDOR, }, { path: `/vendors/new`, @@ -407,6 +427,7 @@ export const getDashboardRoutes = () => [ hotkey: 'ctrl+shift+v', pageTitle: intl.get('new_vendor'), backLink: true, + defaultSearchResource: RESOURCES_TYPES.VENDOR, }, { path: `/vendors`, @@ -416,6 +437,7 @@ export const getDashboardRoutes = () => [ breadcrumb: intl.get('vendors'), hotkey: 'shift+v', pageTitle: intl.get('vendors_list'), + defaultSearchResource: RESOURCES_TYPES.VENDOR, }, { path: `/vendors/contact_duplicate=/:id`, @@ -426,6 +448,7 @@ export const getDashboardRoutes = () => [ breadcrumb: intl.get('duplicate_vendor'), pageTitle: intl.get('new_vendor'), backLink: true, + defaultSearchResource: RESOURCES_TYPES.VENDOR, }, // Estimates @@ -439,6 +462,7 @@ export const getDashboardRoutes = () => [ pageTitle: intl.get('edit_estimate'), backLink: true, sidebarExpand: false, + defaultSearchResource: RESOURCES_TYPES.ESTIMATE, }, { path: `/invoices/new?from_estimate_id=/:id`, @@ -450,6 +474,7 @@ export const getDashboardRoutes = () => [ pageTitle: intl.get('new_estimate'), backLink: true, sidebarExpand: false, + defaultSearchResource: RESOURCES_TYPES.INVOICE, }, { path: `/estimates/new`, @@ -462,6 +487,7 @@ export const getDashboardRoutes = () => [ pageTitle: intl.get('new_estimate'), backLink: true, sidebarExpand: false, + defaultSearchResource: RESOURCES_TYPES.ESTIMATE, }, { path: `/estimates`, @@ -472,6 +498,7 @@ export const getDashboardRoutes = () => [ breadcrumb: intl.get('estimates_list'), hotkey: 'shift+e', pageTitle: intl.get('estimates_list'), + defaultSearchResource: RESOURCES_TYPES.ESTIMATE, }, // Invoices. @@ -485,6 +512,7 @@ export const getDashboardRoutes = () => [ pageTitle: intl.get('edit_invoice'), sidebarExpand: false, backLink: true, + defaultSearchResource: RESOURCES_TYPES.INVOICE, }, { path: `/invoices/new`, @@ -497,6 +525,7 @@ export const getDashboardRoutes = () => [ pageTitle: intl.get('new_invoice'), sidebarExpand: false, backLink: true, + defaultSearchResource: RESOURCES_TYPES.INVOICE, }, { path: `/invoices`, @@ -506,6 +535,7 @@ export const getDashboardRoutes = () => [ breadcrumb: intl.get('invoices_list'), hotkey: 'shift+i', pageTitle: intl.get('invoices_list'), + defaultSearchResource: RESOURCES_TYPES.INVOICE, }, // Sales Receipts. @@ -519,6 +549,7 @@ export const getDashboardRoutes = () => [ pageTitle: intl.get('edit_receipt'), backLink: true, sidebarExpand: false, + defaultSearchResource: RESOURCES_TYPES.RECEIPT, }, { path: `/receipts/new`, @@ -531,6 +562,7 @@ export const getDashboardRoutes = () => [ pageTitle: intl.get('new_receipt'), backLink: true, sidebarExpand: false, + defaultSearchResource: RESOURCES_TYPES.RECEIPT, }, { path: `/receipts`, @@ -540,6 +572,7 @@ export const getDashboardRoutes = () => [ breadcrumb: intl.get('receipts_list'), hotkey: 'shift+r', pageTitle: intl.get('receipts_list'), + defaultSearchResource: RESOURCES_TYPES.RECEIPT, }, // Payment receives @@ -555,6 +588,7 @@ export const getDashboardRoutes = () => [ pageTitle: intl.get('edit_payment_receive'), backLink: true, sidebarExpand: false, + defaultSearchResource: RESOURCES_TYPES.PAYMENT_RECEIVE, }, { path: `/payment-receives/new`, @@ -568,6 +602,7 @@ export const getDashboardRoutes = () => [ pageTitle: intl.get('new_payment_receive'), backLink: true, sidebarExpand: false, + defaultSearchResource: RESOURCES_TYPES.PAYMENT_RECEIVE, }, { path: `/payment-receives`, @@ -578,6 +613,7 @@ export const getDashboardRoutes = () => [ ), breadcrumb: intl.get('payment_receives_list'), pageTitle: intl.get('payment_receives_list'), + defaultSearchResource: RESOURCES_TYPES.PAYMENT_RECEIVE, }, // Bills @@ -591,6 +627,7 @@ export const getDashboardRoutes = () => [ pageTitle: intl.get('edit_bill'), sidebarExpand: false, backLink: true, + defaultSearchResource: RESOURCES_TYPES.BILL, }, { path: `/bills/new`, @@ -603,6 +640,7 @@ export const getDashboardRoutes = () => [ pageTitle: intl.get('new_bill'), sidebarExpand: false, backLink: true, + defaultSearchResource: RESOURCES_TYPES.BILL, }, { path: `/bills`, @@ -612,6 +650,7 @@ export const getDashboardRoutes = () => [ breadcrumb: intl.get('bills_list'), hotkey: 'shift+b', pageTitle: intl.get('bills_list'), + defaultSearchResource: RESOURCES_TYPES.BILL, }, // Subscription billing. @@ -633,6 +672,7 @@ export const getDashboardRoutes = () => [ pageTitle: intl.get('edit_payment_made'), sidebarExpand: false, backLink: true, + defaultSearchResource: RESOURCES_TYPES.PAYMENT_MADE, }, { path: `/payment-mades/new`, @@ -646,6 +686,7 @@ export const getDashboardRoutes = () => [ pageTitle: intl.get('new_payment_made'), sidebarExpand: false, backLink: true, + defaultSearchResource: RESOURCES_TYPES.PAYMENT_MADE, }, { path: `/payment-mades`, @@ -656,6 +697,7 @@ export const getDashboardRoutes = () => [ ), breadcrumb: intl.get('payment_made_list'), pageTitle: intl.get('payment_made_list'), + defaultSearchResource: RESOURCES_TYPES.PAYMENT_MADE, }, // Homepage { diff --git a/client/src/static/json/icons.js b/client/src/static/json/icons.js index 6ab16b686..d4fce096f 100644 --- a/client/src/static/json/icons.js +++ b/client/src/static/json/icons.js @@ -444,4 +444,28 @@ export default { ], viewBox: '0 0 24 24', }, + 'universal-search': { + path: [ + 'M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z' + ], + viewBox: '0 0 20 20', + }, + "arrow-down-24": { + path: [ + 'M20 12l-1.41-1.41L13 16.17V4h-2v12.17l-5.58-5.59L4 12l8 8 8-8z', + ], + viewBox: '0 0 24 24', + }, + "arrow-up-24": { + path: [ + 'M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z' + ], + viewBox: '0 0 24 24', + }, + "caret-right-16": { + path: [ + 'M11 8c0-.15-.07-.28-.17-.37l-4-3.5A.495.495 0 006 4.5v7a.495.495 0 00.83.37l4-3.5c.1-.09.17-.22.17-.37z' + ], + viewBox: '0 0 16 16', + }, }; diff --git a/client/src/store/search/search.actions.js b/client/src/store/search/search.actions.js index b1d3a44ed..16347edee 100644 --- a/client/src/store/search/search.actions.js +++ b/client/src/store/search/search.actions.js @@ -20,3 +20,36 @@ export function generalSearch(name, result) { result, }; } + +export function universalSearchSetResourceType(resourceType) { + return { + type: t.UNIVERSAL_SEARCH_SET_RESOURCE_TYPE, + payload: { + resourceType, + }, + }; +} + +export function universalSearchResetResourceType() { + return { + type: t.UNIVERSAL_SEARCH_RESET_RESOURCE_TYPE, + }; +} + + +export function universalSearchSetSelectedItem(resourceType, resourceId) { + return { + type: t.UNIVERSAL_SEARCH_SET_ITEM_SELECT, + payload: { + resourceType, + resourceId + } + }; +} + +export function universalSearchResetSelectedItem() { + return { + type: t.UNIVERSAL_SEARCH_RESET_ITEM_SELECT, + payload: {} + }; +} \ No newline at end of file diff --git a/client/src/store/search/search.reducer.js b/client/src/store/search/search.reducer.js index ed647f035..ebfcaf102 100644 --- a/client/src/store/search/search.reducer.js +++ b/client/src/store/search/search.reducer.js @@ -1,8 +1,12 @@ import t from 'store/types'; import { createReducer } from '@reduxjs/toolkit'; +const DEFAULT_RESOURCE_TYPE = 'customer'; + const initialState = { isOpen: false, + defaultResourceType: DEFAULT_RESOURCE_TYPE, + selectedItem: {}, }; export default createReducer(initialState, { @@ -13,4 +17,23 @@ export default createReducer(initialState, { [t.CLOSE_SEARCH]: (state, action) => { state.isOpen = false; }, + + [t.UNIVERSAL_SEARCH_SET_RESOURCE_TYPE]: (state, action) => { + state.defaultResourceType = action.payload.resourceType; + }, + + [t.UNIVERSAL_SEARCH_RESET_RESOURCE_TYPE]: (state, action) => { + state.defaultResourceType = DEFAULT_RESOURCE_TYPE; + }, + + [t.UNIVERSAL_SEARCH_SET_ITEM_SELECT]: (state, action) => { + state.selectedItem = { + resourceId: action.payload.resourceId, + resourceType: action.payload.resourceType, + }; + }, + + [t.UNIVERSAL_SEARCH_RESET_ITEM_SELECT]: (state, action) => { + state.selectedItem = {}; + }, }); diff --git a/client/src/store/search/search.type.js b/client/src/store/search/search.type.js index ff9a0af9f..27f6f2834 100644 --- a/client/src/store/search/search.type.js +++ b/client/src/store/search/search.type.js @@ -2,4 +2,8 @@ export default { SEARCH_TYPE: 'SEARCH_TYPE', OPEN_SEARCH: 'OPEN_SEARCH', CLOSE_SEARCH: 'CLOSE_SEARCH', + UNIVERSAL_SEARCH_SET_RESOURCE_TYPE: 'UNIVERSAL_SEARCH_SET_RESOURCE_TYPE', + UNIVERSAL_SEARCH_RESET_RESOURCE_TYPE: 'UNIVERSAL_SEARCH_RESET_RESOURCE_TYPE', + UNIVERSAL_SEARCH_SET_ITEM_SELECT: 'UNIVERSAL_SEARCH_SET_ITEM_SELECT', + UNIVERSAL_SEARCH_RESET_ITEM_SELECT: 'UNIVERSAL_SEARCH_RESET_ITEM_SELECT' }; diff --git a/client/src/style/App.scss b/client/src/style/App.scss index 572c3aa34..ce81ed904 100644 --- a/client/src/style/App.scss +++ b/client/src/style/App.scss @@ -226,16 +226,16 @@ html[lang^="ar"] { z-index: 9999999; margin: 6px; - .bp3-button + .bp3-button{ - margin-left: 8px; - } - .bp3-button{ border-color: rgba(0, 0, 0, 0.25); color: rgb(25, 32, 37); min-height: 30px; padding-left: 14px; padding-right: 14px; + + & + .bp3-button{ + margin-left: 8px; + } } } diff --git a/client/src/style/components/UniversalSearch.scss b/client/src/style/components/UniversalSearch.scss new file mode 100644 index 000000000..7a611d670 --- /dev/null +++ b/client/src/style/components/UniversalSearch.scss @@ -0,0 +1,219 @@ +.universal-search { + position: fixed; + filter: blur(0); + opacity: 1; + background-color: #fff; + border-radius: 3px; + box-shadow: + 0 0 0 1px rgba(16, 22, 26, .1), + 0 4px 8px rgba(16, 22, 26, .2), + 0 18px 46px 6px rgba(16, 22, 26, .2); + left: calc(50% - 250px); + top: 20vh; + width: 500px; + z-index: 20; + + &.bp3-overlay-appear, + &.bp3-overlay-enter { + filter: blur(20px); + opacity: .2 + } + + &.bp3-overlay-appear-active, + &.bp3-overlay-enter-active { + filter: blur(0); + opacity: 1; + transition-delay: 0; + transition-duration: .2s; + transition-property: filter, opacity; + transition-timing-function: cubic-bezier(.4, 1, .75, .9) + } + + &.bp3-overlay-exit { + filter: blur(0); + opacity: 1 + } + + &.bp3-overlay-exit-active { + filter: blur(20px); + opacity: .2; + transition-delay: 0; + transition-duration: .2s; + transition-property: filter, opacity; + transition-timing-function: cubic-bezier(.4, 1, .75, .9) + } + + &__omnibar { + + .bp3-input-group { + + .bp3-icon { + + svg { + stroke: currentColor; + fill: none; + fill-rule: evenodd; + stroke-linecap: round; + stroke-linejoin: round; + } + } + } + + + .bp3-input-group .bp3-input { + border: 0; + box-shadow: 0 0 0 0; + height: 50px; + line-height: 50px; + font-size: 20px; + } + + .bp3-input-group.bp3-large .bp3-input:not(:first-child) { + padding-left: 50px; + } + + .bp3-input {} + + .bp3-input-group { + + .bp3-icon { + margin: 16px; + color: #5c707f; + + svg { + stroke-width: 2; + --text-opacity: 1; + } + } + } + + .bp3-menu { + border-top: 1px solid #d3dce2; + + .bp3-menu-item { + .bp3-text-muted { + font-size: 12px; + + .bp3-icon { + color: #8499a7; + } + } + &.bp3-intent-primary{ + + &.bp3-active{ + background-color: rgb(235, 241, 246); + color: #252b30; + + .bp3-menu-item-label{ + color: #5c7080; + } + } + + } + + &-label { + flex-direction: row; + } + + + } + } + + .bp3-input-action { + height: 100%; + display: flex; + flex-direction: row; + align-items: center; + } + + } + + &__type-select-overlay { + + .bp3-button { + margin: 0 !important; + } + } + + &__footer { + padding: 12px 12px; + border-top: 1px solid #d3dce2; + } + + &__actions { + display: flex; + } + + &__action { + + &:not(:first-of-type) { + margin-left: 14px; + } + + .bp3-tag { + background: #708392; + } + + &--arrows { + + .bp3-tag { + padding: 0; + text-align: center; + line-height: 16px; + margin-left: 4px; + + svg { + fill: #fff; + height: 100%; + display: block; + width: 100%; + padding: 2px; + } + } + } + + .text { + margin-left: 6px; + } + } + + &__footer {} + + + &-input-right-elements { + display: flex; + margin: 10px; + + .bp3-spinner { + margin-right: 6px; + } + } + + &__item { + &--invoice, + &--estimate, + &--bill, + &--receipt { + + .amount { + color: #252b30; + } + + .status { + font-size: 13px; + + &.status-warning { + color: rgb(236, 91, 10); + } + + &.status-success { + color: #249017; + } + } + } + } +} + +.universal-search-overlay .bp3-overlay-backdrop { + background: rgba(0, 10, 30, 0.3); +} \ No newline at end of file diff --git a/client/src/utils.js b/client/src/utils.js index 2b6b38291..088adbae9 100644 --- a/client/src/utils.js +++ b/client/src/utils.js @@ -1,3 +1,4 @@ +import React from 'react'; import moment from 'moment'; import _ from 'lodash'; import * as R from 'ramda'; @@ -751,4 +752,39 @@ export const RESORUCE_TYPE = { ACCOUNTS: 'account', ITEMS: 'items', +} + +function escapeRegExpChars(text) { + return text.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, '\\$1'); +} + +export function highlightText(text, query) { + let lastIndex = 0; + const words = query + .split(/\s+/) + .filter((word) => word.length > 0) + .map(escapeRegExpChars); + if (words.length === 0) { + return [text]; + } + const regexp = new RegExp(words.join('|'), 'gi'); + const tokens = []; + while (true) { + const match = regexp.exec(text); + if (!match) { + break; + } + const length = match[0].length; + const before = text.slice(lastIndex, regexp.lastIndex - length); + if (before.length > 0) { + tokens.push(before); + } + lastIndex = regexp.lastIndex; + tokens.push({match[0]}); + } + const rest = text.slice(lastIndex); + if (rest.length > 0) { + tokens.push(rest); + } + return tokens; } \ No newline at end of file diff --git a/server/src/api/controllers/Contacts/Vendors.ts b/server/src/api/controllers/Contacts/Vendors.ts index c7b4c40cf..3039b5fd4 100644 --- a/server/src/api/controllers/Contacts/Vendors.ts +++ b/server/src/api/controllers/Contacts/Vendors.ts @@ -19,14 +19,16 @@ export default class VendorsController extends ContactsController { router() { const router = Router(); - router.post('/', [ - ...this.contactDTOSchema, - ...this.contactNewDTOSchema, - ...this.vendorDTOSchema, - ], + router.post( + '/', + [ + ...this.contactDTOSchema, + ...this.contactNewDTOSchema, + ...this.vendorDTOSchema, + ], this.validationResult, asyncMiddleware(this.newVendor.bind(this)), - this.handlerServiceErrors, + this.handlerServiceErrors ); router.post( '/:id/opening_balance', @@ -37,36 +39,38 @@ export default class VendorsController extends ContactsController { ], this.validationResult, asyncMiddleware(this.editOpeningBalanceVendor.bind(this)), - this.handlerServiceErrors, + this.handlerServiceErrors ); - router.post('/:id', [ - ...this.contactDTOSchema, - ...this.contactEditDTOSchema, - ...this.vendorDTOSchema, - ], + router.post( + '/:id', + [ + ...this.contactDTOSchema, + ...this.contactEditDTOSchema, + ...this.vendorDTOSchema, + ], this.validationResult, asyncMiddleware(this.editVendor.bind(this)), - this.handlerServiceErrors, + this.handlerServiceErrors ); - router.delete('/:id', [ - ...this.specificContactSchema, - ], + router.delete( + '/:id', + [...this.specificContactSchema], this.validationResult, asyncMiddleware(this.deleteVendor.bind(this)), - this.handlerServiceErrors, + this.handlerServiceErrors ); - router.get('/:id', [ - ...this.specificContactSchema, - ], + router.get( + '/:id', + [...this.specificContactSchema], this.validationResult, asyncMiddleware(this.getVendor.bind(this)), - this.handlerServiceErrors, + this.handlerServiceErrors ); - router.get('/', [ - ...this.vendorsListSchema, - ], + router.get( + '/', + [...this.vendorsListSchema], this.validationResult, - asyncMiddleware(this.getVendorsList.bind(this)), + asyncMiddleware(this.getVendorsList.bind(this)) ); return router; } @@ -102,22 +106,26 @@ export default class VendorsController extends ContactsController { query('page_size').optional().isNumeric().toInt(), query('inactive_mode').optional().isBoolean().toBoolean(), - query('search_keyword').optional({ nullable: true }).isString().trim() + query('search_keyword').optional({ nullable: true }).isString().trim(), ]; } /** * Creates a new vendor. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next */ async newVendor(req: Request, res: Response, next: NextFunction) { const contactDTO: IVendorNewDTO = this.matchedBodyData(req); const { tenantId, user } = req; try { - const vendor = await this.vendorsService.newVendor(tenantId, contactDTO, user); + const vendor = await this.vendorsService.newVendor( + tenantId, + contactDTO, + user + ); return res.status(200).send({ id: vendor.id, @@ -130,9 +138,9 @@ export default class VendorsController extends ContactsController { /** * Edits the given vendor details. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next */ async editVendor(req: Request, res: Response, next: NextFunction) { const contactDTO: IVendorEditDTO = this.matchedBodyData(req); @@ -140,7 +148,12 @@ export default class VendorsController extends ContactsController { const { id: contactId } = req.params; try { - await this.vendorsService.editVendor(tenantId, contactId, contactDTO, user); + await this.vendorsService.editVendor( + tenantId, + contactId, + contactDTO, + user + ); return res.status(200).send({ id: contactId, @@ -157,24 +170,26 @@ export default class VendorsController extends ContactsController { * @param {Response} res - * @param {NextFunction} next - */ - async editOpeningBalanceVendor(req: Request, res: Response, next: NextFunction) { + async editOpeningBalanceVendor( + req: Request, + res: Response, + next: NextFunction + ) { const { tenantId, user } = req; const { id: vendorId } = req.params; - const { - openingBalance, - openingBalanceAt, - } = this.matchedBodyData(req); + const { openingBalance, openingBalanceAt } = this.matchedBodyData(req); try { await this.vendorsService.changeOpeningBalance( tenantId, vendorId, openingBalance, - openingBalanceAt, + openingBalanceAt ); return res.status(200).send({ id: vendorId, - message: 'The opening balance of the given vendor has been changed successfully.', + message: + 'The opening balance of the given vendor has been changed successfully.', }); } catch (error) { next(error); @@ -183,16 +198,16 @@ export default class VendorsController extends ContactsController { /** * Deletes the given vendor from the storage. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next */ async deleteVendor(req: Request, res: Response, next: NextFunction) { const { tenantId, user } = req; const { id: contactId } = req.params; try { - await this.vendorsService.deleteVendor(tenantId, contactId, user) + await this.vendorsService.deleteVendor(tenantId, contactId, user); return res.status(200).send({ id: contactId, @@ -205,18 +220,21 @@ export default class VendorsController extends ContactsController { /** * Retrieve details of the given vendor id. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next */ async getVendor(req: Request, res: Response, next: NextFunction) { const { tenantId, user } = req; const { id: vendorId } = req.params; try { - const vendor = await this.vendorsService.getVendor(tenantId, vendorId, user) - - return res.status(200).send({ vendor }); + const vendor = await this.vendorsService.getVendor( + tenantId, + vendorId, + user + ); + return res.status(200).send(this.transfromToResponse({ vendor })); } catch (error) { next(error); } @@ -224,9 +242,9 @@ export default class VendorsController extends ContactsController { /** * Retrieve vendors datatable list. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next */ async getVendorsList(req: Request, res: Response, next: NextFunction) { const { tenantId } = req; @@ -241,14 +259,11 @@ export default class VendorsController extends ContactsController { }; try { - const { - vendors, - pagination, - filterMeta, - } = await this.vendorsService.getVendorsList(tenantId, vendorsFilter); + const { vendors, pagination, filterMeta } = + await this.vendorsService.getVendorsList(tenantId, vendorsFilter); return res.status(200).send({ - vendors, + vendors: this.transfromToResponse(vendors), pagination: this.transfromToResponse(pagination), filter_meta: this.transfromToResponse(filterMeta), }); @@ -259,7 +274,7 @@ export default class VendorsController extends ContactsController { /** * Handle service errors. - * @param {Error} error - + * @param {Error} error - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - @@ -289,4 +304,4 @@ export default class VendorsController extends ContactsController { } next(error); } -} \ No newline at end of file +} diff --git a/server/src/api/controllers/Expenses.ts b/server/src/api/controllers/Expenses.ts index 3e0b6247d..7d81a1107 100644 --- a/server/src/api/controllers/Expenses.ts +++ b/server/src/api/controllers/Expenses.ts @@ -8,6 +8,7 @@ import { IExpenseDTO } from 'interfaces'; import { ServiceError } from 'exceptions'; import DynamicListingService from 'services/DynamicListing/DynamicListService'; import { DATATYPES_LENGTH } from 'data/DataTypes'; +import HasItemEntries from 'services/Sales/HasItemsEntries'; @Service() export default class ExpensesController extends BaseController { @@ -304,7 +305,7 @@ export default class ExpensesController extends BaseController { await this.expensesService.getExpensesList(tenantId, filter); return res.status(200).send({ - expenses, + expenses: this.transfromToResponse(expenses), pagination: this.transfromToResponse(pagination), filter_meta: this.transfromToResponse(filterMeta), }); @@ -328,7 +329,7 @@ export default class ExpensesController extends BaseController { tenantId, expenseId ); - return res.status(200).send({ expense }); + return res.status(200).send(this.transfromToResponse({ expense })); } catch (error) { next(error); } diff --git a/server/src/api/controllers/Items.ts b/server/src/api/controllers/Items.ts index f937ca755..dd40b4b29 100644 --- a/server/src/api/controllers/Items.ts +++ b/server/src/api/controllers/Items.ts @@ -173,7 +173,7 @@ export default class ItemsController extends BaseController { } /** - * Validate list query schema + * Validate list query schema. */ get validateListQuerySchema() { return [ diff --git a/server/src/api/controllers/ManualJournals.ts b/server/src/api/controllers/ManualJournals.ts index d9dc74acd..fbd886720 100644 --- a/server/src/api/controllers/ManualJournals.ts +++ b/server/src/api/controllers/ManualJournals.ts @@ -221,7 +221,7 @@ export default class ManualJournalsController extends BaseController { manualJournalId ); return res.status(200).send({ - manual_journal: manualJournal, + manual_journal: this.transfromToResponse(manualJournal), }); } catch (error) { next(error); @@ -301,7 +301,7 @@ export default class ManualJournalsController extends BaseController { } = await this.manualJournalsService.getManualJournals(tenantId, filter); return res.status(200).send({ - manual_journals: manualJournals, + manual_journals: this.transfromToResponse(manualJournals), pagination: this.transfromToResponse(pagination), filter_meta: this.transfromToResponse(filterMeta), }); diff --git a/server/src/api/controllers/Purchases/Bills.ts b/server/src/api/controllers/Purchases/Bills.ts index 99496771b..55d974e88 100644 --- a/server/src/api/controllers/Purchases/Bills.ts +++ b/server/src/api/controllers/Purchases/Bills.ts @@ -264,7 +264,7 @@ export default class BillsController extends BaseController { try { const bill = await this.billsService.getBill(tenantId, billId); - return res.status(200).send({ bill }); + return res.status(200).send(this.transfromToResponse({ bill })); } catch (error) { next(error); } @@ -312,7 +312,7 @@ export default class BillsController extends BaseController { await this.billsService.getBills(tenantId, filter); return res.status(200).send({ - bills, + bills: this.transfromToResponse(bills), pagination: this.transfromToResponse(pagination), filter_meta: this.transfromToResponse(filterMeta), }); diff --git a/server/src/api/controllers/Purchases/BillsPayments.ts b/server/src/api/controllers/Purchases/BillsPayments.ts index db23b7d95..666f57012 100644 --- a/server/src/api/controllers/Purchases/BillsPayments.ts +++ b/server/src/api/controllers/Purchases/BillsPayments.ts @@ -335,7 +335,7 @@ export default class BillsPayments extends BaseController { ); return res.status(200).send({ - bill_payments: billPayments, + bill_payments: this.transfromToResponse(billPayments), pagination: this.transfromToResponse(pagination), filter_meta: this.transfromToResponse(filterMeta), }); diff --git a/server/src/api/controllers/Sales/SalesEstimates.ts b/server/src/api/controllers/Sales/SalesEstimates.ts index 4de25df98..14beda35c 100644 --- a/server/src/api/controllers/Sales/SalesEstimates.ts +++ b/server/src/api/controllers/Sales/SalesEstimates.ts @@ -302,6 +302,10 @@ export default class SalesEstimatesController extends BaseController { ); // Response formatter. res.format({ + // JSON content type. + [ACCEPT_TYPE.APPLICATION_JSON]: () => { + return res.status(200).send(this.transfromToResponse({ estimate })); + }, // PDF content type. [ACCEPT_TYPE.APPLICATION_PDF]: async () => { const pdfContent = await this.saleEstimatesPdf.saleEstimatePdf( @@ -314,10 +318,6 @@ export default class SalesEstimatesController extends BaseController { }); res.send(pdfContent); }, - // JSON content type. - default: () => { - return res.status(200).send(this.transfromToResponse({ estimate })); - }, }); } catch (error) { next(error); diff --git a/server/src/api/controllers/Sales/SalesInvoices.ts b/server/src/api/controllers/Sales/SalesInvoices.ts index aa63f75ee..34e192f52 100644 --- a/server/src/api/controllers/Sales/SalesInvoices.ts +++ b/server/src/api/controllers/Sales/SalesInvoices.ts @@ -277,6 +277,10 @@ export default class SaleInvoicesController extends BaseController { ); // Response formatter. res.format({ + // JSON content type. + [ACCEPT_TYPE.APPLICATION_JSON]: () => { + return res.status(200).send(this.transfromToResponse({ saleInvoice })); + }, // PDF content type. [ACCEPT_TYPE.APPLICATION_PDF]: async () => { const pdfContent = await this.saleInvoicePdf.saleInvoicePdf( @@ -289,10 +293,6 @@ export default class SaleInvoicesController extends BaseController { }); res.send(pdfContent); }, - // JSON content type. - [ACCEPT_TYPE.APPLICATION_JSON]: () => { - return res.status(200).send(this.transfromToResponse({ saleInvoice })); - }, }); } catch (error) { next(error); diff --git a/server/src/api/controllers/Sales/SalesReceipts.ts b/server/src/api/controllers/Sales/SalesReceipts.ts index fabaeebdb..57ac2650b 100644 --- a/server/src/api/controllers/Sales/SalesReceipts.ts +++ b/server/src/api/controllers/Sales/SalesReceipts.ts @@ -243,11 +243,13 @@ export default class SalesReceiptsController extends BaseController { }; try { - const { salesReceipts, pagination, filterMeta } = + const { data, pagination, filterMeta } = await this.saleReceiptService.salesReceiptsList(tenantId, filter); const response = this.transfromToResponse({ - salesReceipts, pagination, filterMeta + data, + pagination, + filterMeta, }); return res.status(200).send(response); } catch (error) { @@ -272,6 +274,11 @@ export default class SalesReceiptsController extends BaseController { ); res.format({ + 'application/json': () => { + return res + .status(200) + .send(this.transfromToResponse({ saleReceipt })); + }, 'application/pdf': async () => { const pdfContent = await this.saleReceiptsPdf.saleReceiptPdf( tenantId, @@ -283,10 +290,7 @@ export default class SalesReceiptsController extends BaseController { }); res.send(pdfContent); }, - 'application/json': () => { - return res.status(200).send(this.transfromToResponse({ saleReceipt })); - } - }) + }); } catch (error) { next(error); } diff --git a/server/src/services/Accounts/AccountTransform.ts b/server/src/services/Accounts/AccountTransform.ts new file mode 100644 index 000000000..9cfeb6988 --- /dev/null +++ b/server/src/services/Accounts/AccountTransform.ts @@ -0,0 +1,28 @@ +import { Service } from 'typedi'; +import { IAccount } from 'interfaces'; +import { Transformer } from 'lib/Transformer/Transformer'; +import { formatNumber } from 'utils'; + +@Service() +export default class AccountTransformer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {Array} + */ + protected includeAttributes = (): string[] => { + return [ + 'formattedAmount', + ]; + }; + + /** + * Retrieve formatted account amount. + * @param {IAccount} invoice + * @returns {string} + */ + protected formattedAmount = (account: IAccount): string => { + return formatNumber(account.amount, { + currencyCode: account.currencyCode, + }); + }; +} diff --git a/server/src/services/Accounts/AccountsService.ts b/server/src/services/Accounts/AccountsService.ts index e3f97b300..c8588a7b1 100644 --- a/server/src/services/Accounts/AccountsService.ts +++ b/server/src/services/Accounts/AccountsService.ts @@ -23,6 +23,7 @@ import AccountTypesUtils from 'lib/AccountTypes'; import { ERRORS } from './constants'; import { flatToNestedArray } from 'utils'; import I18nService from 'services/I18n/I18nService'; +import AccountTransformer from './AccountTransform'; @Service() export default class AccountsService { @@ -41,6 +42,9 @@ export default class AccountsService { @Inject() i18nService: I18nService; + @Inject() + accountTransformer: AccountTransformer; + /** * Retrieve account type or throws service error. * @param {number} tenantId - @@ -326,7 +330,10 @@ export default class AccountsService { */ public async getAccount(tenantId: number, accountId: number) { const account = await this.getAccountOrThrowError(tenantId, accountId); - return this.transformAccountResponse(tenantId, account); + + return this.accountTransformer.transform( + this.transformAccountResponse(tenantId, account) + ); } /** @@ -609,13 +616,11 @@ export default class AccountsService { /** * Parsees accounts list filter DTO. - * @param filterDTO - * @returns + * @param filterDTO + * @returns */ private parseListFilterDTO(filterDTO) { - return R.compose( - this.dynamicListService.parseStringifiedFilter - )(filterDTO); + return R.compose(this.dynamicListService.parseStringifiedFilter)(filterDTO); } /** @@ -735,10 +740,12 @@ export default class AccountsService { key: 'base_currency', }); - const _accounts = accounts.map((account) => ({ - ...account.toJSON(), - currencyCode: baseCurrency, - })); + const _accounts = this.accountTransformer.transform( + accounts.map((account) => ({ + ...account.toJSON(), + currencyCode: baseCurrency, + })) + ); return flatToNestedArray( this.i18nService.i18nMapper(_accounts, ['account_type_label'], tenantId), { diff --git a/server/src/services/Contacts/ContactTransformer.ts b/server/src/services/Contacts/ContactTransformer.ts new file mode 100644 index 000000000..494e18bcd --- /dev/null +++ b/server/src/services/Contacts/ContactTransformer.ts @@ -0,0 +1,43 @@ +import { Service, Container } from 'typedi'; +import { isNull } from 'lodash'; +import { Transformer } from 'lib/Transformer/Transformer'; +import { formatNumber } from 'utils'; +import { IContact } from 'interfaces'; + +@Service() +export default class ContactTransfromer extends Transformer { + /** + * Retrieve formatted expense amount. + * @param {IExpense} expense + * @returns {string} + */ + protected formattedBalance = (contact: IContact): string => { + return formatNumber(contact.balance, { + currencyCode: contact.currencyCode, + }); + }; + + /** + * Retrieve formatted expense landed cost amount. + * @param {IExpense} expense + * @returns {string} + */ + protected formattedOpeningBalance = (contact: IContact): string => { + return !isNull(contact.openingBalance) + ? formatNumber(contact.openingBalance, { + currencyCode: contact.currencyCode, + }) + : ''; + }; + + /** + * Retriecve fromatted date. + * @param {IExpense} expense + * @returns {string} + */ + protected formattedOpeningBalanceAt = (contact: IContact): string => { + return !isNull(contact.openingBalanceAt) + ? this.formatDate(contact.openingBalanceAt) + : ''; + }; +} diff --git a/server/src/services/Contacts/Customers/CustomerTransformer.ts b/server/src/services/Contacts/Customers/CustomerTransformer.ts new file mode 100644 index 000000000..e9fb38774 --- /dev/null +++ b/server/src/services/Contacts/Customers/CustomerTransformer.ts @@ -0,0 +1,17 @@ +import { Service } from 'typedi'; +import ContactTransfromer from '../ContactTransformer'; + +@Service() +export default class CustomerTransfromer extends ContactTransfromer { + /** + * Include these attributes to expense object. + * @returns {Array} + */ + protected includeAttributes = (): string[] => { + return [ + 'formattedBalance', + 'formattedOpeningBalance', + 'formattedOpeningBalanceAt' + ]; + }; +} diff --git a/server/src/services/Contacts/CustomersService.ts b/server/src/services/Contacts/CustomersService.ts index 37266d4e4..18ad83358 100644 --- a/server/src/services/Contacts/CustomersService.ts +++ b/server/src/services/Contacts/CustomersService.ts @@ -28,6 +28,7 @@ import { ServiceError } from 'exceptions'; import TenancyService from 'services/Tenancy/TenancyService'; import DynamicListingService from 'services/DynamicListing/DynamicListService'; import events from 'subscribers/events'; +import CustomerTransfromer from './Customers/CustomerTransformer'; const ERRORS = { CUSTOMER_HAS_TRANSACTIONS: 'CUSTOMER_HAS_TRANSACTIONS', @@ -61,6 +62,9 @@ export default class CustomersService { @Inject('SalesEstimates') estimatesService: ISalesEstimatesService; + @Inject() + customerTransformer: CustomerTransfromer; + /** * Converts customer to contact DTO. * @param {ICustomerNewDTO|ICustomerEditDTO} customerDTO @@ -262,7 +266,10 @@ export default class CustomersService { customerId, 'customer' ); - return this.transformContactToCustomer(contact); + return R.compose( + this.customerTransformer.transform, + this.transformContactToCustomer, + )(contact); } /** diff --git a/server/src/services/Contacts/Vendors/VendorTransformer.ts b/server/src/services/Contacts/Vendors/VendorTransformer.ts new file mode 100644 index 000000000..2276dda30 --- /dev/null +++ b/server/src/services/Contacts/Vendors/VendorTransformer.ts @@ -0,0 +1,17 @@ +import { Service } from 'typedi'; +import ContactTransfromer from '../ContactTransformer'; + +@Service() +export default class VendorTransfromer extends ContactTransfromer { + /** + * Include these attributes to expense object. + * @returns {Array} + */ + protected includeAttributes = (): string[] => { + return [ + 'formattedBalance', + 'formattedOpeningBalance', + 'formattedOpeningBalanceAt' + ]; + }; +} diff --git a/server/src/services/Contacts/VendorsService.ts b/server/src/services/Contacts/VendorsService.ts index c2c1a037e..a0f266c21 100644 --- a/server/src/services/Contacts/VendorsService.ts +++ b/server/src/services/Contacts/VendorsService.ts @@ -23,6 +23,7 @@ import { ServiceError } from 'exceptions'; import DynamicListingService from 'services/DynamicListing/DynamicListService'; import TenancyService from 'services/Tenancy/TenancyService'; import events from 'subscribers/events'; +import VendorTransfromer from './Vendors/VendorTransformer'; const ERRORS = { VENDOR_HAS_TRANSACTIONS: 'VENDOR_HAS_TRANSACTIONS', @@ -51,6 +52,9 @@ export default class VendorsService { @Inject('BillPayments') billPaymentsService: IBillPaymentsService; + @Inject() + vendorTransformer: VendorTransfromer; + /** * Converts vendor to contact DTO. * @param {IVendorNewDTO|IVendorEditDTO} vendorDTO @@ -139,8 +143,8 @@ export default class VendorsService { /** * Validate the given vendor has no associated transactions. - * @param {number} tenantId - * @param {number} vendorId + * @param {number} tenantId + * @param {number} vendorId */ private async validateAssociatedTransactions( tenantId: number, @@ -151,8 +155,10 @@ export default class VendorsService { await this.billsService.validateVendorHasNoBills(tenantId, vendorId); // Validate vendor has no paymentys. - await this.billPaymentsService.validateVendorHasNoPayments(tenantId, vendorId); - + await this.billPaymentsService.validateVendorHasNoPayments( + tenantId, + vendorId + ); } catch (error) { throw new ServiceError(ERRORS.VENDOR_HAS_TRANSACTIONS); } @@ -196,7 +202,9 @@ export default class VendorsService { * @param {number} vendorId */ public async getVendor(tenantId: number, vendorId: number) { - return this.contactService.getContact(tenantId, vendorId, 'vendor'); + const vendor = this.contactService.getContact(tenantId, vendorId, 'vendor'); + + return this.vendorTransformer.transform(vendor); } /** @@ -257,9 +265,7 @@ export default class VendorsService { } private parseVendorsListFilterDTO(filterDTO) { - return R.compose( - this.dynamicListService.parseStringifiedFilter - )(filterDTO); + return R.compose(this.dynamicListService.parseStringifiedFilter)(filterDTO); } /** @@ -297,7 +303,7 @@ export default class VendorsService { .pagination(filter.page - 1, filter.pageSize); return { - vendors: results, + vendors: this.vendorTransformer.transform(results), pagination, filterMeta: dynamicList.getResponseMeta(), }; diff --git a/server/src/services/Expenses/ExpenseTransformer.ts b/server/src/services/Expenses/ExpenseTransformer.ts new file mode 100644 index 000000000..31c24e777 --- /dev/null +++ b/server/src/services/Expenses/ExpenseTransformer.ts @@ -0,0 +1,62 @@ +import { Service, Container } from 'typedi'; +import { Transformer } from 'lib/Transformer/Transformer'; +import { formatNumber } from 'utils'; +import { IExpense } from 'interfaces'; + +@Service() +export default class ExpenseTransfromer extends Transformer { + /** + * Include these attributes to expense object. + * @returns {Array} + */ + protected includeAttributes = (): string[] => { + return [ + 'formattedAmount', + 'formattedLandedCostAmount', + 'formattedAllocatedCostAmount', + 'formattedDate' + ]; + }; + + /** + * Retrieve formatted expense amount. + * @param {IExpense} expense + * @returns {string} + */ + protected formattedAmount = (expense: IExpense): string => { + return formatNumber(expense.totalAmount, { + currencyCode: expense.currencyCode, + }); + }; + + /** + * Retrieve formatted expense landed cost amount. + * @param {IExpense} expense + * @returns {string} + */ + protected formattedLandedCostAmount = (expense: IExpense): string => { + return formatNumber(expense.landedCostAmount, { + currencyCode: expense.currencyCode, + }); + }; + + /** + * Retrieve formatted allocated cost amount. + * @param {IExpense} expense + * @returns {string} + */ + protected formattedAllocatedCostAmount = (expense: IExpense): string => { + return formatNumber(expense.allocatedCostAmount, { + currencyCode: expense.currencyCode, + }); + }; + + /** + * Retriecve fromatted date. + * @param {IExpense} expense + * @returns {string} + */ + protected formattedDate = (expense: IExpense): string => { + return this.formatDate(expense.paymentDate); + } +} diff --git a/server/src/services/Expenses/ExpensesService.ts b/server/src/services/Expenses/ExpensesService.ts index 6fc465de8..f778d7984 100644 --- a/server/src/services/Expenses/ExpensesService.ts +++ b/server/src/services/Expenses/ExpensesService.ts @@ -25,6 +25,7 @@ import events from 'subscribers/events'; import ContactsService from 'services/Contacts/ContactsService'; import { ACCOUNT_PARENT_TYPE, ACCOUNT_ROOT_TYPE } from 'data/AccountTypes'; import EntriesService from 'services/Entries'; +import ExpenseTransfromer from './ExpenseTransformer'; const ERRORS = { EXPENSE_NOT_FOUND: 'expense_not_found', @@ -58,6 +59,9 @@ export default class ExpensesService implements IExpensesService { @Inject() entriesService: EntriesService; + @Inject() + expenseTransfromer: ExpenseTransfromer; + /** * Retrieve the payment account details or returns not found server error in case the * given account not found on the storage. @@ -681,7 +685,7 @@ export default class ExpensesService implements IExpensesService { .pagination(filter.page - 1, filter.pageSize); return { - expenses: results, + expenses: this.expenseTransfromer.transform(results), pagination, filterMeta: dynamicList.getResponseMeta(), }; @@ -706,7 +710,7 @@ export default class ExpensesService implements IExpensesService { if (!expense) { throw new ServiceError(ERRORS.EXPENSE_NOT_FOUND); } - return expense; + return this.expenseTransfromer.transform(expense); } /** diff --git a/server/src/services/ManualJournals/ManualJournalTransformer.ts b/server/src/services/ManualJournals/ManualJournalTransformer.ts new file mode 100644 index 000000000..8a9238dce --- /dev/null +++ b/server/src/services/ManualJournals/ManualJournalTransformer.ts @@ -0,0 +1,44 @@ +import { IManualJournal } from 'interfaces'; +import { Transformer } from 'lib/Transformer/Transformer'; +import { Service } from 'typedi'; +import { formatNumber } from 'utils'; + +@Service() +export default class ManualJournalTransfromer extends Transformer { + /** + * Include these attributes to expense object. + * @returns {Array} + */ + protected includeAttributes = (): string[] => { + return ['formattedAmount', 'formattedDate', 'formattedPublishedAt']; + }; + + /** + * Retrieve formatted journal amount. + * @param {IManualJournal} manualJournal + * @returns {string} + */ + protected formattedAmount = (manualJorunal: IManualJournal): string => { + return formatNumber(manualJorunal.amount, { + currencyCode: manualJorunal.currencyCode, + }); + }; + + /** + * Retrieve formatted date. + * @param {IManualJournal} manualJournal + * @returns {string} + */ + protected formattedDate = (manualJorunal: IManualJournal): string => { + return this.formatDate(manualJorunal.date); + }; + + /** + * Retrieve formatted published at date. + * @param {IManualJournal} manualJournal + * @returns {string} + */ + protected formattedPublishedAt = (manualJorunal: IManualJournal): string => { + return this.formatDate(manualJorunal.publishedAt); + }; +} diff --git a/server/src/services/ManualJournals/ManualJournalsService.ts b/server/src/services/ManualJournals/ManualJournalsService.ts index 2c1a02083..ebe8cab08 100644 --- a/server/src/services/ManualJournals/ManualJournalsService.ts +++ b/server/src/services/ManualJournals/ManualJournalsService.ts @@ -24,6 +24,7 @@ import JournalCommands from 'services/Accounting/JournalCommands'; import JournalPosterService from 'services/Sales/JournalPosterService'; import AutoIncrementOrdersService from 'services/Sales/AutoIncrementOrdersService'; import { ERRORS } from './constants'; +import ManualJournalTransfromer from './ManualJournalTransformer'; @Service() export default class ManualJournalsService implements IManualJournalsService { @@ -45,6 +46,9 @@ export default class ManualJournalsService implements IManualJournalsService { @Inject() autoIncrementOrdersService: AutoIncrementOrdersService; + @Inject() + manualJournalTransformer: ManualJournalTransfromer; + /** * Validates the manual journal existance. * @param {number} tenantId @@ -815,7 +819,7 @@ export default class ManualJournalsService implements IManualJournalsService { .pagination(filter.page - 1, filter.pageSize); return { - manualJournals: results, + manualJournals: this.manualJournalTransformer.transform(results), pagination, filterMeta: dynamicService.getResponseMeta(), }; @@ -842,7 +846,7 @@ export default class ManualJournalsService implements IManualJournalsService { .withGraphFetched('transactions') .withGraphFetched('media'); - return manualJournal; + return this.manualJournalTransformer.transform(manualJournal); } /** diff --git a/server/src/services/Sales/PaymentReceives/PaymentReceiveTransformer.ts b/server/src/services/Sales/PaymentReceives/PaymentReceiveTransformer.ts index 26727f97c..366a76f40 100644 --- a/server/src/services/Sales/PaymentReceives/PaymentReceiveTransformer.ts +++ b/server/src/services/Sales/PaymentReceives/PaymentReceiveTransformer.ts @@ -10,10 +10,7 @@ export default class PaymentReceiveTransfromer extends Transformer { * @returns {Array} */ protected includeAttributes = (): string[] => { - return [ - 'formattedPaymentDate', - 'formattedAmount', - ]; + return ['formattedPaymentDate', 'formattedAmount']; }; /** diff --git a/server/src/services/Sales/SalesReceipts.ts b/server/src/services/Sales/SalesReceipts.ts index 07f29060b..09165be09 100644 --- a/server/src/services/Sales/SalesReceipts.ts +++ b/server/src/services/Sales/SalesReceipts.ts @@ -421,7 +421,7 @@ export default class SalesReceiptService implements ISalesReceiptsService { tenantId: number, filterDTO: ISaleReceiptFilter ): Promise<{ - salesReceipts: ISaleReceipt[]; + data: ISaleReceipt[]; pagination: IPaginationMeta; filterMeta: IFilterMeta; }> { @@ -451,7 +451,7 @@ export default class SalesReceiptService implements ISalesReceiptsService { .pagination(filter.page - 1, filter.pageSize); return { - salesReceipts: this.saleReceiptTransformer.transform(results), + data: this.saleReceiptTransformer.transform(results), pagination, filterMeta: dynamicFilter.getResponseMeta(), };