@zeniai/client-epic-state 4.19.95 → 4.19.96-beta1ND

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/lib/commonStateTypes/reduceFetchState.d.ts +9 -0
  2. package/lib/commonStateTypes/reduceFetchState.js +26 -0
  3. package/lib/esm/commonStateTypes/reduceFetchState.js +25 -0
  4. package/lib/esm/index.js +5 -4
  5. package/lib/esm/view/expenseAutomationView/epics/accountRecon/saveReconciliationReviewEpic.js +8 -3
  6. package/lib/esm/view/financeStatement/financeStatementReducer.js +11 -8
  7. package/lib/esm/view/financeStatement/financeStatementSelector.js +10 -5
  8. package/lib/esm/view/profitAndLossClassesView/profitAndLossClassesByClassHorizontalSelector.js +323 -0
  9. package/lib/esm/view/profitAndLossClassesView/profitAndLossClassesByClassHorizontalSelectorTypes.js +1 -0
  10. package/lib/esm/view/profitAndLossClassesView/profitAndLossClassesViewReducer.js +6 -1
  11. package/lib/esm/view/profitAndLossClassesView/profitAndLossClassesViewSelector.js +2 -29
  12. package/lib/esm/view/profitAndLossClassesView/profitAndLossForTimeframeClassesViewEpic.js +7 -5
  13. package/lib/esm/view/reportUIOptions/updateReportUIOptionCOABalancesRangeEpic.js +9 -7
  14. package/lib/index.d.ts +7 -5
  15. package/lib/index.js +29 -25
  16. package/lib/tsconfig.typecheck.tsbuildinfo +1 -1
  17. package/lib/view/expenseAutomationView/epics/accountRecon/saveReconciliationReviewEpic.js +8 -3
  18. package/lib/view/financeStatement/financeStatementReducer.js +11 -8
  19. package/lib/view/financeStatement/financeStatementSelector.d.ts +2 -0
  20. package/lib/view/financeStatement/financeStatementSelector.js +10 -5
  21. package/lib/view/profitAndLossClassesView/profitAndLossClassesByClassHorizontalSelector.d.ts +13 -0
  22. package/lib/view/profitAndLossClassesView/profitAndLossClassesByClassHorizontalSelector.js +327 -0
  23. package/lib/view/profitAndLossClassesView/profitAndLossClassesByClassHorizontalSelectorTypes.d.ts +62 -0
  24. package/lib/view/profitAndLossClassesView/profitAndLossClassesByClassHorizontalSelectorTypes.js +2 -0
  25. package/lib/view/profitAndLossClassesView/profitAndLossClassesViewReducer.d.ts +4 -2
  26. package/lib/view/profitAndLossClassesView/profitAndLossClassesViewReducer.js +7 -2
  27. package/lib/view/profitAndLossClassesView/profitAndLossClassesViewSelector.js +2 -29
  28. package/lib/view/profitAndLossClassesView/profitAndLossClassesViewState.d.ts +4 -0
  29. package/lib/view/profitAndLossClassesView/profitAndLossForTimeframeClassesViewEpic.js +7 -5
  30. package/lib/view/reportUIOptions/updateReportUIOptionCOABalancesRangeEpic.js +8 -6
  31. package/package.json +1 -1
@@ -11,6 +11,15 @@ export declare const isAllFetchCompleted: (fetchStateList: FetchStateAndError[])
11
11
  * @return single fetchStateWithError
12
12
  */
13
13
  export declare function reduceFetchState(fetchStateList: FetchStateAndError[]): FetchStateAndError;
