Created
March 15, 2022 11:20
-
-
Save genert/78e7fcb2a4d172effa063ba213dd8ec3 to your computer and use it in GitHub Desktop.
CRM
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { useContext, useEffect } from 'react'; | |
import { bindActionCreators, compose } from 'redux'; | |
import { connect } from 'react-redux'; | |
import { withRouter } from 'react-router-dom'; | |
import { isEqual, get, sortBy, forEach } from 'lodash'; | |
import moment from 'moment'; | |
import { NavConfigContext } from 'components/Nav/Nav'; | |
import Linkify from 'linkifyjs/react'; | |
import { | |
Button, | |
Icons, | |
Table, | |
Tooltip, | |
withConfirm, | |
withSnackbar, | |
Spacer, | |
EH, | |
} from 'ui'; | |
import { IconPencil, IconArchive, IconBolt, IconTrash } from 'ui/Earhart/Icons'; | |
import Loader from 'components/elements/Loader'; | |
import { NoData } from 'components/elements/NoData'; | |
import config from '@c/config'; | |
import { appendFilter } from '@c/lib/url'; | |
import { getDisplayNotesExcerpt } from 'lib/notes'; | |
import { stopPropagation, prevent, hasTasks } from 'lib/utils'; | |
import manageModal from 'modalManager/manageModalActionCreator'; | |
import { | |
getSearchFilteredProjects, | |
getActiveIntegrations, | |
getProjectsMap, | |
getHaveProjectsWithContextBeenLoaded, | |
getProjectPhases, | |
} from 'selectors'; | |
import withConfirmDelete from 'components/decorators/withConfirmDelete'; | |
import PersonAvatar from 'components/elements/PersonAvatar'; | |
import * as projectActions from 'actions/projects'; | |
import * as phaseActions from 'actions/phases'; | |
import { | |
updateUserPref, | |
updateMultiUserPrefs, | |
setPlaceholder, | |
ensureContextLoaded, | |
updatePrompts, | |
} from 'actions'; | |
import ImportPrompt from 'onboarding-v2/InAppNotifications/ImportPrompt'; | |
import { FEATURES, hasFeature } from '@c/lib/features'; | |
import { canViewTemplates, getUserAccess } from '@c/lib/rights'; | |
import SyncIcon from '../../integrations/SyncIcon'; | |
import BulkEditProjectsModal from './modals/BulkEditProjectsModal/BulkEditProjectsModal'; | |
import ProjectBudgetBar, { getBudgetSortString } from './ProjectBudgetBar'; | |
import ManageTemplatesModal from './templates/ManageTemplatesModal'; | |
import ImgNoProjects from './no-projects.svg'; | |
import ImgImportProjects from './import-projects/import-projects.png'; | |
import ImportProjectsModal from './import-projects/ImportProjectsModal'; | |
import * as styled from './styles'; | |
const PROJECT_COLUMNS = [ | |
{ | |
key: 'project', | |
title: 'Project', | |
width: '28%', | |
}, | |
{ | |
key: 'client', | |
title: 'Client', | |
}, | |
{ | |
key: 'tags', | |
}, | |
{ | |
key: 'statusTags', | |
width: '175px', | |
}, | |
{ | |
key: 'notes', | |
width: '24px', | |
}, | |
{ | |
key: 'budget', | |
title: 'Budget', | |
width: '60px', | |
}, | |
{ | |
key: 'start', | |
title: 'Start', | |
width: '88px', | |
}, | |
{ | |
key: 'end', | |
title: 'End', | |
width: '88px', | |
}, | |
{ | |
key: 'pm', | |
title: 'Owner', | |
width: '42px', | |
}, | |
]; | |
const PROJECT_STATUS = { | |
ACTIVE: 1, | |
ACTIVE_ARCHIVED: 2, | |
ARCHIVED: 0, | |
}; | |
const PROMPT_ID = config.prompts.projectImport; | |
const formatDate = d => | |
d && d !== '0000-00-00' && moment(d).format('DD MMM YYYY'); | |
const ProjectQuickActions = ({ | |
canAct, | |
showManageTemplatesModal, | |
showImportProjectModal, | |
}) => { | |
const navCtx = useContext(NavConfigContext); | |
useEffect(() => { | |
if (!canAct) { | |
return; | |
} | |
const quickActionControls = []; | |
if (hasFeature(FEATURES.projectTemplates)) { | |
quickActionControls.push( | |
<styled.QuickActionButton | |
minWidth="160px" | |
appearance="secondary" | |
size="small" | |
icon={Icons.CopyRound} | |
onClick={showManageTemplatesModal} | |
> | |
Manage templates | |
</styled.QuickActionButton>, | |
); | |
} | |
quickActionControls.push( | |
<styled.QuickActionButton | |
appearance="ghost" | |
size="small" | |
iconRight={EH.Icons.IconExport} | |
onClick={showImportProjectModal} | |
> | |
Import | |
</styled.QuickActionButton>, | |
); | |
navCtx.registerQuickActionCtrls(quickActionControls); | |
return () => { | |
navCtx.registerQuickActionCtrls([]); | |
}; | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
}, []); | |
return null; | |
}; | |
class Projects extends React.Component { | |
state = { | |
filteredProjects: [], | |
projectView: 1, | |
modal: {}, | |
expandedProjects: {}, | |
}; | |
static getDerivedStateFromProps(props) { | |
const s = { | |
byMy: Number(get(props, 'location.query.my', 0)), | |
sort: { | |
type: 'project_name', | |
dir: 'asc', | |
}, | |
}; | |
if (get(props, 'currentUser.prefs.proj_sort_by')) { | |
s.sort = { | |
type: props.currentUser.prefs.proj_sort_by, | |
dir: 'asc', | |
}; | |
} | |
if (get(props, 'currentUser.prefs.proj_sort_dir')) { | |
s.sort.dir = props.currentUser.prefs.proj_sort_dir; | |
} | |
if (get(props, 'currentUser.prefs.proj_view')) { | |
s.projectView = Number(props.currentUser.prefs.proj_view); | |
} | |
return s; | |
} | |
isLoaded = () => { | |
// Note that we only want to render the loader on the very first page load. | |
// Subsequent renders might trigger a full project update, but we don't want | |
// to unmount any components (like the import modal) to show the spinner. | |
return this.props.hasBeenLoaded; | |
}; | |
// --------------------------------------------------------------------------- | |
// !!! React Lifecycle ------------------------------------------------------- | |
// --------------------------------------------------------------------------- | |
componentDidMount() { | |
this.fetchAllData(); | |
this.loadExpandedPhasesFromLocalStorage(); | |
} | |
componentDidUpdate(prevProps, prevState) { | |
const propChangesToDetect = [ | |
'search.context', | |
'search.filters', | |
'projects', | |
'projectPhases', | |
'currentUser.prefs.me_filter', | |
]; | |
const stateChangesToDetect = [ | |
'sort', | |
'projectView', | |
'byMy', | |
'expandedProjects', | |
]; | |
const propsChanged = propChangesToDetect.some( | |
// These are from Redux so we can rely on immutability | |
path => get(this.props, path) !== get(prevProps, path), | |
); | |
const stateChanged = stateChangesToDetect.some( | |
path => !isEqual(get(this.state, path), get(prevState, path)), | |
); | |
if (this.isLoaded() && (propsChanged || stateChanged)) { | |
if (stateChanged && this.state.projectView !== prevState.projectView) { | |
this.fetchAllData(); | |
return; | |
} | |
this.filterProjects(); | |
} | |
} | |
// --------------------------------------------------------------------------- | |
// !!! Actions --------------------------------------------------------------- | |
// --------------------------------------------------------------------------- | |
changeSortType = async (type, dir) => { | |
this.props.actions.updateMultiUserPrefs({ | |
proj_sort_by: type, | |
proj_sort_dir: dir, | |
}); | |
}; | |
changeSortDir = value => { | |
if (!['asc', 'desc'].includes(value)) { | |
return; | |
} | |
if (value !== this.state.sort.dir) { | |
this.props.actions.updateUserPref('proj_sort_dir', value); | |
} | |
}; | |
changeByMy = byMy => { | |
const { | |
location: { pathname, query = {} }, | |
} = this.props; | |
if (byMy) { | |
query.my = 1; | |
} else { | |
delete query.my; | |
} | |
this.setState({ byMy }, () => { | |
this.props.history.replace({ pathname, query }); | |
}); | |
}; | |
onChangeFilterActive = isSelected => { | |
let newValue = PROJECT_STATUS.ACTIVE_ARCHIVED; | |
if (!isSelected) { | |
if (this.state.projectView !== PROJECT_STATUS.ACTIVE_ARCHIVED) { | |
return; | |
} | |
newValue = PROJECT_STATUS.ARCHIVED; | |
} | |
this.props.actions.updateUserPref('proj_view', `${newValue}`); | |
}; | |
onChangeFilterArchived = isSelected => { | |
let newValue = PROJECT_STATUS.ACTIVE_ARCHIVED; | |
if (!isSelected) { | |
if (this.state.projectView !== PROJECT_STATUS.ACTIVE_ARCHIVED) { | |
return; | |
} | |
newValue = PROJECT_STATUS.ACTIVE; | |
} | |
this.props.actions.updateUserPref('proj_view', `${newValue}`); | |
}; | |
onChangeSort = ({ value }) => { | |
const [sort, order] = value.split('-'); | |
this.changeSortType(sort, order); | |
}; | |
onImport = () => { | |
if (!this.props.importPromptDismissed) { | |
this.props.dismissPrompt(); | |
} | |
this.hideModal(); | |
}; | |
toggleProjectExpanded = projectId => { | |
this.setState( | |
ps => ({ | |
expandedProjects: { | |
...ps.expandedProjects, | |
[projectId]: !ps.expandedProjects[projectId], | |
}, | |
}), | |
this.storeExpandedPhasesInLocalStorage, | |
); | |
}; | |
expandProject = projectId => { | |
if (!this.state.expandedProjects[projectId]) { | |
this.toggleProjectExpanded(projectId); | |
} | |
}; | |
// --------------------------------------------------------------------------- | |
// !!! Helpers --------------------------------------------------------------- | |
// --------------------------------------------------------------------------- | |
getProjectColor = project => { | |
const color = /^[a-fA-F0-9]{3,6}$/.test(project.color) | |
? project.color | |
: config.defaults.color; | |
return `#${color}`; | |
}; | |
checkIsProjectExpanded = project => { | |
const isExpanded = this.state.expandedProjects[project.project_id]; | |
return isExpanded; | |
}; | |
getProjectProps = project => { | |
const clientName = get( | |
this.props, | |
`clients.clients.${project.client_id}.client_name`, | |
); | |
const pm = get( | |
this.props, | |
`accounts.accounts.${project.project_manager}.name`, | |
); | |
return { | |
project: { | |
label: this.renderProjectName(project), | |
value: project.project_name || '', | |
}, | |
client: { | |
value: clientName || 'No client', | |
label: this.renderClientName({ | |
clientId: project.client_id, | |
isActive: project.active, | |
}), | |
}, | |
tags: this.renderTags(project), | |
statusTags: this.renderStatusTags(project), | |
notes: this.renderNotes(project), | |
budget: { | |
label: this.renderBudget(project), | |
value: project.budget_total || 0, | |
}, | |
start: { | |
label: this.renderStart(project), | |
value: this.getDateGroupValue(project.start_date), | |
}, | |
end: { | |
label: this.renderEnd(project), | |
value: this.getDateGroupValue(project.end_date, 'End'), | |
}, | |
pm: { | |
label: this.renderPM({ | |
isActive: project.active, | |
peopleId: project.project_manager, | |
}), | |
value: pm || 'No project owner', | |
}, | |
color: this.getProjectColor(project), | |
key: project.project_id, | |
canEdit: this.canBulkEdit(project), | |
entity: this.props.projects[project.project_id], | |
onClick: evt => { | |
evt.preventDefault(); | |
this.showEditProjectModal(project); | |
}, | |
}; | |
}; | |
getPhaseProps(phase) { | |
const project = this.props.projects[phase.project_id]; | |
const ret = { | |
selectable: false, | |
preventSelect: true, | |
selectablePadding: this.canAct(), | |
className: 'phase-row', | |
key: `phase:${phase.phase_id}`, | |
color: this.getProjectColor(phase), | |
project: { | |
label: () => ( | |
<> | |
<Spacer size={21} /> | |
<Table.Text disabled={!phase.active} primary> | |
{phase.phase_name} | |
</Table.Text> | |
</> | |
), | |
}, | |
client: { | |
label: this.renderClientName({ | |
clientId: project.client_id, | |
isActive: phase.active, | |
}), | |
}, | |
statusTags: this.renderStatusTags(phase), | |
notes: this.renderNotes(phase), | |
budget: { label: this.renderBudget(phase) }, | |
start: { label: this.renderStart(phase) }, | |
end: { label: this.renderEnd(phase) }, | |
pm: { | |
label: this.renderPM({ | |
isActive: phase.active, | |
peopleId: project.project_manager, | |
}), | |
}, | |
canEdit: phase.canEdit, | |
parent: project, | |
entity: phase, | |
onClick: evt => { | |
prevent(evt); | |
this.showEditPhaseModal(phase); | |
}, | |
startDate: phase.start_date, | |
}; | |
return ret; | |
} | |
getRowProps = rowData => { | |
if (rowData.render) { | |
return rowData; | |
} | |
if (rowData.phase_id) { | |
return this.getPhaseProps(rowData); | |
} | |
return this.getProjectProps(rowData); | |
}; | |
filterProjects = () => { | |
const filteredProjects = this.props.searchFilteredProjects.filter(p => { | |
const acctId = Number(get(this.props, 'currentUser.admin_id')); | |
if (this.state.byMy && p.project_manager !== acctId) return false; | |
if (this.state.projectView === 1 && !p.active) return false; | |
if (this.state.projectView === 0 && p.active) return false; | |
return true; | |
}); | |
const sorted = sortBy(filteredProjects, p => { | |
const sortVal = (() => { | |
if (this.state.sort.type === 'client') { | |
return get( | |
this.props, | |
`clients.clients.${p.client_id}.client_name`, | |
'zno client', | |
).toLowerCase(); | |
} | |
if (this.state.sort.type === 'pm') { | |
return get( | |
this.props, | |
`accounts.accounts.${p.project_manager}.name`, | |
'zno pm', | |
).toLowerCase(); | |
} | |
if (this.state.sort.type === 'start') { | |
return moment(get(p, 'start_date', new Date(2100, 1, 1))).valueOf(); | |
} | |
if (this.state.sort.type === 'end') { | |
return moment(get(p, 'end_date', new Date(2100, 1, 1))).valueOf(); | |
} | |
if (this.state.sort.type === 'budget') { | |
return getBudgetSortString(p, this.state.sort.dir); | |
} | |
if (this.state.sort.type === 'created') { | |
return get(p, 'created'); | |
} | |
return ''; | |
})(); | |
return `${sortVal}-${get(p, 'project_name', '').toLowerCase()}`; | |
}); | |
if (this.state.sort.type === 'budget') { | |
if (this.state.sort.dir === 'asc') { | |
sorted.reverse(); | |
} | |
} else if (this.state.sort.dir === 'desc') { | |
sorted.reverse(); | |
} | |
// After we've sorted the projects page by the appropriate project | |
// attribute, we want to splice in the phase rows for any phases that | |
// are currently open. | |
forEach(this.state.expandedProjects, (isOpen, projectId) => { | |
if (!isOpen) return; | |
const idx = sorted.findIndex(s => s.project_id == projectId); | |
if (idx === -1) { | |
// If we couldn't find the index of the project, it was filtered out | |
// in the filteredProjects object. In this case, we'll treat the | |
// expanded project as closed. | |
return; | |
} | |
// If we've loaded a stored expandedProjects state from local storage, | |
// we may not have fetched phases yet, so default to an empty array. | |
const phases = this.props.projectPhases[projectId] || []; | |
const toAdd = phases.sort((a, b) => | |
a.start_date.localeCompare(b.start_date), | |
); | |
sorted.splice(idx + 1, 0, ...toAdd); | |
}); | |
const searchFiltersApplied = !!this.props.search.filters.length; | |
const projectsCount = filteredProjects.length; | |
const isLoaded = this.isLoaded(); | |
const showImportPrompt = | |
isLoaded && | |
!this.props.importPromptDismissed && | |
!searchFiltersApplied && | |
projectsCount && | |
this.canAct(); | |
if (showImportPrompt) { | |
sorted.push({ | |
key: 'import-prompt', | |
preventSelect: true, | |
render: this.renderImportPrompt, | |
style: { | |
height: 340, | |
padding: 20, | |
background: 'transparent', | |
cursor: 'initial', | |
}, | |
}); | |
} | |
this.setState({ filteredProjects: sorted }); | |
this.setPlaceholder(filteredProjects); | |
}; | |
setPlaceholder = filteredProjects => { | |
const active = filteredProjects.filter(p => p.active).length; | |
const archived = filteredProjects.length - active; | |
if (this.state.projectView === 1) { | |
this.props.actions.setPlaceholder( | |
`${active} ${active === 1 ? 'project' : 'projects'}`, | |
); | |
} | |
if (this.state.projectView === 0) { | |
this.props.actions.setPlaceholder( | |
`${archived} ${archived === 1 ? 'project' : 'projects'}`, | |
); | |
} | |
if (this.state.projectView === 2) { | |
const text = `${active} active, ${archived} archived`; | |
this.props.actions.setPlaceholder(text); | |
} | |
}; | |
clearSelected = () => { | |
this.table.toggleSelectAll(); | |
}; | |
// members and department managers cannot add or edit projects | |
canAct = () => ![4, 6].includes(Number(this.props.currentUser.account_tid)); | |
canBulkEdit = project => { | |
return ( | |
project.canEdit && | |
!this.isCurrentUserDepartmentManager() && | |
!this.isCurrentUserPmButNotOwnerOf(project) | |
); | |
}; | |
canAddPhase = project => { | |
return project.canEdit && !this.isCurrentUserDepartmentManager(); | |
}; | |
isCurrentUserPmButNotOwnerOf = project => { | |
const { | |
account_tid: accountType, | |
admin_id: accountId, | |
} = this.props.currentUser; | |
const isPM = +accountType === 3; | |
const isOwner = accountId == project.project_manager; | |
return isPM && !isOwner; | |
}; | |
isCurrentUserDepartmentManager = () => { | |
return +this.props.currentUser?.account_tid === 6; | |
}; | |
isFilteredByMy = () => { | |
const { | |
location: { query = {} }, | |
} = this.props; | |
return !!query.my; | |
}; | |
fetchAllData = () => { | |
const includeArchived = this.state.projectView !== 1; | |
this.props.actions.ensureProjectsLoaded({ includeArchived }); | |
this.filterProjects(); | |
this.fetchAllBudgetUsage(); | |
}; | |
fetchAllBudgetUsage = () => { | |
const includeArchived = this.state.projectView !== 1; | |
if (includeArchived && !this.props.archivedBudgetsLoaded) { | |
this.props.actions.ensureBudgetsLoaded(null, { | |
forceLoad: true, | |
includeArchived: true, | |
}); | |
} | |
}; | |
getExpandedPhasesLocalStorageKey = () => { | |
return `${this.props.currentUser.cid}|${this.props.currentUser.account_id}`; | |
}; | |
storeExpandedPhasesInLocalStorage = () => { | |
const key = this.getExpandedPhasesLocalStorageKey(); | |
localStorage.setItem(key, JSON.stringify(this.state.expandedProjects)); | |
}; | |
loadExpandedPhasesFromLocalStorage = () => { | |
const key = this.getExpandedPhasesLocalStorageKey(); | |
const res = JSON.parse(localStorage.getItem(key) || '{}'); | |
this.setState({ expandedProjects: res }); | |
}; | |
// --------------------------------------------------------------------------- | |
// !!! Modal / Multi Actions ------------------------------------------------- | |
// --------------------------------------------------------------------------- | |
afterBulkUpdate = ({ clearSelection = false } = {}) => { | |
if (clearSelection) { | |
this.clearSelected(); | |
} | |
this.props.confirmClose(); | |
this.props.confirmDeleteClose(); | |
this.setState({ modal: {} }); | |
}; | |
bulkUpdate = ({ ids, fields, messageSuffix = 'updated', updateFn }) => { | |
const title = | |
ids.length > 1 | |
? `${ids.length} projects` | |
: this.props.projects[ids[0]].project_name; | |
let fn = updateFn; | |
if (!fn) { | |
switch (messageSuffix) { | |
case 'deleted': | |
fn = this.props.actions.bulkDeleteProjects; | |
break; | |
default: | |
fn = this.props.actions.bulkUpdateProjects; | |
break; | |
} | |
} | |
return fn(ids, fields) | |
.then(() => { | |
this.afterBulkUpdate({ clearSelection: messageSuffix !== 'updated' }); | |
this.showMessage(`${title} ${messageSuffix}.`); | |
}) | |
.catch(err => { | |
this.afterBulkUpdate(); | |
this.showMessage((err && err.message) || 'An error occurred.'); | |
}); | |
}; | |
showMessage = message => { | |
this.props.showSnackbar(message); | |
}; | |
hideModal = clearSelection => { | |
this.setState({ modal: {} }); | |
if (clearSelection === true) { | |
this.clearSelected(); | |
} | |
}; | |
archive = ids => { | |
const itemsTitle = | |
ids.length > 1 | |
? `${ids.length} projects` | |
: this.props.projects[ids[0]].project_name; | |
this.props.confirm({ | |
title: 'Move to archive', | |
confirmLabel: 'Move to archive', | |
showLoaderOnConfirm: true, | |
message: ( | |
<p> | |
<span>Archive </span> | |
<strong>{itemsTitle}</strong> | |
<span>?</span> | |
</p> | |
), | |
onConfirm: () => { | |
this.bulkUpdate({ | |
ids, | |
fields: { active: 0 }, | |
messageSuffix: 'archived', | |
}); | |
}, | |
}); | |
}; | |
activate = ids => { | |
const itemsTitle = | |
ids.length > 1 | |
? `${ids.length} projects` | |
: this.props.projects[ids[0]].project_name; | |
this.props.confirm({ | |
title: 'Move to active', | |
confirmLabel: 'Move to active', | |
showLoaderOnConfirm: true, | |
message: ( | |
<p> | |
<span>Move </span> | |
<strong>{itemsTitle}</strong> | |
<span> to active?</span> | |
</p> | |
), | |
onConfirm: () => { | |
this.bulkUpdate({ | |
ids, | |
fields: { active: 1 }, | |
messageSuffix: 'activated', | |
}); | |
}, | |
}); | |
}; | |
delete = ids => { | |
const itemsTitle = | |
ids.length > 1 | |
? `${ids.length} projects` | |
: this.props.projects[ids[0]].project_name; | |
const impact = hasTasks({ | |
ids, | |
type: 'project', | |
searchContext: this.props.search?.context, | |
}); | |
this.props.confirmDelete({ | |
title: 'Delete projects', | |
item: itemsTitle, | |
impact, | |
twoStep: impact, | |
onDelete: () => { | |
this.bulkUpdate({ ids, messageSuffix: 'deleted' }); | |
}, | |
onArchive: | |
!this.hasActiveSelected || !impact | |
? undefined | |
: () => { | |
this.bulkUpdate({ | |
ids, | |
fields: { active: 0 }, | |
messageSuffix: 'archived', | |
}); | |
}, | |
}); | |
}; | |
getModal = () => { | |
const { type, data } = this.state.modal; | |
if (!type) return null; | |
switch (type) { | |
case 'bulkEdit': | |
return ( | |
<BulkEditProjectsModal | |
ids={data} | |
onCancel={this.hideModal} | |
onUpdate={this.bulkUpdate} | |
/> | |
); | |
case 'import': | |
return ( | |
<ImportProjectsModal | |
projects={this.props.projects} | |
onSave={this.onImport} | |
onClose={this.hideModal} | |
/> | |
); | |
case 'manage-templates': | |
return <ManageTemplatesModal onClose={this.hideModal} />; | |
default: | |
console.error(`Unknown modal type ${type}`); | |
return null; | |
} | |
}; | |
getMultiSelectActions = selected => { | |
const actions = [ | |
{ | |
label: 'Edit', | |
icon: IconPencil, | |
action: data => { | |
const isEditingSingleItem = data.length === 1; | |
if (isEditingSingleItem) { | |
const projectId = data[0]; | |
const project = this.props.projects[projectId]; | |
if (project) { | |
this.showEditProjectModal(project); | |
} | |
return; | |
} | |
this.setState({ modal: { type: 'bulkEdit', data } }); | |
}, | |
}, | |
]; | |
this.hasActiveSelected = selected.some(id => { | |
const p = this.props.projects[id]; | |
return p && p.active; | |
}); | |
if (this.hasActiveSelected) { | |
actions.push({ | |
label: 'Archive', | |
icon: IconArchive, | |
action: this.archive, | |
}); | |
} | |
const hasArchivedSelected = selected.some(id => { | |
const p = this.props.projects[id]; | |
return p && !p.active; | |
}); | |
if (hasArchivedSelected) { | |
actions.push({ | |
label: 'Activate', | |
icon: IconBolt, | |
action: this.activate, | |
}); | |
} | |
actions.push({ | |
label: 'Delete', | |
icon: IconTrash, | |
action: this.delete, | |
}); | |
return actions; | |
}; | |
showAddProjectModal = evt => { | |
evt.preventDefault(); | |
evt.stopPropagation(); | |
const projectTemplates = hasFeature(FEATURES.projectTemplates); | |
if ( | |
projectTemplates && | |
canViewTemplates(this.props.currentUser) && | |
!this.props.currentUser.prefs?.project_from_scratch | |
) { | |
this.props.actions.manageModal({ | |
visible: true, | |
modalType: 'scratchTemplateProjectModal', | |
}); | |
return; | |
} | |
this.props.actions.manageModal({ | |
visible: true, | |
modalType: 'projectModal', | |
modalSettings: { | |
project: { | |
project_manager: this.props.currentUser.account_id, | |
}, | |
isAdding: true, | |
editing: true, | |
}, | |
}); | |
}; | |
showAddPhaseModal = (evt, project) => { | |
prevent(evt); | |
this.props.actions.manageModal({ | |
visible: true, | |
modalType: 'phaseModal', | |
modalSettings: { | |
phase: {}, | |
projectId: project.project_id, | |
isAdding: true, | |
editing: true, | |
postSave: () => this.expandProject(project.project_id), | |
}, | |
}); | |
}; | |
showEditProjectModal = project => { | |
this.props.actions.manageModal({ | |
visible: true, | |
modalType: 'projectModal', | |
modalSettings: { | |
project, | |
editing: true, | |
}, | |
}); | |
}; | |
showEditPhaseModal = phase => { | |
this.props.actions.manageModal({ | |
visible: true, | |
modalType: 'phaseModal', | |
modalSettings: { | |
phase, | |
projectId: phase.project_id, | |
editing: true, | |
}, | |
}); | |
}; | |
showImportProjectModal = evt => { | |
evt.preventDefault(); | |
this.setState({ modal: { type: 'import' } }); | |
}; | |
showManageTemplatesModal = evt => { | |
evt.preventDefault(); | |
this.setState({ modal: { type: 'manage-templates' } }); | |
}; | |
getDateGroupValue = (date, prefix = 'Start') => { | |
if (!date) { | |
return 'Unscheduled'; | |
} | |
const days = moment().diff(date, 'days'); | |
if (days <= 0) { | |
return `${prefix}s in the future`; | |
} | |
if (days < 30) { | |
return `${prefix}ed in the last 30 days`; | |
} | |
if (days > 30) { | |
return `${prefix}ed more than 30 days ago`; | |
} | |
return 'Unscheduled'; | |
}; | |
// --------------------------------------------------------------------------- | |
// !!! Render ---------------------------------------------------------------- | |
// --------------------------------------------------------------------------- | |
renderProjectName = p => () => { | |
const name = p.project_name || ''; | |
const activeIntegration = this.props.activeIntegrations.find( | |
({ coIntId }) => +p.integrations_co_id === coIntId, | |
); | |
let phasesExpand; | |
const phases = this.props.projectPhases[p.project_id]; | |
if (phases?.length > 0) { | |
const isOpen = this.state.expandedProjects[p.project_id]; | |
phasesExpand = ( | |
<> | |
<Spacer size={9} /> | |
<styled.PhasesExpand | |
isOpen={isOpen} | |
disabled={!p.active} | |
onClick={evt => { | |
prevent(evt); | |
this.toggleProjectExpanded(p.project_id); | |
}} | |
> | |
{phases.length} | |
<Icons.DownSmall /> | |
</styled.PhasesExpand> | |
</> | |
); | |
} | |
return ( | |
<styled.ProjectNameContainer | |
isActive={p.active} | |
hasPhase={phases?.length > 0} | |
> | |
<Table.Text disabled={!p.active} primary> | |
{name} | |
</Table.Text> | |
{activeIntegration && ( | |
<SyncIcon | |
itemType="project" | |
integrationType={activeIntegration.label || activeIntegration.type} | |
disabled={!p.active} | |
/> | |
)} | |
<Spacer axis="x" /> | |
<Spacer size={6} /> | |
<Table.HoverLinks> | |
{this.canAddPhase(p) && ( | |
<Table.HoverLinkIcon | |
label="Add Phase" | |
className="project desktop" | |
onClick={evt => this.showAddPhaseModal(evt, p)} | |
> | |
<Icons.Plus variant="bold" /> | |
</Table.HoverLinkIcon> | |
)} | |
<Table.HoverLinkIcon | |
to={`/?project=${encodeURIComponent(name)}`} | |
icon={<Icons.NavTimeline size={24} />} | |
label="Timeline" | |
className="project" | |
onClick={() => { | |
this.props.actions.updateUserPref('sked_view_type', 'projects'); | |
}} | |
/> | |
<Table.HoverLinkIcon | |
to={`/report?project=${encodeURIComponent(name)}`} | |
icon={<Icons.NavReports size={24} />} | |
className="project desktop" | |
label="Report" | |
/> | |
</Table.HoverLinks> | |
{phasesExpand} | |
</styled.ProjectNameContainer> | |
); | |
}; | |
renderClientName = ({ clientId, isActive }) => () => { | |
const clientName = get( | |
this.props, | |
`clients.clients.${clientId}.client_name`, | |
); | |
if (!clientName) { | |
return <Table.EmptyCell disabled={!isActive} />; | |
} | |
return ( | |
<Table.HoverLink | |
disabled={!isActive} | |
to={ | |
clientName && | |
appendFilter({ client: clientName }, this.props.location) | |
} | |
> | |
{clientName} | |
</Table.HoverLink> | |
); | |
}; | |
renderTags = p => () => { | |
const tags = sortBy(p.tags, t => t.toLowerCase()).map(t => ({ | |
to: appendFilter({ projectTag: t }, this.props.location), | |
label: t, | |
})); | |
return <Table.Tags tags={tags} appearance="dynamic" />; | |
}; | |
renderStatusTags = p => () => { | |
const statusTags = []; | |
if (p.non_billable) { | |
statusTags.push({ | |
label: 'Non-billable', | |
to: appendFilter({ taskStatus: 'Non-billable' }, this.props.location), | |
}); | |
} | |
if (p.tentative) { | |
statusTags.push({ | |
label: 'Tentative', | |
to: appendFilter({ taskStatus: 'Tentative' }, this.props.location), | |
}); | |
} | |
return ( | |
<Table.Tags tags={statusTags} ellipsis={false} appearance="dynamic" /> | |
); | |
}; | |
renderNotes = p => () => { | |
const notes = p.description || p.notes || ''; | |
if (!notes) { | |
return null; | |
} | |
return ( | |
<Tooltip | |
key="more-tags" | |
interactive | |
placement="top" | |
trigger="mouseenter" | |
appendTo={document.body} | |
content={ | |
<styled.Notes onClick={stopPropagation}> | |
<Linkify | |
options={{ | |
attributes: { | |
contentEditable: false, | |
tabIndex: -1, | |
}, | |
}} | |
> | |
{getDisplayNotesExcerpt({ notes, max: 100 })} | |
</Linkify> | |
</styled.Notes> | |
} | |
> | |
<span style={{ height: 16, outline: 'none' }}> | |
<Icons.NotesLarge /> | |
</span> | |
</Tooltip> | |
); | |
}; | |
renderBudget = projectOrPhase => () => { | |
const project = projectOrPhase.phase_id | |
? this.props.projects[projectOrPhase.project_id] | |
: projectOrPhase; | |
const phase = projectOrPhase.phase_id ? projectOrPhase : null; | |
if (phase && !project?.budget_per_phase) return null; | |
return ( | |
<ProjectBudgetBar | |
project={project} | |
phase={phase} | |
phases={this.props.projectPhases[project.project_id]} | |
/> | |
); | |
}; | |
renderStart = p => () => { | |
const start = formatDate(p.start_date); | |
return ( | |
<Table.Text | |
disabled={!p.active} | |
style={{ | |
overflow: 'visible', | |
}} | |
> | |
{start} | |
</Table.Text> | |
); | |
}; | |
renderEnd = p => { | |
const start = formatDate(p.start_date); | |
const end = formatDate(p.end_date); | |
if (end && start !== end) { | |
const hyphenColumnSeparator = ( | |
<Table.Text | |
disabled={!p.active} | |
style={{ | |
position: 'absolute', | |
left: -8, | |
}} | |
> | |
- | |
</Table.Text> | |
); | |
return ( | |
<> | |
{start && hyphenColumnSeparator} | |
<Table.Text disabled={!p.active} ellipsis={false}> | |
{end} | |
</Table.Text> | |
</> | |
); | |
} | |
return ''; | |
}; | |
renderPM = ({ isActive, peopleId }) => () => { | |
const pm = get(this.props, `accounts.accounts.${peopleId}`); | |
if (!pm) { | |
return <Table.EmptyCell style={{ marginLeft: 8 }} />; | |
} | |
return ( | |
<Tooltip content={pm.name} className="hint" attachToRoot={true}> | |
<div style={{ outline: 'none' }}> | |
<PersonAvatar | |
readOnly | |
accountId={pm.account_id} | |
size="xs" | |
tooltip={false} | |
disabled={!isActive} | |
/> | |
</div> | |
</Tooltip> | |
); | |
}; | |
renderActions = () => { | |
const { projectView, sort } = this.state; | |
const { currentUser } = this.props; | |
// same logic as in web/app/serena/Cell/CornerCell/index.js | |
const perms = getUserAccess(currentUser); | |
const showAddIcon = !perms.isMember() && !perms.isDepartmentManager(); | |
const AddIcon = showAddIcon ? ( | |
<styled.AddEntity | |
className="desktop" | |
size="xsmall" | |
appearance="secondary" | |
onClick={this.showAddProjectModal} | |
> | |
<Icons.AddProject /> | |
</styled.AddEntity> | |
) : null; | |
return ( | |
<> | |
<Table.Viewing | |
actionLeft={AddIcon} | |
optionsLeft={[ | |
{ | |
label: 'Active', | |
isSelected: [ | |
PROJECT_STATUS.ACTIVE, | |
PROJECT_STATUS.ACTIVE_ARCHIVED, | |
].includes(projectView), | |
onChange: this.onChangeFilterActive, | |
}, | |
{ | |
label: 'Archived', | |
isSelected: [ | |
PROJECT_STATUS.ARCHIVED, | |
PROJECT_STATUS.ACTIVE_ARCHIVED, | |
].includes(projectView), | |
onChange: this.onChangeFilterArchived, | |
}, | |
]} | |
optionsRight={[ | |
{ | |
label: 'My projects', | |
isSelected: this.isFilteredByMy(), | |
onChange: this.changeByMy, | |
}, | |
]} | |
/> | |
<Table.ActionsGroup> | |
<Table.Sort | |
value={sort} | |
columns={PROJECT_COLUMNS} | |
onChange={this.onChangeSort} | |
/> | |
</Table.ActionsGroup> | |
</> | |
); | |
}; | |
renderImportPrompt = () => { | |
return ( | |
<ImportPrompt | |
className="projects" | |
img={ImgImportProjects} | |
label="Import your projects" | |
onClick={this.showImportProjectModal} | |
onClose={this.props.dismissPrompt} | |
/> | |
); | |
}; | |
render() { | |
const searchFiltersApplied = !!this.props.search.filters.length; | |
const noProjects = !this.state.filteredProjects.length; | |
const isLoaded = this.isLoaded(); | |
const canAct = this.canAct(); | |
return ( | |
<styled.ProjectsSection> | |
<ProjectQuickActions | |
canAct={canAct} | |
showManageTemplatesModal={this.showManageTemplatesModal} | |
showImportProjectModal={this.showImportProjectModal} | |
/> | |
{!isLoaded && <Loader />} | |
<Table | |
ref={el => { | |
this.table = el; | |
}} | |
selectable={canAct} | |
columns={PROJECT_COLUMNS} | |
rows={this.state.filteredProjects} | |
rowRenderer={this.getRowProps} | |
sortBy={this.state.sort.type} | |
sortOrder={this.state.sort.dir} | |
nonGroupKeys={['project', 'budget']} | |
printMode={this.props.printMode} | |
onSortByChange={this.changeSortType} | |
onSortOrderChange={this.changeSortDir} | |
renderActions={this.renderActions} | |
getMultiSelectActions={this.getMultiSelectActions} | |
/> | |
{isLoaded && | |
noProjects && | |
(searchFiltersApplied ? ( | |
<NoData useGridStyles /> | |
) : ( | |
<> | |
<NoData | |
img={ImgNoProjects} | |
title="There are no projects to schedule" | |
subtitle={null} | |
useGridStyles | |
> | |
{canAct && ( | |
<Button onClick={this.showAddProjectModal}> | |
Add a project | |
</Button> | |
)} | |
</NoData> | |
</> | |
))} | |
{this.getModal()} | |
</styled.ProjectsSection> | |
); | |
} | |
} | |
const mapStateToProps = state => ({ | |
projects: getProjectsMap(state), | |
accounts: state.accounts, | |
clients: state.clients, | |
search: state.search, | |
searchFilteredProjects: getSearchFilteredProjects(state), | |
currentUser: state.currentUser, | |
hasBeenLoaded: getHaveProjectsWithContextBeenLoaded(state), | |
printMode: state.app.printMode, | |
activeIntegrations: getActiveIntegrations(state), | |
archivedProjectsLoaded: state.projects.archivedProjectsLoaded, | |
archivedBudgetsLoaded: state.projects.archivedBudgetsLoaded, | |
importPromptDismissed: | |
+state.currentUser.account_tid !== 1 || | |
state.onboarding.prompts.includes(PROMPT_ID), | |
projectPhases: getProjectPhases(state), | |
}); | |
const mapDispatchToProps = dispatch => ({ | |
actions: { | |
...bindActionCreators( | |
{ | |
setPlaceholder, | |
ensureContextLoaded, | |
updateUserPref, | |
updateMultiUserPrefs, | |
manageModal, | |
}, | |
dispatch, | |
), | |
...bindActionCreators(projectActions, dispatch), | |
...bindActionCreators(phaseActions, dispatch), | |
}, | |
dismissPrompt: () => dispatch(updatePrompts(PROMPT_ID)), | |
}); | |
const Component = compose( | |
withRouter, | |
withConfirm, | |
withConfirmDelete, | |
withSnackbar, | |
)(Projects); | |
export default connect(mapStateToProps, mapDispatchToProps)(Component); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment