Add probability picker on Opportunity card (#747)
* Fix padding * Update date input component * Add Probability picker component on opportunity card * lint
This commit is contained in:
@ -1,16 +1,16 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { getOperationName } from '@apollo/client/utilities';
|
import { getOperationName } from '@apollo/client/utilities';
|
||||||
import { useTheme } from '@emotion/react';
|
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { useRecoilState } from 'recoil';
|
import { useRecoilState } from 'recoil';
|
||||||
|
|
||||||
import { companyProgressesFamilyState } from '@/companies/states/companyProgressesFamilyState';
|
import { companyProgressesFamilyState } from '@/companies/states/companyProgressesFamilyState';
|
||||||
|
import { ProbabilityEditableField } from '@/pipeline/editable-field/components/ProbabilityEditableField';
|
||||||
import { GET_PIPELINE_PROGRESS, GET_PIPELINES } from '@/pipeline/queries';
|
import { GET_PIPELINE_PROGRESS, GET_PIPELINES } from '@/pipeline/queries';
|
||||||
import { BoardCardContext } from '@/pipeline/states/BoardCardContext';
|
import { BoardCardContext } from '@/pipeline/states/BoardCardContext';
|
||||||
import { pipelineProgressIdScopedState } from '@/pipeline/states/pipelineProgressIdScopedState';
|
import { pipelineProgressIdScopedState } from '@/pipeline/states/pipelineProgressIdScopedState';
|
||||||
import { selectedBoardCardsState } from '@/pipeline/states/selectedBoardCardsState';
|
import { selectedBoardCardsState } from '@/pipeline/states/selectedBoardCardsState';
|
||||||
import { BoardCardEditableFieldDate } from '@/ui/board/card-field/components/BoardCardEditableFieldDate';
|
|
||||||
import { ChipVariant } from '@/ui/chip/components/EntityChip';
|
import { ChipVariant } from '@/ui/chip/components/EntityChip';
|
||||||
|
import { DateEditableField } from '@/ui/editable-field/variants/components/DateEditableField';
|
||||||
import { NumberEditableField } from '@/ui/editable-field/variants/components/NumberEditableField';
|
import { NumberEditableField } from '@/ui/editable-field/variants/components/NumberEditableField';
|
||||||
import { IconCheck, IconCurrencyDollar } from '@/ui/icon';
|
import { IconCheck, IconCurrencyDollar } from '@/ui/icon';
|
||||||
import { IconCalendarEvent } from '@/ui/icon';
|
import { IconCalendarEvent } from '@/ui/icon';
|
||||||
@ -74,8 +74,6 @@ const StyledBoardCardBody = styled.div`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export function CompanyBoardCard() {
|
export function CompanyBoardCard() {
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
const [updatePipelineProgress] = useUpdateOnePipelineProgressMutation();
|
const [updatePipelineProgress] = useUpdateOnePipelineProgressMutation();
|
||||||
|
|
||||||
const [pipelineProgressId] = useRecoilScopedState(
|
const [pipelineProgressId] = useRecoilScopedState(
|
||||||
@ -155,21 +153,19 @@ export function CompanyBoardCard() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<CompanyAccountOwnerEditableField company={company} />
|
<CompanyAccountOwnerEditableField company={company} />
|
||||||
<span>
|
<DateEditableField
|
||||||
<IconCalendarEvent size={theme.icon.size.md} />
|
icon={<IconCalendarEvent />}
|
||||||
<BoardCardEditableFieldDate
|
value={pipelineProgress.closeDate || new Date().toISOString()}
|
||||||
value={new Date(pipelineProgress.closeDate || Date.now())}
|
onSubmit={(value) =>
|
||||||
onChange={(value) => {
|
handleCardUpdate({
|
||||||
handleCardUpdate({
|
...pipelineProgress,
|
||||||
...pipelineProgress,
|
closeDate: value,
|
||||||
closeDate: value.toISOString(),
|
})
|
||||||
});
|
}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
</span>
|
<ProbabilityEditableField
|
||||||
<NumberEditableField
|
|
||||||
icon={<IconCheck />}
|
icon={<IconCheck />}
|
||||||
placeholder="Opportunity probability for closing"
|
|
||||||
value={pipelineProgress.probability}
|
value={pipelineProgress.probability}
|
||||||
onSubmit={(value) =>
|
onSubmit={(value) =>
|
||||||
handleCardUpdate({
|
handleCardUpdate({
|
||||||
|
|||||||
@ -0,0 +1,71 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { EditableField } from '@/ui/editable-field/components/EditableField';
|
||||||
|
import { FieldContext } from '@/ui/editable-field/states/FieldContext';
|
||||||
|
import { RecoilScope } from '@/ui/recoil-scope/components/RecoilScope';
|
||||||
|
|
||||||
|
import { ProbabilityFieldEditMode } from './ProbabilityFieldEditMode';
|
||||||
|
|
||||||
|
type OwnProps = {
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
value: number | null | undefined;
|
||||||
|
onSubmit?: (newValue: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProbabilityEditableField({ icon, value, onSubmit }: OwnProps) {
|
||||||
|
const [internalValue, setInternalValue] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInternalValue(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
async function handleChange(newValue: number) {
|
||||||
|
setInternalValue(newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!internalValue) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const numberValue = internalValue;
|
||||||
|
|
||||||
|
if (isNaN(numberValue)) {
|
||||||
|
throw new Error('Not a number');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numberValue < 0 || numberValue > 100) {
|
||||||
|
throw new Error('Not a probability');
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit?.(numberValue);
|
||||||
|
|
||||||
|
setInternalValue(numberValue);
|
||||||
|
} catch {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancel() {
|
||||||
|
setInternalValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RecoilScope SpecificContext={FieldContext}>
|
||||||
|
<EditableField
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
iconLabel={icon}
|
||||||
|
displayModeContentOnly
|
||||||
|
disableHoverEffect
|
||||||
|
displayModeContent={
|
||||||
|
<ProbabilityFieldEditMode
|
||||||
|
value={internalValue ?? 0}
|
||||||
|
onChange={(newValue: number) => {
|
||||||
|
handleChange(newValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</RecoilScope>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,112 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
|
import { useEditableField } from '@/ui/editable-field/hooks/useEditableField';
|
||||||
|
import { HotkeyScope } from '@/ui/hotkey/types/HotkeyScope';
|
||||||
|
|
||||||
|
const StyledContainer = styled.div`
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledProgressBarItemContainer = styled.div`
|
||||||
|
padding-right: ${({ theme }) => theme.spacing(1)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledProgressBarItem = styled.div<{
|
||||||
|
isFirst: boolean;
|
||||||
|
isLast: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
}>`
|
||||||
|
background-color: ${({ theme, isActive }) =>
|
||||||
|
isActive
|
||||||
|
? theme.font.color.secondary
|
||||||
|
: theme.background.transparent.medium};
|
||||||
|
border-bottom-left-radius: ${({ theme, isFirst }) =>
|
||||||
|
isFirst ? theme.border.radius.sm : theme.border.radius.xs};
|
||||||
|
border-bottom-right-radius: ${({ theme, isLast }) =>
|
||||||
|
isLast ? theme.border.radius.sm : theme.border.radius.xs};
|
||||||
|
border-top-left-radius: ${({ theme, isFirst }) =>
|
||||||
|
isFirst ? theme.border.radius.sm : theme.border.radius.xs};
|
||||||
|
border-top-right-radius: ${({ theme, isLast }) =>
|
||||||
|
isLast ? theme.border.radius.sm : theme.border.radius.xs};
|
||||||
|
height: ${({ theme }) => theme.spacing(2)};
|
||||||
|
width: ${({ theme }) => theme.spacing(3)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledProgressBarContainer = styled.div`
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledLabel = styled.div`
|
||||||
|
width: ${({ theme }) => theme.spacing(12)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
type OwnProps = {
|
||||||
|
value: number;
|
||||||
|
onChange?: (newValue: number) => void;
|
||||||
|
parentHotkeyScope?: HotkeyScope;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROBABILITY_VALUES = [
|
||||||
|
{ label: 'Lost', value: 0 },
|
||||||
|
{ label: '25%', value: 25 },
|
||||||
|
{ label: '50%', value: 50 },
|
||||||
|
{ label: '75%', value: 75 },
|
||||||
|
{ label: '100%', value: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ProbabilityFieldEditMode({ value, onChange }: OwnProps) {
|
||||||
|
const [nextProbabilityIndex, setNextProbabilityIndex] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const probabilityIndex = Math.ceil(value / 25);
|
||||||
|
const { closeEditableField } = useEditableField();
|
||||||
|
|
||||||
|
function handleChange(newValue: number) {
|
||||||
|
onChange?.(newValue);
|
||||||
|
closeEditableField();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledContainer>
|
||||||
|
<StyledLabel>
|
||||||
|
{
|
||||||
|
PROBABILITY_VALUES[
|
||||||
|
nextProbabilityIndex || nextProbabilityIndex === 0
|
||||||
|
? nextProbabilityIndex
|
||||||
|
: probabilityIndex
|
||||||
|
].label
|
||||||
|
}
|
||||||
|
</StyledLabel>
|
||||||
|
<StyledProgressBarContainer>
|
||||||
|
{PROBABILITY_VALUES.map((probability, i) => (
|
||||||
|
<StyledProgressBarItemContainer
|
||||||
|
onClick={() => handleChange(probability.value)}
|
||||||
|
onMouseEnter={() => setNextProbabilityIndex(i)}
|
||||||
|
onMouseLeave={() => setNextProbabilityIndex(null)}
|
||||||
|
>
|
||||||
|
<StyledProgressBarItem
|
||||||
|
isActive={
|
||||||
|
nextProbabilityIndex || nextProbabilityIndex === 0
|
||||||
|
? i <= nextProbabilityIndex
|
||||||
|
: i <= probabilityIndex
|
||||||
|
}
|
||||||
|
key={probability.label}
|
||||||
|
isFirst={i === 0}
|
||||||
|
isLast={i === PROBABILITY_VALUES.length - 1}
|
||||||
|
/>
|
||||||
|
</StyledProgressBarItemContainer>
|
||||||
|
))}
|
||||||
|
</StyledProgressBarContainer>
|
||||||
|
</StyledContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ export const StyledBoard = styled.div`
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
padding-left: ${({ theme }) => theme.spacing(2)};
|
||||||
width: 100%;
|
width: 100%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@ -59,7 +59,9 @@ type OwnProps = {
|
|||||||
label?: string;
|
label?: string;
|
||||||
labelFixedWidth?: number;
|
labelFixedWidth?: number;
|
||||||
useEditButton?: boolean;
|
useEditButton?: boolean;
|
||||||
editModeContent: React.ReactNode;
|
editModeContent?: React.ReactNode;
|
||||||
|
displayModeContentOnly?: boolean;
|
||||||
|
disableHoverEffect?: boolean;
|
||||||
displayModeContent: React.ReactNode;
|
displayModeContent: React.ReactNode;
|
||||||
parentHotkeyScope?: HotkeyScope;
|
parentHotkeyScope?: HotkeyScope;
|
||||||
customEditHotkeyScope?: HotkeyScope;
|
customEditHotkeyScope?: HotkeyScope;
|
||||||
@ -77,7 +79,9 @@ export function EditableField({
|
|||||||
displayModeContent,
|
displayModeContent,
|
||||||
parentHotkeyScope,
|
parentHotkeyScope,
|
||||||
customEditHotkeyScope,
|
customEditHotkeyScope,
|
||||||
|
disableHoverEffect,
|
||||||
isDisplayModeContentEmpty,
|
isDisplayModeContentEmpty,
|
||||||
|
displayModeContentOnly,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: OwnProps) {
|
}: OwnProps) {
|
||||||
@ -115,12 +119,13 @@ export function EditableField({
|
|||||||
<StyledLabel labelFixedWidth={labelFixedWidth}>{label}</StyledLabel>
|
<StyledLabel labelFixedWidth={labelFixedWidth}>{label}</StyledLabel>
|
||||||
)}
|
)}
|
||||||
</StyledLabelAndIconContainer>
|
</StyledLabelAndIconContainer>
|
||||||
{isFieldInEditMode ? (
|
{isFieldInEditMode && !displayModeContentOnly ? (
|
||||||
<EditableFieldEditMode onSubmit={onSubmit} onCancel={onCancel}>
|
<EditableFieldEditMode onSubmit={onSubmit} onCancel={onCancel}>
|
||||||
{editModeContent}
|
{editModeContent}
|
||||||
</EditableFieldEditMode>
|
</EditableFieldEditMode>
|
||||||
) : (
|
) : (
|
||||||
<EditableFieldDisplayMode
|
<EditableFieldDisplayMode
|
||||||
|
disableHoverEffect={disableHoverEffect}
|
||||||
disableClick={useEditButton}
|
disableClick={useEditButton}
|
||||||
onClick={handleDisplayModeClick}
|
onClick={handleDisplayModeClick}
|
||||||
isDisplayModeContentEmpty={isDisplayModeContentEmpty}
|
isDisplayModeContentEmpty={isDisplayModeContentEmpty}
|
||||||
|
|||||||
@ -2,7 +2,10 @@ import { css } from '@emotion/react';
|
|||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
export const EditableFieldNormalModeOuterContainer = styled.div<
|
export const EditableFieldNormalModeOuterContainer = styled.div<
|
||||||
Pick<OwnProps, 'disableClick' | 'isDisplayModeContentEmpty'>
|
Pick<
|
||||||
|
OwnProps,
|
||||||
|
'disableClick' | 'isDisplayModeContentEmpty' | 'disableHoverEffect'
|
||||||
|
>
|
||||||
>`
|
>`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||||
@ -37,7 +40,8 @@ export const EditableFieldNormalModeOuterContainer = styled.div<
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: ${props.theme.background.transparent.light};
|
background-color: ${!props.disableHoverEffect &&
|
||||||
|
props.theme.background.transparent.light};
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -62,6 +66,7 @@ type OwnProps = {
|
|||||||
disableClick?: boolean;
|
disableClick?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
isDisplayModeContentEmpty?: boolean;
|
isDisplayModeContentEmpty?: boolean;
|
||||||
|
disableHoverEffect?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EditableFieldDisplayMode({
|
export function EditableFieldDisplayMode({
|
||||||
@ -69,12 +74,14 @@ export function EditableFieldDisplayMode({
|
|||||||
disableClick,
|
disableClick,
|
||||||
onClick,
|
onClick,
|
||||||
isDisplayModeContentEmpty,
|
isDisplayModeContentEmpty,
|
||||||
|
disableHoverEffect,
|
||||||
}: React.PropsWithChildren<OwnProps>) {
|
}: React.PropsWithChildren<OwnProps>) {
|
||||||
return (
|
return (
|
||||||
<EditableFieldNormalModeOuterContainer
|
<EditableFieldNormalModeOuterContainer
|
||||||
onClick={disableClick ? undefined : onClick}
|
onClick={disableClick ? undefined : onClick}
|
||||||
disableClick={disableClick}
|
disableClick={disableClick}
|
||||||
isDisplayModeContentEmpty={isDisplayModeContentEmpty}
|
isDisplayModeContentEmpty={isDisplayModeContentEmpty}
|
||||||
|
disableHoverEffect={disableHoverEffect}
|
||||||
>
|
>
|
||||||
<EditableFieldNormalModeInnerContainer>
|
<EditableFieldNormalModeInnerContainer>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
Reference in New Issue
Block a user