14
+ /**
15
+ * Combine multiple independent fetches (e.g. month / quarter / year for one report).
16
+ * In-Progress if any fetch is in progress; Completed only when all complete; Error if
17
+ * any failed once nothing is still in progress.
18
+ *
19
+ * Differs from {@link reduceFetchState} (requires all In-Progress for that state) and
20
+ * from {@link reduceAllFetchState} (Error only when every fetch failed).
21
+ */
22
+ export declare function reduceFetchStateParallelTimeframes(fetchStateList: FetchStateAndError[]): FetchStateAndError;
14
23
  /**
15
24
  * Reduces list of fetch state to boolean
16
25
  * @param fetchStateList list of fetch states
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.reduceAnyCompletedFetchState = exports.reduceAllFetchState = exports.reduceAnyFetchState = exports.isAnyFetchCompleted = exports.isAnyFetchInProgress = exports.isAllFetchCompleted = void 0;
4
4
  exports.reduceFetchState = reduceFetchState;
5
+ exports.reduceFetchStateParallelTimeframes = reduceFetchStateParallelTimeframes;
5
6
  /**
6
7
  * Reduces list of fetch state to boolean
7
8
  * @param fetchStateList list of fetch states
@@ -55,6 +56,31 @@ function reduceFetchState(fetchStateList) {
55
56
  error,
56
57
  };
57
58
  }
59
+ /**
60
+ * Combine multiple independent fetches (e.g. month / quarter / year for one report).
61
+ * In-Progress if any fetch is in progress; Completed only when all complete; Error if
62
+ * any failed once nothing is still in progress.
63
+ *
64
+ * Differs from {@link reduceFetchState} (requires all In-Progress for that state) and
65
+ * from {@link reduceAllFetchState} (Error only when every fetch failed).
66
+ */
67
+ function reduceFetchStateParallelTimeframes(fetchStateList) {
68
+ const isAnyProgress = fetchStateList.some((s) => s.fetchState === 'In-Progress');
69
+ const isAllCompleted = fetchStateList.every((s) => s.fetchState === 'Completed');
70
+ const isAnyError = fetchStateList.some((s) => s.fetchState === 'Error');
71
+ const error = reduceError(fetchStateList);
72
+ let fetchState = 'Not-Started';
73
+ if (isAnyProgress) {
74
+ fetchState = 'In-Progress';
75
+ }
76
+ else if (isAllCompleted) {
77
+ fetchState = 'Completed';
78
+ }
79
+ else if (isAnyError) {
80
+ fetchState = 'Error';
81
+ }
82
+ return { fetchState, error };
83
+ }
58
84
  /**
59
85
  * Reduces list of fetch state to boolean
60
86
  * @param fetchStateList list of fetch states
@@ -50,6 +50,31 @@ export function reduceFetchState(fetchStateList) {
50
50
  error,
51
51
  };
52
52
  }
53
+ /**
54
+ * Combine multiple independent fetches (e.g. month / quarter / year for one report).
55
+ * In-Progress if any fetch is in progress; Completed only when all complete; Error if
56
+ * any failed once nothing is still in progress.
57
+ *
58
+ * Differs from {@link reduceFetchState} (requires all In-Progress for that state) and
59
+ * from {@link reduceAllFetchState} (Error only when every fetch failed).
60
+ */
61
+ export function reduceFetchStateParallelTimeframes(fetchStateList) {
62
+ const isAnyProgress = fetchStateList.some((s) => s.fetchState === 'In-Progress');
63
+ const isAllCompleted = fetchStateList.every((s) => s.fetchState === 'Completed');
64
+ const isAnyError = fetchStateList.some((s) => s.fetchState === 'Error');
65
+ const error = reduceError(fetchStateList);
66
+ let fetchState = 'Not-Started';
67
+ if (isAnyProgress) {
68
+ fetchState = 'In-Progress';
69
+ }
70
+ else if (isAllCompleted) {
71
+ fetchState = 'Completed';
72
+ }
73
+ else if (isAnyError) {
74
+ fetchState = 'Error';
75
+ }
76
+ return { fetchState, error };
77
+ }
53
78
  /**
54
79
  * Reduces list of fetch state to boolean
55
80
  * @param fetchStateList list of fetch states
package/lib/esm/index.js CHANGED
@@ -14,7 +14,7 @@ import { getActualPeriodOfFY, getActualPeriodOfFYQtr, } from './commonStateTypes
14
14
  import { getFYMonths } from './commonStateTypes/fiscalYearHelpers/getFYMonths';
15
15
  import { getFYQuarterAndYear, getLastMonthOfFYQuarter, getLastMonthOfFYYear, } from './commonStateTypes/fiscalYearHelpers/getFYQuarterAndYear';
16
16
  import { getStartOfAndEndOfTimeframeForFY } from './commonStateTypes/fiscalYearHelpers/getStartOfAndEndOfTimeframeFY';
17
- import { isAllFetchCompleted, isAnyFetchInProgress, reduceAllFetchState, reduceAnyCompletedFetchState, reduceAnyFetchState, reduceFetchState, } from './commonStateTypes/reduceFetchState';
17
+ import { isAllFetchCompleted, isAnyFetchInProgress, reduceAllFetchState, reduceAnyCompletedFetchState, reduceAnyFetchState, reduceFetchState, reduceFetchStateParallelTimeframes, } from './commonStateTypes/reduceFetchState';
18
18
  import { toReimbursementTypeCode } from './commonStateTypes/reimbursementTypeCode';
19
19
  import { stringToUnion, stringToUnionStrict, } from './commonStateTypes/stringToUnion';
20
20
  import { SCHEDULE_DAYS_OF_MONTH, convertToPeriod, toAbsoluteDay, toMonth, toMonthStrict, toMonthYearPeriodId, toQuarter, toQuarterStrict, toScheduleDaysOfMonth, } from './commonStateTypes/timePeriod';
@@ -217,7 +217,8 @@ import { changeZeniPersonRoles, deletePerson, fetchAllPeopleRequiredViews, fetch
217
217
  import { getPeople, getPeopleLocalData } from './view/people/peopleSelector';
218
218
  import { fetchProfitAndLoss, fetchProfitAndLossForTimeframe, resetProfitAndLossNodeCollapseState, updateProfitAndLossUIState, } from './view/profitAndLoss/profitAndLossReducer';
219
219
  import { getPandLReportFetchState, getProfitAndLossReport, } from './view/profitAndLoss/profitAndLossSelector';
220
- import { fetchProfitAndLossClassesView, resetProfitAndLossClassesNodeCollapseState, updateAccountViewMode as updateProfitAndLossAccountViewMode, updateClassesToFilterOut as updateProfitAndLossClassesToFilterOut, updateProfitAndLossClassesViewUIState, } from './view/profitAndLossClassesView/profitAndLossClassesViewReducer';
220
+ import { fetchProfitAndLossClassesView, resetProfitAndLossClassesNodeCollapseState, updateAccountViewMode as updateProfitAndLossAccountViewMode, updateClassesToFilterOut as updateProfitAndLossClassesToFilterOut, updateClassViewLayout, updateProfitAndLossClassesViewUIState, } from './view/profitAndLossClassesView/profitAndLossClassesViewReducer';
221
+ import { getProfitAndLossClassesHorizontalView } from './view/profitAndLossClassesView/profitAndLossClassesByClassHorizontalSelector';
221
222
  import { getProfitAndLossClassesView } from './view/profitAndLossClassesView/profitAndLossClassesViewSelector';
222
223
  import { isPandLReportViewCalculatedSectionID, isPandLReportViewSectionID, } from './view/profitAndLossClassesView/profitAndLossClassesViewSelectorTypes';
223
224
  import { fetchEntityRecommendationsByTransactionId, fetchRecommendationByEntityId, fetchRecommendationByEntityName, } from './view/recommendation/recommendationReducer';
@@ -451,7 +452,7 @@ export { fetchDashboard, getDashboard, updateTreasuryVideoClosed, };
451
452
  export { updateDashboardLayout };
452
453
  export { getPandLWithForecast, } from './view/profitAndLoss/pAndLWithForecast/pAndLWithForecastSelector';
453
454
  export { fetchProfitAndLoss, resetProfitAndLossNodeCollapseState, updateProfitAndLossUIState, getProfitAndLossReport, getPandLReportFetchState, fetchProfitAndLossForTimeframe, };
454
- export { fetchProfitAndLossClassesView, updateProfitAndLossClassesToFilterOut, resetProfitAndLossClassesNodeCollapseState, updateProfitAndLossClassesViewUIState, updateProfitAndLossAccountViewMode, getProfitAndLossClassesView, isPandLReportViewCalculatedSectionID, isPandLReportViewSectionID, };
455
+ export { fetchProfitAndLossClassesView, updateProfitAndLossClassesToFilterOut, resetProfitAndLossClassesNodeCollapseState, updateProfitAndLossClassesViewUIState, updateProfitAndLossAccountViewMode, updateClassViewLayout, getProfitAndLossClassesView, getProfitAndLossClassesHorizontalView, isPandLReportViewCalculatedSectionID, isPandLReportViewSectionID, };
455
456
  export { getAccountingProviderAttachment };
456
457
  export { fetchUserListByType, getUserList };
457
458
  export { fetchAllPeopleRequiredViews, fetchPeoplePage, fetchPeople, deletePerson, resendInvite, changeZeniPersonRoles, invitePeople, inviteZeniPeople, updatePeopleUIState, peopleSaveUpdates, resetUpdateErrorMessage, peopleSaveDataInLocalStore, peopleClearDataInLocalStore, getPeople, getPeopleLocalData, initializeEditPerson, };
@@ -543,7 +544,7 @@ export { saveRealTimeApproval, updateIsEditModeRealTimeApprovals, };
543
544
  export { deleteRemi, fetchRemiDetail, cancelAndDeleteRemi, approveOrRejectRemi, clearRemiDetailView, fetchDuplicateReimbursement, clearDuplicateReimbursementDetail, removeDuplicateReimbursementByLineId, };
544
545
  export { getEditRemiDetail, };
545
546
  export { fetchEditRemiDetailPage, fetchRecommendationsAndUpdateMerchantRecommendations, fetchRemiAndInitializeLocalStore, initializeRemiToLocalStore, saveRemiUpdatesToLocalStore, fetchCurrencyConversionValue, discardRemiUpdatesInLocalStore, removeFileFromRemiUpdatesInLocalStore, saveRemiDetail, saveRemiSuccessOrFailure, parseReceiptsToRemi, clearEditRemiViewDetail, updateAddRemiAutoFields, updateUploadFetchState, updateTentativeMerchantNames, clearTentativeMerchantName, updateReimbursementType, updateHomeCurrencyConversion, clearAddRemiAutoFields, };
546
- export { reduceFetchState, reduceAnyFetchState, reduceAllFetchState, reduceAnyCompletedFetchState, isAnyFetchInProgress, isAllFetchCompleted, ALL_FILE_TYPES, toFileTypeStrict, };
547
+ export { reduceFetchState, reduceFetchStateParallelTimeframes, reduceAnyFetchState, reduceAllFetchState, reduceAnyCompletedFetchState, isAnyFetchInProgress, isAllFetchCompleted, ALL_FILE_TYPES, toFileTypeStrict, };
547
548
  export { getNextNthWorkingDay, getPreviousNthWorkingDay, isHolidayToday, filterDays, getYearsList, isHoliday, holidaysFormatted, PAYMENT_BUSINESS_DAYS, };
548
549
  export { fetchCompanyOnboardingView, fetchQBOConnectionPool, updateQBOConnectionPoolExternalConnection, fetchOnboardingCompletedCompanies, saveOnboardingCustomerCompletedStatus, toProductType, toProductTypeStrict, getOnboardingCockpitView, getNewOnboardingCustomerView, getOnboardingEmailGroup, initializeOnboardingCustomerViewUpdateData, clearOnboardingCustomerViewUpdateData, saveOnboardingCustomerViewUpdateData, updateOnboardingCustomerListUIState, updateCustomerCreationStatus, updateStatusAfterOnboardingCompleted, saveOnboardingCustomerViewUpdates, saveOnboardingCustomerNotes, saveOnboardingCustomerDataInLocalStore, updateOnboardingCustomerDataInLocalStore, resetNewOnboardedCustomerId, sendOnboardingCustomerViewInvite, retryBankAccountConnectionForOnboarding, };
549
550
  export { getTransactionsListByCategoryType, getTransactionListUIStateByCategoryType, };
@@ -4,6 +4,7 @@ import { DEFAULT_DATE_FORMAT } from '../../../../commonStateTypes/fiscalYearHelp
4
4
  import { addTransactionToReconcileList, excludeReconReviewTransaction, } from '../../../../entity/accountRecon/accountReconReducer';
5
5
  import { getAccountReconByAccountIdAndSelectedPeriod, } from '../../../../entity/accountRecon/accountReconSelector';
6
6
  import { toAccountReconKey, } from '../../../../entity/accountRecon/accountReconState';
7
+ import { getEntityByEntityIDs } from '../../../../entity/genericEntity/entitySelector';
7
8
  import { openSnackbar } from '../../../../entity/snackbar/snackbarReducer';
8
9
  import { getCurrentTenant } from '../../../../entity/tenant/tenantSelector';
9
10
  import { updateMultipleReconcileTransactions } from '../../../../entity/transaction/transactionReducer';
@@ -12,6 +13,7 @@ import { createZeniAPIStatus, isSuccessResponse, } from '../../../../responsePay
12
13
  import { saveReconciliationReview, updateReconcileTabLocalData, updateSaveReconciliationReviewFetchStatus, } from '../../reducers/reconciliationViewReducer';
13
14
  export const saveReconciliationReviewEpic = (actions$, state$, zeniAPI) => actions$.pipe(filter(saveReconciliationReview.match), mergeMap((action) => {
14
15
  const state = state$.value;
16
+ const entityState = state.entityState;
15
17
  const { transactionId } = action.payload;
16
18
  const localData = state.expenseAutomationReconciliationViewState.reconTabsState.review
17
19
  .localData[transactionId];
@@ -59,7 +61,7 @@ export const saveReconciliationReviewEpic = (actions$, state$, zeniAPI) => actio
59
61
  payload = getPayloadForCCPayment(updatedTransaction, localData, reconciliationData?.account.accountType);
60
62
  break;
61
63
  case 'record_deposit':
62
- payload = getPayloadForDeposit(updatedTransaction, localData);
64
+ payload = getPayloadForDeposit(updatedTransaction, localData, entityState);
63
65
  break;
64
66
  }
65
67
  }
@@ -159,7 +161,10 @@ function getPayloadForExpenseRecord(updatedTransaction, localData) {
159
161
  currency_code: updatedTransaction.amount.currencyCode,
160
162
  };
161
163
  }
162
- function getPayloadForDeposit(updatedTransaction, localData) {
164
+ function getPayloadForDeposit(updatedTransaction, localData, entityState) {
165
+ const customerZeniId = localData.customer?.id != null
166
+ ? getEntityByEntityIDs(entityState, [localData.customer?.id])[0]?.zeniId
167
+ : undefined;
163
168
  return {
164
169
  amount: updatedTransaction.amount.amount,
165
170
  transaction_memo: updatedTransaction.memo,
@@ -167,7 +172,7 @@ function getPayloadForDeposit(updatedTransaction, localData) {
167
172
  transaction_date: updatedTransaction.transactionDate.format(DEFAULT_DATE_FORMAT),
168
173
  transaction_direction: updatedTransaction.transactionDirection,
169
174
  transaction_id: updatedTransaction.transactionId,
170
- customer_id: localData.customer?.id,
175
+ customer_id: customerZeniId,
171
176
  customer_name: localData.customer?.name,
172
177
  account_id: localData.categoryId,
173
178
  accounting_class_id: localData.classId,
@@ -44,19 +44,20 @@ const financeStatement = createSlice({
44
44
  updateFinanceStatementThisPeriod(draft, action) {
45
45
  const { firstMonthOfFY, coaBalances, maxNumOfPeriodsToHighlight } = action.payload;
46
46
  const { timeframe, selectedCOABalancesRange } = draft;
47
- if (coaBalances.length > 0) {
48
- const prevThisPeriod = extractThisPeriod(action.payload.firstMonthOfFY, timeframe, selectedCOABalancesRange, coaBalances);
47
+ const safeCoaBalances = coaBalances != null ? coaBalances : [];
48
+ if (safeCoaBalances.length > 0) {
49
+ const prevThisPeriod = extractThisPeriod(action.payload.firstMonthOfFY, timeframe, selectedCOABalancesRange, safeCoaBalances);
49
50
  if (prevThisPeriod != null) {
50
51
  const selectedCoaBalancesRangeWithThisPeriod = {
51
52
  ...selectedCOABalancesRange,
52
53
  thisPeriod: prevThisPeriod,
53
54
  };
54
- const newThisPeriod = getMatchingThisPeriod(action.payload.firstMonthOfFY, timeframe, action.payload.thisPeriod, coaBalances);
55
+ const newThisPeriod = getMatchingThisPeriod(action.payload.firstMonthOfFY, timeframe, action.payload.thisPeriod, safeCoaBalances);
55
56
  if (newThisPeriod != null) {
56
57
  const selectionRanges = getSelectedAndHighlightedRangesForThisPeriod({
57
58
  firstMonthOfFY,
58
59
  thisPeriod: newThisPeriod,
59
- coaBalances,
60
+ coaBalances: safeCoaBalances,
60
61
  timeframe,
61
62
  maxNumOfPeriodsToSelect: maxNumOfPeriodsToHighlight,
62
63
  currentSelection: {
@@ -75,7 +76,9 @@ const financeStatement = createSlice({
75
76
  draft.selectedReportId = action.payload;
76
77
  },
77
78
  updateFinanceStatementAdditionalBalancesSelection(draft, action) {
78
- const { firstMonthOfFY, additionalBalances, coaBalances, maxNumOfPeriodsToHighlight, } = action.payload;
79
+ const { firstMonthOfFY, additionalBalances: additionalBalancesPayload, coaBalances, maxNumOfPeriodsToHighlight, } = action.payload;
80
+ const safeCoaBalances = coaBalances != null ? coaBalances : [];
81
+ const additionalBalances = additionalBalancesPayload ?? [];
79
82
  let tempAdditionalBalances = [...additionalBalances];
80
83
  if (additionalBalances.includes('this_period_vs_last_period') ||
81
84
  additionalBalances.includes('this_period_vs_last_period_percent')) {
@@ -91,8 +94,8 @@ const financeStatement = createSlice({
91
94
  draft.isAdditionalBalancesShown =
92
95
  additionalBalances.length != 0 ? true : false;
93
96
  const { timeframe, selectedCOABalancesRange } = draft;
94
- if (coaBalances.length > 0) {
95
- const thisPeriod = extractThisPeriod(action.payload.firstMonthOfFY, timeframe, selectedCOABalancesRange, coaBalances);
97
+ if (safeCoaBalances.length > 0) {
98
+ const thisPeriod = extractThisPeriod(action.payload.firstMonthOfFY, timeframe, selectedCOABalancesRange, safeCoaBalances);
96
99
  if (thisPeriod != null) {
97
100
  const selectedCoaBalancesRangeWithThisPeriod = {
98
101
  ...selectedCOABalancesRange,
@@ -101,7 +104,7 @@ const financeStatement = createSlice({
101
104
  const selectionRanges = getSelectedAndHighlightedRangesForThisPeriod({
102
105
  firstMonthOfFY,
103
106
  thisPeriod: thisPeriod,
104
- coaBalances,
107
+ coaBalances: safeCoaBalances,
105
108
  timeframe,
106
109
  maxNumOfPeriodsToHighlight: maxNumOfPeriodsToHighlight,
107
110
  currentSelection: {
@@ -8,6 +8,7 @@ import { extractThisPeriod } from '../../commonStateTypes/viewAndReport/thisPeri
8
8
  import { getBalanceSheet } from '../balanceSheet/balanceSheetSelector';
9
9
  import { getCashFlow } from '../cashFlow/cashFlowSelector';
10
10
  import { getProfitAndLossReport, } from '../profitAndLoss/profitAndLossSelector';
11
+ import { getProfitAndLossClassesHorizontalView } from '../profitAndLossClassesView/profitAndLossClassesByClassHorizontalSelector';
11
12
  import { getProfitAndLossClassesView } from '../profitAndLossClassesView/profitAndLossClassesViewSelector';
12
13
  function getDateRangeForFinanceReports(profitAndLossState, balanceSheetState, cashFlowState) {
13
14
  const datesSorted = [
@@ -42,11 +43,11 @@ export const getFinanceStatement = createSelector((state) => state.accountState,
42
43
  if (dataAvailable != null) {
43
44
  const noFilter = newBalancesFilterForDateRange(firstMonthOfFY, financeStatementState.timeframe, dataAvailable, 'ascending_date', []);
44
45
  const profitAndLossForTimeframeTicks = getProfitAndLossReport(noFilter, undefined, accountState, accountGroupState, sectionAccountsViewState, profitAndLossState, forecastState);
45
- if (profitAndLossForTimeframeTicks.sections.expenses != null) {
46
- balancesInTimeframe =
47
- profitAndLossForTimeframeTicks.sections.expenses.balances.length > 0
48
- ? profitAndLossForTimeframeTicks.sections['expenses'].balances
49
- : profitAndLossForTimeframeTicks.sections['income'].balances;
46
+ const expensesBalances = profitAndLossForTimeframeTicks.sections.expenses?.balances ?? [];
47
+ const incomeBalances = profitAndLossForTimeframeTicks.sections.income?.balances ?? [];
48
+ balancesInTimeframe =
49
+ expensesBalances.length > 0 ? expensesBalances : incomeBalances;
50
+ if (balancesInTimeframe.length > 0) {
50
51
  allTimeframeTicks = balancesInTimeframe.map((balance) => toTimeframeTick(balance));
51
52
  }
52
53
  }
@@ -60,6 +61,9 @@ export const getFinanceStatement = createSelector((state) => state.accountState,
60
61
  const profitAndLossReport = getProfitAndLossReport(filter, undefined, accountState, accountGroupState, sectionAccountsViewState, profitAndLossState, forecastState);
61
62
  const classFilter = newBalancesFilterClassesView(filter.firstMonthOfFY, filter.timeframe, filter.balancesRange.numberOfPeriods, filter.balancesRange.thisPeriod, filter.balancesRange.orderBy, filter.additionalBalances, profitAndLossClassesViewState.uiState.classesToFilterOut);
62
63
  const profitAndLossByClassReport = getProfitAndLossClassesView(classFilter, accountState, classState, sectionsState, profitAndLossClassesViewState);
64
+ const profitAndLossByClassHorizontalReport = getProfitAndLossClassesHorizontalView(classFilter, accountState, classState, sectionsState, profitAndLossClassesViewState, profitAndLossClassesViewState.uiState.classViewLayout === 'horizontal'
65
+ ? thisPeriod
66
+ : undefined);
63
67
  const balanceSheetReport = getBalanceSheet(filter, accountState, accountGroupState, sectionAccountsViewState, balanceSheetState);
64
68
  const cashFlowReport = getCashFlow(filter, accountState, accountGroupState, sectionAccountsViewState, cashFlowState);
65
69
  const allReports = [
@@ -107,6 +111,7 @@ export const getFinanceStatement = createSelector((state) => state.accountState,
107
111
  dataAvailable,
108
112
  profitAndLossReport,
109
113
  profitAndLossByClassReport,
114
+ profitAndLossByClassHorizontalReport,
110
115
  balanceSheetReport,
111
116
  cashFlowReport,
112
117
  filter,
@@ -0,0 +1,323 @@
1
+ import { toAmountWC } from '../../commonStateTypes/amount';
2
+ import { reduceFetchStateParallelTimeframes } from '../../commonStateTypes/reduceFetchState';
3
+ import { getSectionClassesViewReport } from '../../entity/sectionClassesViewV2/sectionClassesViewSelector';
4
+ /** If `ProfitAndLossClassesViewState.currency` is unset (e.g. tests). */
5
+ const FALLBACK_REPORT_CURRENCY = {
6
+ currencyCode: 'USD',
7
+ currencySymbol: '$',
8
+ };
9
+ export const profitAndLossHorizontalIdsInOrder = [
10
+ 'income',
11
+ 'cogs',
12
+ 'grossProfit',
13
+ 'expenses',
14
+ 'earningsBeforeInterestAndTax',
15
+ 'otherIncome',
16
+ 'otherExpenses',
17
+ 'netOtherIncome',
18
+ 'netIncome',
19
+ ];
20
+ const SECTION_TITLES = {
21
+ income: 'Income',
22
+ cogs: 'Cost of Goods Sold',
23
+ grossProfit: 'Gross Profit',
24
+ expenses: 'Expenses',
25
+ earningsBeforeInterestAndTax: 'Net Operating Income',
26
+ otherIncome: 'Other Income',
27
+ otherExpenses: 'Other Expenses',
28
+ netOtherIncome: 'Net Other Income',
29
+ netIncome: 'Net Income',
30
+ };
31
+ /** Sections fetched and pivoted for horizontal layout (excludes e.g. `netOperatingIncome`). */
32
+ const HORIZONTAL_PANDL_SECTION_IDS = [
33
+ 'income',
34
+ 'cogs',
35
+ 'expenses',
36
+ 'otherIncome',
37
+ 'otherExpenses',
38
+ ];
39
+ export const getProfitAndLossClassesHorizontalView = (filter, accountState, classState, sectionsState, profitAndLossClassesViewState, selectedPeriod) => {
40
+ const fetchState = reduceFetchStateParallelTimeframes([
41
+ profitAndLossClassesViewState.fetchState.month,
42
+ profitAndLossClassesViewState.fetchState.quarter,
43
+ profitAndLossClassesViewState.fetchState.year,
44
+ ]);
45
+ const reportId = 'profit_and_loss_by_classes';
46
+ // Build class columns from the class hierarchy (top-level only).
47
+ // Use `filter.classesToFilterOut` so PDF/Excel downloads (fresh store, empty UI state)
48
+ // respect `classes_to_filter_out` from the request; in-app `classFilter` mirrors uiState.
49
+ const classColumns = buildClassColumns(profitAndLossClassesViewState.classHierarchy, filter.classesToFilterOut);
50
+ const sections = {};
51
+ const calculatedSections = {};
52
+ let reportCurrency = FALLBACK_REPORT_CURRENCY;
53
+ if (fetchState.fetchState === 'Completed' && selectedPeriod != null) {
54
+ // Get section reports using existing selector infrastructure
55
+ const sectionReports = {};
56
+ HORIZONTAL_PANDL_SECTION_IDS.forEach((sectionId) => {
57
+ sectionReports[sectionId] = getSectionClassesViewReport(accountState, classState, sectionsState, { reportId, sectionId }, filter);
58
+ });
59
+ reportCurrency =
60
+ profitAndLossClassesViewState.currency ?? FALLBACK_REPORT_CURRENCY;
61
+ // Pivot each section from class→account to account→class
62
+ HORIZONTAL_PANDL_SECTION_IDS.forEach((sectionId) => {
63
+ const sectionReport = sectionReports[sectionId];
64
+ if (sectionReport != null) {
65
+ sections[sectionId] = pivotSectionToHorizontal(sectionReport, classColumns, SECTION_TITLES[sectionId] ?? sectionId, reportCurrency);
66
+ }
67
+ });
68
+ // Calculate derived sections
69
+ const incomeSection = sections['income'];
70
+ const cogsSection = sections['cogs'];
71
+ const expensesSection = sections['expenses'];
72
+ const otherIncomeSection = sections['otherIncome'];
73
+ const otherExpensesSection = sections['otherExpenses'];
74
+ // Gross Profit = Income - COGS
75
+ if (incomeSection != null && cogsSection != null) {
76
+ calculatedSections['grossProfit'] = computeCalculatedSection(incomeSection.classTotals, cogsSection.classTotals, incomeSection.sectionTotal, cogsSection.sectionTotal, 'subtract', SECTION_TITLES['grossProfit']);
77
+ }
78
+ // EBIT = Gross Profit - Expenses
79
+ const grossProfit = calculatedSections['grossProfit'];
80
+ if (grossProfit != null && expensesSection != null) {
81
+ calculatedSections['earningsBeforeInterestAndTax'] =
82
+ computeCalculatedSection(grossProfit.classTotals, expensesSection.classTotals, grossProfit.sectionTotal, expensesSection.sectionTotal, 'subtract', SECTION_TITLES['earningsBeforeInterestAndTax']);
83
+ }
84
+ // Net Other Income = Other Income - Other Expenses
85
+ if (otherIncomeSection != null && otherExpensesSection != null) {
86
+ calculatedSections['netOtherIncome'] = computeCalculatedSection(otherIncomeSection.classTotals, otherExpensesSection.classTotals, otherIncomeSection.sectionTotal, otherExpensesSection.sectionTotal, 'subtract', SECTION_TITLES['netOtherIncome']);
87
+ }
88
+ // Net Income = EBIT + Net Other Income
89
+ const ebit = calculatedSections['earningsBeforeInterestAndTax'];
90
+ const netOtherIncome = calculatedSections['netOtherIncome'];
91
+ if (ebit != null && netOtherIncome != null) {
92
+ calculatedSections['netIncome'] = computeCalculatedSection(ebit.classTotals, netOtherIncome.classTotals, ebit.sectionTotal, netOtherIncome.sectionTotal, 'add', SECTION_TITLES['netIncome']);
93
+ }
94
+ }
95
+ // Build synthetic header COABalances for rendering class names as column headers
96
+ const headerBalances = buildHeaderBalances(classColumns, filter.timeframe, selectedPeriod, reportCurrency);
97
+ return {
98
+ reportId,
99
+ reportTitle: 'Profit and Loss by Class',
100
+ firstMonthOfFY: profitAndLossClassesViewState.firstMonthOfFY,
101
+ fetchState: fetchState.fetchState,
102
+ bookCloseDate: profitAndLossClassesViewState.bookCloseDate,
103
+ dataAvailable: profitAndLossClassesViewState.dataAvailable,
104
+ selectedPeriod,
105
+ classColumns,
106
+ sections,
107
+ calculatedSections,
108
+ idsInOrder: profitAndLossHorizontalIdsInOrder,
109
+ uiState: profitAndLossClassesViewState.uiState,
110
+ headerBalances,
111
+ };
112
+ };
113
+ /**
114
+ * Build column definitions from class hierarchy (top-level classes only).
115
+ */
116
+ function buildClassColumns(classHierarchy, classesToFilterOut) {
117
+ const filteredIds = new Set(classesToFilterOut.map((id) => id[0]));
118
+ return classHierarchy
119
+ .filter((c) => !filteredIds.has(c.classId[0]))
120
+ .map((c) => ({
121
+ classId: c.classId[0],
122
+ className: c.className,
123
+ nestedClassId: c.classId,
124
+ qboId: c.qboId,
125
+ }))
126
+ .sort((a, b) => a.className.localeCompare(b.className, [], { sensitivity: 'base' }));
127
+ }
128
+ /**
129
+ * Pivot a section from class→account→balances to account→class→balance (single period).
130
+ */
131
+ function pivotSectionToHorizontal(section, classColumns, title, currency) {
132
+ // Map: accountId -> { accountName, classId -> balance }
133
+ const accountMap = new Map();
134
+ // Also track class totals
135
+ const classTotalMap = new Map();
136
+ classColumns.forEach((col) => classTotalMap.set(col.classId, 0));
137
+ // Iterate over each class in the section
138
+ section.classes.forEach((classReport) => {
139
+ const classId = classReport.id[0];
140
+ // Only process classes that are in our columns
141
+ if (!classTotalMap.has(classId)) {
142
+ return;
143
+ }
144
+ // Sum all sliced period balances (same slice as vertical columns; multi-period = range total)
145
+ const classBalance = sumBalancesInSlice(classReport.childrenBalancesTotal.balances);
146
+ classTotalMap.set(classId, classBalance);
147
+ // Iterate through accounts in this class
148
+ collectAccountsFromClass(classReport, classId, accountMap);
149
+ });
150
+ // Build account rows in COA tree order (same parent/child ordering as vertical P&L by class).
151
+ // Sorting by depth + name flattened the hierarchy and broke sub-account grouping.
152
+ const orderedIds = collectAccountIdsInTreeOrder(section, classColumns);
153
+ const usedIds = new Set();
154
+ const accounts = [];
155
+ const pushAccountRow = (accountId) => {
156
+ const accountData = accountMap.get(accountId);
157
+ if (accountData == null) {
158
+ return;
159
+ }
160
+ usedIds.add(accountId);
161
+ const classBalances = classColumns.map((col) => ({
162
+ classId: col.classId,
163
+ balance: toAmountWC(accountData.classBalances.get(col.classId) ?? 0, currency),
164
+ }));
165
+ const rowTotal = classBalances.reduce((sum, cb) => sum + cb.balance.amount, 0);
166
+ accounts.push({
167
+ accountId,
168
+ accountName: accountData.accountName,
169
+ accountType: accountData.accountType,
170
+ depth: accountData.depth,
171
+ classBalances,
172
+ rowTotal: toAmountWC(rowTotal, currency),
173
+ });
174
+ };
175
+ for (const id of orderedIds) {
176
+ pushAccountRow(id);
177
+ }
178
+ for (const accountId of accountMap.keys()) {
179
+ if (!usedIds.has(accountId)) {
180
+ pushAccountRow(accountId);
181
+ }
182
+ }
183
+ // Build class totals
184
+ const classTotals = classColumns.map((col) => ({
185
+ classId: col.classId,
186
+ balance: toAmountWC(classTotalMap.get(col.classId) ?? 0, currency),
187
+ }));
188
+ const sectionTotal = classTotals.reduce((sum, ct) => sum + ct.balance.amount, 0);
189
+ return {
190
+ title,
191
+ accounts,
192
+ classTotals,
193
+ sectionTotal: toAmountWC(sectionTotal, currency),
194
+ };
195
+ }
196
+ /**
197
+ * DFS account ids in the same order as vertical P&L by class, using the first class
198
+ * column that appears in this section as the canonical tree (structure is identical per class).
199
+ */
200
+ function collectAccountIdsInTreeOrder(section, classColumns) {
201
+ const ids = [];
202
+ const visitNested = (nested) => {
203
+ ids.push(nested.account.accountId);
204
+ nested.children.forEach(visitNested);
205
+ };
206
+ for (const col of classColumns) {
207
+ const classReport = section.classes.find((c) => c.id[0] === col.classId);
208
+ if (classReport == null) {
209
+ continue;
210
+ }
211
+ classReport.class.accounts.forEach(visitNested);
212
+ return ids;
213
+ }
214
+ return ids;
215
+ }
216
+ /**
217
+ * Recursively collect account balances from a NestedClassReportV2 into accountMap.
218
+ */
219
+ function collectAccountsFromClass(classReport, classId, accountMap) {
220
+ // Process accounts directly on this class (depth 2 matches vertical `AccountsSection` first level)
221
+ classReport.class.accounts.forEach((nestedAccount) => {
222
+ collectAccountsFromNestedAccount(nestedAccount, classId, accountMap, 2);
223
+ });
224
+ }
225
+ /**
226
+ * Recursively collect accounts from NestedAccountReport (handles sub-accounts).
227
+ */
228
+ function collectAccountsFromNestedAccount(nestedAccount, classId, accountMap, depth) {
229
+ const accountId = nestedAccount.account.accountId;
230
+ const accountName = nestedAccount.account.accountName;
231
+ const balance = sumBalancesInSlice(nestedAccount.account.balancesInTimeframe.balances);
232
+ if (!accountMap.has(accountId)) {
233
+ accountMap.set(accountId, {
234
+ accountName,
235
+ accountType: nestedAccount.account.accountType,
236
+ depth,
237
+ classBalances: new Map(),
238
+ });
239
+ }
240
+ const accountData = accountMap.get(accountId);
241
+ if (accountData == null) {
242
+ return;
243
+ }
244
+ accountData.depth = Math.max(accountData.depth, depth);
245
+ if (accountData.accountType == null && nestedAccount.account.accountType != null) {
246
+ accountData.accountType = nestedAccount.account.accountType;
247
+ }
248
+ const existing = accountData.classBalances.get(classId) ?? 0;
249
+ accountData.classBalances.set(classId, existing + balance);
250
+ // Process sub-accounts
251
+ nestedAccount.children.forEach((child) => {
252
+ collectAccountsFromNestedAccount(child, classId, accountMap, depth + 1);
253
+ });
254
+ }
255
+ /**
256
+ * Total amount for the current report slice. `balances` is already narrowed by
257
+ * `getCOABalances` / `sliceCOABalances` to the selected range; summing matches
258
+ * multi-period selections and avoids mismatches when `thisPeriod` does not
259
+ * align with a row (e.g. slice fallback) or when several period rows are shown.
260
+ */
261
+ function sumBalancesInSlice(balances) {
262
+ let sum = 0;
263
+ for (const bal of balances) {
264
+ sum += bal.balance?.amount ?? 0;
265
+ }
266
+ return sum;
267
+ }
268
+ /**
269
+ * Compute a calculated section by adding or subtracting two sets of class totals.
270
+ */
271
+ function computeCalculatedSection(aTotals, bTotals, aTotal, bTotal, operation, title) {
272
+ const currency = {
273
+ currencyCode: aTotal.currencyCode,
274
+ currencySymbol: aTotal.currencySymbol,
275
+ };
276
+ const bTotalsMap = new Map(bTotals.map((b) => [b.classId, b.balance.amount]));
277
+ const classTotals = aTotals.map((a) => {
278
+ const bAmount = bTotalsMap.get(a.classId) ?? 0;
279
+ const resultAmount = operation === 'add'
280
+ ? a.balance.amount + bAmount
281
+ : a.balance.amount - bAmount;
282
+ return {
283
+ classId: a.classId,
284
+ balance: toAmountWC(resultAmount, currency),
285
+ };
286
+ });
287
+ const sectionTotalAmount = operation === 'add'
288
+ ? aTotal.amount + bTotal.amount
289
+ : aTotal.amount - bTotal.amount;
290
+ return {
291
+ title,
292
+ classTotals,
293
+ sectionTotal: toAmountWC(sectionTotalAmount, currency),
294
+ };
295
+ }
296
+ /**
297
+ * Build synthetic COABalance array for header rendering.
298
+ * One entry per class column + one for "Total".
299
+ */
300
+ function buildHeaderBalances(classColumns, timeframe, selectedPeriod, currency) {
301
+ if (selectedPeriod == null) {
302
+ return [];
303
+ }
304
+ const { endDate, startDate } = selectedPeriod;
305
+ const balances = classColumns.map((col) => ({
306
+ balance: toAmountWC(0, currency),
307
+ balanceTimeFrame: timeframe,
308
+ endDate,
309
+ startDate,
310
+ type: 'default',
311
+ label: col.className,
312
+ }));
313
+ // Add a "Total" column
314
+ balances.push({
315
+ balance: toAmountWC(0, currency),
316
+ balanceTimeFrame: timeframe,
317
+ endDate,
318
+ startDate,
319
+ type: 'default',
320
+ label: 'Total',
321
+ });
322
+ return balances;
323
+ }
@@ -23,6 +23,7 @@ export const initialState = {
23
23
  nodeCollapseState: {},
24
24
  scrollPosition: undefined,
25
25
  accountViewMode: 'account_group',
26
+ classViewLayout: 'vertical',
26
27
  classesToFilterOut: [],
27
28
  },
28
29
  firstMonthOfFY: 1,
@@ -94,6 +95,9 @@ const profitAndLossClassesView = createSlice({
94
95
  updateAccountViewMode(draft, action) {
95
96
  draft.uiState.accountViewMode = action.payload.viewMode;
96
97
  },
98
+ updateClassViewLayout(draft, action) {
99
+ draft.uiState.classViewLayout = action.payload.classViewLayout;
100
+ },
97
101
  resetProfitAndLossClassesNodeCollapseState(draft) {
98
102
  draft.uiState.nodeCollapseState = {};
99
103
  },
@@ -102,10 +106,11 @@ const profitAndLossClassesView = createSlice({
102
106
  },
103
107
  },
104
108
  });
105
- export const { fetchProfitAndLossClassesView, fetchProfitAndLossForTimeframeClassesView, updateProfitAndLossForTimeframeClassesView, updateProfitAndLossForTimeframeClassesViewFailure, updateClassesToFilterOut, updateProfitAndLossClassesViewUIState, updateAccountViewMode, resetProfitAndLossClassesNodeCollapseState, clearProfitAndLossClassesView, } = profitAndLossClassesView.actions;
109
+ export const { fetchProfitAndLossClassesView, fetchProfitAndLossForTimeframeClassesView, updateProfitAndLossForTimeframeClassesView, updateProfitAndLossForTimeframeClassesViewFailure, updateClassesToFilterOut, updateProfitAndLossClassesViewUIState, updateAccountViewMode, updateClassViewLayout, resetProfitAndLossClassesNodeCollapseState, clearProfitAndLossClassesView, } = profitAndLossClassesView.actions;
106
110
  export default profitAndLossClassesView.reducer;
107
111
  const doUpdatedProfitAndLossClassesView = (draft, timeframe, pandLReport) => {
108
112
  const report = mapReportPayloadV2ToReportV2(pandLReport.report);
113
+ draft.currency = report.currency;
109
114
  draft.dataAvailable = report.dataAvailable;
110
115
  draft.firstMonthOfFY = report.firstMonthOfFY;
111
116
  draft.bookCloseDate = report.bookCloseDate;