@zeniai/client-epic-state 5.2.93-betaNB2 → 5.2.94

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 (88) hide show
  1. package/lib/commonStateTypes/timeframeTick.d.ts +1 -0
  2. package/lib/commonStateTypes/timeframeTick.js +1 -0
  3. package/lib/commonStateTypes/viewAndReport/reportUIState.d.ts +2 -0
  4. package/lib/commonStateTypes/viewAndReport/viewAndReport.d.ts +2 -0
  5. package/lib/entity/reconciliationReport/reconciliationReportPayload.d.ts +0 -1
  6. package/lib/entity/reconciliationReport/reconciliationReportReducer.js +0 -1
  7. package/lib/entity/reconciliationReport/reconciliationReportState.d.ts +2 -4
  8. package/lib/entity/reconciliationReport/reconciliationReportState.js +1 -4
  9. package/lib/esm/commonStateTypes/timeframeTick.js +1 -0
  10. package/lib/esm/entity/reconciliationReport/reconciliationReportReducer.js +0 -1
  11. package/lib/esm/entity/reconciliationReport/reconciliationReportState.js +1 -4
  12. package/lib/esm/view/balanceSheet/balanceSheetForTimeframeEpic.js +4 -0
  13. package/lib/esm/view/balanceSheet/balanceSheetReducer.js +8 -2
  14. package/lib/esm/view/cashFlow/cashFlowForTimeframeEpic.js +4 -0
  15. package/lib/esm/view/cashFlow/cashFlowReducer.js +2 -2
  16. package/lib/esm/view/cashInCashOut/cashInCashOutEpic.js +22 -14
  17. package/lib/esm/view/cashInCashOut/cashInCashOutReducer.js +161 -41
  18. package/lib/esm/view/cashInCashOut/cashInCashOutSelector.js +42 -15
  19. package/lib/esm/view/cashPosition/cashPositionEpic.js +21 -13
  20. package/lib/esm/view/cashPosition/cashPositionReducer.js +155 -44
  21. package/lib/esm/view/cashPosition/cashPositionSelector.js +28 -9
  22. package/lib/esm/view/netBurnOrIncome/netBurnOrIncomeEpic.js +7 -7
  23. package/lib/esm/view/netBurnOrIncome/netBurnOrIncomeForTimeframeEpic.js +25 -10
  24. package/lib/esm/view/netBurnOrIncome/netBurnOrIncomeReducer.js +184 -40
  25. package/lib/esm/view/netBurnOrIncome/netBurnOrIncomeSelector.js +26 -8
  26. package/lib/esm/view/opEx/opExEpic.js +7 -7
  27. package/lib/esm/view/opEx/opExForTimeFrameEpic.js +21 -6
  28. package/lib/esm/view/opEx/opExReducer.js +169 -32
  29. package/lib/esm/view/opEx/opExSelector.js +28 -10
  30. package/lib/esm/view/profitAndLoss/profitAndLossForTimeframeEpic.js +4 -0
  31. package/lib/esm/view/profitAndLoss/profitAndLossReducer.js +9 -2
  32. package/lib/esm/view/revenue/revenueEpic.js +7 -7
  33. package/lib/esm/view/revenue/revenueForTimeframeEpic.js +23 -8
  34. package/lib/esm/view/revenue/revenueReducer.js +183 -40
  35. package/lib/esm/view/revenue/revenueSelector.js +30 -10
  36. package/lib/view/balanceSheet/balanceSheetForTimeframeEpic.js +4 -0
  37. package/lib/view/balanceSheet/balanceSheetReducer.d.ts +3 -1
  38. package/lib/view/balanceSheet/balanceSheetReducer.js +8 -2
  39. package/lib/view/cashFlow/cashFlowForTimeframeEpic.js +4 -0
  40. package/lib/view/cashFlow/cashFlowReducer.d.ts +3 -1
  41. package/lib/view/cashFlow/cashFlowReducer.js +2 -2
  42. package/lib/view/cashInCashOut/cashInCashOutEpic.d.ts +8 -2
  43. package/lib/view/cashInCashOut/cashInCashOutEpic.js +22 -14
  44. package/lib/view/cashInCashOut/cashInCashOutReducer.d.ts +13 -1
  45. package/lib/view/cashInCashOut/cashInCashOutReducer.js +161 -41
  46. package/lib/view/cashInCashOut/cashInCashOutSelector.d.ts +2 -1
  47. package/lib/view/cashInCashOut/cashInCashOutSelector.js +42 -15
  48. package/lib/view/cashInCashOut/cashInCashOutState.d.ts +5 -1
  49. package/lib/view/cashPosition/cashPositionEpic.d.ts +8 -2
  50. package/lib/view/cashPosition/cashPositionEpic.js +21 -13
  51. package/lib/view/cashPosition/cashPositionReducer.d.ts +13 -2
  52. package/lib/view/cashPosition/cashPositionReducer.js +155 -44
  53. package/lib/view/cashPosition/cashPositionSelector.d.ts +2 -1
  54. package/lib/view/cashPosition/cashPositionSelector.js +28 -9
  55. package/lib/view/cashPosition/cashPositionState.d.ts +5 -1
  56. package/lib/view/financeStatement/financeStatementEpic.d.ts +6 -0
  57. package/lib/view/netBurnOrIncome/fetchNetBurnOrIncomeWithForecastEpic.d.ts +1 -0
  58. package/lib/view/netBurnOrIncome/netBurnOrIncomeEpic.js +7 -7
  59. package/lib/view/netBurnOrIncome/netBurnOrIncomeForTimeframeEpic.d.ts +2 -1
  60. package/lib/view/netBurnOrIncome/netBurnOrIncomeForTimeframeEpic.js +26 -11
  61. package/lib/view/netBurnOrIncome/netBurnOrIncomeReducer.d.ts +12 -3
  62. package/lib/view/netBurnOrIncome/netBurnOrIncomeReducer.js +184 -40
  63. package/lib/view/netBurnOrIncome/netBurnOrIncomeSelector.d.ts +1 -1
  64. package/lib/view/netBurnOrIncome/netBurnOrIncomeSelector.js +26 -8
  65. package/lib/view/netBurnOrIncome/netBurnOrIncomeState.d.ts +8 -0
  66. package/lib/view/opEx/fetchOpExWithForecastEpic.d.ts +1 -0
  67. package/lib/view/opEx/opExEpic.js +7 -7
  68. package/lib/view/opEx/opExForTimeFrameEpic.d.ts +2 -1
  69. package/lib/view/opEx/opExForTimeFrameEpic.js +22 -7
  70. package/lib/view/opEx/opExReducer.d.ts +12 -3
  71. package/lib/view/opEx/opExReducer.js +168 -31
  72. package/lib/view/opEx/opExSelector.d.ts +1 -1
  73. package/lib/view/opEx/opExSelector.js +28 -10
  74. package/lib/view/opEx/opExState.d.ts +6 -0
  75. package/lib/view/profitAndLoss/profitAndLossForTimeframeEpic.js +4 -0
  76. package/lib/view/profitAndLoss/profitAndLossReducer.d.ts +3 -1
  77. package/lib/view/profitAndLoss/profitAndLossReducer.js +9 -2
  78. package/lib/view/reportUIOptions/updateReportUIOptionCOABalancesRangeEpic.d.ts +15 -0
  79. package/lib/view/revenue/fetchRevenueWithForecastEpic.d.ts +1 -0
  80. package/lib/view/revenue/revenueEpic.js +7 -7
  81. package/lib/view/revenue/revenueForTimeframeEpic.d.ts +2 -1
  82. package/lib/view/revenue/revenueForTimeframeEpic.js +24 -9
  83. package/lib/view/revenue/revenueReducer.d.ts +12 -3
  84. package/lib/view/revenue/revenueReducer.js +182 -39
  85. package/lib/view/revenue/revenueSelector.d.ts +1 -1
  86. package/lib/view/revenue/revenueSelector.js +30 -10
  87. package/lib/view/revenue/revenueState.d.ts +8 -0
  88. package/package.json +1 -1
