diff --git a/src/components/DatePicker/DatePicker.tsx b/src/components/DatePicker/DatePicker.tsx index 50aaa7205..f2c147085 100644 --- a/src/components/DatePicker/DatePicker.tsx +++ b/src/components/DatePicker/DatePicker.tsx @@ -74,7 +74,11 @@ export const DatePicker = ({ ? 'MMM, yyyy' : mode === 'timePicker' ? 'h:mm aa' - : 'MMM d, yyyy', + : mode === 'yearPicker' + ? 'yyyy' + : mode === 'quarterPicker' + ? '\'Q\'Q yyyy' + : 'MMM d, yyyy', timeIntervals = 15, timeCaption, placeholderText: _placeholderText, @@ -134,6 +138,7 @@ export const DatePicker = ({ } catch (_err) { return } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [selected]) useEffect(() => { @@ -145,12 +150,14 @@ export const DatePicker = ({ } else { setPickerDate(false) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedDates]) useEffect(() => { if (isRangeMode(mode)) { setSelectedDates([startDate, endDate]) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [startDate, endDate]) const wrapperClassNames = classNames( @@ -286,6 +293,8 @@ export const DatePicker = ({ showMonthYearPicker={ mode === 'monthPicker' || mode === 'monthRangePicker' } + showQuarterYearPicker={mode === 'quarterPicker'} + showYearPicker={mode === 'yearPicker'} dateFormat={dateFormat} renderDayContents={day => ( {day} diff --git a/src/components/DatePicker/ModeSelector/DatePickerModeSelector.tsx b/src/components/DatePicker/ModeSelector/DatePickerModeSelector.tsx index 0a9847af9..5631fffa4 100644 --- a/src/components/DatePicker/ModeSelector/DatePickerModeSelector.tsx +++ b/src/components/DatePicker/ModeSelector/DatePickerModeSelector.tsx @@ -7,6 +7,8 @@ export type RangePickerMode = | 'dayRangePicker' | 'monthRangePicker' | 'monthPicker' + | 'quarterPicker' + | 'yearPicker' export type DatePickerMode = SingularPickerMode | RangePickerMode @@ -18,6 +20,8 @@ const DATE_RANGE_MODE_CONFIG: Record = { dayRangePicker: { label: 'Select dates' }, monthPicker: { label: 'Month' }, monthRangePicker: { label: 'Select months' }, + quarterPicker: { label: 'Quarter' }, + yearPicker: { label: 'Year' }, } function toToggleOptions(allowedModes: ReadonlyArray) { diff --git a/src/components/ProfitAndLoss/ProfitAndLoss.tsx b/src/components/ProfitAndLoss/ProfitAndLoss.tsx index 08b2ae250..e6825432e 100644 --- a/src/components/ProfitAndLoss/ProfitAndLoss.tsx +++ b/src/components/ProfitAndLoss/ProfitAndLoss.tsx @@ -5,6 +5,7 @@ import { useProfitAndLossComparison } from '../../hooks/useProfitAndLossComparis import { ReportingBasis } from '../../types' import { Container } from '../Container' import { ProfitAndLossChart } from '../ProfitAndLossChart' +import { ProfitAndLossChartLegend } from '../ProfitAndLossChartLegend/ProfitAndLossChartLegend' import { ProfitAndLossCompareOptions } from '../ProfitAndLossCompareOptions' import { ProfitAndLossDatePicker } from '../ProfitAndLossDatePicker' import { ProfitAndLossDetailedCharts } from '../ProfitAndLossDetailedCharts' @@ -40,6 +41,8 @@ const PNLContext = createContext({ revenue: undefined, }, tagFilter: undefined, + period: 'month', + setPeriod: () => {}, }) type Props = PropsWithChildren & { @@ -74,6 +77,7 @@ const ProfitAndLoss = ({ } ProfitAndLoss.Chart = ProfitAndLossChart +ProfitAndLoss.ChartLegend = ProfitAndLossChartLegend ProfitAndLoss.Context = PNLContext ProfitAndLoss.ComparisonContext = PNLComparisonContext ProfitAndLoss.DatePicker = ProfitAndLossDatePicker diff --git a/src/components/ProfitAndLossChart/Indicator.tsx b/src/components/ProfitAndLossChart/Indicator.tsx index 2cfebe767..e27e72616 100644 --- a/src/components/ProfitAndLossChart/Indicator.tsx +++ b/src/components/ProfitAndLossChart/Indicator.tsx @@ -15,8 +15,11 @@ type Props = BaseProps & { setAnimateFrom: (x: number) => void customCursorSize: { width: number; height: number } setCustomCursorSize: (width: number, height: number, x: number) => void + barMargin?: number } + const emptyViewBox = { x: 0, y: 0, width: 0, height: 0 } + export const Indicator = ({ className, animateFrom, @@ -24,17 +27,18 @@ export const Indicator = ({ customCursorSize, setCustomCursorSize, viewBox = {}, + barMargin = 6, }: Props) => { const [opacityIndicator, setOpacityIndicator] = useState(0) const { x: animateTo = 0, width = 0 } = 'x' in viewBox ? viewBox : emptyViewBox - const margin = width > 12 ? 12 : 6 + const margin = barMargin const boxWidth = width + margin const xOffset = boxWidth / 2 const borderRadius = 6 const rectWidth = `${boxWidth}px` - const rectHeight = 'calc(100% - 38px)' + const rectHeight = 'calc(100% - 8px)' // useEffect callbacks run after the browser paints useEffect(() => { @@ -46,6 +50,7 @@ export const Indicator = ({ setTimeout(() => { setOpacityIndicator(1) }, 200) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [animateTo]) if (!className?.match(/selected/)) { @@ -57,15 +62,14 @@ export const Indicator = ({ const refRectWidth = ref.getBoundingClientRect().width const refRectHeight = ref.getBoundingClientRect().height if ( - customCursorSize.width !== refRectWidth || - customCursorSize.height !== refRectHeight + customCursorSize.width !== refRectWidth + || customCursorSize.height !== refRectHeight ) { - setCustomCursorSize(refRectWidth, refRectHeight, actualX - xOffset) + setCustomCursorSize(refRectWidth, refRectHeight, animateTo - xOffset) } } } - const actualX = animateFrom === -1 ? animateTo : animateFrom return ( { - const today = startOfMonth(Date.now()) - const yearAgo = sub(today, { months: 11 }) - const current = startOfMonth(new Date(currentYear, currentMonth - 1, 1)) - - if ( - differenceInMonths(startOfMonth(chartWindow.start), current) < 0 - && differenceInMonths(startOfMonth(chartWindow.end), current) > 1 - ) { - return chartWindow - } +import { collectData, formatYAxisValue, getBarSizing, getChartWindow, getLoadingValue, hasAnyData } from './utils' +import { Header, HeaderCol, HeaderRow } from '../Header' - if (differenceInMonths(startOfMonth(chartWindow.start), current) === 0) { - return { - start: startOfMonth(sub(current, { months: 1 })), - end: endOfMonth(add(current, { months: 11 })), - } - } - - if ( - differenceInMonths(endOfMonth(chartWindow.end), endOfMonth(current)) - === 1 - && differenceInMonths(today, current) >= 1 - ) { - return { - start: startOfMonth(sub(current, { months: 10 })), - end: endOfMonth(add(current, { months: 2 })), - } - } - - if ( - differenceInMonths(current, startOfMonth(chartWindow.end)) === 0 - && differenceInMonths(current, startOfMonth(today)) > 0 - ) { - return { - start: startOfMonth(sub(current, { months: 11 })), - end: endOfMonth(add(current, { months: 1 })), - } - } - - if (current >= yearAgo) { - return { - start: startOfMonth(yearAgo), - end: endOfMonth(today), - } - } - - if (Number(current) > Number(chartWindow.end)) { - return { - start: startOfMonth(sub(current, { months: 12 })), - end: endOfMonth(current), - } - } - - if (differenceInMonths(current, startOfMonth(chartWindow.start)) < 0) { - return { - start: startOfMonth(current), - end: endOfMonth(add(current, { months: 11 })), - } - } - - return chartWindow -} - -const getLoadingValue = (data?: ProfitAndLossSummaryData[]) => { - if (!data) { - return 10000 - } - - let max = 0 - - data.forEach(x => { - const current = Math.max( - Math.abs(x.income), - Math.abs(Math.abs((x?.income || 0) - (x?.netProfit || 0))), - ) - if (current > max) { - max = current - } - }) - - return max === 0 ? 10000 : max * 0.6 -} +export type ViewSize = 'xs' | 'md' | 'lg' export interface Props { + title?: string + withDatePicker?: boolean + enablePeriods?: boolean forceRerenderOnDataChange?: boolean tagFilter?: { key: string @@ -140,14 +49,23 @@ export interface Props { } export const ProfitAndLossChart = ({ + title, + withDatePicker = false, + enablePeriods, forceRerenderOnDataChange = false, tagFilter = undefined, }: Props) => { - const [compactView, setCompactView] = useState(false) - const barSize = compactView ? 10 : 20 - const { getColor, business } = useLayerContext() - const { changeDateRange, dateRange } = useContext(PNL.Context) + const { changeDateRange, dateRange, period } = useContext(PNL.Context) + const { data: linkedAccounts } = useLinkedAccounts() + + const { data, loaded, pullData } = useProfitAndLossLTM({ + currentDate: startOfMonth(Date.now()), + tagFilter: tagFilter, + period, + }) + + const [viewSize, setViewSize] = useState('lg') const [localDateRange, setLocalDateRange] = useState(dateRange) const [customCursorSize, setCustomCursorSize] = useState({ width: 0, @@ -159,6 +77,7 @@ export const ProfitAndLossChart = ({ start: startOfMonth(sub(Date.now(), { months: 11 })), end: endOfMonth(Date.now()), }) + const [animateFrom, setAnimateFrom] = useState(-1) const selectionMonth = useMemo( () => ({ @@ -168,6 +87,15 @@ export const ProfitAndLossChart = ({ [localDateRange], ) + const anyData = useMemo(() => hasAnyData(data), [data]) + + const isSyncing = useMemo( + () => Boolean(linkedAccounts?.some(item => item.is_syncing)), + [linkedAccounts], + ) + + const loadingValue = useMemo(() => getLoadingValue(data), [data]) + useEffect(() => { if ( Number(dateRange.startDate) !== Number(localDateRange.startDate) @@ -175,37 +103,9 @@ export const ProfitAndLossChart = ({ ) { setLocalDateRange(dateRange) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [dateRange]) - const { data, loaded, pullData } = useProfitAndLossLTM({ - currentDate: startOfMonth(Date.now()), - tagFilter: tagFilter, - }) - - const anyData = useMemo(() => { - return Boolean( - data?.find( - x => - x.income !== 0 - || x.costOfGoodsSold !== 0 - || x.grossProfit !== 0 - || x.operatingExpenses !== 0 - || x.profitBeforeTaxes !== 0 - || x.taxes !== 0 - || x.totalExpenses !== 0, - ), - ) - }, [data]) - - const { data: linkedAccounts } = useLinkedAccounts() - - const isSyncing = useMemo( - () => Boolean(linkedAccounts?.some(item => item.is_syncing)), - [linkedAccounts], - ) - - const loadingValue = useMemo(() => getLoadingValue(data), [data]) - useEffect(() => { if (loaded === 'complete' && data) { const foundCurrent = data.find( @@ -216,7 +116,7 @@ export const ProfitAndLossChart = ({ < Number(localDateRange.endDate), ) - if (!foundCurrent) { + if (!foundCurrent && theData?.length > 1) { const newDate = startOfMonth(localDateRange.startDate) pullData(newDate) return @@ -230,13 +130,14 @@ export const ProfitAndLossChart = ({ < Number(sub(localDateRange.endDate, { months: 1 })), ) - if (!foundBefore) { + if (!foundBefore && theData?.length > 1) { const newDate = startOfMonth( sub(localDateRange.startDate, { months: 1 }), ) pullData(newDate) } } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [localDateRange]) useEffect(() => { @@ -252,6 +153,7 @@ export const ProfitAndLossChart = ({ ) { setChartWindow(newChartWindow) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [localDateRange]) useEffect(() => { @@ -262,99 +164,21 @@ export const ProfitAndLossChart = ({ } }, [loaded]) - const getMonthName = (pnl: ProfitAndLossSummaryData | undefined) => - pnl - ? format( - new Date(pnl.year, pnl.month - 1, 1), - compactView ? 'LLLLL' : 'LLL', - ) - : '' - - const summarizePnL = (pnl: ProfitAndLossSummaryData | undefined) => ({ - name: getMonthName(pnl), - revenue: pnl?.income || 0, - revenueUncategorized: pnl?.uncategorizedInflows || 0, - expenses: -(pnl?.totalExpenses || 0), - expensesUncategorized: -(pnl?.uncategorizedOutflows || 0), - totalExpensesInverse: pnl?.totalExpensesInverse || 0, - uncategorizedOutflowsInverse: pnl?.uncategorizedOutflowsInverse || 0, - netProfit: pnl?.netProfit || 0, - selected: - !!pnl - && pnl.month === selectionMonth.month + 1 - && pnl.year === selectionMonth.year, - year: pnl?.year, - month: pnl?.month, - base: 0, - loading: pnl?.isLoading ? loadingValue : 0, - loadingExpenses: pnl?.isLoading ? -loadingValue : 0, - }) - - const theData = useMemo(() => { - if (loaded !== 'complete' || (loaded === 'complete' && !anyData)) { - const loadingData = [] - const today = Date.now() - for (let i = 11; i >= 0; i--) { - const currentDate = sub(today, { months: i }) - loadingData.push({ - name: format(currentDate, compactView ? 'LLLLL' : 'LLL'), - revenue: 0, - revenueUncategorized: 0, - totalExpensesInverse: 0, - uncategorizedOutflowsInverse: 0, - expenses: 0, - expensesUncategorized: 0, - netProfit: 0, - selected: false, - year: currentDate.getFullYear(), - month: currentDate.getMonth() + 1, - loading: 1000 * Math.pow(-1, i + 1) * (((i + 1) % 12) + 1) + 90000, - loadingExpenses: - -1000 * Math.pow(-1, i + 1) * (((i + 1) % 2) + 1) - 90000, - base: 0, - }) - } - return loadingData - } - - return data - ?.map(x => { - const totalExpenses = x.totalExpenses || 0 - if (totalExpenses < 0 || x.uncategorizedOutflows < 0) { - return { - ...x, - totalExpenses: totalExpenses < 0 ? 0 : totalExpenses, - uncategorizedOutflows: - x.uncategorizedOutflows < 0 ? 0 : x.uncategorizedOutflows, - totalExpensesInverse: totalExpenses < 0 ? -totalExpenses : 0, - uncategorizedOutflowsInverse: - x.uncategorizedOutflows < 0 ? -x.uncategorizedOutflows : 0, - } - } - - return x - }) - ?.filter( - x => - differenceInMonths( - startOfMonth(new Date(x.year, x.month - 1, 1)), - chartWindow.start, - ) >= 0 - && differenceInMonths( - startOfMonth(new Date(x.year, x.month - 1, 1)), - chartWindow.start, - ) < 12 - && differenceInMonths( - chartWindow.end, - startOfMonth(new Date(x.year, x.month - 1, 1)), - ) >= 0 - && differenceInMonths( - chartWindow.end, - startOfMonth(new Date(x.year, x.month - 1, 1)), - ) <= 12, - ) - .map(x => summarizePnL(x)) - }, [selectionMonth, chartWindow, data, loaded, compactView]) + /** @TODO temp */ + const len = period === 'year' ? 1 : period === 'quarter' ? 4 : 12 + const theData = useMemo(() => collectData({ + data, + loaded, + loadingValue, + anyData, + chartWindow, + viewSize, + selectionMonth, + }).slice(0, len), + [data, loaded, loadingValue, anyData, chartWindow, viewSize, selectionMonth, len] + ) + // @TODO - Tom + // }), [selectionMonth, chartWindow, data, loaded, viewSize]) const onClick: CategoricalChartFunc = ({ activePayload }) => { if (loaded !== 'complete' || !anyData) { @@ -424,90 +248,6 @@ export const ProfitAndLossChart = ({ return null } - const formatYAxisValue = (value?: string | number) => { - if (!value) { - return value - } - - try { - let suffix = '' - const base = Number(value) / 100 - let val = base - - if (Math.abs(base) >= 1000000000) { - suffix = 'B' - val = base / 1000000000 - } else if (Math.abs(base) >= 1000000) { - suffix = 'M' - val = base / 1000000 - } else if (Math.abs(base) >= 1000) { - suffix = 'k' - val = base / 1000 - } - return `${val}${suffix}` - } catch (_err) { - return value - } - } - - const renderLegend = (props: LegendProps) => { - return ( -
    - {props.payload?.map((entry, idx) => { - if (entry.id === 'UncategorizedLegend') { - return ( -
  • - - - - {entry.value} -
  • - ) - } - return ( -
  • - - - - {entry.value} -
  • - ) - })} -
- ) - } - const CustomizedYTick = ( { verticalAnchor: _verticalAnchor, @@ -539,7 +279,7 @@ export const ProfitAndLossChart = ({ fill='#F7F8FA' stroke='none' x={points[0].x - width / 2} - y={points[0].y} + y={points[0].y - 12} width={width} height={height} radius={6} @@ -548,10 +288,48 @@ export const ProfitAndLossChart = ({ ) } - const [animateFrom, setAnimateFrom] = useState(-1) + const [barSize, barMargin] = useMemo(() => + getBarSizing((theData ?? []).length, viewSize), + [viewSize, theData] + ) return ( -
+
+ {title || withDatePicker ? ( +
+ + + {title} + + {!withDatePicker && ( + + + + )} + + {withDatePicker && ( + <> + + + + + + + + + + + + + + + )} +
+ ) : ( +
+ +
+ )} { - if (width && width < 620 && !compactView) { - setCompactView(true) + if (width && width < 500 && viewSize !== 'xs') { + setViewSize('xs') + return + } + + if (width && width >= 500 && width < 680 && viewSize !== 'md') { + setViewSize('md') return } - if (width && width >= 620 && compactView) { - setCompactView(false) + if (width && width >= 680 && viewSize !== 'lg') { + setViewSize('lg') return } }} @@ -622,31 +405,9 @@ export const ProfitAndLossChart = ({ stroke={getColor(200)?.hex ?? '#fff'} strokeDasharray='5 5' /> - - + } /> diff --git a/src/components/ProfitAndLossChart/utils.ts b/src/components/ProfitAndLossChart/utils.ts new file mode 100644 index 000000000..2e36d3590 --- /dev/null +++ b/src/components/ProfitAndLossChart/utils.ts @@ -0,0 +1,295 @@ +import { + add, + differenceInMonths, + endOfMonth, + format, + startOfMonth, + sub, +} from 'date-fns' +import { ProfitAndLossSummaryData } from '../../hooks/useProfitAndLoss/useProfitAndLossLTM' +import { LoadedStatus } from '../../types/general' +import { ViewSize } from './ProfitAndLossChart' + +// Find start and end date for the chart +export const getChartWindow = ({ + chartWindow, + currentYear, + currentMonth, +}: { + chartWindow: { start: Date; end: Date } + currentYear: number + currentMonth: number +}) => { + const today = startOfMonth(Date.now()) + const yearAgo = sub(today, { months: 11 }) + const current = startOfMonth(new Date(currentYear, currentMonth - 1, 1)) + + if ( + differenceInMonths(startOfMonth(chartWindow.start), current) < 0 + && differenceInMonths(startOfMonth(chartWindow.end), current) > 1 + ) { + return chartWindow + } + + if (differenceInMonths(startOfMonth(chartWindow.start), current) === 0) { + return { + start: startOfMonth(sub(current, { months: 1 })), + end: endOfMonth(add(current, { months: 11 })), + } + } + + if ( + differenceInMonths(endOfMonth(chartWindow.end), endOfMonth(current)) + === 1 + && differenceInMonths(today, current) >= 1 + ) { + return { + start: startOfMonth(sub(current, { months: 10 })), + end: endOfMonth(add(current, { months: 2 })), + } + } + + if ( + differenceInMonths(current, startOfMonth(chartWindow.end)) === 0 + && differenceInMonths(current, startOfMonth(today)) > 0 + ) { + return { + start: startOfMonth(sub(current, { months: 11 })), + end: endOfMonth(add(current, { months: 1 })), + } + } + + if (current >= yearAgo) { + return { + start: startOfMonth(yearAgo), + end: endOfMonth(today), + } + } + + if (Number(current) > Number(chartWindow.end)) { + return { + start: startOfMonth(sub(current, { months: 12 })), + end: endOfMonth(current), + } + } + + if (differenceInMonths(current, startOfMonth(chartWindow.start)) < 0) { + return { + start: startOfMonth(current), + end: endOfMonth(add(current, { months: 11 })), + } + } + + return chartWindow +} + +// Find a value for a loading bar so it has relatively same height as other +// bars (when data loaded), or just use 10,000 if no data. +export const getLoadingValue = (data?: ProfitAndLossSummaryData[]) => { + if (!data) { + return 10000 + } + + let max = 0 + + data.forEach(x => { + const current = Math.max( + Math.abs(x.income), + Math.abs(Math.abs((x?.income || 0) - (x?.netProfit || 0))), + ) + if (current > max) { + max = current + } + }) + + return max === 0 ? 10000 : max * 0.6 +} + +// Get full or shorten month name based on the view size +export const getMonthName = (pnl: ProfitAndLossSummaryData | undefined, viewSize: ViewSize) => + pnl + ? format( + new Date(pnl.year, pnl.month - 1, 1), + viewSize !== 'lg' ? 'LLLLL' : 'LLL', + ) + : '' + +// Parse ProfitAndLossSummaryData into format used by the chart +export const summarizePnL = ( + pnl: ProfitAndLossSummaryData | undefined, + loadingValue: number, + selectionMonth: { month: number; year: number }, + viewSize: ViewSize, +) => ({ + name: getMonthName(pnl, viewSize), + revenue: pnl?.income || 0, + revenueUncategorized: pnl?.uncategorizedInflows || 0, + expenses: -(pnl?.totalExpenses || 0), + expensesUncategorized: -(pnl?.uncategorizedOutflows || 0), + totalExpensesInverse: pnl?.totalExpensesInverse || 0, + uncategorizedOutflowsInverse: pnl?.uncategorizedOutflowsInverse || 0, + netProfit: pnl?.netProfit || 0, + selected: + !!pnl + && pnl.month === selectionMonth.month + 1 + && pnl.year === selectionMonth.year, + year: pnl?.year, + month: pnl?.month, + base: 0, + loading: pnl?.isLoading ? loadingValue : 0, + loadingExpenses: pnl?.isLoading ? -loadingValue : 0, +}) + +// Format Y-axis labels using denominators like billions, millions, thousands +export const formatYAxisValue = (value?: string | number) => { + if (!value) { + return value + } + + try { + let suffix = '' + const base = Number(value) / 100 + let val = base + + if (Math.abs(base) >= 1000000000) { + suffix = 'B' + val = base / 1000000000 + } else if (Math.abs(base) >= 1000000) { + suffix = 'M' + val = base / 1000000 + } else if (Math.abs(base) >= 1000) { + suffix = 'k' + val = base / 1000 + } + return `${val}${suffix}` + } catch (_err) { + return value + } +} + +// Check if there is any non-zero value in the data +export const hasAnyData = (data: ProfitAndLossSummaryData[]) => ( + Boolean( + data?.find( + x => + x.income !== 0 + || x.costOfGoodsSold !== 0 + || x.grossProfit !== 0 + || x.operatingExpenses !== 0 + || x.profitBeforeTaxes !== 0 + || x.taxes !== 0 + || x.totalExpenses !== 0, + ), + ) +) + +// Parse and filter raw data for the chart. +// If data has not be loaded yet, then it returns data for "loading" bars +export const collectData = ({ + data, + loaded, + loadingValue, + anyData, + chartWindow, + viewSize, + selectionMonth, +}: { + data: ProfitAndLossSummaryData[] + loaded?: LoadedStatus + anyData: boolean + chartWindow: { + start: Date; + end: Date; + } + viewSize: ViewSize + loadingValue: number + selectionMonth: { + year: number; + month: number; + } +}) => { + if (loaded !== 'complete' || (loaded === 'complete' && !anyData)) { + const loadingData = [] + const today = Date.now() + for (let i = 11; i >= 0; i--) { + const currentDate = sub(today, { months: i }) + loadingData.push({ + name: format(currentDate, viewSize !== 'lg' ? 'LLLLL' : 'LLL'), + revenue: 0, + revenueUncategorized: 0, + totalExpensesInverse: 0, + uncategorizedOutflowsInverse: 0, + expenses: 0, + expensesUncategorized: 0, + netProfit: 0, + selected: false, + year: currentDate.getFullYear(), + month: currentDate.getMonth() + 1, + loading: 1000 * Math.pow(-1, i + 1) * (((i + 1) % 12) + 1) + 90000, + loadingExpenses: + -1000 * Math.pow(-1, i + 1) * (((i + 1) % 2) + 1) - 90000, + base: 0, + }) + } + return loadingData + } + + return data + ?.map(x => { + const totalExpenses = x.totalExpenses || 0 + if (totalExpenses < 0 || x.uncategorizedOutflows < 0) { + return { + ...x, + totalExpenses: totalExpenses < 0 ? 0 : totalExpenses, + uncategorizedOutflows: + x.uncategorizedOutflows < 0 ? 0 : x.uncategorizedOutflows, + totalExpensesInverse: totalExpenses < 0 ? -totalExpenses : 0, + uncategorizedOutflowsInverse: + x.uncategorizedOutflows < 0 ? -x.uncategorizedOutflows : 0, + } + } + + return x + }) + ?.filter( + x => + differenceInMonths( + startOfMonth(new Date(x.year, x.month - 1, 1)), + chartWindow.start, + ) >= 0 + && differenceInMonths( + startOfMonth(new Date(x.year, x.month - 1, 1)), + chartWindow.start, + ) < 12 + && differenceInMonths( + chartWindow.end, + startOfMonth(new Date(x.year, x.month - 1, 1)), + ) >= 0 + && differenceInMonths( + chartWindow.end, + startOfMonth(new Date(x.year, x.month - 1, 1)), + ) <= 12, + ) + .map(x => summarizePnL(x, loadingValue, selectionMonth, viewSize)) +} + +// Returns [barSize, margin] for chart based on number of bars and view mode +export const getBarSizing = (itemsLength: number, view: ViewSize) => { + const divider = view === 'xs' ? 2 : view === 'md' ? 1.5 : 1 + let base = Math.round(240 / divider) + + if (itemsLength >= 12) { + base = 20 + } else if (itemsLength >= 8) { + base = 40 + } else if (itemsLength >= 6) { + base = 60 + } else if (itemsLength >= 4) { + base = 80 + } + + const margin = Math.max(Math.min(Math.round(base * 0.25 / divider), 24), 2) + + // [barSize, margin] + return [base / divider, margin] +} diff --git a/src/components/ProfitAndLossChartLegend/ProfitAndLossChartLegend.tsx b/src/components/ProfitAndLossChartLegend/ProfitAndLossChartLegend.tsx new file mode 100644 index 000000000..cd82e41fc --- /dev/null +++ b/src/components/ProfitAndLossChartLegend/ProfitAndLossChartLegend.tsx @@ -0,0 +1,32 @@ +import React from 'react' + +const LEGEND_ITEMS = ['Revenue', 'Expenses', 'Uncategorized'] + +export const ProfitAndLossChartLegend = () => { + return ( +
    + {LEGEND_ITEMS.map((item, idx) => ( +
  • + + + + {item} +
  • + ))} +
+ ) +} diff --git a/src/components/ProfitAndLossDatePicker/ProfitAndLossDatePicker.tsx b/src/components/ProfitAndLossDatePicker/ProfitAndLossDatePicker.tsx index 6a3029a9a..55e17dce6 100644 --- a/src/components/ProfitAndLossDatePicker/ProfitAndLossDatePicker.tsx +++ b/src/components/ProfitAndLossDatePicker/ProfitAndLossDatePicker.tsx @@ -9,18 +9,47 @@ import { } from '../DatePicker/ModeSelector/DatePickerModeSelector' import { DatePickerModeSelector } from '../DatePicker/ModeSelector/DatePickerModeSelector' import { ProfitAndLoss } from '../ProfitAndLoss' -import { endOfMonth, startOfMonth } from 'date-fns' +import { endOfMonth, endOfQuarter, endOfYear, startOfMonth, startOfQuarter, startOfYear } from 'date-fns' +import { Select } from '../Input' +import { Period } from '../../hooks/useProfitAndLoss/useProfitAndLoss' -export type ProfitAndLossDatePickerProps = TimeRangePickerConfig +export type ProfitAndLossDatePickerProps = TimeRangePickerConfig & { enablePeriods?: boolean } + +const PERIOD_OPTIONS = [ + { label: 'Compare 12 months', value: 'month' }, + { label: 'Compare quarter', value: 'quarter' }, + { label: 'Compare year', value: 'year' }, +] + +const getDateRange = (date: Date, mode: DatePickerMode) => { + switch(mode) { + case 'quarterPicker': + return { + startDate: startOfQuarter(date), + endDate: endOfQuarter(date), + } + case 'yearPicker': + return { + startDate: startOfYear(date), + endDate: endOfYear(date), + } + default: + return { + startDate: startOfMonth(date), + endDate: endOfMonth(date), + } + } +} export const ProfitAndLossDatePicker = ({ allowedDatePickerModes, datePickerMode: deprecated_datePickerMode, defaultDatePickerMode, customDateRanges, + enablePeriods = false, }: ProfitAndLossDatePickerProps) => { const { business } = useLayerContext() - const { changeDateRange, dateRange } = useContext(ProfitAndLoss.Context) + const { changeDateRange, dateRange, period, setPeriod } = useContext(ProfitAndLoss.Context) const { refetch, compareMode, compareMonths } = useContext( ProfitAndLoss.ComparisonContext, ) @@ -67,25 +96,50 @@ export const ProfitAndLossDatePicker = ({ } return ( - { - if (!Array.isArray(date)) { - getComparisonData(date) - changeDateRange({ - startDate: startOfMonth(date), - endDate: endOfMonth(date), - }) - } - }} - minDate={minDate} - slots={{ - ModeSelector: DatePickerModeSelector, - }} - /> +
+ {enablePeriods && ( +
+