diff --git a/src/common/tables.js b/src/common/tables.js index 59c50dff7..419b0e9bb 100644 --- a/src/common/tables.js +++ b/src/common/tables.js @@ -16,7 +16,8 @@ export const TABLES = { CASHFLOW_Transactions: 'cashflow_transactions', CREDIT_NOTES: 'credit_notes', VENDOR_CREDITS: 'vendor_credits', - WAREHOUSE_TRANSFERS:'warehouse_transfers' + WAREHOUSE_TRANSFERS: 'warehouse_transfers', + PROJECTS: 'projects', }; export const TABLE_SIZE = { diff --git a/src/components/DialogsContainer.js b/src/components/DialogsContainer.js index 8ddff3496..8d1a0c56c 100644 --- a/src/components/DialogsContainer.js +++ b/src/components/DialogsContainer.js @@ -40,6 +40,8 @@ import BranchActivateDialog from '../containers/Dialogs/BranchActivateDialog'; import WarehouseActivateDialog from '../containers/Dialogs/WarehouseActivateDialog'; import CustomerOpeningBalanceDialog from '../containers/Dialogs/CustomerOpeningBalanceDialog'; import VendorOpeningBalanceDialog from '../containers/Dialogs/VendorOpeningBalanceDialog'; +import ProjectFormDialog from '../containers/Projects/containers/ProjectFormDialog'; +import TaskFormDialog from '../containers/Projects/containers/TaskFormDialog'; /** * Dialogs container. @@ -90,6 +92,8 @@ export default function DialogsContainer() { + + ); } diff --git a/src/components/Forms/BlueprintFormik.js b/src/components/Forms/BlueprintFormik.js index a82afbf3c..497049ad4 100644 --- a/src/components/Forms/BlueprintFormik.js +++ b/src/components/Forms/BlueprintFormik.js @@ -9,6 +9,7 @@ import { TextArea, } from '@blueprintjs-formik/core'; import { Select, MultiSelect } from '@blueprintjs-formik/select'; +import { DateInput } from '@blueprintjs-formik/datetime'; export { FormGroup as FFormGroup, @@ -21,4 +22,5 @@ export { MultiSelect as FMultiSelect, EditableText as FEditableText, TextArea as FTextArea, + DateInput as FDateInput, }; diff --git a/src/config/sidebarMenu.js b/src/config/sidebarMenu.js index b11315670..9f4155005 100644 --- a/src/config/sidebarMenu.js +++ b/src/config/sidebarMenu.js @@ -538,6 +538,27 @@ export const SidebarMenu = [ }, ], }, + // --------------------- + // # Projects Management + // --------------------- + { + text: 'Projects', + type: ISidebarMenuItemType.Overlay, + overlayId: ISidebarMenuOverlayIds.Projects, + children: [ + { + text: 'Projects Management', + type: ISidebarMenuItemType.Group, + children: [ + { + text: 'Projects', + href: '/projects', + type: ISidebarMenuItemType.Link, + }, + ], + }, + ], + }, // --------------- // # Reports // --------------- diff --git a/src/containers/Dashboard/Sidebar/interfaces.ts b/src/containers/Dashboard/Sidebar/interfaces.ts index b3f7eaa3a..76e6a75b1 100644 --- a/src/containers/Dashboard/Sidebar/interfaces.ts +++ b/src/containers/Dashboard/Sidebar/interfaces.ts @@ -69,6 +69,7 @@ export enum ISidebarMenuOverlayIds { Contacts = 'Contacts', Cashflow = 'Cashflow', Expenses = 'Expenses', + Projects = 'Projects', } export enum ISidebarSubscriptionAbility { diff --git a/src/containers/Dialogs/TaskDialog/components/index.ts b/src/containers/Dialogs/TaskDialog/components/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/containers/Projects/containers/ProjectFormDialog/ProjectForm.schema.tsx b/src/containers/Projects/containers/ProjectFormDialog/ProjectForm.schema.tsx new file mode 100644 index 000000000..530fec56b --- /dev/null +++ b/src/containers/Projects/containers/ProjectFormDialog/ProjectForm.schema.tsx @@ -0,0 +1,21 @@ +import * as Yup from 'yup'; +import intl from 'react-intl-universal'; +import { DATATYPES_LENGTH } from 'common/dataTypes'; + +const Schema = Yup.object().shape({ + contact: Yup.string().label(intl.get('project.schema.label.contact')), + projectName: Yup.string() + .label(intl.get('project.schema.label.project_name')) + .required(), + projectDeadline: Yup.date() + .label(intl.get('project.schema.label.deadline')) + .required(), + projectState: Yup.boolean().label( + intl.get('project.schema.label.project_state'), + ), + projectCost: Yup.number().label( + intl.get('project.schema.label.project_cost'), + ), +}); + +export const CreateProjectFormSchema = Schema; diff --git a/src/containers/Projects/containers/ProjectFormDialog/ProjectForm.tsx b/src/containers/Projects/containers/ProjectFormDialog/ProjectForm.tsx new file mode 100644 index 000000000..115e8a0ff --- /dev/null +++ b/src/containers/Projects/containers/ProjectFormDialog/ProjectForm.tsx @@ -0,0 +1,68 @@ +// @ts-nocheck +import React from 'react'; +import moment from 'moment'; +import intl from 'react-intl-universal'; +import { Formik } from 'formik'; +import { AppToaster } from 'components'; +import ProjectFormContent from './ProjectFormContent'; +import { CreateProjectFormSchema } from './ProjectForm.schema'; +import { useProjectFormContext } from './ProjectFormProvider'; +import withDialogActions from 'containers/Dialog/withDialogActions'; + +import { compose } from 'utils'; + +const defaultInitialValues = { + contact: '', + projectName: '', + projectDeadline: moment(new Date()).format('YYYY-MM-DD'), + projectState: true, + projectCost: '', +}; + +/** + * Project form + * @returns + */ +function ProjectForm({ + // #withDialogActions + closeDialog, +}) { + // project form dialog context. + const { dialogName } = useProjectFormContext(); + + // Initial form values + const initialValues = { + ...defaultInitialValues, + }; + + // Handles the form submit. + const handleFormSubmit = (values, { setSubmitting, setErrors }) => { + const form = {}; + + // Handle request response success. + const onSuccess = (response) => { + AppToaster.show({}); + closeDialog(dialogName); + }; + + // Handle request response errors. + const onError = ({ + response: { + data: { errors }, + }, + }) => { + setSubmitting(false); + }; + }; + + return ( + + ); +} + +export default compose(withDialogActions)(ProjectForm); diff --git a/src/containers/Projects/containers/ProjectFormDialog/ProjectFormContent.tsx b/src/containers/Projects/containers/ProjectFormDialog/ProjectFormContent.tsx new file mode 100644 index 000000000..0bcbed687 --- /dev/null +++ b/src/containers/Projects/containers/ProjectFormDialog/ProjectFormContent.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Form } from 'formik'; + +import ProjectFormFields from './ProjectFormFields'; +import ProjectFormFloatingActions from './ProjectFormFloatingActions'; + +/** + * Project form content. + */ +export default function ProjectFormContent() { + return ( +
+ + + + ); +} diff --git a/src/containers/Projects/containers/ProjectFormDialog/ProjectFormDialogContent.tsx b/src/containers/Projects/containers/ProjectFormDialog/ProjectFormDialogContent.tsx new file mode 100644 index 000000000..63921866e --- /dev/null +++ b/src/containers/Projects/containers/ProjectFormDialog/ProjectFormDialogContent.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +import { ProjectFormProvider } from './ProjectFormProvider'; +import ProjectForm from './ProjectForm'; + +/** + * Project form dialog content. + * @returns {ReactNode} + */ +export default function ProjectFormDialogContent({ + // #ownProps + dialogName, + project, +}) { + return ( + + + + ); +} diff --git a/src/containers/Projects/containers/ProjectFormDialog/ProjectFormFields.tsx b/src/containers/Projects/containers/ProjectFormDialog/ProjectFormFields.tsx new file mode 100644 index 000000000..7047ae048 --- /dev/null +++ b/src/containers/Projects/containers/ProjectFormDialog/ProjectFormFields.tsx @@ -0,0 +1,107 @@ +// @ts-nocheck +import React from 'react'; +import intl from 'react-intl-universal'; + +import { Classes, Position, FormGroup, ControlGroup } from '@blueprintjs/core'; +import { FastField } from 'formik'; +import { CLASSES } from 'common/classes'; +import classNames from 'classnames'; +import { + FFormGroup, + FInputGroup, + FCheckbox, + FDateInput, + FMoneyInputGroup, + InputPrependText, + FormattedMessage as T, + CustomerSelectField, +} from 'components'; +import { + inputIntent, + momentFormatter, + tansformDateValue, + handleDateChange, +} from 'utils'; +import { useProjectFormContext } from './ProjectFormProvider'; + +/** + * Project form fields. + * @returns + */ +function ProjectFormFields() { + const { customers } = useProjectFormContext(); + + return ( +
+ {/*------------ Contact -----------*/} + + {({ form, field: { value }, meta: { error, touched } }) => ( + + { + form.setFieldValue('contact', customer.id); + }} + allowCreate={true} + popoverFill={true} + /> + + )} + + {/*------------ Project Name -----------*/} + + + + {/*------------ DeadLine -----------*/} + + date.toLocaleString()} + popoverProps={{ + position: Position.BOTTOM, + minimal: true, + }} + /> + + + {/*------------ CheckBox -----------*/} + + + + {/*------------ Cost Estimate -----------*/} + + + + + + +
+ ); +} + +export default ProjectFormFields; diff --git a/src/containers/Projects/containers/ProjectFormDialog/ProjectFormFloatingActions.tsx b/src/containers/Projects/containers/ProjectFormDialog/ProjectFormFloatingActions.tsx new file mode 100644 index 000000000..57b798d35 --- /dev/null +++ b/src/containers/Projects/containers/ProjectFormDialog/ProjectFormFloatingActions.tsx @@ -0,0 +1,48 @@ +// @ts-nocheck +import React from 'react'; +import { useFormikContext } from 'formik'; +import { Intent, Button, Classes } from '@blueprintjs/core'; +import { FormattedMessage as T } from 'components'; +import { useProjectFormContext } from './ProjectFormProvider'; +import withDialogActions from 'containers/Dialog/withDialogActions'; +import { compose } from 'utils'; + +/** + * Project form floating actions. + * @returns + */ +function ProjectFormFloatingActions({ + // #withDialogActions + closeDialog, +}) { + // project form dialog context. + const { dialogName } = useProjectFormContext(); + + // Formik context. + const { isSubmitting } = useFormikContext(); + + // Handle close button click. + const handleCancelBtnClick = () => { + closeDialog(dialogName); + }; + + return ( +
+
+ + +
+
+ ); +} + +export default compose(withDialogActions)(ProjectFormFloatingActions); diff --git a/src/containers/Projects/containers/ProjectFormDialog/ProjectFormProvider.tsx b/src/containers/Projects/containers/ProjectFormDialog/ProjectFormProvider.tsx new file mode 100644 index 000000000..3c68c865a --- /dev/null +++ b/src/containers/Projects/containers/ProjectFormDialog/ProjectFormProvider.tsx @@ -0,0 +1,39 @@ +// @ts-nocheck +import React from 'react'; +import { useCustomers } from 'hooks/query'; +import { DialogContent } from 'components'; + +const ProjectFormContext = React.createContext(); + +/** + * Project form provider. + * @returns + */ +function ProjectFormProvider({ + // #ownProps + dialogName, + projectId, + ...props +}) { + // Handle fetch customers data table or list + const { + data: { customers }, + isLoading: isCustomersLoading, + } = useCustomers({ page_size: 10000 }); + + // State provider. + const provider = { + customers, + dialogName, + }; + + return ( + + + + ); +} + +const useProjectFormContext = () => React.useContext(ProjectFormContext); + +export { ProjectFormProvider, useProjectFormContext }; diff --git a/src/containers/Projects/containers/ProjectFormDialog/index.tsx b/src/containers/Projects/containers/ProjectFormDialog/index.tsx new file mode 100644 index 000000000..c9d035d86 --- /dev/null +++ b/src/containers/Projects/containers/ProjectFormDialog/index.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Dialog, DialogSuspense, FormattedMessage as T } from 'components'; +import withDialogRedux from 'components/DialogReduxConnect'; +import { compose } from 'utils'; + +const ProjectDialogContent = React.lazy( + () => import('./ProjectFormDialogContent'), +); + +/** + * Project form dialog. + * @returns + */ +function ProjectFormDialog({ dialogName, payload: { projectId = null }, isOpen }) { + return ( + } + isOpen={isOpen} + autoFocus={true} + canEscapeKeyClose={true} + style={{ width: '400px' }} + > + + + + + ); +} + +export default compose(withDialogRedux())(ProjectFormDialog); + +const ProjectFormDialogRoot = styled(Dialog)` + .bp3-dialog-body { + .bp3-form-group { + margin-bottom: 15px; + margin-top: 15px; + + label.bp3-label { + margin-bottom: 3px; + font-size: 13px; + } + } + + .bp3-dialog-footer { + padding-top: 10px; + } + } +`; diff --git a/src/containers/Projects/containers/ProjectsLanding/ProjectsActionsBar.tsx b/src/containers/Projects/containers/ProjectsLanding/ProjectsActionsBar.tsx new file mode 100644 index 000000000..c1a9d2c12 --- /dev/null +++ b/src/containers/Projects/containers/ProjectsLanding/ProjectsActionsBar.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { + Button, + NavbarGroup, + Classes, + NavbarDivider, + Alignment, +} from '@blueprintjs/core'; +import { + Icon, + AdvancedFilterPopover, + DashboardActionViewsList, + DashboardFilterButton, + DashboardRowsHeightButton, + FormattedMessage as T, +} from 'components'; + +import DashboardActionsBar from 'components/Dashboard/DashboardActionsBar'; + +import withProjects from './withProjects'; +import withProjectsActions from './withProjectsActions'; +import withSettings from '../../../Settings/withSettings'; +import withSettingsActions from '../../../Settings/withSettingsActions'; +import withDialogActions from 'containers/Dialog/withDialogActions'; + +import { compose } from 'utils'; + +/** + * Projects actions bar. + * @returns + */ +function ProjectsActionsBar({ + // #withDialogActions + openDialog, + + // #withProjects + projectsFilterRoles, + + // #withProjectsActions + setProjectsTableState, + + // #withSettings + projectsTableSize, + + // #withSettingsActions + addSetting, +}) { + // Handle tab change. + const handleTabChange = (view) => { + setProjectsTableState({ + viewSlug: view ? view.slug : null, + }); + }; + + // Handle click a refresh projects list. + const handleRefreshBtnClick = () => {}; + + // Handle table row size change. + const handleTableRowSizeChange = (size) => { + addSetting('projects', 'tableSize', size); + }; + + // Handle new project button click. + const handleNewProjectBtnClick = () => { + openDialog('project-form'); + }; + + return ( + + + } + views={[]} + onChange={handleTabChange} + /> + + + + + + ); +} + +export default compose(withDialogActions)(TaskFormFloatingActions); diff --git a/src/containers/Projects/containers/TaskFormDialog/TaskFormProvider.tsx b/src/containers/Projects/containers/TaskFormDialog/TaskFormProvider.tsx new file mode 100644 index 000000000..ea9bf5be3 --- /dev/null +++ b/src/containers/Projects/containers/TaskFormDialog/TaskFormProvider.tsx @@ -0,0 +1,31 @@ +//@ts-nocheck +import React from 'react'; +import { DialogContent } from 'components'; + +const TaskFormContext = React.createContext(); + +/** + * Task form provider. + * @returns + */ +function TaskFormProvider({ + // #ownProps + dialogName, + taskId, + ...props +}) { + // State provider. + const provider = { + dialogName, + }; + + return ( + + + + ); +} + +const useTaskFormContext = () => React.useContext(TaskFormContext); + +export { TaskFormProvider, useTaskFormContext }; diff --git a/src/containers/Projects/containers/TaskFormDialog/index.tsx b/src/containers/Projects/containers/TaskFormDialog/index.tsx new file mode 100644 index 000000000..d57287c46 --- /dev/null +++ b/src/containers/Projects/containers/TaskFormDialog/index.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import styled from 'styled-components'; +import intl from 'react-intl-universal'; +import { Dialog, DialogSuspense, FormattedMessage as T } from 'components'; +import withDialogRedux from 'components/DialogReduxConnect'; +import { compose } from 'utils'; + +const TaskFormDialogContent = React.lazy( + () => import('./TaskFormDialogContent'), +); + +/** + * Task form dialog. + * @returns + */ +function TaskFormDialog({ dialogName, payload: { taskId = null }, isOpen }) { + return ( + + + + + + ); +} +export default compose(withDialogRedux())(TaskFormDialog); + +const TaskFormDialogRoot = styled(Dialog)``; diff --git a/src/containers/Projects/index.ts b/src/containers/Projects/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/containers/Settings/withSettings.js b/src/containers/Settings/withSettings.js index 08e7fd6dc..e1c1aa7db 100644 --- a/src/containers/Settings/withSettings.js +++ b/src/containers/Settings/withSettings.js @@ -22,6 +22,7 @@ export default (mapState) => { creditNoteSettings: state.settings.data.creditNote, vendorsCreditNoteSetting: state.settings.data.vendorCredit, warehouseTransferSettings: state.settings.data.warehouseTransfers, + projectSettings:state.settings.data.projects }; return mapState ? mapState(mapped, state, props) : mapped; }; diff --git a/src/lang/en/index.json b/src/lang/en/index.json index 6f35aaa18..fea33284a 100644 --- a/src/lang/en/index.json +++ b/src/lang/en/index.json @@ -2044,5 +2044,30 @@ "expense.entries.remove_row": "Remove line", "warehouse_transfer.entries.remove_row": "Remove line", "item.details.inactive": "Inactive", - "bill.validation.due_date": "{path} field must be later than {min}" + "bill.validation.due_date": "{path} field must be later than {min}", + "sidebar.projects": "Projects", + "projects.action.edit_project": "Edit Project", + "projects.action.new_task": "New Task", + "projects.action.delete_project": "Delete Project", + "projects.label.new_project": "New Project", + "projects.label.contact": "Contact", + "projects.label.project_name": "Project Name", + "projects.label.deadline": "Deadline", + "projects.label.calculator_expenses": "Calculator from tasks & estimated expenses", + "projects.label.cost_estimate": "Cost Estimate", + "projects.label.create": "Create", + "task.label.new_task": "New Task", + "task.label.task_name": "Task Name", + "task.label.estimated_hours": "Task Name", + "task.label.charge": "Charge", + "task.label.estimated_amount": "Estimated Amount", + "project.schema.label.contact": "Contact", + "project.schema.label.project_name": "Project name", + "project.schema.label.deadline": "Deadline", + "project.schema.label.project_state": "Project state", + "project.schema.label.project_cost": "Project cost", + "task.schema.label.task_name": "Task name", + "task.schema.label.task_house": "Task house", + "task.schema.label.charge": "Charge", + "task.schema.label.amount": "Amount" } \ No newline at end of file diff --git a/src/routes/dashboard.js b/src/routes/dashboard.js index 9283b492f..f5b5c2c12 100644 --- a/src/routes/dashboard.js +++ b/src/routes/dashboard.js @@ -969,6 +969,13 @@ export const getDashboardRoutes = () => [ ), pageTitle: intl.get('sidebar.transactions_locaking'), }, + { + path: '/projects', + component: lazy(() => + import('../containers/Projects/containers/ProjectsLanding/ProjectsList'), + ), + pageTitle: intl.get('sidebar.projects'), + }, // Homepage { path: `/`, diff --git a/src/store/Project/projects.actions.ts b/src/store/Project/projects.actions.ts new file mode 100644 index 000000000..f39531d81 --- /dev/null +++ b/src/store/Project/projects.actions.ts @@ -0,0 +1,14 @@ +import t from 'store/types'; + +export const setProjectsTableState = (queries) => { + return { + type: t.PROJECTS_TABLE_STATE_SET, + payload: { queries }, + }; +}; + +export const resetProjectsTableState = () => { + return { + type: t.PROJECTS_TABLE_STATE_RESET, + }; +}; diff --git a/src/store/Project/projects.reducer.ts b/src/store/Project/projects.reducer.ts new file mode 100644 index 000000000..cd219035b --- /dev/null +++ b/src/store/Project/projects.reducer.ts @@ -0,0 +1,33 @@ +import { createReducer } from '@reduxjs/toolkit'; +import { persistReducer, purgeStoredState } from 'redux-persist'; +import storage from 'redux-persist/lib/storage'; +import { createTableStateReducers } from 'store/tableState.reducer'; +import t from 'store/types'; + +export const defaultTableQuery = { + pageSize: 20, + pageIndex: 0, + filterRoles: [], + viewSlug: null, +}; + +const initialState = { + tableState: defaultTableQuery, +}; + +const STORAGE_KEY = 'bigcapital:projects'; + +const CONFIG = { + key: STORAGE_KEY, + whitelist: [], + storage, +}; +const reducerInstance = createReducer(initialState, { + ...createTableStateReducers('PROJECTS', defaultTableQuery), + + [t.RESET]: () => { + purgeStoredState(CONFIG); + }, +}); + +export default persistReducer(CONFIG, reducerInstance); diff --git a/src/store/Project/projects.selectors.ts b/src/store/Project/projects.selectors.ts new file mode 100644 index 000000000..10ab19fab --- /dev/null +++ b/src/store/Project/projects.selectors.ts @@ -0,0 +1,24 @@ +import { isEqual } from 'lodash'; +import { createDeepEqualSelector } from 'utils'; +import { paginationLocationQuery } from 'store/selectors'; +import { defaultTableQuery } from './projects.reducer'; + +const projectsTableState = (state) => state.projects.tableState; + +// Retrieve projects table query. +export const getProjectsTableStateFactory = () => + createDeepEqualSelector( + paginationLocationQuery, + projectsTableState, + (locationQuery, tableState) => { + return { + ...locationQuery, + ...tableState, + }; + }, + ); + +export const isProjectsTableStateChangedFactory = () => + createDeepEqualSelector(projectsTableState, (tableState) => { + return !isEqual(tableState, defaultTableQuery); + }); diff --git a/src/store/Project/projects.type.ts b/src/store/Project/projects.type.ts new file mode 100644 index 000000000..8026b0cf6 --- /dev/null +++ b/src/store/Project/projects.type.ts @@ -0,0 +1,4 @@ +export default { + PROJECTS_TABLE_STATE_SET: 'PROJECTS/TABLE_STATE_SET', + PROJECTS_TABLE_STATE_RESET: 'PROJECTS/TABLE_STATE_RESET', +}; diff --git a/src/store/reducers.js b/src/store/reducers.js index 0223270ad..b89241911 100644 --- a/src/store/reducers.js +++ b/src/store/reducers.js @@ -35,6 +35,7 @@ import plans from './plans/plans.reducer'; import creditNotes from './CreditNote/creditNote.reducer'; import vendorCredit from './VendorCredit/VendorCredit.reducer'; import warehouseTransfers from './WarehouseTransfer/warehouseTransfer.reducer'; +import projects from './Project/projects.reducer'; const appReducer = combineReducers({ authentication, @@ -70,6 +71,7 @@ const appReducer = combineReducers({ creditNotes, vendorCredit, warehouseTransfers, + projects, }); // Reset the state of a redux store diff --git a/src/store/settings/settings.reducer.js b/src/store/settings/settings.reducer.js index ee140bf98..fb28d894f 100644 --- a/src/store/settings/settings.reducer.js +++ b/src/store/settings/settings.reducer.js @@ -61,6 +61,9 @@ const initialState = { warehouseTransfer: { tableSize: 'medium', }, + projects: { + tableSize: 'medium', + }, }, }; diff --git a/src/store/types.js b/src/store/types.js index 079344984..602953034 100644 --- a/src/store/types.js +++ b/src/store/types.js @@ -31,6 +31,7 @@ import inventoryAdjustments from './inventoryAdjustments/inventoryAdjustment.typ import creditNote from './CreditNote/creditNote.type'; import vendorCredit from './VendorCredit/vendorCredit.type'; import WarehouseTransfer from './WarehouseTransfer/warehouseTransfer.type'; +import projects from './Project/projects.type' import plans from './plans/plans.types'; export default { @@ -68,4 +69,5 @@ export default { ...creditNote, ...vendorCredit, ...WarehouseTransfer, + ...projects };