@zeniai/client-epic-state 5.0.76 → 5.0.77-betaAD01

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 (200) hide show
  1. package/lib/commonStateTypes/cacheOverrideFetchReducer.d.ts +17 -0
  2. package/lib/commonStateTypes/cacheOverrideFetchReducer.js +20 -0
  3. package/lib/commonStateTypes/filterPrimitives.d.ts +23 -0
  4. package/lib/commonStateTypes/filterPrimitives.js +21 -0
  5. package/lib/commonStateTypes/pusherActions.d.ts +11 -0
  6. package/lib/commonStateTypes/pusherActions.js +6 -0
  7. package/lib/coreEpics.js +1 -1
  8. package/lib/entity/account/accountSelector.d.ts +27 -1
  9. package/lib/entity/account/accountSelector.js +47 -1
  10. package/lib/entity/account/accountState.d.ts +6 -1
  11. package/lib/entity/account/accountState.js +11 -4
  12. package/lib/entity/account/subAccountSelector.d.ts +1 -1
  13. package/lib/entity/aiAccountantCustomer/aiAccountantCustomerPayload.js +1 -3
  14. package/lib/entity/aiAccountantCustomer/aiAccountantCustomerReducer.js +1 -2
  15. package/lib/entity/aiAccountantCustomer/aiAccountantCustomerSelector.d.ts +1 -1
  16. package/lib/entity/aiCfo/aiCfoPayload.d.ts +2 -0
  17. package/lib/entity/aiCfo/aiCfoReducer.d.ts +1 -1
  18. package/lib/entity/aiCfo/aiCfoReducer.js +33 -3
  19. package/lib/entity/aiCfo/aiCfoSelector.js +13 -1
  20. package/lib/entity/aiCfo/aiCfoState.d.ts +2 -0
  21. package/lib/entity/chargeCard/chargeCardReducer.d.ts +19 -2
  22. package/lib/entity/chargeCard/chargeCardReducer.js +35 -2
  23. package/lib/entity/chargeCardTransaction/chargeCardTransactionReducer.d.ts +14 -1
  24. package/lib/entity/chargeCardTransaction/chargeCardTransactionReducer.js +36 -2
  25. package/lib/entity/class/classSelector.d.ts +35 -0
  26. package/lib/entity/class/classSelector.js +50 -0
  27. package/lib/entity/sectionProjectView/sectionProjectViewReducer.d.ts +1 -1
  28. package/lib/entity/sectionProjectView/sectionProjectViewSelector.js +1 -1
  29. package/lib/entity/tenant/SessionManager.js +6 -3
  30. package/lib/entity/tenant/clearAllEpic.d.ts +2 -2
  31. package/lib/entity/tenant/clearAllEpic.js +2 -2
  32. package/lib/entity/tenant/epic/saveAPIKeyConnectionEpic.js +6 -2
  33. package/lib/entity/tenant/epic/saveOAuthConnectionEpic.js +6 -2
  34. package/lib/entity/tenant/tenantReducer.js +6 -1
  35. package/lib/entity/transaction/payloadTypes/transactionLinePayload.js +3 -1
  36. package/lib/epic.d.ts +14 -10
  37. package/lib/epic.js +15 -11
  38. package/lib/esm/commonStateTypes/cacheOverrideFetchReducer.js +16 -0
  39. package/lib/esm/commonStateTypes/filterPrimitives.js +20 -0
  40. package/lib/esm/commonStateTypes/pusherActions.js +3 -0
  41. package/lib/esm/coreEpics.js +1 -1
  42. package/lib/esm/entity/account/accountSelector.js +46 -1
  43. package/lib/esm/entity/account/accountState.js +7 -1
  44. package/lib/esm/entity/aiAccountantCustomer/aiAccountantCustomerPayload.js +1 -3
  45. package/lib/esm/entity/aiAccountantCustomer/aiAccountantCustomerReducer.js +1 -2
  46. package/lib/esm/entity/aiCfo/aiCfoReducer.js +32 -2
  47. package/lib/esm/entity/aiCfo/aiCfoSelector.js +13 -1
  48. package/lib/esm/entity/chargeCard/chargeCardReducer.js +34 -1
  49. package/lib/esm/entity/chargeCardTransaction/chargeCardTransactionReducer.js +35 -1
  50. package/lib/esm/entity/class/classSelector.js +48 -0
  51. package/lib/esm/entity/sectionProjectView/sectionProjectViewSelector.js +1 -1
  52. package/lib/esm/entity/tenant/SessionManager.js +4 -1
  53. package/lib/esm/entity/tenant/clearAllEpic.js +2 -2
  54. package/lib/esm/entity/tenant/epic/saveAPIKeyConnectionEpic.js +6 -2
  55. package/lib/esm/entity/tenant/epic/saveOAuthConnectionEpic.js +6 -2
  56. package/lib/esm/entity/tenant/tenantReducer.js +6 -1
  57. package/lib/esm/entity/transaction/payloadTypes/transactionLinePayload.js +3 -1
  58. package/lib/esm/epic.js +15 -11
  59. package/lib/esm/index.js +34 -24
  60. package/lib/esm/reducer.js +2 -2
  61. package/lib/esm/view/aiAccountantView/epics/fetchAiAccountantCustomersEpic.js +2 -5
  62. package/lib/esm/view/aiAccountantView/epics/fetchAiAccountantJobsEpic.js +2 -5
  63. package/lib/esm/view/aiAccountantView/epics/triggerAiAccountantJobEpic.js +4 -1
  64. package/lib/esm/view/aiCfoView/aiCfoViewReducer.js +11 -1
  65. package/lib/esm/view/aiCfoView/epics/updateChatSessionPinEpic.js +46 -0
  66. package/lib/esm/view/companyHealthMetricView/companyHealthMetricViewSelector.js +1 -1
  67. package/lib/esm/view/companyTaskManagerView/companyTaskManagerViewSelector.js +1 -1
  68. package/lib/esm/view/companyTaskManagerView/epics/fetchTaskManagerMetricsEpic.js +1 -1
  69. package/lib/esm/view/companyView/helpers/cockpitHelpers.js +1 -1
  70. package/lib/esm/view/companyView/selector/companyManagementViewSelector.js +1 -1
  71. package/lib/esm/view/companyView/selector/companyPortfolioViewSelector.js +1 -1
  72. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/fetchBulkUploadBatchesEpic.js +2 -2
  73. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/fetchCompletedTransactionsEpic.js +2 -1
  74. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/fetchMultipleBatchDetailsEpic.js +21 -5
  75. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/refreshBatchDetailsForBatchIdEpic.js +52 -0
  76. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/restoreBulkUploadAutomatchingOnMountEpic.js +8 -6
  77. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/searchTransactionsForManualMatchEpic.js +7 -1
  78. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/syncTabsAfterAutomatchEpic.js +56 -0
  79. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/watchBulkUploadBatchStatusEpic.js +13 -9
  80. package/lib/esm/view/expenseAutomationView/epics/transactionCategorization/fetchTransactionCategorizationEpic.js +11 -1
  81. package/lib/esm/view/expenseAutomationView/helpers/transactionCategorizationLocalDataHelper.js +21 -1
  82. package/lib/esm/view/expenseAutomationView/reducers/missingReceiptsViewReducer.js +81 -2
  83. package/lib/esm/view/expenseAutomationView/reducers/transactionsViewReducer.js +253 -14
  84. package/lib/esm/view/expenseAutomationView/selectors/missingReceiptsSelector.js +2 -0
  85. package/lib/esm/view/expenseAutomationView/selectors/transactionCategorizationSelector.js +147 -13
  86. package/lib/esm/view/expenseAutomationView/transactionFilterHelpers.js +258 -0
  87. package/lib/esm/view/expenseAutomationView/types/completedSubTab.js +17 -0
  88. package/lib/esm/view/expenseAutomationView/types/missingReceiptsViewState.js +0 -2
  89. package/lib/esm/view/financeStatement/financeStatementSelector.js +1 -1
  90. package/lib/esm/view/recommendation/fetchEntityRecommendationsForLineUpdateEpic.js +87 -0
  91. package/lib/esm/view/recommendation/recommendationHelper.js +29 -0
  92. package/lib/esm/view/recommendation/recommendationReducer.js +26 -1
  93. package/lib/esm/view/spendManagement/chargeCards/cashbackDetail/cashbackDetailReducer.js +3 -2
  94. package/lib/esm/view/spendManagement/chargeCards/chargeCardConfig/chargeCardConfigReducer.js +3 -2
  95. package/lib/esm/view/spendManagement/chargeCards/chargeCardDetail/chargeCardDetailReducer.js +55 -18
  96. package/lib/esm/view/spendManagement/chargeCards/chargeCardDetail/fetchChargeCardDetailEpic.js +16 -2
  97. package/lib/esm/view/spendManagement/chargeCards/chargeCardDetail/fetchChargeCardDetailPageEpic.js +5 -4
  98. package/lib/esm/view/spendManagement/chargeCards/chargeCardList/chargeCardListReducer.js +26 -9
  99. package/lib/esm/view/spendManagement/chargeCards/chargeCardList/fetchChargeCardListPageEpic.js +9 -6
  100. package/lib/esm/view/spendManagement/chargeCards/chargeCardPaymentHistory/chargeCardPaymentHistoryReducer.js +3 -2
  101. package/lib/esm/view/spendManagement/chargeCards/chargeCardPaymentHistory/fetchChargeCardPaymentPageEpic.js +1 -1
  102. package/lib/esm/view/spendManagement/chargeCards/chargeCardRepaymentDetail/chargeCardRepaymentDetailReducer.js +7 -3
  103. package/lib/esm/view/spendManagement/spendManagementFilterHelpers.js +4 -2
  104. package/lib/esm/view/subscriptionView/subscriptionViewSelector.js +1 -1
  105. package/lib/esm/view/taskManager/cannedResponsesView/epics/fetchCannedResponsesEpic.js +1 -1
  106. package/lib/esm/view/taskManager/cannedResponsesView/epics/saveCannedResponseEpic.js +1 -1
  107. package/lib/esm/view/taskManager/taskDetailView/epics/unsnoozeTaskEpic.js +1 -1
  108. package/lib/esm/view/taskManager/taskListView/taskListReducer.js +1 -1
  109. package/lib/esm/view/transactionDetail/transactionDetailReducer.js +121 -6
  110. package/lib/esm/view/transactionDetail/transactionDetailSelector.js +29 -1
  111. package/lib/index.d.ts +33 -26
  112. package/lib/index.js +89 -58
  113. package/lib/reducer.d.ts +2 -2
  114. package/lib/reducer.js +2 -2
  115. package/lib/view/aiAccountantView/aiAccountantViewPayload.d.ts +2 -2
  116. package/lib/view/aiAccountantView/aiAccountantViewReducer.d.ts +1 -1
  117. package/lib/view/aiAccountantView/epics/fetchAiAccountantCustomersEpic.js +2 -5
  118. package/lib/view/aiAccountantView/epics/fetchAiAccountantJobsEpic.js +2 -5
  119. package/lib/view/aiAccountantView/epics/triggerAiAccountantJobEpic.js +4 -1
  120. package/lib/view/aiCfoView/aiCfoViewReducer.d.ts +4 -1
  121. package/lib/view/aiCfoView/aiCfoViewReducer.js +12 -2
  122. package/lib/view/aiCfoView/epics/updateChatSessionPinEpic.d.ts +8 -0
  123. package/lib/view/aiCfoView/epics/updateChatSessionPinEpic.js +50 -0
  124. package/lib/view/companyHealthMetricView/companyHealthMetricViewSelector.js +1 -1
  125. package/lib/view/companyTaskManagerView/companyTaskManagerViewSelector.d.ts +1 -1
  126. package/lib/view/companyTaskManagerView/companyTaskManagerViewSelector.js +1 -1
  127. package/lib/view/companyView/helpers/cockpitHelpers.js +1 -1
  128. package/lib/view/companyView/selector/companyManagementViewSelector.js +1 -1
  129. package/lib/view/companyView/selector/companyPortfolioViewSelector.js +1 -1
  130. package/lib/view/companyView/types/cockpitTypes.d.ts +1 -1
  131. package/lib/view/expenseAutomationView/epics/missingReceipts/fetchBulkUploadBatchesEpic.js +2 -2
  132. package/lib/view/expenseAutomationView/epics/missingReceipts/fetchCompletedTransactionsEpic.js +2 -1
  133. package/lib/view/expenseAutomationView/epics/missingReceipts/fetchMultipleBatchDetailsEpic.js +21 -5
  134. package/lib/view/expenseAutomationView/epics/missingReceipts/refreshBatchDetailsForBatchIdEpic.d.ts +18 -0
  135. package/lib/view/expenseAutomationView/epics/missingReceipts/refreshBatchDetailsForBatchIdEpic.js +56 -0
  136. package/lib/view/expenseAutomationView/epics/missingReceipts/restoreBulkUploadAutomatchingOnMountEpic.js +8 -6
  137. package/lib/view/expenseAutomationView/epics/missingReceipts/searchTransactionsForManualMatchEpic.js +7 -1
  138. package/lib/view/expenseAutomationView/epics/missingReceipts/syncTabsAfterAutomatchEpic.d.ts +28 -0
  139. package/lib/view/expenseAutomationView/epics/missingReceipts/syncTabsAfterAutomatchEpic.js +60 -0
  140. package/lib/view/expenseAutomationView/epics/missingReceipts/watchBulkUploadBatchStatusEpic.d.ts +11 -5
  141. package/lib/view/expenseAutomationView/epics/missingReceipts/watchBulkUploadBatchStatusEpic.js +12 -8
  142. package/lib/view/expenseAutomationView/epics/transactionCategorization/fetchTransactionCategorizationEpic.d.ts +2 -2
  143. package/lib/view/expenseAutomationView/epics/transactionCategorization/fetchTransactionCategorizationEpic.js +10 -0
  144. package/lib/view/expenseAutomationView/helpers/transactionCategorizationLocalDataHelper.d.ts +2 -1
  145. package/lib/view/expenseAutomationView/helpers/transactionCategorizationLocalDataHelper.js +23 -2
  146. package/lib/view/expenseAutomationView/payload/transactionCategorizationPayload.d.ts +21 -0
  147. package/lib/view/expenseAutomationView/reducers/missingReceiptsViewReducer.d.ts +15 -2
  148. package/lib/view/expenseAutomationView/reducers/missingReceiptsViewReducer.js +83 -3
  149. package/lib/view/expenseAutomationView/reducers/transactionsViewReducer.d.ts +28 -5
  150. package/lib/view/expenseAutomationView/reducers/transactionsViewReducer.js +253 -14
  151. package/lib/view/expenseAutomationView/selectorTypes/missingReceiptsSelectorTypes.d.ts +14 -1
  152. package/lib/view/expenseAutomationView/selectorTypes/transactionsViewSelectorTypes.d.ts +16 -0
  153. package/lib/view/expenseAutomationView/selectors/missingReceiptsSelector.js +2 -0
  154. package/lib/view/expenseAutomationView/selectors/transactionCategorizationSelector.d.ts +7 -1
  155. package/lib/view/expenseAutomationView/selectors/transactionCategorizationSelector.js +150 -13
  156. package/lib/view/expenseAutomationView/transactionFilterHelpers.d.ts +66 -0
  157. package/lib/view/expenseAutomationView/transactionFilterHelpers.js +262 -0
  158. package/lib/view/expenseAutomationView/types/completedSubTab.d.ts +10 -0
  159. package/lib/view/expenseAutomationView/types/completedSubTab.js +21 -0
  160. package/lib/view/expenseAutomationView/types/missingReceiptsViewState.d.ts +20 -2
  161. package/lib/view/expenseAutomationView/types/missingReceiptsViewState.js +1 -4
  162. package/lib/view/expenseAutomationView/types/transactionsViewState.d.ts +44 -2
  163. package/lib/view/financeStatement/financeStatementSelector.js +1 -1
  164. package/lib/view/profitAndLossProjectView/profitAndLossProjectViewSelectorTypes.d.ts +1 -1
  165. package/lib/view/recommendation/fetchEntityRecommendationsForLineUpdateEpic.d.ts +9 -0
  166. package/lib/view/recommendation/fetchEntityRecommendationsForLineUpdateEpic.js +91 -0
  167. package/lib/view/recommendation/recommendationHelper.d.ts +9 -0
  168. package/lib/view/recommendation/recommendationHelper.js +31 -1
  169. package/lib/view/recommendation/recommendationReducer.d.ts +20 -1
  170. package/lib/view/recommendation/recommendationReducer.js +27 -2
  171. package/lib/view/spendManagement/billPay/billList/billListState.d.ts +1 -1
  172. package/lib/view/spendManagement/chargeCards/cashbackDetail/cashbackDetailReducer.d.ts +3 -1
  173. package/lib/view/spendManagement/chargeCards/cashbackDetail/cashbackDetailReducer.js +3 -2
  174. package/lib/view/spendManagement/chargeCards/chargeCardConfig/chargeCardConfigReducer.d.ts +3 -1
  175. package/lib/view/spendManagement/chargeCards/chargeCardConfig/chargeCardConfigReducer.js +3 -2
  176. package/lib/view/spendManagement/chargeCards/chargeCardDetail/chargeCardDetailReducer.js +55 -18
  177. package/lib/view/spendManagement/chargeCards/chargeCardDetail/fetchChargeCardDetailEpic.js +16 -2
  178. package/lib/view/spendManagement/chargeCards/chargeCardDetail/fetchChargeCardDetailPageEpic.js +5 -4
  179. package/lib/view/spendManagement/chargeCards/chargeCardList/chargeCardListReducer.d.ts +18 -3
  180. package/lib/view/spendManagement/chargeCards/chargeCardList/chargeCardListReducer.js +27 -10
  181. package/lib/view/spendManagement/chargeCards/chargeCardList/fetchChargeCardListPageEpic.js +9 -6
  182. package/lib/view/spendManagement/chargeCards/chargeCardPaymentHistory/chargeCardPaymentHistory.d.ts +1 -1
  183. package/lib/view/spendManagement/chargeCards/chargeCardPaymentHistory/chargeCardPaymentHistoryReducer.d.ts +3 -1
  184. package/lib/view/spendManagement/chargeCards/chargeCardPaymentHistory/chargeCardPaymentHistoryReducer.js +3 -2
  185. package/lib/view/spendManagement/chargeCards/chargeCardPaymentHistory/fetchChargeCardPaymentPageEpic.js +1 -1
  186. package/lib/view/spendManagement/chargeCards/chargeCardRepaymentDetail/chargeCardRepaymentDetailReducer.d.ts +3 -1
  187. package/lib/view/spendManagement/chargeCards/chargeCardRepaymentDetail/chargeCardRepaymentDetailReducer.js +7 -3
  188. package/lib/view/spendManagement/reimbursement/remiListView/remiListState.d.ts +1 -1
  189. package/lib/view/spendManagement/spendManagementFilterHelpers.d.ts +12 -3
  190. package/lib/view/spendManagement/spendManagementFilterHelpers.js +4 -2
  191. package/lib/view/subscriptionView/subscriptionViewSelector.js +1 -1
  192. package/lib/view/taskManager/taskListView/taskList.d.ts +1 -1
  193. package/lib/view/taskManager/taskListView/taskListReducer.js +1 -1
  194. package/lib/view/transactionDetail/transactionDetailReducer.d.ts +11 -1
  195. package/lib/view/transactionDetail/transactionDetailReducer.js +122 -7
  196. package/lib/view/transactionDetail/transactionDetailSelector.d.ts +2 -0
  197. package/lib/view/transactionDetail/transactionDetailSelector.js +31 -2
  198. package/lib/view/transactionDetail/transactionDetailTypes.d.ts +1 -0
  199. package/lib/view/zeniOAuthView/zeniOAuthSelector.d.ts +1 -1
  200. package/package.json +9 -4
@@ -31,10 +31,19 @@ export function fetchBatchDetailsByIds(batchIds, zeniAPI) {
31
31
  }
32
32
  export const fetchMultipleBatchDetailsEpic = (actions$, state$, zeniAPI) => actions$.pipe(filter(fetchBulkUploadBatchesSuccess.match), mergeMap((action) => {
33
33
  const batchList = action.payload.batchList;
34
- const { batchDetailsById } = state$.value.expenseAutomationMissingReceiptsViewState.bulkUpload;
34
+ const bulkUpload = state$.value.expenseAutomationMissingReceiptsViewState.bulkUpload;
35
+ const { batchDetailsById, batchDetailsRevalidating } = bulkUpload;
35
36
  /** Only completed list rows may trigger batch-details API; preserve platform order. */
36
- const completedBatchesMissingDetails = filterBatchListItemsForBatchDetailsFetch(batchList).filter((batch) => batchDetailsById[batch.batchId] == null);
37
- const completedBatchIds = completedBatchesMissingDetails.map((batch) => batch.batchId);
37
+ const completedRows = filterBatchListItemsForBatchDetailsFetch(batchList);
38
+ /**
39
+ * Stale-while-revalidate: when a manual refresh has set `batchDetailsRevalidating`, refresh
40
+ * every completed list row (replacing entries via `storeBatchDetails` as they arrive). On a
41
+ * normal first fetch we still only request rows whose details are missing.
42
+ */
43
+ const candidateRows = batchDetailsRevalidating === true
44
+ ? completedRows
45
+ : completedRows.filter((batch) => batchDetailsById[batch.batchId] == null);
46
+ const completedBatchIds = candidateRows.map((batch) => batch.batchId);
38
47
  const batchIdsToFetch = completedBatchIds.slice(0, INITIAL_BATCH_PAGE_SIZE);
39
48
  if (batchIdsToFetch.length === 0) {
40
49
  /**
@@ -42,7 +51,6 @@ export const fetchMultipleBatchDetailsEpic = (actions$, state$, zeniAPI) => acti
42
51
  * `currentBatchId` may not appear in the filtered list but still needs `GET /batches/:id`.
43
52
  * Fall back to the single-batch-details epic so `phase` and details can resolve.
44
53
  */
45
- const bulkUpload = state$.value.expenseAutomationMissingReceiptsViewState.bulkUpload;
46
54
  const currentId = bulkUpload.currentBatchId;
47
55
  if (currentId != null &&
48
56
  bulkUpload.phase === 'matching' &&
@@ -50,7 +58,15 @@ export const fetchMultipleBatchDetailsEpic = (actions$, state$, zeniAPI) => acti
50
58
  shouldFetchBatchDetailsForBatchId(bulkUpload.batchStatusById, bulkUpload.batchListByPeriod, currentId)) {
51
59
  return of(fetchBulkUploadBatchDetails());
52
60
  }
53
- return EMPTY;
61
+ /**
62
+ * Close the pagination loop so the Unmatched-tab loading heuristic can settle.
63
+ * `fetchMoreBatchDetailsComplete` also clears `batchDetailsRevalidating` and flips
64
+ * `initialBatchDetailsLoaded` to true. Without this, `batchDetailsPaginationState` stays
65
+ * at `Not-Started` forever after a list refresh that finds zero completed rows (or all
66
+ * already cached) — the direct-open-Unmatched-without-upload path used to render an
67
+ * infinite skeleton.
68
+ */
69
+ return of(fetchMoreBatchDetailsComplete());
54
70
  }
55
71
  return concat(of(setInitialBatchDetailsLoading()), fetchBatchDetailsByIds(batchIdsToFetch, zeniAPI), of(fetchMoreBatchDetailsComplete()));
56
72
  }));
@@ -0,0 +1,52 @@
1
+ import { EMPTY, concat, from, of } from 'rxjs';
2
+ import { catchError, filter, mergeMap } from 'rxjs/operators';
3
+ import { updateTransactions } from '../../../../entity/transaction/transactionReducer';
4
+ import { createZeniAPIStatus, isSuccessResponse, } from '../../../../responsePayload';
5
+ import { extractTransactionPayloadsFromBatchFiles, isBatchDetailsApiStatusCompleted, toBatchDetails, } from '../../payload/missingReceiptsPayload';
6
+ import { batchDetailFetchFailed, markBatchDetailRefreshAttempted, refreshBatchDetailsForBatchId, storeBatchDetails, } from '../../reducers/missingReceiptsViewReducer';
7
+ /**
8
+ * Pusher resends the same `completed` event in some cases (reconnect, server retry). Skip a
9
+ * targeted refresh if we issued one for the same batch within this window — the cached details
10
+ * are already fresh, so another `GET /1.0/batches/:id` is wasteful and visually flickers nothing.
11
+ */
12
+ const TARGETED_REFRESH_DEDUPE_MS = 5000;
13
+ /**
14
+ * Single-batch stale-while-revalidate refresh. Triggered by `pusherBatchStatusCompletionEpic`
15
+ * (and any other path that wants to update one batch without wiping the cache). The response is
16
+ * stored via `storeBatchDetails`, which replaces the entry by `batchId` so the row updates in
17
+ * place — no skeleton, no list reflow.
18
+ *
19
+ * Throttle: `lastBatchDetailRefreshAtById[batchId]` is checked against `TARGETED_REFRESH_DEDUPE_MS`
20
+ * before issuing the call. The marker is emitted first so concurrent dispatches (e.g. Pusher
21
+ * resend during an in-flight request) see the dedupe timestamp and collapse to a single call.
22
+ */
23
+ export const refreshBatchDetailsForBatchIdEpic = (actions$, state$, zeniAPI) => actions$.pipe(filter(refreshBatchDetailsForBatchId.match), mergeMap((action) => {
24
+ const { batchId } = action.payload;
25
+ const now = Date.now();
26
+ const lastAttempt = state$.value.expenseAutomationMissingReceiptsViewState.bulkUpload
27
+ .lastBatchDetailRefreshAtById[batchId];
28
+ if (lastAttempt != null &&
29
+ now - lastAttempt < TARGETED_REFRESH_DEDUPE_MS) {
30
+ return EMPTY;
31
+ }
32
+ const apiCall = zeniAPI
33
+ .getJSON(`${zeniAPI.apiEndPoints.communicationAgentMicroServiceBaseUrl}/1.0/batches/${batchId}`)
34
+ .pipe(mergeMap((response) => {
35
+ if (!isSuccessResponse(response) ||
36
+ response.data == null ||
37
+ !isBatchDetailsApiStatusCompleted(response.data.status)) {
38
+ return EMPTY;
39
+ }
40
+ const actions = [];
41
+ const transactionPayloads = extractTransactionPayloadsFromBatchFiles(response.data.files);
42
+ if (transactionPayloads.length > 0) {
43
+ actions.push(updateTransactions(transactionPayloads, (value) => value.transaction_id));
44
+ }
45
+ actions.push(storeBatchDetails(toBatchDetails(response.data, zeniAPI.apiEndPoints.fileMicroServiceBaseUrl)));
46
+ return from(actions);
47
+ }), catchError((error) => {
48
+ console.error(`Failed to refresh batch details for ${batchId}:`, createZeniAPIStatus('Unexpected Error', 'Refresh batch details errored out: ' + JSON.stringify(error)));
49
+ return of(batchDetailFetchFailed({ batchId }));
50
+ }));
51
+ return concat(of(markBatchDetailRefreshAttempted({ batchId, timestamp: now })), apiCall);
52
+ }));
@@ -1,12 +1,12 @@
1
1
  import { EMPTY, concat, from, of } from 'rxjs';
2
2
  import { catchError, concatMap, filter, mergeMap, take } from 'rxjs/operators';
3
+ import { toMonthYearPeriodId } from '../../../../commonStateTypes/timePeriod';
4
+ import { getCurrentTenant } from '../../../../entity/tenant/tenantSelector';
3
5
  import { isSuccessResponse } from '../../../../responsePayload';
4
- import { isBatchDetailsStatusPendingOrProcessing, toBatchListItem, } from '../../payload/missingReceiptsPayload';
5
- import { fetchBulkUploadBatchesSuccess, restoreBulkUploadAutomatchingOnMount, restoreBulkUploadMatchingState, } from '../../reducers/missingReceiptsViewReducer';
6
6
  import { BULK_UPLOAD_BATCH_LIST_DATE_RANGE_DAYS } from '../../helpers/bulkUploadTiming';
7
7
  import { rollingCalendarDateRangeInclusive } from '../../helpers/rollingCalendarDateRangeInclusive';
8
- import { toMonthYearPeriodId } from '../../../../commonStateTypes/timePeriod';
9
- import { getCurrentTenant } from '../../../../entity/tenant/tenantSelector';
8
+ import { isBatchDetailsStatusPendingOrProcessing, toBatchListItem, } from '../../payload/missingReceiptsPayload';
9
+ import { fetchBulkUploadBatchesSuccess, restoreBulkUploadAutomatchingOnMount, restoreBulkUploadMatchingState, } from '../../reducers/missingReceiptsViewReducer';
10
10
  /**
11
11
  * On missing-receipts entry / period switch, hydrate the automatching banner for the logged-in
12
12
  * user without relying on UI-driven list fetches (the Missing tab does not fetch the batch list
@@ -49,10 +49,12 @@ export const restoreBulkUploadAutomatchingOnMountEpic = (actions$, state$, zeniA
49
49
  bulkUpload.batchListFetchState.fetchState === 'Completed';
50
50
  const listSource = alreadyCached
51
51
  ? of(null)
52
- : zeniAPI.getJSON(listUrl).pipe(catchError(() => of(null)));
52
+ : zeniAPI
53
+ .getJSON(listUrl)
54
+ .pipe(catchError(() => of(null)));
53
55
  return listSource.pipe(mergeMap((listResponse) => {
54
56
  const batchList = alreadyCached
55
- ? bulkUpload.batchListByPeriod[periodId] ?? []
57
+ ? (bulkUpload.batchListByPeriod[periodId] ?? [])
56
58
  : listResponse != null &&
57
59
  isSuccessResponse(listResponse) &&
58
60
  listResponse.data != null
@@ -2,10 +2,11 @@ import { EMPTY, from, merge, of } from 'rxjs';
2
2
  import { catchError, debounceTime, filter, mergeMap, switchMap, } from 'rxjs/operators';
3
3
  import { updateTransactions } from '../../../../entity/transaction/transactionReducer';
4
4
  import { createZeniAPIStatus, isSuccessResponse, } from '../../../../responsePayload';
5
+ import { rollingCalendarDateRangeInclusive } from '../../helpers/rollingCalendarDateRangeInclusive';
5
6
  import { supportedTransactionPayloadToManualSearchResult } from '../../payload/missingReceiptsPayload';
6
7
  import { searchTransactionsForManualMatch, searchTransactionsForManualMatchFailure, searchTransactionsForManualMatchSuccess, } from '../../reducers/missingReceiptsViewReducer';
8
+ import { DEFAULT_COMPLETED_SUB_TAB } from '../../types/completedSubTab';
7
9
  import { toTransactionsSortKey } from '../../types/transactionsViewState';
8
- import { rollingCalendarDateRangeInclusive } from '../../helpers/rollingCalendarDateRangeInclusive';
9
10
  /**
10
11
  * `auto_categorized` for manual transaction search (Unmatched).
11
12
  * Backend contract TBD — align with `fetchTransactionCategorizationEpic` (`selectedTab === 'autoCategorized'`)
@@ -46,6 +47,11 @@ export const searchTransactionsForManualMatchEpic = (actions$, state$, zeniAPI)
46
47
  page_token: pageToken ?? null,
47
48
  page_size: manualSearch.pageSize,
48
49
  search_text: query,
50
+ // Manual search ignores the Completed sub-tab axis — always span the
51
+ // default sentinel so the receipt-matching UX returns results
52
+ // regardless of where the transaction would otherwise be bucketed on
53
+ // the Completed tab.
54
+ sub_tab: DEFAULT_COMPLETED_SUB_TAB,
49
55
  };
50
56
  return zeniAPI
51
57
  .getJSON(`${zeniAPI.apiEndPoints.accountMicroServiceBaseUrl}/1.0/expense-automation/transactions?query=${encodeURIComponent(JSON.stringify(queryParam))}`)
@@ -0,0 +1,56 @@
1
+ import { EMPTY, from, merge } from 'rxjs';
2
+ import { filter, switchMap, take, takeUntil } from 'rxjs/operators';
3
+ import { convertToPeriod, toAbsoluteDay, } from '../../../../commonStateTypes/timePeriod';
4
+ import { getCurrentTenant } from '../../../../entity/tenant/tenantSelector';
5
+ import { bulkUploadReceiptsSuccess, clearBulkUpload, fetchBulkUploadBatchDetailsSuccess, fetchCompletedTransactions, fetchMissingReceipts, restoreBulkUploadMatchingState, storeBatchDetails, } from '../../reducers/missingReceiptsViewReducer';
6
+ /**
7
+ * After automatch completes for the active bulk-upload batch, refresh the two
8
+ * caches that are not invalidated by the existing batch-details refresh path:
9
+ * - Missing Receipts list (`transactionIdsBySelectedPeriod`)
10
+ * - Completed transactions cache (`bulkUpload.completedTransactionsByPeriod`)
11
+ *
12
+ * The Unmatched tab is already kept fresh via `pusherBatchStatusCompletionEpic`
13
+ * dispatching `fetchBulkUploadBatches({invalidateBatchDetailsCache: true})`,
14
+ * which feeds `fetchMultipleBatchDetailsEpic` and re-stores batch details.
15
+ *
16
+ * Lifecycle mirrors `bulkUploadMatchResultToastEpic`: trigger on
17
+ * `bulkUploadReceiptsSuccess` (fresh upload) or `restoreBulkUploadMatchingState`
18
+ * (restored session), then wait for the first batch-details action for that
19
+ * batchId — landing as either `fetchBulkUploadBatchDetailsSuccess` or
20
+ * `storeBatchDetails`. `clearBulkUpload` cancels the in-flight wait.
21
+ *
22
+ * Refetches use `cacheOverride: true` to bypass per-tab cache guards, and
23
+ * `fetchMissingReceipts` uses `refreshViewInBackground: true` to keep the
24
+ * existing rows visible while fresh data is in flight (the authoritative
25
+ * server filter `is_attachment_missing_only=true` removes matched rows once
26
+ * the response lands).
27
+ */
28
+ export const syncTabsAfterAutomatchEpic = (actions$, state$) => actions$.pipe(filter((action) => bulkUploadReceiptsSuccess.match(action) ||
29
+ restoreBulkUploadMatchingState.match(action)), switchMap((uploadAction) => {
30
+ const { batchId } = uploadAction.payload;
31
+ return merge(actions$.pipe(filter(fetchBulkUploadBatchDetailsSuccess.match)), actions$.pipe(filter(storeBatchDetails.match))).pipe(filter((a) => a.payload.batchId === batchId), take(1), switchMap((action) => {
32
+ const { summary } = action.payload;
33
+ if (summary.matched <= 0) {
34
+ return EMPTY;
35
+ }
36
+ const state = state$.value;
37
+ const currentTenant = getCurrentTenant(state);
38
+ if (currentTenant == null) {
39
+ return EMPTY;
40
+ }
41
+ const selectedPeriod = state.expenseAutomationViewState.selectedPeriodByTenantId[currentTenant.tenantId];
42
+ if (selectedPeriod == null) {
43
+ return from([fetchCompletedTransactions(undefined, true)]);
44
+ }
45
+ const period = convertToPeriod(selectedPeriod);
46
+ const timePeriodStart = toAbsoluteDay(period.start);
47
+ const timePeriodEnd = toAbsoluteDay(period.end);
48
+ if (timePeriodStart == null || timePeriodEnd == null) {
49
+ return from([fetchCompletedTransactions(undefined, true)]);
50
+ }
51
+ return from([
52
+ fetchMissingReceipts({ start: timePeriodStart, end: timePeriodEnd }, { amount: 75.0, currencyCode: 'USD', currencySymbol: '$' }, true, true),
53
+ fetchCompletedTransactions(undefined, true),
54
+ ]);
55
+ }), takeUntil(actions$.pipe(filter(clearBulkUpload.match))));
56
+ }));
@@ -5,23 +5,27 @@ import { updateTransactions } from '../../../../entity/transaction/transactionRe
5
5
  import { isSuccessResponse } from '../../../../responsePayload';
