Skip to content

Instantly share code, notes, and snippets.

@genert
Created March 15, 2022 11:20
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save genert/78e7fcb2a4d172effa063ba213dd8ec3 to your computer and use it in GitHub Desktop.
Save genert/78e7fcb2a4d172effa063ba213dd8ec3 to your computer and use it in GitHub Desktop.
CRM
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