@@ -5,7 +5,7 @@ import { getDataAvailableDateRange, getNumberOfPeriods, } from '../../commonStat
5
5
  import { extractThisPeriod, getMatchingThisPeriod, } from '../../commonStateTypes/viewAndReport/thisPeriodHelpers';
6
6
  import { mapInsightsPayloadtoInsightsState } from '../../entity/insights/insightSummaryPayload';
7
7
  import { clearAll } from '../../rootActions';
8
- import { dateNow } from '../../zeniDayJS';
8
+ import { date, dateNow } from '../../zeniDayJS';
9
9
  import { getAvailableCOABalancesWithMostRecentNonZeroBalance, getInitialSelectedAndHighlightedRanges, getSelectedAndHighlightedRangesForNewSelection, getSelectedAndHighlightedRangesForThisPeriod, } from '../common/getSelectedAndHighlightedRanges';
10
10
  export const defaultPeriods = {
11
11
  numberOfPeriods: 8,
@@ -65,8 +65,16 @@ const operatingExpenses = createSlice({
65
65
  initialState,
66
66
  reducers: {
67
67
  fetchOpEx: {
68
- prepare(fetchFullReport = false, forecastId, cacheOverride = false) {
69
- return { payload: { fetchFullReport, forecastId, cacheOverride } };
68
+ prepare(fetchFullReport = false, forecastId, cacheOverride = false, startDate, endDate) {
69
+ return {
70
+ payload: {
71
+ fetchFullReport,
72
+ forecastId,
73
+ cacheOverride,
74
+ startDate,
75
+ endDate,
76
+ },
77
+ };
70
78
  },
71
79
  reducer(
72
80
  //eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -77,22 +85,49 @@ const operatingExpenses = createSlice({
77
85
  },
78
86
  },
79
87
  fetchOpExReportForTimeframe: {
80
- prepare(timeframe, forecastId, fetchFullReport = true, cacheOverride = false) {
88
+ prepare(timeframe, forecastId, fetchFullReport = true, cacheOverride = false, startDate, endDate) {
81
89
  return {
82
- payload: { forecastId, timeframe, fetchFullReport, cacheOverride },
90
+ payload: {
91
+ forecastId,
92
+ timeframe,
93
+ fetchFullReport,
94
+ cacheOverride,
95
+ startDate,
96
+ endDate,
97
+ },
83
98
  };
84
99
  },
85
100
  reducer(draft, action) {
86
101
  const timeframe = action.payload.timeframe;
87
102
  const opExReport = getState(draft, draft.uiState.isForecastModeOn, action.payload.forecastId);
88
- const fetchState = {
89
- ...opExReport.fetchState,
90
- };
91
- fetchState[timeframe] = {
92
- fetchState: 'In-Progress',
93
- error: undefined,
94
- };
95
- updateState(draft, action.payload.forecastId, { fetchState });
103
+ // Route by the fetch's own dates, not the global flag: a custom fetch
104
+ // (carries start/end) marks customFetchState so the shared fetchState the
105
+ // dashboard reads is never left In-Progress by a detail-screen fetch.
106
+ const isCustomDateRange = action.payload.startDate != null && action.payload.endDate != null;
107
+ if (isCustomDateRange) {
108
+ const customFetchState = {
109
+ ...(opExReport.customFetchState ?? {
110
+ month: { fetchState: 'Not-Started', error: undefined },
111
+ quarter: { fetchState: 'Not-Started', error: undefined },
112
+ year: { fetchState: 'Not-Started', error: undefined },
113
+ }),
114
+ };
115
+ customFetchState[timeframe] = {
116
+ fetchState: 'In-Progress',
117
+ error: undefined,
118
+ };
119
+ updateState(draft, action.payload.forecastId, { customFetchState });
120
+ }
121
+ else {
122
+ const fetchState = {
123
+ ...opExReport.fetchState,
124
+ };
125
+ fetchState[timeframe] = {
126
+ fetchState: 'In-Progress',
127
+ error: undefined,
128
+ };
129
+ updateState(draft, action.payload.forecastId, { fetchState });
130
+ }
96
131
  },
97
132
  },
98
133
  fetchOpExWithForecast: {
@@ -122,25 +157,44 @@ const operatingExpenses = createSlice({
122
157
  },
123
158
  },
124
159
  updateOpExForTimeframe(draft, action) {
125
- doUpdatedOpExReport(draft, action.payload.forecastId, action.payload.timeframe, action.payload.report);
160
+ doUpdatedOpExReport(draft, action.payload.forecastId, action.payload.timeframe, action.payload.report, action.payload.isCustomDateRange === true);
126
161
  },
127
162
  updateOpExFailureForTimeframe: {
128
- prepare(error, forecastId, timeframe) {
163
+ prepare(error, forecastId, timeframe, isCustomDateRange = false) {
129
164
  return {
130
165
  payload: {
131
166
  error,
132
167
  forecastId,
133
168
  timeframe,
169
+ isCustomDateRange,
134
170
  },
135
171
  };
136
172
  },
137
173
  reducer(draft, action) {
138
174
  const timeframe = action.payload.timeframe;
139
175
  const opExReport = getState(draft, draft.uiState.isForecastModeOn, draft.uiState.selectedForecast?.id);
140
- opExReport.fetchState[timeframe] = {
141
- fetchState: 'Error',
142
- error: action.payload.error,
143
- };
176
+ // Route the error like the request/success: a custom (dated) fetch's
177
+ // failure lands in customFetchState so the shared default the dashboard
178
+ // reads isn't marked Error, and the detail's customFetchState isn't left
179
+ // stuck In-Progress.
180
+ if (action.payload.isCustomDateRange === true) {
181
+ const customFetchState = opExReport.customFetchState ?? {
182
+ month: { fetchState: 'Not-Started', error: undefined },
183
+ quarter: { fetchState: 'Not-Started', error: undefined },
184
+ year: { fetchState: 'Not-Started', error: undefined },
185
+ };
186
+ customFetchState[timeframe] = {
187
+ fetchState: 'Error',
188
+ error: action.payload.error,
189
+ };
190
+ opExReport.customFetchState = customFetchState;
191
+ }
192
+ else {
193
+ opExReport.fetchState[timeframe] = {
194
+ fetchState: 'Error',
195
+ error: action.payload.error,
196
+ };
197
+ }
144
198
  updateState(draft, action.payload.forecastId, opExReport);
145
199
  },
146
200
  },
@@ -158,8 +212,12 @@ const operatingExpenses = createSlice({
158
212
  action.payload.additionalBalances.length != 0
159
213
  ? true
160
214
  : false;
161
- // Note: We don't want to update selected & highlighted ranges in Ui State when forecast is ON
162
- if (dataAvailable != null && draft.uiState.isForecastModeOn === false) {
215
+ // Note: We don't want to update selected & highlighted ranges in Ui State when forecast is ON.
216
+ // Also skip when a custom date range is driving the window — otherwise the
217
+ // auto-window re-derivation below clamps the user's range to the default.
218
+ if (dataAvailable != null &&
219
+ draft.uiState.isForecastModeOn === false &&
220
+ draft.uiState.customTimeframe == null) {
163
221
  const coaBalances = getAvailableCOABalancesWithMostRecentNonZeroBalance(opExReport.firstMonthOfFY, draft.uiState.timeframe, [opExBalancesByTimeframe], dataAvailable);
164
222
  const thisPeriod = extractThisPeriod(opExReport.firstMonthOfFY, draft.uiState.timeframe, draft.uiState.selectedCOABalancesRange, coaBalances);
165
223
  if (thisPeriod != null &&
@@ -188,6 +246,48 @@ const operatingExpenses = createSlice({
188
246
  }
189
247
  },
190
248
  updateOpExCOABalancesRange(draft, action) {
249
+ if (action.payload.isCustomDateRange != null) {
250
+ if (action.payload.isCustomDateRange === true) {
251
+ // Convert the string dates the action carries to ZeniDate values and
252
+ // store as a single customTimeframe. Presence signals custom-mode active.
253
+ draft.uiState.customTimeframe =
254
+ action.payload.customStartDate != null &&
255
+ action.payload.customEndDate != null
256
+ ? {
257
+ startDate: date(action.payload.customStartDate),
258
+ endDate: date(action.payload.customEndDate),
259
+ }
260
+ : undefined;
261
+ // Invalidate the shared default-window fetchState so dashboardEpic
262
+ // refetches the default data on the next dashboard visit — the custom
263
+ // fetch writes into the shared section store, and this lets the card
264
+ // self-heal without a reload. The detail screen reads customFetchState.
265
+ draft.opEx.fetchState = {
266
+ month: { fetchState: 'Not-Started', error: undefined },
267
+ quarter: { fetchState: 'Not-Started', error: undefined },
268
+ year: { fetchState: 'Not-Started', error: undefined },
269
+ };
270
+ }
271
+ else {
272
+ draft.uiState.customTimeframe = undefined;
273
+ }
274
+ }
275
+ // A custom date range drives the visible window directly: honor the exact
276
+ // numberOfPeriods + thisPeriod from the payload instead of re-deriving
277
+ // (which clamps the range to the available/non-zero periods).
278
+ if (draft.uiState.customTimeframe != null) {
279
+ draft.uiState.selectedCOABalancesRange =
280
+ action.payload.selectedCOABalancesRange;
281
+ if (action.payload.highlightedCOABalancesRange != null) {
282
+ // Clamp the highlight to the same cap the non-custom path enforces so
283
+ // a wide custom range doesn't highlight all N periods at once.
284
+ draft.uiState.highlightedCOABalancesRange = {
285
+ ...action.payload.highlightedCOABalancesRange,
286
+ numberOfPeriods: Math.min(action.payload.highlightedCOABalancesRange.numberOfPeriods, draft.uiState.maxNumOfPeriodsToHighlight),
287
+ };
288
+ }
289
+ return;
290
+ }
191
291
  // Note: When thisPeriod changes the selected and highlighted balances ranges should be calculated based on full report period.
192
292
  const opExReport = getState(draft, undefined, undefined);
193
293
  const { opExBalancesByTimeframe, dataAvailable } = opExReport;
@@ -232,6 +332,13 @@ const operatingExpenses = createSlice({
232
332
  }
233
333
  },
234
334
  updateOpExThisPeriod(draft, action) {
335
+ // In custom-date mode the picker drives the window; skip re-deriving the
336
+ // selected/highlighted ranges from the default dataAvailable (the epic
337
+ // re-dispatches the default thisPeriod on every month response, which
338
+ // would otherwise clamp the custom window back to the default).
339
+ if (draft.uiState.customTimeframe != null) {
340
+ return;
341
+ }
235
342
  // Note: When thisPeriod changes the selected and highlighted balances ranges should be calculated based on full report period.
236
343
  const opExReport = getState(draft, undefined, undefined);
237
344
  const { opExBalancesByTimeframe, dataAvailable } = opExReport;
@@ -310,7 +417,7 @@ const operatingExpenses = createSlice({
310
417
  draft.uiState.maxNumOfPeriodsToHighlight =
311
418
  initialState.uiState.maxNumOfPeriodsToHighlight;
312
419
  }
313
- if (dataAvailable != null) {
420
+ if (dataAvailable != null && draft.uiState.customTimeframe == null) {
314
421
  const timeframe = draft.uiState.timeframe;
315
422
  const coaBalances = getAvailableCOABalancesWithMostRecentNonZeroBalance(opExReport.firstMonthOfFY, timeframe, [opExBalancesByTimeframe], dataAvailable);
316
423
  const selectionRanges = getInitialSelectedAndHighlightedRanges({
@@ -338,27 +445,57 @@ const operatingExpenses = createSlice({
338
445
  });
339
446
  export const { fetchOpEx, fetchOpExReportForTimeframe, updateOpExForTimeframe, updateOpExFailureForTimeframe, updateOpExCOABalancesRange, updateOpExAdditionalBalancesSelection, updateOpExThisPeriod, updateOpExDownloadState, updateOpExUIState, opExToggleForecastMode, fetchOpExWithForecast, waitForForecastListThenFetchOpExWithForecast, updateOpExTimeFrame, } = operatingExpenses.actions;
340
447
  export default operatingExpenses.reducer;
341
- const doUpdatedOpExReport = (draft, forecastId, timeframe, data) => {
448
+ const doUpdatedOpExReport = (draft, forecastId, timeframe, data, isCustomDateRange) => {
342
449
  const report = mapReportPayloadV2ToReportV2(data.report);
343
450
  const opExReport = getState(draft, draft.uiState.isForecastModeOn, forecastId);
344
451
  const { currency, expenses, insights } = data.report;
345
452
  const { firstMonthOfFY, bookCloseDate, dataAvailable } = report;
346
453
  const expensesCOABalance = mapCOABalanceGroupedByPeriodPayloadV2ToCOABalanceGroupedByPeriod(expenses, currency, timeframe);
347
- opExReport.opExBalancesByTimeframe = Object.assign({}, opExReport.opExBalancesByTimeframe, expensesCOABalance);
348
- if (timeframe === 'month' && insights != null) {
454
+ // Route custom-date results (identified by the fetch's own dates, not the
455
+ // global flag) to the separate slot so the dashboard card (which reads
456
+ // opExBalancesByTimeframe) keeps the default window.
457
+ if (isCustomDateRange) {
458
+ opExReport.customOpExBalancesByTimeframe = Object.assign({}, opExReport.customOpExBalancesByTimeframe, expensesCOABalance);
459
+ }
460
+ else {
461
+ opExReport.opExBalancesByTimeframe = Object.assign({}, opExReport.opExBalancesByTimeframe, expensesCOABalance);
462
+ }
463
+ // Insights are shared (read by the dashboard card); a custom slice must not
464
+ // overwrite them.
465
+ if (timeframe === 'month' && insights != null && !isCustomDateRange) {
349
466
  // we will get insights only in the month timeframe api
350
467
  opExReport.insights = mapInsightsPayloadtoInsightsState(insights);
351
468
  }
352
- opExReport.dataAvailable = dataAvailable
353
- ? dataAvailable
354
- : getDataAvailableDateRange(expensesCOABalance);
469
+ // Don't let a custom slice shrink the shared dataAvailable the dashboard uses
470
+ // for its period count; a default fetch always updates it, a custom one only
471
+ // populates it on first load (when nothing has set it yet).
472
+ if (!isCustomDateRange || opExReport.dataAvailable == null) {
473
+ opExReport.dataAvailable = dataAvailable
474
+ ? dataAvailable
475
+ : getDataAvailableDateRange(expensesCOABalance);
476
+ }
355
477
  opExReport.firstMonthOfFY = firstMonthOfFY;
356
478
  opExReport.bookCloseDate = bookCloseDate;
357
- opExReport.fetchState[timeframe].fetchState = 'Completed';
358
- opExReport.fetchState[timeframe].error = undefined;
479
+ // Mark the matching fetchState Completed. A custom fetch uses customFetchState so
480
+ // dashboardEpic's Completed check on the default fetchState is untouched.
481
+ if (isCustomDateRange) {
482
+ const customFetchState = opExReport.customFetchState ?? {
483
+ month: { fetchState: 'Not-Started', error: undefined },
484
+ quarter: { fetchState: 'Not-Started', error: undefined },
485
+ year: { fetchState: 'Not-Started', error: undefined },
486
+ };
487
+ customFetchState[timeframe] = { fetchState: 'Completed', error: undefined };
488
+ opExReport.customFetchState = customFetchState;
489
+ }
490
+ else {
491
+ opExReport.fetchState[timeframe].fetchState = 'Completed';
492
+ opExReport.fetchState[timeframe].error = undefined;
493
+ }
359
494
  if (opExReport.dataAvailable != null &&
360
- forecastId == null // Note: We don't want to update selected & highlighted ranges in Ui State when forecast is ON
361
- ) {
495
+ forecastId == null && // Note: We don't want to update selected & highlighted ranges in Ui State when forecast is ON
496
+ // A custom date range drives the window; don't reset it to the default on
497
+ // every report receipt (this is what clamped wide ranges back to 8).
498
+ draft.uiState.customTimeframe == null) {
362
499
  const timeframe = draft.uiState.timeframe;
363
500
  const coaBalances = getAvailableCOABalancesWithMostRecentNonZeroBalance(opExReport.firstMonthOfFY, timeframe, [expensesCOABalance], opExReport.dataAvailable);
364
501
  const selectionRanges = getInitialSelectedAndHighlightedRanges({
@@ -58,8 +58,10 @@ export const getOperatingExpensesUIState = createSelector((state) => state.opExS
58
58
  reportFormat,
59
59
  };
60
60
  });
61
- const getOpExBalancesInTimeFrame = createSelector((state) => state.opExState, (state) => state.sectionAccountsViewState, (opExState, sectionState) => {
62
- const reportId = 'operating_expenses';
61
+ const getOpExBalancesInTimeFrame = createSelector((state) => state.opExState, (state) => state.sectionAccountsViewState, (_state, readCustomRangeSlot) => readCustomRangeSlot === true, (opExState, sectionState, readCustomRangeSlot) => {
62
+ // Custom detail reads its own section-accounts slot so a default fetch can't
63
+ // overwrite the partial-period data; the dashboard reads the default slot.
64
+ const reportId = reportIdUniqueKey(undefined, readCustomRangeSlot && opExState.uiState.customTimeframe != null);
63
65
  const opExStateReport = getState(opExState, false, undefined);
64
66
  if (opExStateReport.dataAvailable != null) {
65
67
  const noFilter = newBalancesFilterForDateRange(opExStateReport.firstMonthOfFY, opExState.uiState.timeframe, opExStateReport.dataAvailable, 'ascending_date', []);
@@ -72,14 +74,25 @@ const getOpExBalancesInTimeFrame = createSelector((state) => state.opExState, (s
72
74
  }
73
75
  return [];
74
76
  });
75
- export const getOperatingExpensesReport = createSelector((state) => state, (state) => state.accountState, (state) => state.accountGroupState, (state) => state.sectionAccountsViewState, (state) => state.opExState, (state) => state.forecastState, (state) => state.forecastListState, (_state, filter) => filter, (_state, _filter, forecastId) => forecastId, (state, accountState, accountGroupState, sectionsState, opExState, forecastState, forecastListState, filter, forecastId) => {
77
+ export const getOperatingExpensesReport = createSelector((state) => state, (state) => state.accountState, (state) => state.accountGroupState, (state) => state.sectionAccountsViewState, (state) => state.opExState, (state) => state.forecastState, (state) => state.forecastListState, (_state, filter) => filter, (_state, _filter, forecastId) => forecastId, (_state, _filter, _forecastId, readCustomRangeSlot) => readCustomRangeSlot === true, (state, accountState, accountGroupState, sectionsState, opExState, forecastState, forecastListState, filter, forecastId, readCustomRangeSlot) => {
76
78
  const opEx = getState(opExState, opExState.uiState.isForecastModeOn, forecastId);
79
+ // The detail screen opts into customFetchState so its Completed check fires
80
+ // off the custom fetch; the dashboard (no flag) reads the shared fetchState,
81
+ // which custom-mode activation reset to Not-Started so dashboardEpic refetches.
82
+ const activeFetchState = readCustomRangeSlot &&
83
+ opExState.uiState.customTimeframe != null &&
84
+ opEx.customFetchState != null
85
+ ? opEx.customFetchState
86
+ : opEx.fetchState;
77
87
  const fetchState = reduceAnyFetchState([
78
- opEx.fetchState.month,
79
- opEx.fetchState.quarter,
80
- opEx.fetchState.year,
88
+ activeFetchState.month,
89
+ activeFetchState.quarter,
90
+ activeFetchState.year,
81
91
  ]);
82
- const reportId = reportIdUniqueKey(forecastId);
92
+ // Custom detail reads the isolated custom section-accounts slot; dashboard
93
+ // (no flag) reads the default slot, so a default fetch can't clobber it.
94
+ const useCustomRange = readCustomRangeSlot && opExState.uiState.customTimeframe != null;
95
+ const reportId = reportIdUniqueKey(forecastId, useCustomRange);
83
96
  const sections = {};
84
97
  const calculatedSections = {};
85
98
  if (fetchState.fetchState === 'Completed') {
@@ -90,7 +103,7 @@ export const getOperatingExpensesReport = createSelector((state) => state, (stat
90
103
  }, filter);
91
104
  });
92
105
  }
93
- const balancesInTimeframe = getOpExBalancesInTimeFrame(state);
106
+ const balancesInTimeframe = getOpExBalancesInTimeFrame(state, useCustomRange);
94
107
  const allTimeframeTicks = balancesInTimeframe.map((balance) => toTimeframeTick(balance));
95
108
  const forecast = opEx.forecastId != null
96
109
  ? getForecast(forecastState, opEx.forecastId)
@@ -117,6 +130,8 @@ export const getOperatingExpensesReport = createSelector((state) => state, (stat
117
130
  allTimeframeTicks,
118
131
  balancesInTimeframe,
119
132
  availableNumberOfPeriods: getNumberOfPeriods(opEx.firstMonthOfFY, filter.timeframe, opEx.dataAvailable),
133
+ // Surface the saved custom timeframe so the detail picker can restore them.
134
+ customTimeframe: opExState.uiState.customTimeframe,
120
135
  };
121
136
  });
122
137
  export const getOperatingExpensesForSelectedRange = createSelector((state) => state, (state) => state.opExState, (state, opExWithForecastState) => {
@@ -126,7 +141,10 @@ export const getOperatingExpensesForSelectedRange = createSelector((state) => st
126
141
  selectedForecast?.id != null &&
127
142
  getOperatingExpensesReportFetchState(selectedForecast.id, opExWithForecastState).fetchState === 'Completed'
128
143
  ? getOpExWithForecastOnDataAvailableDateRange(state, noFilter, selectedForecast?.id)
129
- : getOperatingExpensesReport(state, noFilter, opExWithForecastState.uiState.selectedForecast?.id);
144
+ : getOperatingExpensesReport(state, noFilter, opExWithForecastState.uiState.selectedForecast?.id,
145
+ // Detail selectors read the custom slot when the detail is in custom
146
+ // mode; the dashboard's getOperatingExpensesForLast5Periods does not.
147
+ opExWithForecastState.uiState.customTimeframe != null);
130
148
  });
131
149
  export const getOperatingExpensesForHighlightedRange = createSelector((state) => state, (state) => state.opExState, (state, opExWithForecastState) => {
132
150
  const { timeframe, highlightedCOABalancesRange, selectedAdditionalBalances, maxNumOfPeriodsToHighlight, isForecastModeOn, selectedForecast, } = opExWithForecastState.uiState;
@@ -136,7 +154,7 @@ export const getOperatingExpensesForHighlightedRange = createSelector((state) =>
136
154
  selectedForecast?.id != null &&
137
155
  getOperatingExpensesReportFetchState(selectedForecast.id, opExWithForecastState).fetchState === 'Completed'
138
156
  ? getOpExWithForecastOnDataAvailableDateRange(state, filter, selectedForecast?.id)
139
- : getOperatingExpensesReport(state, filter, opExWithForecastState.uiState.selectedForecast?.id);
157
+ : getOperatingExpensesReport(state, filter, opExWithForecastState.uiState.selectedForecast?.id, opExWithForecastState.uiState.customTimeframe != null);
140
158
  });
141
159
  /**
142
160
  * Get Net Burn or Income with forecast data overlaid on actuals.
@@ -25,6 +25,10 @@ export const fetchProfitAndLossForTimeframeEpic = (actions$, _, zeniAPI) => acti
25
25
  queryValue.forecast_id = action.payload.forecastId;
26
26
  queryValue.is_forecast_vs_actual = true;
27
27
  }
28
+ if (action.payload.startDate != null && action.payload.endDate != null) {
29
+ queryValue.custom_start_date = action.payload.startDate;
30
+ queryValue.custom_end_date = action.payload.endDate;
31
+ }
28
32
  return zeniAPI
29
33
  .getJSON(`${zeniAPI.apiEndPoints.accountMicroServiceBaseUrl}/2.0/reports/profit_and_loss?query=${encodeURIComponent(JSON.stringify(queryValue))}`)
30
34
  .pipe(mergeMap((response) => {
@@ -49,9 +49,16 @@ const profitAndLoss = createSlice({
49
49
  },
50
50
  },
51
51
  fetchProfitAndLossForTimeframe: {
52
- prepare(timeframe, forecastId, fetchFullReport = true, cacheOverride = false) {
52
+ prepare(timeframe, forecastId, fetchFullReport = true, cacheOverride = false, startDate, endDate) {
53
53
  return {
54
- payload: { forecastId, timeframe, fetchFullReport, cacheOverride },
54
+ payload: {
55
+ forecastId,
56
+ timeframe,
57
+ fetchFullReport,
58
+ cacheOverride,
59
+ startDate,
60
+ endDate,
61
+ },
55
62
  };
56
63
  },
57
64
  reducer(draft, action) {
@@ -2,12 +2,12 @@ import { from } from 'rxjs';
2
2
  import { filter, switchMap } from 'rxjs/operators';
3
3
  import { fetchRevenue, fetchRevenueForTimeframe, getState, } from './revenueReducer';
4
4
  export const fetchRevenueEpic = (actions$, state) => actions$.pipe(filter(fetchRevenue.match), switchMap((action) => {
5
- const { cacheOverride, forecastId } = action.payload;
5
+ const { cacheOverride, endDate, forecastId, startDate } = action.payload;
6
6
  if (cacheOverride === true) {
7
7
  return from([
8
- fetchRevenueForTimeframe('month', forecastId),
9
- fetchRevenueForTimeframe('quarter', forecastId),
10
- fetchRevenueForTimeframe('year', forecastId),
8
+ fetchRevenueForTimeframe('month', forecastId, true, false, startDate, endDate),
9
+ fetchRevenueForTimeframe('quarter', forecastId, true, false, startDate, endDate),
10
+ fetchRevenueForTimeframe('year', forecastId, true, false, startDate, endDate),
11
11
  ]);
12
12
  }
13
13
  else {
@@ -16,15 +16,15 @@ export const fetchRevenueEpic = (actions$, state) => actions$.pipe(filter(fetchR
16
16
  const timeframeFetchState = revState.fetchState;
17
17
  if (timeframeFetchState['month'].fetchState !== 'In-Progress' &&
18
18
  timeframeFetchState['month'].fetchState !== 'Completed') {
19
- fetchActions.push(fetchRevenueForTimeframe('month', forecastId));
19
+ fetchActions.push(fetchRevenueForTimeframe('month', forecastId, true, false, startDate, endDate));
20
20
  }
21
21
  if (timeframeFetchState['quarter'].fetchState !== 'In-Progress' &&
22
22
  timeframeFetchState['quarter'].fetchState !== 'Completed') {
23
- fetchActions.push(fetchRevenueForTimeframe('quarter', forecastId));
23
+ fetchActions.push(fetchRevenueForTimeframe('quarter', forecastId, true, false, startDate, endDate));
24
24
  }
25
25
  if (timeframeFetchState['year'].fetchState !== 'In-Progress' &&
26
26
  timeframeFetchState['year'].fetchState !== 'Completed') {
27
- fetchActions.push(fetchRevenueForTimeframe('year', forecastId));
27
+ fetchActions.push(fetchRevenueForTimeframe('year', forecastId, true, false, startDate, endDate));
28
28
  }
29
29
  return from(fetchActions);
30
30
  }
@@ -5,9 +5,15 @@ import { updateCalculatedSectionAccountsView, updateSectionAccountsView, } from
5
5
  import { createZeniAPIStatus, isSuccessResponse } from '../../responsePayload';
6
6
  import { getTimeSeriesGroup } from '../common/reportsHelpers';
7
7
  import { fetchRevenueForTimeframe, updateRevenueForTimeframe, updateRevenueForTimeframeFailure, updateRevenueThisPeriod, } from './revenueReducer';
8
- export const reportIdUniqueKey = (forecastId) => forecastId != null
9
- ? `revenue+${forecastId}`
10
- : 'revenue';
8
+ // Reserved forecast-slot marker so custom-date section-accounts data lives under
9
+ // its own key (revenue+<marker>) and a default fetch can never overwrite it.
10
+ // Real forecast ids are UUIDs, so this never collides.
11
+ export const CUSTOM_DATE_RANGE_SECTION_MARKER = '__custom_date_range__';
12
+ export const reportIdUniqueKey = (forecastId, isCustomDateRange) => isCustomDateRange === true
13
+ ? `revenue+${CUSTOM_DATE_RANGE_SECTION_MARKER}`
14
+ : forecastId != null
15
+ ? `revenue+${forecastId}`
16
+ : 'revenue';
11
17
  export const fetchRevenueForTimeframeEpic = (actions$, state, zeniAPI) => actions$.pipe(filter(fetchRevenueForTimeframe.match), mergeMap((action) => {
12
18
  const queryValue = {
13
19
  is_full_response: action.payload.fetchFullReport,
@@ -18,18 +24,25 @@ export const fetchRevenueForTimeframeEpic = (actions$, state, zeniAPI) => action
18
24
  queryValue.forecast_id = action.payload.forecastId;
19
25
  queryValue.is_forecast_vs_actual = true;
20
26
  }
27
+ if (action.payload.startDate != null && action.payload.endDate != null) {
28
+ queryValue.custom_start_date = action.payload.startDate;
29
+ queryValue.custom_end_date = action.payload.endDate;
30
+ }
21
31
  return zeniAPI
22
32
  .getJSON(`${zeniAPI.apiEndPoints.accountMicroServiceBaseUrl}/2.0/reports/revenue?query=${encodeURIComponent(JSON.stringify(queryValue))}`)
23
33
  .pipe(mergeMap((response) => {
24
34
  if (response.data != null && isSuccessResponse(response)) {
25
35
  const actions = [];
26
- actions.push(updateSectionAccountsView(action.payload.timeframe, 'cogs', reportIdUniqueKey(action.payload.forecastId), response.data.report.cogs, response.data.report.accounts, response.data.report.account_groups, response.data.report.currency));
27
- actions.push(updateSectionAccountsView(action.payload.timeframe, 'income', reportIdUniqueKey(action.payload.forecastId), response.data.report.income, response.data.report.accounts, response.data.report.account_groups, response.data.report.currency));
28
- actions.push(updateCalculatedSectionAccountsView(action.payload.timeframe, 'grossMargin', reportIdUniqueKey(action.payload.forecastId), response.data.report.gross_profit, response.data.report.currency));
36
+ const isCustomDateRange = action.payload.startDate != null &&
37
+ action.payload.endDate != null;
38
+ actions.push(updateSectionAccountsView(action.payload.timeframe, 'cogs', reportIdUniqueKey(action.payload.forecastId, isCustomDateRange), response.data.report.cogs, response.data.report.accounts, response.data.report.account_groups, response.data.report.currency));
39
+ actions.push(updateSectionAccountsView(action.payload.timeframe, 'income', reportIdUniqueKey(action.payload.forecastId, isCustomDateRange), response.data.report.income, response.data.report.accounts, response.data.report.account_groups, response.data.report.currency));
40
+ actions.push(updateCalculatedSectionAccountsView(action.payload.timeframe, 'grossMargin', reportIdUniqueKey(action.payload.forecastId, isCustomDateRange), response.data.report.gross_profit, response.data.report.currency));
29
41
  actions.push(updateRevenueForTimeframe({
30
42
  timeframe: action.payload.timeframe,
31
43
  forecastId: action.payload.forecastId,
32
44
  report: response.data,
45
+ isCustomDateRange,
33
46
  }));
34
47
  if (action.payload.forecastId == null &&
35
48
  action.payload.timeframe === 'month') {
@@ -44,7 +57,9 @@ export const fetchRevenueForTimeframeEpic = (actions$, state, zeniAPI) => action
44
57
  return from(actions);
45
58
  }
46
59
  else {
47
- return of(updateRevenueForTimeframeFailure(response.status, action.payload.forecastId, action.payload.timeframe));
60
+ return of(updateRevenueForTimeframeFailure(response.status, action.payload.forecastId, action.payload.timeframe, action.payload.startDate != null &&
61
+ action.payload.endDate != null));
48
62
  }
49
- }), catchError((error) => of(updateRevenueForTimeframeFailure(createZeniAPIStatus('Unexpected Error', 'Revenue REST API call errored out' + JSON.stringify(error)), action.payload.forecastId, action.payload.timeframe))));
63
+ }), catchError((error) => of(updateRevenueForTimeframeFailure(createZeniAPIStatus('Unexpected Error', 'Revenue REST API call errored out' + JSON.stringify(error)), action.payload.forecastId, action.payload.timeframe, action.payload.startDate != null &&
64
+ action.payload.endDate != null))));
50
65
  }));