6
6
  import { BULK_UPLOAD_AUTOMATCHING_TIMEOUT_MS } from '../../helpers/bulkUploadTiming';
7
7
  import { extractTransactionPayloadsFromBatchFiles, isBatchDetailsApiStatusCompleted, toBatchDetails, } from '../../payload/missingReceiptsPayload';
8
- import { bulkUploadAutomatchingTimedOut, bulkUploadReceiptsSuccess, clearBulkUpload, fetchBulkUploadBatchDetails, fetchBulkUploadBatchDetailsSuccess, fetchBulkUploadBatches, pusherBatchStatusUpdate, restoreBulkUploadMatchingState, } from '../../reducers/missingReceiptsViewReducer';
8
+ import { bulkUploadAutomatchingTimedOut, bulkUploadReceiptsSuccess, clearBulkUpload, fetchBulkUploadBatchDetails, fetchBulkUploadBatchDetailsSuccess, fetchBulkUploadBatches, pusherBatchStatusUpdate, refreshBatchDetailsForBatchId, restoreBulkUploadMatchingState, } from '../../reducers/missingReceiptsViewReducer';
9
9
  /** First poll after upload; then interval while batch not resolved via Pusher or API. */
10
10
  const FALLBACK_FIRST_DELAY_MS = 30000;
