@zeniai/client-epic-state 5.0.64 → 5.0.65-betaML2

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 (53) hide show
  1. package/lib/epic.d.ts +2 -1
  2. package/lib/epic.js +2 -1
  3. package/lib/esm/epic.js +2 -1
  4. package/lib/esm/index.js +9 -8
  5. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/fetchCompletedTransactionsEpic.js +2 -1
  6. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/searchTransactionsForManualMatchEpic.js +6 -0
  7. package/lib/esm/view/expenseAutomationView/epics/transactionCategorization/fetchTransactionCategorizationEpic.js +11 -1
  8. package/lib/esm/view/expenseAutomationView/helpers/transactionCategorizationLocalDataHelper.js +21 -1
  9. package/lib/esm/view/expenseAutomationView/reducers/missingReceiptsViewReducer.js +2 -1
  10. package/lib/esm/view/expenseAutomationView/reducers/transactionsViewReducer.js +243 -14
  11. package/lib/esm/view/expenseAutomationView/selectors/transactionCategorizationSelector.js +48 -1
  12. package/lib/esm/view/expenseAutomationView/types/completedSubTab.js +17 -0
  13. package/lib/esm/view/expenseAutomationView/types/missingReceiptsViewState.js +0 -2
  14. package/lib/esm/view/recommendation/fetchEntityRecommendationsForLineUpdateEpic.js +88 -0
  15. package/lib/esm/view/recommendation/recommendationHelper.js +29 -0
  16. package/lib/esm/view/recommendation/recommendationReducer.js +36 -1
  17. package/lib/esm/view/transactionDetail/transactionDetailReducer.js +110 -1
  18. package/lib/esm/view/transactionDetail/transactionDetailSelector.js +22 -0
  19. package/lib/index.d.ts +10 -8
  20. package/lib/index.js +42 -36
  21. package/lib/tsconfig.typecheck.tsbuildinfo +1 -1
  22. package/lib/view/expenseAutomationView/epics/missingReceipts/fetchCompletedTransactionsEpic.js +2 -1
  23. package/lib/view/expenseAutomationView/epics/missingReceipts/searchTransactionsForManualMatchEpic.js +6 -0
  24. package/lib/view/expenseAutomationView/epics/transactionCategorization/fetchTransactionCategorizationEpic.d.ts +2 -2
  25. package/lib/view/expenseAutomationView/epics/transactionCategorization/fetchTransactionCategorizationEpic.js +10 -0
  26. package/lib/view/expenseAutomationView/helpers/transactionCategorizationLocalDataHelper.d.ts +1 -0
  27. package/lib/view/expenseAutomationView/helpers/transactionCategorizationLocalDataHelper.js +23 -2
  28. package/lib/view/expenseAutomationView/payload/transactionCategorizationPayload.d.ts +21 -0
  29. package/lib/view/expenseAutomationView/reducers/missingReceiptsViewReducer.d.ts +2 -1
  30. package/lib/view/expenseAutomationView/reducers/missingReceiptsViewReducer.js +2 -1
  31. package/lib/view/expenseAutomationView/reducers/transactionsViewReducer.d.ts +22 -3
  32. package/lib/view/expenseAutomationView/reducers/transactionsViewReducer.js +243 -14
  33. package/lib/view/expenseAutomationView/selectorTypes/missingReceiptsSelectorTypes.d.ts +2 -1
  34. package/lib/view/expenseAutomationView/selectorTypes/transactionsViewSelectorTypes.d.ts +16 -0
  35. package/lib/view/expenseAutomationView/selectors/transactionCategorizationSelector.d.ts +3 -0
  36. package/lib/view/expenseAutomationView/selectors/transactionCategorizationSelector.js +49 -1
  37. package/lib/view/expenseAutomationView/types/completedSubTab.d.ts +10 -0
  38. package/lib/view/expenseAutomationView/types/completedSubTab.js +21 -0
  39. package/lib/view/expenseAutomationView/types/missingReceiptsViewState.d.ts +1 -2
  40. package/lib/view/expenseAutomationView/types/missingReceiptsViewState.js +1 -4
  41. package/lib/view/expenseAutomationView/types/transactionsViewState.d.ts +41 -1
  42. package/lib/view/recommendation/fetchEntityRecommendationsForLineUpdateEpic.d.ts +9 -0
  43. package/lib/view/recommendation/fetchEntityRecommendationsForLineUpdateEpic.js +92 -0
  44. package/lib/view/recommendation/recommendationHelper.d.ts +9 -0
  45. package/lib/view/recommendation/recommendationHelper.js +31 -1
  46. package/lib/view/recommendation/recommendationReducer.d.ts +18 -1
  47. package/lib/view/recommendation/recommendationReducer.js +37 -2
  48. package/lib/view/transactionDetail/transactionDetailReducer.d.ts +11 -1
  49. package/lib/view/transactionDetail/transactionDetailReducer.js +111 -2
  50. package/lib/view/transactionDetail/transactionDetailSelector.d.ts +2 -0
  51. package/lib/view/transactionDetail/transactionDetailSelector.js +24 -1
  52. package/lib/view/transactionDetail/transactionDetailTypes.d.ts +1 -0
  53. package/package.json +1 -1
@@ -59,6 +59,22 @@ export const filterAutoTabLineItems = (tabView, transactionId, updatedTransactio
59
59
  }
60
60
  };
61
61
  export const MAX_SELECTION_LIMIT = 5;