11
11
  /** Shorter first poll for restored sessions — batch may already be near completion. */
12
12
  const RESTORE_FIRST_DELAY_MS = 5000;
13
13
  const FALLBACK_POLL_INTERVAL_MS = 10000;
14
14
  /**
15
- * On Pusher batch completion: refresh batch list (then fetchMultipleBatchDetailsEpic runs).
16
- * Debounced to reduce duplicate refreshes. The post-match tab decision
17
- * (Unmatched vs Completed) is made client-side in `ExpenseAutomationPage` once fresh
15
+ * On Pusher batch completion: refresh the batch list (so summary counts update) and dispatch a
16
+ * targeted single-batch details refresh for the batch that just completed. Stale-while-revalidate:
17
+ * we no longer pass `invalidateBatchDetailsCache: true` existing `batchDetailsById` entries stay
18
+ * on screen and the just-completed row replaces in place via `storeBatchDetails`. The post-match
19
+ * tab decision (Unmatched vs Completed) is made client-side in `ExpenseAutomationPage` once fresh
18
20
  * batch details land -- do not dispatch `requestMissingReceiptsTabNavigation` here.
21
+ *
22
+ * Pusher resend storms (same batch completing twice within the dedupe window) are throttled in
23
+ * `refreshBatchDetailsForBatchIdEpic` via `lastBatchDetailRefreshAtById`, so this epic stays
24
+ * stateless and just forwards the event.
19
25
  */
20
- export const pusherBatchStatusCompletionEpic = (actions$) => actions$.pipe(filter(pusherBatchStatusUpdate.match), filter((action) => action.payload.status === 'completed'), debounceTime(300), mergeMap(() => from([
21
- fetchBulkUploadBatches({
22
- cacheOverride: true,
23
- invalidateBatchDetailsCache: true,
24
- }),
26
+ export const pusherBatchStatusCompletionEpic = (actions$) => actions$.pipe(filter(pusherBatchStatusUpdate.match), filter((action) => action.payload.status === 'completed'), debounceTime(300), mergeMap((action) => from([
27
+ fetchBulkUploadBatches({ cacheOverride: true }),
28
+ refreshBatchDetailsForBatchId({ batchId: action.payload.batchId }),
25
29
  ])));
26
30
  /**
27
31
  * If Pusher and batch-details are delayed beyond {@link BULK_UPLOAD_AUTOMATCHING_TIMEOUT_MS},
@@ -5,7 +5,8 @@ import { getCurrentTenant } from '../../../../entity/tenant/tenantSelector';
5
5
  import { updateTransactions } from '../../../../entity/transaction/transactionReducer';
6
6
  import { createZeniAPIStatus, isSuccessResponse, } from '../../../../responsePayload';
7
7
  import { getLineItemsByTransactionsIdsFromResponse } from '../../helpers/transactionCategorizationLocalDataHelper';
8
- import { fetchTransactionCategorization, fetchTransactionCategorizationFailure, fetchTransactionCategorizationSuccess, initializeTransactionCategorizationViewLocalData, resetOtherTabsFetchState, updateTotalCountForTransactionCategorization, updateTransactionCategorizationUIState, } from '../../reducers/transactionsViewReducer';
8
+ import { fetchTransactionCategorization, fetchTransactionCategorizationFailure, fetchTransactionCategorizationSuccess, initializeTransactionCategorizationViewLocalData, resetOtherTabsFetchState, updateParentTotalCountForTab, updateTotalCountForTransactionCategorization, updateTransactionCategorizationUIState, } from '../../reducers/transactionsViewReducer';
9
+ import { DEFAULT_COMPLETED_SUB_TAB } from '../../types/completedSubTab';
9
10
  import { TRANSACTIONS_TABS, toTransactionsSortKey, } from '../../types/transactionsViewState';
10
11
  export const fetchTransactionCategorizationEpic = (actions$, state$, zeniAPI) => actions$.pipe(filter(fetchTransactionCategorization.match), switchMap((action) => {
11
12
  const { expenseAutomationViewState: { selectedPeriodByTenantId }, expenseAutomationTransactionsViewState, } = state$.value;
@@ -23,6 +24,9 @@ export const fetchTransactionCategorizationEpic = (actions$, state$, zeniAPI) =>
23
24
  page_token: pageToken,
24
25
  page_size: 25,
25
26
  search_text: uiState.searchString,
27
+ sub_tab: selectedTab === 'autoCategorized'
28
+ ? expenseAutomationTransactionsViewState.selectedTransactionCategorizationCompletedSubTab
29
+ : DEFAULT_COMPLETED_SUB_TAB,
26
30
  };
27
31
  return zeniAPI
28
32
  .getJSON(`${zeniAPI.apiEndPoints.accountMicroServiceBaseUrl}/1.0/expense-automation/transactions?query=${encodeURIComponent(JSON.stringify(queryParam))}`)
@@ -45,6 +49,12 @@ export const fetchTransactionCategorizationEpic = (actions$, state$, zeniAPI) =>
45
49
  selectedTab,
46
50
  totalCount: response.data.total_count,
47
51
  }));
52
+ updateActions.push(updateParentTotalCountForTab({
53
+ selectedPeriod,
54
+ selectedTab,
55
+ parentTotalCount: response.data.parent_tab_total_count ??
56
+ response.data.total_count,
57
+ }));
48
58
  updateActions.push(resetOtherTabsFetchState({
49
59
  refreshViewInBackground,
50
60
  tabs: TRANSACTIONS_TABS.filter((tab) => selectedTab !== tab),
@@ -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';
@@ -9,15 +10,18 @@ export const getCompletedTransactionsCacheKey = (periodId, sortKey, sortOrder, s
9
10
  export const initialBulkUploadState = {
10
11
  batchDetailsById: {},
11
12
  batchDetailsPaginationState: { fetchState: 'Not-Started', error: undefined },
13
+ batchDetailsRevalidating: false,
12
14
  batchListByPeriod: {},
13
15
  batchListFetchState: { fetchState: 'Not-Started', error: undefined },
14
16
  batchStatusById: {},
15
- completedSubTab: 'all',
17
+ completedSubTab: DEFAULT_COMPLETED_SUB_TAB,
16
18
  completedTransactionsByPeriod: {},
17
19
  confirmMatchStatus: { fetchState: 'Not-Started', error: undefined },
18
20
  currentBatchId: undefined,
19
21
  currentResultsTab: 'unmatched',
20
22
  failedBatchDetailIds: [],
23
+ initialBatchDetailsLoaded: false,
24
+ lastBatchDetailRefreshAtById: {},
21
25
  manualSearchUiResetKey: 0,
22
26
  manualSearch: {
23
27
  fetchState: { fetchState: 'Not-Started', error: undefined },
@@ -64,6 +68,13 @@ export const fetchBulkUploadBatchDetails = createAction('expenseAutomationMissin
64
68
  * after a hard refresh. The epic owns the list + details API calls end-to-end.
65
69
  */
66
70
  export const restoreBulkUploadAutomatchingOnMount = createAction('expenseAutomationMissingReceiptsView/restoreBulkUploadAutomatchingOnMount');
71
+ /**
72
+ * Epic trigger only. Targeted stale-while-revalidate refresh for a single batch's details — used
73
+ * by `pusherBatchStatusCompletionEpic` so a Pusher 'completed' event re-fetches only that one
74
+ * batch instead of wiping the entire `batchDetailsById` cache. The epic dedupes via
75
+ * `lastBatchDetailRefreshAtById` so resend storms don't trigger waves of API calls.
76
+ */
77
+ export const refreshBatchDetailsForBatchId = createAction('expenseAutomationMissingReceiptsView/refreshBatchDetailsForBatchId');
67
78
  const expenseAutomationMissingReceiptsView = createSlice({
68
79
  name: 'expenseAutomationMissingReceiptsView',
69
80
  initialState,
@@ -250,6 +261,16 @@ const expenseAutomationMissingReceiptsView = createSlice({
250
261
  const details = action.payload;
251
262
  draft.bulkUpload.batchDetailsById[details.batchId] = details;
252
263
  draft.bulkUpload.phase = 'completed';
264
+ draft.bulkUpload.initialBatchDetailsLoaded = true;
265
+ /**
266
+ * A successful detail load supersedes any previous failure for this batch — without this,
267
+ * `getPrimaryBatchIdFromBatchListOrder` (and pagination) would keep skipping the batch
268
+ * across the recovery, hiding now-valid Unmatched-tab work.
269
+ */
270
+ const failedIndex = draft.bulkUpload.failedBatchDetailIds.indexOf(details.batchId);
271
+ if (failedIndex >= 0) {
272
+ draft.bulkUpload.failedBatchDetailIds.splice(failedIndex, 1);
273
+ }
253
274
  },
254
275
  fetchBulkUploadBatchDetailsFailure: {
255
276
  reducer(draft) {
@@ -271,6 +292,19 @@ const expenseAutomationMissingReceiptsView = createSlice({
271
292
  draft.bulkUpload.currentBatchId === details.batchId) {
272
293
  draft.bulkUpload.phase = 'completed';
273
294
  }
295
+ /** Stale-while-revalidate: any successful detail load means we have rendered data. */
296
+ draft.bulkUpload.initialBatchDetailsLoaded = true;
297
+ /**
298
+ * Stale-while-revalidate recovery: if this batch had previously failed, a successful
299
+ * targeted refresh (Pusher `completed` → `refreshBatchDetailsForBatchIdEpic`) or
300
+ * multi-batch revalidation (manual refresh) supersedes the failure. Selectors that gate
301
+ * on `failedBatchDetailIds` (primary-batch resolution, `hasMoreBatchDetails`) must see the
302
+ * batch as recovered so it can show fresh Unmatched work.
303
+ */
304
+ const failedIndex = draft.bulkUpload.failedBatchDetailIds.indexOf(details.batchId);
305
+ if (failedIndex >= 0) {
306
+ draft.bulkUpload.failedBatchDetailIds.splice(failedIndex, 1);
307
+ }
274
308
  },
275
309
  batchDetailFetchFailed(draft, action) {
276
310
  const { batchId } = action.payload;
@@ -299,6 +333,14 @@ const expenseAutomationMissingReceiptsView = createSlice({
299
333
  fetchState: 'Completed',
300
334
  error: undefined,
301
335
  };
336
+ /**
337
+ * Stale-while-revalidate: a completed pagination round means the initial page (or a
338
+ * subsequent revalidation) has fully resolved. Once `initialBatchDetailsLoaded` flips to
339
+ * `true` it stays `true` for the session — only `clearBulkUpload` /
340
+ * `clearBulkUploadBatchDetailsForScopeChange` can reset it.
341
+ */
342
+ draft.bulkUpload.initialBatchDetailsLoaded = true;
343
+ draft.bulkUpload.batchDetailsRevalidating = false;
302
344
  const currentId = draft.bulkUpload.currentBatchId;
303
345
  const currentDetails = currentId != null
304
346
  ? draft.bulkUpload.batchDetailsById[currentId]
@@ -314,12 +356,24 @@ const expenseAutomationMissingReceiptsView = createSlice({
314
356
  fetchState: 'Error',
315
357
  error: action.payload.error,
316
358
  };
359
+ draft.bulkUpload.batchDetailsRevalidating = false;
317
360
  },
318
361
  fetchBulkUploadBatches: {
319
362
  reducer(draft, action) {
363
+ /**
364
+ * Manual refresh button (and any other caller passing `invalidateBatchDetailsCache: true`):
365
+ * wipe the details cache, failed-id list, and pagination state so the Unmatched tab drops
366
+ * its existing rows and re-renders the initial-load skeleton while the next list +
367
+ * details fetches resolve. The post-success epic (`fetchMultipleBatchDetailsEpic`) then
368
+ * re-runs the details chain from a clean slate.
369
+ */
320
370
  if (action.payload.invalidateBatchDetailsCache === true) {
321
371
  draft.bulkUpload.batchDetailsById = {};
322
372
  draft.bulkUpload.failedBatchDetailIds = [];
373
+ draft.bulkUpload.batchDetailsPaginationState = {
374
+ fetchState: 'Not-Started',
375
+ error: undefined,
376
+ };
323
377
  }
324
378
  draft.bulkUpload.batchListFetchState = {
325
379
  fetchState: 'In-Progress',
@@ -336,6 +390,31 @@ const expenseAutomationMissingReceiptsView = createSlice({
336
390
  };
337
391
  },
338
392
  },
393
+ /**
394
+ * Tenant/period scope change — the new scope's data is genuinely unknown so the existing
395
+ * details cache and `initialBatchDetailsLoaded` flag must be reset (the Unmatched skeleton
396
+ * is acceptable on scope change). Distinct from a stale-while-revalidate refresh.
397
+ */
398
+ clearBulkUploadBatchDetailsForScopeChange(draft) {
399
+ draft.bulkUpload.batchDetailsById = {};
400
+ draft.bulkUpload.failedBatchDetailIds = [];
401
+ draft.bulkUpload.batchDetailsPaginationState = {
402
+ fetchState: 'Not-Started',
403
+ error: undefined,
404
+ };
405
+ draft.bulkUpload.batchDetailsRevalidating = false;
406
+ draft.bulkUpload.initialBatchDetailsLoaded = false;
407
+ draft.bulkUpload.lastBatchDetailRefreshAtById = {};
408
+ },
409
+ /**
410
+ * Records the last time we issued a targeted refresh for `batchId`. Set by
411
+ * `refreshBatchDetailsForBatchIdEpic` before each API call so subsequent Pusher resends in the
412
+ * dedupe window are skipped.
413
+ */
414
+ markBatchDetailRefreshAttempted(draft, action) {
415
+ draft.bulkUpload.lastBatchDetailRefreshAtById[action.payload.batchId] =
416
+ action.payload.timestamp;
417
+ },
339
418
  fetchBulkUploadBatchesSuccess(draft, action) {
340
419
  const periodId = toMonthYearPeriodId(action.payload.selectedPeriod);
341
420
  draft.bulkUpload.batchListByPeriod[periodId] = action.payload.batchList;
@@ -578,7 +657,7 @@ const expenseAutomationMissingReceiptsView = createSlice({
578
657
  });
579
658
  export const { fetchMissingReceipts, fetchMissingReceiptsSuccess, fetchMissingReceiptsFailure, uploadMissingReceiptSuccess, updateMissingReceiptUploadState, updateMissingReceiptsUIState, markMissingReceiptAsDone, clearExpenseAutomationMissingReceiptsView,
580
659
  // Bulk Upload
581
- bulkUploadReceipts, updateBulkUploadProgress, setBulkUploadUploadedFileCount, bulkUploadReceiptsSuccess, bulkUploadReceiptsFailure, restoreBulkUploadMatchingState, bulkUploadAutomatchingTimedOut, pusherBatchStatusUpdate, requestMissingReceiptsTabNavigation, clearMissingReceiptsTabNavigation, fetchBulkUploadBatchDetailsSuccess, fetchBulkUploadBatchDetailsFailure, storeBatchDetails, batchDetailFetchFailed, setInitialBatchDetailsLoading, fetchMoreBatchDetails, fetchMoreBatchDetailsComplete, fetchMoreBatchDetailsFailure, fetchBulkUploadBatches, fetchBulkUploadBatchesSuccess, fetchBulkUploadBatchesFailure, confirmBulkUploadMatch, confirmBulkUploadMatchSuccess, confirmBulkUploadMatchFailure, setBulkUploadResultsTab, setBulkUploadCompletedSubTab, setBulkUploadSortConfig, clearBulkUpload, searchTransactionsForManualMatch, searchTransactionsForManualMatchSuccess, searchTransactionsForManualMatchFailure, clearManualSearchResults, acknowledgeBulkUploadConfirmMatchComplete,
660
+ bulkUploadReceipts, updateBulkUploadProgress, setBulkUploadUploadedFileCount, bulkUploadReceiptsSuccess, bulkUploadReceiptsFailure, restoreBulkUploadMatchingState, bulkUploadAutomatchingTimedOut, pusherBatchStatusUpdate, requestMissingReceiptsTabNavigation, clearMissingReceiptsTabNavigation, fetchBulkUploadBatchDetailsSuccess, fetchBulkUploadBatchDetailsFailure, storeBatchDetails, batchDetailFetchFailed, setInitialBatchDetailsLoading, fetchMoreBatchDetails, fetchMoreBatchDetailsComplete, fetchMoreBatchDetailsFailure, fetchBulkUploadBatches, fetchBulkUploadBatchesSuccess, fetchBulkUploadBatchesFailure, clearBulkUploadBatchDetailsForScopeChange, markBatchDetailRefreshAttempted, confirmBulkUploadMatch, confirmBulkUploadMatchSuccess, confirmBulkUploadMatchFailure, setBulkUploadResultsTab, setBulkUploadCompletedSubTab, setBulkUploadSortConfig, clearBulkUpload, searchTransactionsForManualMatch, searchTransactionsForManualMatchSuccess, searchTransactionsForManualMatchFailure, clearManualSearchResults, acknowledgeBulkUploadConfirmMatchComplete,
582
661
  // Completed Transactions
583
662
  fetchCompletedTransactions, fetchCompletedTransactionsSuccess, fetchCompletedTransactionsFailure, } = expenseAutomationMissingReceiptsView.actions;
584
663
  export default expenseAutomationMissingReceiptsView.reducer;