62
+ // Returns true when the transaction should be auto-added to the
63
+ // selectedCheckBoxTransactionIds list after a categorization-related
64
+ // state mutation: all line items are filled, the selection limit has
65
+ // not been hit, and the id isn't already in the list. Consolidates the
66
+ // triplet that was inlined at four sites (updateSelectedCustomer/Vendor,
67
+ // setAllItemsToCategoryClassInLocalDataForCategorization, and
68
+ // setEntityRecommendationForLineIdInLocalData below).
69
+ export const shouldAutoSelectAndAdd = (transaction, localData, selectedCheckBoxTransactionIds, uncategorizedAccounts, isUncategorizedExpenseCategoryEnabled, isAccountingClassesEnabled) => {
70
+ if (selectedCheckBoxTransactionIds.includes(transaction.id)) {
71
+ return false;
72
+ }
73
+ if (selectedCheckBoxTransactionIds.length >= MAX_SELECTION_LIMIT) {
74
+ return false;
75
+ }
76
+ return checkIfAllLineItemsAreCategoryClassFilled(transaction, localData, uncategorizedAccounts, isUncategorizedExpenseCategoryEnabled, isAccountingClassesEnabled);
77
+ };
62
78
  export const toTransactionDetailLocalData = (transaction, selectedTab, lineItemsByTransactionIds, uncategorizedIncomeExpense, isUncategorizedExpenseCategoryEnabled) => {
63
79
  const transactionLocal = {
64
80
  ...initialSupportedTransactionCategorization.transactionReviewLocalData,
@@ -544,10 +560,14 @@ export const setEntityRecommendationForLineIdInLocalData = (draft, transaction,
544
560
  selectedEntity: transactionDetailLocalData.selectedEntity,
545
561
  tabSpecificLineItems: transactionDetailLocalData.tabSpecificLineItems,
546
562
  };
563
+ // Auto-uncheck if not all line items are filled (the row's
564
+ // category/class became stale after the entity update). Otherwise
565
+ // route through shouldAutoSelectAndAdd so the MAX_SELECTION_LIMIT
566
+ // gate is consistently applied across all auto-add sites.
547
567
  if (!checkIfAllLineItemsAreCategoryClassFilled(transaction, newLocalData, uncategorizedAccounts, isUncategorizedExpenseCategoryEnabled, isAccountingClassesEnabled)) {
548
568
  selectedCheckBoxTransactionIds = selectedCheckBoxTransactionIds.filter((id) => id !== transaction.id);
549
569
  }
550
- else if (selectedCheckBoxTransactionIds.includes(transaction.id) === false) {
570
+ else if (shouldAutoSelectAndAdd(transaction, newLocalData, selectedCheckBoxTransactionIds, uncategorizedAccounts, isUncategorizedExpenseCategoryEnabled, isAccountingClassesEnabled)) {
551
571
  selectedCheckBoxTransactionIds.push(transaction.id);
552
572
  }
553
573
  draft.selectedCheckBoxTransactionIds = selectedCheckBoxTransactionIds;
@@ -1,5 +1,6 @@
1
1
  import { createAction, createSlice } from '@reduxjs/toolkit';
2
2
  import { toMonthYearPeriodId } from '../../../commonStateTypes/timePeriod';
3
+ import { DEFAULT_COMPLETED_SUB_TAB, } from '../types/completedSubTab';
3
4
  import { MIN_MANUAL_TRANSACTION_SEARCH_LENGTH, } from '../types/missingReceiptsViewState';
4
5
  export const getCompletedTransactionsCacheKey = (periodId, sortKey, sortOrder, subTab) => {
5
6
  const sortKeyPart = sortKey ?? 'none';
@@ -12,7 +13,7 @@ export const initialBulkUploadState = {
12
13
  batchListByPeriod: {},
13
14
  batchListFetchState: { fetchState: 'Not-Started', error: undefined },
14
15
  batchStatusById: {},
15
- completedSubTab: 'all',
16
+ completedSubTab: DEFAULT_COMPLETED_SUB_TAB,
16
17
  completedTransactionsByPeriod: {},
17
18
  confirmMatchStatus: { fetchState: 'Not-Started', error: undefined },
18
19
  currentBatchId: undefined,
@@ -3,7 +3,8 @@ import recordGet from 'lodash/get';
3
3
  import uniq from 'lodash/uniq';
4
4
  import { toMonthYearPeriodId, } from '../../../commonStateTypes/timePeriod';
5
5
  import { isVendorTransaction } from '../../../entity/transaction/stateTypes/vendorTransaction';
6
- import { MAX_SELECTION_LIMIT, checkIfAllLineItemsAreCategoryClassFilled, filterAutoTabLineItems, mergeTabSpecificLineItems, removeTransactionFromTabView, setEntityRecommendationForLineIdInLocalData, toSetAllLineItemsToCategoryClass, toTransactionDetailLocalData, } from '../helpers/transactionCategorizationLocalDataHelper';
6
+ import { filterAutoTabLineItems, mergeTabSpecificLineItems, removeTransactionFromTabView, setEntityRecommendationForLineIdInLocalData, shouldAutoSelectAndAdd, toSetAllLineItemsToCategoryClass, toTransactionDetailLocalData, } from '../helpers/transactionCategorizationLocalDataHelper';
7
+ import { DEFAULT_COMPLETED_SUB_TAB, } from '../types/completedSubTab';
7
8
  export const initialTransactionTabViewState = {
8
9
  saveStatus: {
9
10
  fetchState: 'Not-Started',
@@ -41,12 +42,74 @@ export const initialTransactionTabViewState = {
41
42
  selectedTransactionLineId: undefined,
42
43
  };
43
44
  export const initialState = {
45
+ autoCategorizedSubTabCache: {},
46
+ parentTotalCountByTab: {
47
+ review: {},
48
+ autoCategorized: {},
49
+ },
44
50
  selectedTransactionCategorizationTab: 'review',
51
+ selectedTransactionCategorizationCompletedSubTab: DEFAULT_COMPLETED_SUB_TAB,
45
52
  transactionCategorizationView: {
46
53
  review: { ...initialTransactionTabViewState },
47
54
  autoCategorized: { ...initialTransactionTabViewState },
48
55
  },
49
56
  };
57
+ /**
58
+ * Capture the autoCategorized tab's current paginated dataset for a sub-tab.
59
+ * The mid-flight `'In-Progress'` state is normalised to `'Completed'` because
60
+ * the in-flight request is cancelled by `switchMap` in the fetch epic when the
61
+ * sub-tab changes — restoring `'In-Progress'` later would leave the UI stuck
62
+ * showing a phantom loading state with no fetch actually running. Length-zero
63
+ * datasets degrade to `'Not-Started'` so the next visit triggers a fresh
64
+ * fetch from page 1 instead of short-circuiting on stale empty caches.
65
+ *
66
+ * Snapshot scope (deliberately narrow):
67
+ * - Captured: `pageToken`, `totalCount`, `transactionIdsBySelectedPeriod`,
68
+ * `fetchState`, `error`, `scrollPosition`. The first five are the keys the
69
+ * fetch epic's cache check reads to decide whether to short-circuit a
70
+ * sub-tab revisit; `scrollPosition` is captured per sub-tab so revisiting
71
+ * restores the user's last offset and switching to a never-visited sub-tab
72
+ * starts at the top instead of inheriting the outgoing sub-tab's offset
73
+ * (which would land the user at a meaningless row index against a
74
+ * completely different list).
75
+ * - Intentionally NOT captured (left in place on `autoCat` and inherited by
76
+ * the incoming sub-tab):
77
+ * - `uiState.searchString`, `uiState.sortKey`, `uiState.sortOrder`,
78
+ * `uiState.nodeCollapseState`, `uiState.limit` — search and sort are
79
+ * user-applied preferences that persist across sub-tabs by design (a
80
+ * search query stays active when the user toggles between Manual / AI
81
+ * Accountant).
82
+ * - `selectedCheckBoxTransactionIds` — selection state is intentionally
83
+ * cleared at the action source (the fetch epic / save flow), not
84
+ * by the snapshot path. Carrying selection across sub-tabs is safe
85
+ * because every sub-tab change goes through a fetch round-trip that
86
+ * reconciles selection against the new row IDs.
87
+ * - `transactionReviewLocalDataById`, `transactionIdsWithUnsavedData`,
88
+ * `uploadReceiptStatusById`, `saveStatus`, `refreshStatus`,
89
+ * `markAsNotMiscategorizedStatus` — these are per-row / per-flight
90
+ * caches that are write-through global state, not sub-tab-scoped.
91
+ *
92
+ * If the contract changes (e.g. selection should reset on every sub-tab
93
+ * switch), update both this comment and the snapshot return shape together.
94
+ */
95
+ function snapshotAutoCategorizedTab(autoCat) {
96
+ const transactionIdsBySelectedPeriod = {
97
+ ...autoCat.transactionIdsBySelectedPeriod,
98
+ };
99
+ const hasAnyLoadedRows = Object.values(transactionIdsBySelectedPeriod).some((ids) => ids.length > 0);
100
+ let normalisedFetchState = autoCat.fetchState;
101
+ if (normalisedFetchState === 'In-Progress') {
102
+ normalisedFetchState = hasAnyLoadedRows ? 'Completed' : 'Not-Started';
103
+ }
104
+ return {
105
+ error: autoCat.error,
106
+ fetchState: normalisedFetchState,
107
+ pageToken: autoCat.uiState.pageToken,
108
+ scrollPosition: autoCat.uiState.scrollPosition,
109
+ totalCount: { ...autoCat.uiState.totalCount },
110
+ transactionIdsBySelectedPeriod,
111
+ };
112
+ }
50
113
  const expenseAutomationTransactionsView = createSlice({
51
114
  name: 'expenseAutomationTransactionsView',
52
115
  initialState,
@@ -260,6 +323,55 @@ const expenseAutomationTransactionsView = createSlice({
260
323
  return { payload: { newSelectedTab } };
261
324
  },
262
325
  },
326
+ updateTransactionCategorizationCompletedSubTab: {
327
+ reducer(draft, action) {
328
+ const { newSubTab } = action.payload;
329
+ const previousSubTab = draft.selectedTransactionCategorizationCompletedSubTab;
330
+ if (previousSubTab === newSubTab) {
331
+ return;
332
+ }
333
+ const autoCat = draft.transactionCategorizationView.autoCategorized;
334
+ // Snapshot the outgoing sub-tab so a future revisit can short-circuit
335
+ // the fetch epic (mirrors the parent-tab "don't refetch what's
336
+ // already cached" behaviour). Period-scoped data is preserved as-is
337
+ // because the snapshot itself is keyed only by sub-tab; the active
338
+ // tab's per-period maps remain the source of truth while live.
339
+ draft.autoCategorizedSubTabCache[previousSubTab] =
340
+ snapshotAutoCategorizedTab(autoCat);
341
+ const cached = draft.autoCategorizedSubTabCache[newSubTab];
342
+ if (cached != null) {
343
+ autoCat.uiState.pageToken = cached.pageToken;
344
+ autoCat.uiState.totalCount = { ...cached.totalCount };
345
+ autoCat.uiState.scrollPosition = cached.scrollPosition;
346
+ autoCat.transactionIdsBySelectedPeriod = {
347
+ ...cached.transactionIdsBySelectedPeriod,
348
+ };
349
+ autoCat.fetchState = cached.fetchState;
350
+ autoCat.error = cached.error;
351
+ }
352
+ else {
353
+ // Sub-tab has never been visited — clear the active slot so the
354
+ // view-epic's `fetchState === 'Not-Started' || transactionIds.length
355
+ // === 0` cache check falls through and triggers a fresh page-1
356
+ // fetch for the new dataset. `scrollPosition` is reset to the top
357
+ // so the incoming list isn't anchored to the outgoing sub-tab's
358
+ // offset (different rows → previous offset is meaningless).
359
+ autoCat.uiState.pageToken = null;
360
+ autoCat.uiState.totalCount = {};
361
+ autoCat.uiState.scrollPosition = {
362
+ scrollTop: 0,
363
+ scrollLeft: undefined,
364
+ };
365
+ autoCat.transactionIdsBySelectedPeriod = {};
366
+ autoCat.fetchState = 'Not-Started';
367
+ autoCat.error = undefined;
368
+ }
369
+ draft.selectedTransactionCategorizationCompletedSubTab = newSubTab;
370
+ },
371
+ prepare(newSubTab) {
372
+ return { payload: { newSubTab } };
373
+ },
374
+ },
263
375
  updateTransactionCategorization: {
264
376
  reducer(draft, action) {
265
377
  const selectedTab = action.payload.selectedTab;
@@ -308,12 +420,9 @@ const expenseAutomationTransactionsView = createSlice({
308
420
  const selectedCheckBoxTransactionIds = draft.transactionCategorizationView[selectedTab]
309
421
  .selectedCheckBoxTransactionIds;
310
422
  if (selectedTransaction != null &&
311
- checkIfAllLineItemsAreCategoryClassFilled(selectedTransaction, newTransactionLocalData, uncategorizedAccounts, isUncategorizedExpenseCategoryEnabled, isAccountingClassesEnabled) === true && // check if all line items are filled
312
- selectedCheckBoxTransactionIds.length < MAX_SELECTION_LIMIT &&
313
- selectedCheckBoxTransactionIds.includes(transactionId) === false) {
423
+ shouldAutoSelectAndAdd(selectedTransaction, newTransactionLocalData, selectedCheckBoxTransactionIds, uncategorizedAccounts, isUncategorizedExpenseCategoryEnabled, isAccountingClassesEnabled)) {
314
424
  draft.transactionCategorizationView[selectedTab].selectedCheckBoxTransactionIds = [
315
- ...draft.transactionCategorizationView[selectedTab]
316
- .selectedCheckBoxTransactionIds,
425
+ ...selectedCheckBoxTransactionIds,
317
426
  transactionId,
318
427
  ];
319
428
  }
@@ -396,9 +505,26 @@ const expenseAutomationTransactionsView = createSlice({
396
505
  const transactionLocalData = recordGet(draft.transactionCategorizationView[selectedTab]
397
506
  .transactionReviewLocalDataById, transactionId.id, undefined);
398
507
  if (transactionLocalData != null) {
508
+ const newLocalData = setEntityRecommendationForLineIdInLocalData(draft.transactionCategorizationView[selectedTab], transaction, transactionLocalData.transactionReviewLocalData, entity, lineIds, uncategorizedAccounts, action.payload.recommendationWithCOTByLineId, isUncategorizedExpenseCategoryEnabled, isAccountingClassesEnabled);
509
+ // Release the per-line fetch flag for every line this fetch
510
+ // covered. Done in-place on the freshly returned local data
511
+ // (the helper produces a new mutable object), before we write
512
+ // it back to the draft.
513
+ lineIds.forEach((lineId) => {
514
+ const lineItem = newLocalData.lineItemById[lineId];
515
+ if (lineItem != null) {
516
+ newLocalData.lineItemById[lineId] = {
517
+ ...lineItem,
518
+ categoryClassRecommendationsFetchState: {
519
+ fetchState: 'Completed',
520
+ error: undefined,
521
+ },
522
+ };
523
+ }
524
+ });
399
525
  draft.transactionCategorizationView[selectedTab].transactionReviewLocalDataById[transaction.id] = {
400
526
  transactionId: transaction.id,
401
- transactionReviewLocalData: setEntityRecommendationForLineIdInLocalData(draft.transactionCategorizationView[selectedTab], transaction, transactionLocalData.transactionReviewLocalData, entity, lineIds, uncategorizedAccounts, action.payload.recommendationWithCOTByLineId, isUncategorizedExpenseCategoryEnabled, isAccountingClassesEnabled),
527
+ transactionReviewLocalData: newLocalData,
402
528
  };
403
529
  // Mark transaction as dirty (autofill happened)
404
530
  if (!draft.transactionCategorizationView[selectedTab].transactionIdsWithUnsavedData.includes(transaction.id)) {
@@ -411,6 +537,99 @@ const expenseAutomationTransactionsView = createSlice({
411
537
  }
412
538
  },
413
539
  },
540
+ // Start arm of the line-update recommendations fetch. Flips covered
541
+ // lines to In-Progress AND removes the transaction from
542
+ // selectedCheckBoxTransactionIds — the row is unchecked + disabled
543
+ // until either the success arm (recheck if limit allows) or the
544
+ // failure arm releases the per-line flag.
545
+ markCategoryClassRecommendationsInProgressForCategorization: {
546
+ reducer(draft, action) {
547
+ const { selectedTab, transactionId, lineIds } = action.payload;
548
+ const tab = draft.transactionCategorizationView[selectedTab];
549
+ const transactionLocalData = recordGet(tab.transactionReviewLocalDataById, transactionId.id, undefined);
550
+ if (transactionLocalData != null) {
551
+ lineIds.forEach((lineId) => {
552
+ const lineItem = transactionLocalData.transactionReviewLocalData.lineItemById[lineId];
553
+ if (lineItem != null) {
554
+ transactionLocalData.transactionReviewLocalData.lineItemById[lineId] = {
555
+ ...lineItem,
556
+ categoryClassRecommendationsFetchState: {
557
+ fetchState: 'In-Progress',
558
+ error: undefined,
559
+ },
560
+ };
561
+ }
562
+ });
563
+ }
564
+ // Uncheck on fetch start: the row's existing category/class are
565
+ // stale after a payee change, so we drop the auto-selection until
566
+ // fresh recommendations land.
567
+ if (tab.selectedCheckBoxTransactionIds.includes(transactionId.id)) {
568
+ tab.selectedCheckBoxTransactionIds =
569
+ tab.selectedCheckBoxTransactionIds.filter((id) => id !== transactionId.id);
570
+ }
571
+ },
572
+ prepare(selectedTab, transactionId, lineIds) {
573
+ return { payload: { selectedTab, transactionId, lineIds } };
574
+ },
575
+ },
576
+ // Completed arm. Used by the epic when the HTTP call succeeds but the
577
+ // server returned no per-line recommendations — we still need to
578
+ // release the In-Progress flag so the skeleton resolves. Successful
579
+ // responses with recommendations go through
580
+ // setEntityRecommendationForLineIdsForCategorization instead (which
581
+ // also flips the lines to Completed).
582
+ markCategoryClassRecommendationsCompletedForCategorization: {
583
+ reducer(draft, action) {
584
+ const { selectedTab, transactionId, lineIds } = action.payload;
585
+ const transactionLocalData = recordGet(draft.transactionCategorizationView[selectedTab]
586
+ .transactionReviewLocalDataById, transactionId.id, undefined);
587
+ if (transactionLocalData != null) {
588
+ lineIds.forEach((lineId) => {
589
+ const lineItem = transactionLocalData.transactionReviewLocalData.lineItemById[lineId];
590
+ if (lineItem != null) {
591
+ transactionLocalData.transactionReviewLocalData.lineItemById[lineId] = {
592
+ ...lineItem,
593
+ categoryClassRecommendationsFetchState: {
594
+ fetchState: 'Completed',
595
+ error: undefined,
596
+ },
597
+ };
598
+ }
599
+ });
600
+ }
601
+ },
602
+ prepare(selectedTab, transactionId, lineIds) {
603
+ return { payload: { selectedTab, transactionId, lineIds } };
604
+ },
605
+ },
606
+ // Failure arm. Flips covered lines to Error so the skeleton resolves
607
+ // and downstream UI can react to the failure. The transaction stays
608
+ // unchecked from the start-arm action.
609
+ markCategoryClassRecommendationsFailureForCategorization: {
610
+ reducer(draft, action) {
611
+ const { selectedTab, transactionId, lineIds, error } = action.payload;
612
+ const transactionLocalData = recordGet(draft.transactionCategorizationView[selectedTab]
613
+ .transactionReviewLocalDataById, transactionId.id, undefined);
614
+ if (transactionLocalData != null) {
615
+ lineIds.forEach((lineId) => {
616
+ const lineItem = transactionLocalData.transactionReviewLocalData.lineItemById[lineId];
617
+ if (lineItem != null) {
618
+ transactionLocalData.transactionReviewLocalData.lineItemById[lineId] = {
619
+ ...lineItem,
620
+ categoryClassRecommendationsFetchState: {
621
+ fetchState: 'Error',
622
+ error,
623
+ },
624
+ };
625
+ }
626
+ });
627
+ }
628
+ },
629
+ prepare(selectedTab, transactionId, lineIds, error) {
630
+ return { payload: { selectedTab, transactionId, lineIds, error } };
631
+ },
632
+ },
414
633
  fetchTransactionCategorizationSuccess(draft, action) {
415
634
  const { selectedTab, refreshViewInBackground } = action.payload;
416
635
  if (draft.transactionCategorizationView[selectedTab].fetchState ===
@@ -506,9 +725,7 @@ const expenseAutomationTransactionsView = createSlice({
506
725
  .selectedCheckBoxTransactionIds;
507
726
  if (newTransactionLocalData.customer != null &&
508
727
  newTransactionLocalData.customer.id != null &&
509
- checkIfAllLineItemsAreCategoryClassFilled(selectedTransaction, newTransactionLocalData, uncategorizedAccounts, isUncategorizedExpenseCategoryEnabled, isAccountingClassesEnabled) === true && // check if all line items are filled
510
- selectedCheckBoxTransactionIds.length < MAX_SELECTION_LIMIT &&
511
- selectedCheckBoxTransactionIds.includes(transactionId) === false) {
728
+ shouldAutoSelectAndAdd(selectedTransaction, newTransactionLocalData, selectedCheckBoxTransactionIds, uncategorizedAccounts, isUncategorizedExpenseCategoryEnabled, isAccountingClassesEnabled)) {
512
729
  draft.transactionCategorizationView[selectedTab].selectedCheckBoxTransactionIds = [
513
730
  ...selectedCheckBoxTransactionIds,
514
731
  transactionId,
@@ -582,9 +799,7 @@ const expenseAutomationTransactionsView = createSlice({
582
799
  .selectedCheckBoxTransactionIds;
583
800
  if (newTransactionLocalData.vendor != null &&
584
801
  newTransactionLocalData.vendor.id != null &&
585
- checkIfAllLineItemsAreCategoryClassFilled(selectedTransaction, newTransactionLocalData, uncategorizedAccounts, isUncategorizedExpenseCategoryEnabled, isAccountingClassesEnabled) === true && // check if all line items are filled
586
- selectedCheckBoxTransactionIds.length < MAX_SELECTION_LIMIT &&
587
- selectedCheckBoxTransactionIds.includes(transactionId) === false) {
802
+ shouldAutoSelectAndAdd(selectedTransaction, newTransactionLocalData, selectedCheckBoxTransactionIds, uncategorizedAccounts, isUncategorizedExpenseCategoryEnabled, isAccountingClassesEnabled)) {
588
803
  draft.transactionCategorizationView[selectedTab].selectedCheckBoxTransactionIds = [
589
804
  ...selectedCheckBoxTransactionIds,
590
805
  transactionId,
@@ -697,6 +912,10 @@ const expenseAutomationTransactionsView = createSlice({
697
912
  const { selectedTab, selectedPeriod, totalCount } = action.payload;
698
913
  draft.transactionCategorizationView[selectedTab].uiState.totalCount[toMonthYearPeriodId(selectedPeriod)] = totalCount;
699
914
  },
915
+ updateParentTotalCountForTab(draft, action) {
916
+ const { selectedTab, selectedPeriod, parentTotalCount } = action.payload;
917
+ draft.parentTotalCountByTab[selectedTab][toMonthYearPeriodId(selectedPeriod)] = parentTotalCount;
918
+ },
700
919
  updateSelectedCheckboxTransactionIds(draft, action) {
701
920
  const selectedTab = action.payload.selectedTab;
702
921
  const transactionIds = action.payload.transactionIds;
@@ -707,6 +926,16 @@ const expenseAutomationTransactionsView = createSlice({
707
926
  draft.transactionCategorizationView[selectedTab] = {
708
927
  ...initialTransactionTabViewState,
709
928
  };
929
+ draft.parentTotalCountByTab[selectedTab] = {};
930
+ // The per-sub-tab snapshot cache lives at the slice root and is
931
+ // exclusively populated by the autoCategorized tab's sub-tab switching
932
+ // (see updateTransactionCategorizationCompletedSubTab). When that tab
933
+ // is reset, leaving stale snapshots behind would short-circuit the
934
+ // next sub-tab visit with phantom row IDs / page tokens that no longer
935
+ // correspond to anything in state — drop them in lockstep.
936
+ if (selectedTab === 'autoCategorized') {
937
+ draft.autoCategorizedSubTabCache = {};
938
+ }
710
939
  },
711
940
  clearExpenseAutomationTransactionsView(draft) {
712
941
  Object.assign(draft, initialState);
@@ -803,5 +1032,5 @@ const expenseAutomationTransactionsView = createSlice({
803
1032
  },
804
1033
  },
805
1034
  });
806
- export const { fetchTransactionCategorization, updateTransactionCategorizationUIState, initializeTransactionCategorizationViewLocalData, saveTransactionCategorizationLocalData, fetchTransactionCategorizationFailure, saveTransactionCategorization, updateTransactionCategorization, updateCurrentSelectedTransactionCategorizationTab, updateTransactionCategorizationSaveStatus, markTransactionAsNotMiscategorized, updateStatusForTransactionNotMiscategorizedUpdateForCategorization, updateSelectedVendorForTransaction, updateSelectedCustomerForTransaction, setAllItemsToCategoryClassInLocalDataForCategorization, updateTotalCountForTransactionCategorization, fetchTransactionCategorizationSuccess, resetOtherTabsFetchState, fetchTransactionCategorizationView, updateSelectedCheckboxTransactionIds, clearExpenseAutomationTransactionsViewPerTabView, clearExpenseAutomationTransactionsView, setEntityRecommendationForLineIdsForCategorization, updateSelectedTransactionId, syncTransactionCategorizationFromDetailSave, backgroundRefetchReviewTab, updateTransactionCategorizationUploadReceiptState, uploadTransactionCategorizationReceiptSuccess, } = expenseAutomationTransactionsView.actions;
1035
+ export const { fetchTransactionCategorization, updateTransactionCategorizationUIState, initializeTransactionCategorizationViewLocalData, saveTransactionCategorizationLocalData, fetchTransactionCategorizationFailure, saveTransactionCategorization, updateTransactionCategorization, updateCurrentSelectedTransactionCategorizationTab, updateTransactionCategorizationCompletedSubTab, updateTransactionCategorizationSaveStatus, markTransactionAsNotMiscategorized, updateStatusForTransactionNotMiscategorizedUpdateForCategorization, updateSelectedVendorForTransaction, updateSelectedCustomerForTransaction, setAllItemsToCategoryClassInLocalDataForCategorization, updateTotalCountForTransactionCategorization, updateParentTotalCountForTab, fetchTransactionCategorizationSuccess, resetOtherTabsFetchState, fetchTransactionCategorizationView, updateSelectedCheckboxTransactionIds, clearExpenseAutomationTransactionsViewPerTabView, clearExpenseAutomationTransactionsView, setEntityRecommendationForLineIdsForCategorization, markCategoryClassRecommendationsInProgressForCategorization, markCategoryClassRecommendationsCompletedForCategorization, markCategoryClassRecommendationsFailureForCategorization, updateSelectedTransactionId, syncTransactionCategorizationFromDetailSave, backgroundRefetchReviewTab, updateTransactionCategorizationUploadReceiptState, uploadTransactionCategorizationReceiptSuccess, } = expenseAutomationTransactionsView.actions;
807
1036
  export default expenseAutomationTransactionsView.reducer;
@@ -1,3 +1,4 @@
1
+ import { createSelector } from '@reduxjs/toolkit';
1
2
  import omit from 'lodash/omit';
2
3
  import { reduceAllFetchState, reduceAnyFetchState, } from '../../../commonStateTypes/reduceFetchState';
3
4
  import { toMonthYearPeriodId } from '../../../commonStateTypes/timePeriod';
@@ -8,9 +9,32 @@ import { getAccountList, getNestedAccountListHierarchy, getUncategorizedAccounts
8
9
  import { getClassList, getNestedClassListHierarchy, } from '../../classList/classListSelector';
9
10
  import { getProjectList } from '../../projectList/projectListSelector';
10
11
  import { getAllSteps } from '../selectorTypes/expenseAutomationViewSelectorTypes';
12
+ // Per-transaction "any line is fetching category/class recommendations?"
13
+ // map for the active categorization tab. Memoized on the active tab's
14
+ // transactionReviewLocalDataById reference, so steady state (no fetches
15
+ // in flight) recomputes only when local data changes — and the row-table
16
+ // can read a single map lookup per row instead of re-iterating the
17
+ // row's lineItemById in the render path.
18
+ export const getCategorizationInFlightRecommendationsByTransactionId = createSelector((state) => state.expenseAutomationTransactionsViewState
19
+ .selectedTransactionCategorizationTab, (state) => state.expenseAutomationTransactionsViewState
20
+ .transactionCategorizationView, (selectedTab, transactionCategorizationView) => {
21
+ const tab = transactionCategorizationView[selectedTab];
22
+ const result = {};
23
+ for (const [transactionId, data] of Object.entries(tab.transactionReviewLocalDataById)) {
24
+ const lineItems = Object.values(data.transactionReviewLocalData.lineItemById);
25
+ if (lineItems.some((lineItem) => lineItem.categoryClassRecommendationsFetchState?.fetchState ===
26
+ 'In-Progress')) {
27
+ result[transactionId] = true;
28
+ }
29
+ }
30
+ return result;
31
+ });
32
+ // Page-level "any line on the active tab is in flight?" flag. Drives the
33
+ // list-page Save disable.
34
+ export const getCategorizationHasInFlightRecommendations = createSelector(getCategorizationInFlightRecommendationsByTransactionId, (inFlightByTransactionId) => Object.keys(inFlightByTransactionId).length > 0);
11
35
  export function getExpenseAutomationTransactionView(state) {
12
36
  const { accountState, classState, classListState, accountListState, expenseAutomationTransactionsViewState, expenseAutomationViewState, projectState, projectListState, transactionState, monthEndCloseChecksState, monthEndCloseChecksViewState, } = state;
13
- const { selectedTransactionCategorizationTab } = expenseAutomationTransactionsViewState;
37
+ const { selectedTransactionCategorizationCompletedSubTab, selectedTransactionCategorizationTab, } = expenseAutomationTransactionsViewState;
14
38
  const { uiState, refreshStatus, saveStatus, markAsNotMiscategorizedStatus, transactionIdsBySelectedPeriod, transactionReviewLocalDataById, selectedCheckBoxTransactionIds, transactionIdsWithUnsavedData, uploadReceiptStatusById, selectedTransactionId, selectedTransactionLineId, } = expenseAutomationTransactionsViewState.transactionCategorizationView[selectedTransactionCategorizationTab];
15
39
  const uncategorizedIncomeExpense = getUncategorizedAccounts(accountState, accountListState, 'accountList');
16
40
  const accountList = getAccountList(accountState, accountListState, 'accountList');
@@ -63,10 +87,27 @@ export function getExpenseAutomationTransactionView(state) {
63
87
  .uiState.totalCount;
64
88
  const totalCountByAutoCat = expenseAutomationTransactionsViewState.transactionCategorizationView
65
89
  .autoCategorized.uiState.totalCount;
90
+ // Per-(sub-)tab counts powering pagination, in-list "showing X of Y", and
91
+ // any save/refresh round-trips. Sourced from `uiState.totalCount` which
92
+ // mirrors the response's `total_count` for whatever tab/sub-tab the user is
93
+ // viewing (e.g. only the Manual sub-tab rows for Completed → Manual).
66
94
  const totalCountByTab = {
67
95
  review: monthYearPeriodId != null ? totalCountByReview[monthYearPeriodId] : 0,
68
96
  autoCategorized: monthYearPeriodId != null ? totalCountByAutoCat[monthYearPeriodId] : 0,
69
97
  };
98
+ // Parent-tab badge counts. Sourced from `parent_tab_total_count` in the
99
+ // listing response and used exclusively by the navbar / tab strip badges.
100
+ // Independent of which Completed sub-tab is active so the badge stays
101
+ // stable across sub-tab switches.
102
+ const parentTotalCountByTab = expenseAutomationTransactionsViewState.parentTotalCountByTab;
103
+ const parentTabTotalCountByTab = {
104
+ review: monthYearPeriodId != null
105
+ ? (parentTotalCountByTab.review[monthYearPeriodId] ?? 0)
106
+ : 0,
107
+ autoCategorized: monthYearPeriodId != null
108
+ ? (parentTotalCountByTab.autoCategorized[monthYearPeriodId] ?? 0)
109
+ : 0,
110
+ };
70
111
  const fetchStateByTab = {
71
112
  review: {
72
113
  fetchState: expenseAutomationTransactionsViewState.transactionCategorizationView
@@ -86,6 +127,8 @@ export function getExpenseAutomationTransactionView(state) {
86
127
  : [];
87
128
  const transactionCompletionStatus = allSteps.find((step) => step.step === 'transaction_categorization')?.completionStatus;
88
129
  const completionStatus = transactionCompletionStatus ?? 'incomplete';
130
+ const inFlightCategoryClassRecommendationsByTransactionId = getCategorizationInFlightRecommendationsByTransactionId(state);
131
+ const hasInFlightCategoryClassRecommendations = getCategorizationHasInFlightRecommendations(state);
89
132
  return {
90
133
  version: 0,
91
134
  fetchState: fetchStatus.fetchState,
@@ -99,6 +142,8 @@ export function getExpenseAutomationTransactionView(state) {
99
142
  classList: allClasses,
100
143
  accountsHierarchyList,
101
144
  classHierarchyList,
145
+ hasInFlightCategoryClassRecommendations,
146
+ inFlightCategoryClassRecommendationsByTransactionId,
102
147
  isAccountingProjectsEnabled,
103
148
  projectList,
104
149
  transactionLocalData,
@@ -108,9 +153,11 @@ export function getExpenseAutomationTransactionView(state) {
108
153
  markAsNotMiscategorizedStatus,
109
154
  completionStatus,
110
155
  totalCountByTab,
156
+ parentTabTotalCountByTab,
111
157
  fetchStateByTransactionTabs: fetchStateByTab,
112
158
  uploadReceiptStatusById,
113
159
  selectedTransactionId,
114
160
  selectedTransactionLineId,
161
+ selectedTransactionCategorizationCompletedSubTab,
115
162
  };
116
163
  }
@@ -0,0 +1,17 @@
1
+ import { stringToUnion } from '../../../commonStateTypes/stringToUnion';
2
+ /**
3
+ * Sub-tabs that can appear under any "Completed" tab in the Expense Automation
4
+ * surface (Transaction Categorization Completed and Missing Receipts Completed
5
+ * both use this exact union). Forwarded verbatim to listing APIs as `sub_tab`
6
+ * / `match_type`. `'all'` preserves parent-tab semantics on the backend.
7
+ */
8
+ const COMPLETED_SUB_TABS = ['all', 'ai_accountant', 'manual'];
9
+ export const toCompletedSubTab = (v) => stringToUnion(v.trim().toLowerCase(), COMPLETED_SUB_TABS);
10
+ /**
11
+ * "No sub-tab filter" sentinel — single source of truth for the default
12
+ * value of {@link CompletedSubTab}. Use this constant in initial slice
13
+ * state and at any call site that needs to fall back to the parent-tab
14
+ * listing semantics (review tab requests, manual-search overrides, etc.)
15
+ * so a future rename of the union doesn't require chasing literals.
16
+ */
17
+ export const DEFAULT_COMPLETED_SUB_TAB = 'all';
@@ -39,8 +39,6 @@ const BULK_UPLOAD_RESULTS_TABS = ['completed', 'unmatched'];
39
39
  export const toBulkUploadResultsTab = (v) => stringToUnion(v.trim().toLowerCase(), BULK_UPLOAD_RESULTS_TABS);
40
40
  const MISSING_RECEIPTS_TABS = ['missing', 'completed', 'unmatched'];
41
41
  export const toMissingReceiptsTab = (v) => stringToUnion(v.trim().toLowerCase(), MISSING_RECEIPTS_TABS);
42
- const COMPLETED_SUB_TABS = ['all', 'ai_accountant', 'manual'];
43
- export const toCompletedSubTab = (v) => stringToUnion(v.trim().toLowerCase(), COMPLETED_SUB_TABS);
44
42
  const MATCH_SOURCES = ['ai', 'manual'];
45
43
  export const toMatchSource = (v) => stringToUnion(v.trim().toLowerCase(), MATCH_SOURCES);
46
44
  const BULK_UPLOAD_SORT_KEYS = [
@@ -0,0 +1,88 @@
1
+ import { concat, of, timer } from 'rxjs';
2
+ import { catchError, debounce, filter, groupBy, mergeMap, switchMap, } from 'rxjs/operators';
3
+ import { getIsAccountingClassesEnabled } from '../../entity/tenant/tenantSelector';
4
+ import { getTransactionWithCOT, getTransactionWithCOTFromRecommendations, } from '../../entity/transaction/transactionHelper';
5
+ import { createZeniAPIStatus, isSuccessResponse } from '../../responsePayload';
6
+ import { getUncategorizedAccounts } from '../accountList/accountListSelector';
7
+ import { markCategoryClassRecommendationsCompletedForCategorization, markCategoryClassRecommendationsFailureForCategorization, markCategoryClassRecommendationsInProgressForCategorization, setEntityRecommendationForLineIdsForCategorization, } from '../expenseAutomationView/reducers/transactionsViewReducer';
8
+ import { markCategoryClassRecommendationsCompletedForTransactionDetail, markCategoryClassRecommendationsFailureForTransactionDetail, markCategoryClassRecommendationsInProgressForTransactionDetail, setEntityRecommendationForLineIdsForTransactionDetail, } from '../transactionDetail/transactionDetailReducer';
9
+ import { buildRecommendationUrl, getRecommendationByLineIdWithCOTFromRecommendationPayloadWithCOT, } from './recommendationHelper';
10
+ import { fetchEntityRecommendationsForLineUpdate } from './recommendationReducer';
11
+ // Close the per-target groupBy bucket after this much idle time so inactive
12
+ // groups don't accumulate forever (each group holds a Subject + a chain of
13
+ // active subscribers; without a duration selector groupBy retains them for
14
+ // the lifetime of the source observable, which is the lifetime of the app).
15
+ // Picked at 60s to leave a generous window for re-fetches on the same
16
+ // payee/transaction/line target while still bounding memory.
17
+ const GROUP_INACTIVITY_TIMEOUT_MS = 60000;
18
+ // Build the groupBy key. Same flow + same transaction + same line set
19
+ // share a target so switchMap can cancel an in-flight prior fetch. Use
20
+ // NUL (\u0000) as separator because it can never appear in transaction
21
+ // or line ids (vs the `|` used in an earlier WIP which collides with
22
+ // pipe-delimited synthetic ids).
23
+ const SEPARATOR = '\u0000';
24
+ const toGroupKey = (flowType, transactionId, lineIds) => `${flowType}${SEPARATOR}${transactionId}${SEPARATOR}${[...lineIds]
25
+ .sort((a, b) => a.localeCompare(b))
26
+ .join(SEPARATOR)}`;
27
+ // Targeted recommendations fetch that drives the per-line skeleton, the
28
+ // list-page row-checkbox uncheck/recheck, and the page-level Save
29
+ // disable.
30
+ //
31
+ // Concurrency model:
32
+ // - groupBy by (flowType, transactionId, sorted lineIds) so distinct
33
+ // targets run in parallel.
34
+ // - inside each group, switchMap so a re-fetch on the SAME target
35
+ // cancels any prior in-flight request for that target.
36
+ // - groupBy duration selector closes idle groups to bound memory.
37
+ export const fetchEntityRecommendationsForLineUpdateEpic = (actions$, state$, zeniAPI) => actions$.pipe(filter(fetchEntityRecommendationsForLineUpdate.match), groupBy((action) => toGroupKey(action.payload.flowType, action.payload.transactionDetails.transactionId.id, action.payload.transactionDetails.lineIds), undefined, (group$) => group$.pipe(debounce(() => timer(GROUP_INACTIVITY_TIMEOUT_MS)))), mergeMap((group$) => group$.pipe(switchMap((action) => {
38
+ const { entity, amount, transactionDetails, flowType, selectedTab, isFetchCOT, isUncategorizedExpenseCategoryEnabled, } = action.payload;
39
+ const { transactionId, transaction, lineIds } = transactionDetails;
40
+ // Synchronously emit the start arm so the per-line skeleton +
41
+ // row uncheck happen before the HTTP request begins.
42
+ const startAction = flowType === 'categorization' && selectedTab != null
43
+ ? markCategoryClassRecommendationsInProgressForCategorization(selectedTab, transactionId, lineIds)
44
+ : markCategoryClassRecommendationsInProgressForTransactionDetail(transactionId, lineIds);
45
+ // TEMP-LOG[JE-skeleton]: epic dispatch payload — confirms the
46
+ // targeted-fetch fired with the expected flowType / txnId / lineIds.
47
+ // eslint-disable-next-line no-console
48
+ console.log('[JE-skeleton][epic] dispatch start arm', {
49
+ flowType,
50
+ selectedTab,
51
+ transactionId,
52
+ lineIds,
53
+ transactionType: transaction?.type,
54
+ });
55
+ const url = buildRecommendationUrl(zeniAPI.apiEndPoints.accountMicroServiceBaseUrl, entity, amount, transactionDetails, isFetchCOT === true);
56
+ const httpStream$ = zeniAPI
57
+ .getJSON(url)
58
+ .pipe(mergeMap((response) => {
59
+ if (!isSuccessResponse(response) || response.data == null) {
60
+ return of(flowType === 'categorization' && selectedTab != null
61
+ ? markCategoryClassRecommendationsFailureForCategorization(selectedTab, transactionId, lineIds, response.status)
62
+ : markCategoryClassRecommendationsFailureForTransactionDetail(transactionId, lineIds, response.status));
63
+ }
64
+ const recommendationWithCOTByLineId = getRecommendationByLineIdWithCOTFromRecommendationPayloadWithCOT(response.data.recommendations, lineIds);
65
+ // HTTP 200 with no per-line recommendations: NOT an error.
66
+ // Flip lines to Completed so the skeleton resolves and
67
+ // downstream UI can treat this as "we tried, nothing to
68
+ // apply".
69
+ if (recommendationWithCOTByLineId == null) {
70
+ return of(flowType === 'categorization' && selectedTab != null
71
+ ? markCategoryClassRecommendationsCompletedForCategorization(selectedTab, transactionId, lineIds)
72
+ : markCategoryClassRecommendationsCompletedForTransactionDetail(transactionId, lineIds));
73
+ }
74
+ const transactionWithCOT = isFetchCOT === true
75
+ ? getTransactionWithCOTFromRecommendations(transaction, recommendationWithCOTByLineId)
76
+ : getTransactionWithCOT(transaction);
77
+ const uncategorizedIncomeExpense = getUncategorizedAccounts(state$.value.accountState, state$.value.accountListState, 'accountList');
78
+ if (flowType === 'categorization' && selectedTab != null) {
79
+ return of(setEntityRecommendationForLineIdsForCategorization(selectedTab, transactionId, transactionWithCOT, lineIds, entity, amount, recommendationWithCOTByLineId, uncategorizedIncomeExpense, isUncategorizedExpenseCategoryEnabled, getIsAccountingClassesEnabled(state$.value)));
80
+ }
81
+ return of(setEntityRecommendationForLineIdsForTransactionDetail(transactionId, transactionWithCOT, lineIds, entity, amount, recommendationWithCOTByLineId, uncategorizedIncomeExpense));
82
+ }), catchError((error) => of(flowType === 'categorization' && selectedTab != null
83
+ ? markCategoryClassRecommendationsFailureForCategorization(selectedTab, transactionId, lineIds, createZeniAPIStatus('Unexpected Error', 'fetchEntityRecommendationsForLineUpdate failed: ' +
84
+ JSON.stringify(error)))
85
+ : markCategoryClassRecommendationsFailureForTransactionDetail(transactionId, lineIds, createZeniAPIStatus('Unexpected Error', 'fetchEntityRecommendationsForLineUpdate failed: ' +
86
+ JSON.stringify(error))))));
87
+ return concat(of(startAction), httpStream$);
88
+ }))));