@zeniai/client-epic-state 4.20.7 → 4.20.8

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 (178) hide show
  1. package/lib/commonStateTypes/coaBalance/additionalBalances/getSummationBalance.d.ts +1 -1
  2. package/lib/commonStateTypes/coaBalance/coaBalanceType.d.ts +2 -0
  3. package/lib/commonStateTypes/coaBalance/coaBalanceType.js +6 -0
  4. package/lib/commonStateTypes/currencyHelper.d.ts +3 -0
  5. package/lib/commonStateTypes/currencyHelper.js +6 -1
  6. package/lib/commonStateTypes/reduceFetchState.d.ts +9 -0
  7. package/lib/commonStateTypes/reduceFetchState.js +26 -0
  8. package/lib/entity/account/accountPayload.d.ts +2 -0
  9. package/lib/entity/account/accountPayload.js +2 -0
  10. package/lib/entity/account/accountState.d.ts +2 -0
  11. package/lib/entity/customer/customerState.d.ts +0 -2
  12. package/lib/entity/customer/customerState.js +0 -5
  13. package/lib/entity/depositAccount/depositAccount.d.ts +1 -1
  14. package/lib/entity/depositAccount/depositAccount.js +8 -1
  15. package/lib/entity/reimbursement/reimbursementPayload.d.ts +2 -0
  16. package/lib/entity/reimbursement/reimbursementPayload.js +7 -0
  17. package/lib/entity/reimbursement/reimbursementState.d.ts +2 -0
  18. package/lib/entity/snackbar/snackbarTypes.d.ts +1 -1
  19. package/lib/entity/snackbar/snackbarTypes.js +2 -0
  20. package/lib/entity/task/taskPayload.d.ts +1 -0
  21. package/lib/entity/task/taskPayload.js +1 -0
  22. package/lib/entity/task/taskState.d.ts +1 -0
  23. package/lib/entity/userGroups/userGroupsPayload.d.ts +7 -0
  24. package/lib/entity/userGroups/userGroupsPayload.js +10 -0
  25. package/lib/entity/userGroups/userGroupsReducer.d.ts +8 -0
  26. package/lib/entity/userGroups/userGroupsReducer.js +29 -0
  27. package/lib/entity/userGroups/userGroupsSelector.d.ts +4 -0
  28. package/lib/entity/userGroups/userGroupsSelector.js +12 -0
  29. package/lib/entity/userGroups/userGroupsState.d.ts +9 -0
  30. package/lib/entity/userGroups/userGroupsState.js +2 -0
  31. package/lib/epic.d.ts +15 -1
  32. package/lib/epic.js +15 -1
  33. package/lib/esm/commonStateTypes/coaBalance/coaBalanceType.js +5 -0
  34. package/lib/esm/commonStateTypes/currencyHelper.js +5 -0
  35. package/lib/esm/commonStateTypes/reduceFetchState.js +25 -0
  36. package/lib/esm/entity/account/accountPayload.js +2 -0
  37. package/lib/esm/entity/customer/customerState.js +1 -4
  38. package/lib/esm/entity/depositAccount/depositAccount.js +8 -1
  39. package/lib/esm/entity/reimbursement/reimbursementPayload.js +7 -0
  40. package/lib/esm/entity/snackbar/snackbarTypes.js +2 -0
  41. package/lib/esm/entity/task/taskPayload.js +1 -0
  42. package/lib/esm/entity/userGroups/userGroupsPayload.js +7 -0
  43. package/lib/esm/entity/userGroups/userGroupsReducer.js +25 -0
  44. package/lib/esm/entity/userGroups/userGroupsSelector.js +8 -0
  45. package/lib/esm/entity/userGroups/userGroupsState.js +1 -0
  46. package/lib/esm/epic.js +15 -1
  47. package/lib/esm/index.js +19 -10
  48. package/lib/esm/reducer.js +6 -0
  49. package/lib/esm/view/arAgingView/arAgingReport/arAgingReducer.js +1 -1
  50. package/lib/esm/view/arAgingView/arAgingReport/arAgingSelector.js +1 -1
  51. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/bulkUploadMatchResultToastEpic.js +33 -0
  52. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/bulkUploadReceiptsEpic.js +29 -0
  53. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/confirmBulkUploadMatchEpic.js +49 -0
  54. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/fetchBulkUploadBatchDetailsEpic.js +40 -0
  55. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/fetchBulkUploadBatchesEpic.js +49 -0
  56. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/fetchCompletedTransactionsEpic.js +61 -0
  57. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/fetchMoreBatchDetailsEpic.js +33 -0
  58. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/fetchMultipleBatchDetailsEpic.js +56 -0
  59. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/refetchCompletedTransactionsOnBulkUploadSortEpic.js +12 -0
  60. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/restoreBulkUploadAutomatchingOnMountEpic.js +98 -0
  61. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/searchTransactionsForManualMatchEpic.js +71 -0
  62. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/uploadMissingReceiptSuccessEpic.js +7 -5
  63. package/lib/esm/view/expenseAutomationView/epics/missingReceipts/watchBulkUploadBatchStatusEpic.js +86 -0
  64. package/lib/esm/view/expenseAutomationView/epics/transactionCategorization/uploadTransactionReceiptSuccessEpic.js +24 -0
  65. package/lib/esm/view/expenseAutomationView/helpers/bulkUploadTiming.js +16 -0
  66. package/lib/esm/view/expenseAutomationView/helpers/rollingCalendarDateRangeInclusive.js +13 -0
  67. package/lib/esm/view/expenseAutomationView/payload/missingReceiptsPayload.js +198 -1
  68. package/lib/esm/view/expenseAutomationView/reducers/missingReceiptsViewReducer.js +456 -2
  69. package/lib/esm/view/expenseAutomationView/reducers/transactionsViewReducer.js +23 -1
  70. package/lib/esm/view/expenseAutomationView/selectors/missingReceiptsSelector.js +245 -1
  71. package/lib/esm/view/expenseAutomationView/selectors/transactionCategorizationSelector.js +2 -1
  72. package/lib/esm/view/expenseAutomationView/types/missingReceiptsViewState.js +44 -0
  73. package/lib/esm/view/expenseAutomationView/types/transactionsViewState.js +2 -1
  74. package/lib/esm/view/financeStatement/financeStatementReducer.js +11 -8
  75. package/lib/esm/view/financeStatement/financeStatementSelector.js +24 -9
  76. package/lib/esm/view/profitAndLossClassesView/horizontalSectionEmptyBalancesSlice.js +17 -0
  77. package/lib/esm/view/profitAndLossClassesView/profitAndLossClassesByClassHorizontalSelector.js +331 -0
  78. package/lib/esm/view/profitAndLossClassesView/profitAndLossClassesByClassHorizontalSelectorTypes.js +9 -0
  79. package/lib/esm/view/profitAndLossClassesView/profitAndLossClassesViewReducer.js +6 -1
  80. package/lib/esm/view/profitAndLossClassesView/profitAndLossClassesViewSelector.js +2 -29
  81. package/lib/esm/view/profitAndLossClassesView/profitAndLossForTimeframeClassesViewEpic.js +7 -5
  82. package/lib/esm/view/profitAndLossClassesView/profitAndLossHorizontalEnrichment.js +262 -0
  83. package/lib/esm/view/reportUIOptions/updateReportUIOptionCOABalancesRangeEpic.js +9 -7
  84. package/lib/esm/view/taskManager/taskDetailView/taskDetailSelector.js +1 -1
  85. package/lib/esm/view/taskManager/taskListView/epics/bulkUpdateTaskListEpic.js +1 -0
  86. package/lib/esm/view/taskManager/taskListView/taskListSelector.js +3 -1
  87. package/lib/esm/view/userGroupListView/userGroupListViewReducer.js +65 -0
  88. package/lib/esm/view/userGroupListView/userGroupListViewSelector.js +16 -0
  89. package/lib/esm/view/userGroupListView/userGroupListViewState.js +1 -0
  90. package/lib/esm/view/userListView/fetchUserListByTypeEpic.js +11 -0
  91. package/lib/esm/zeniAPI.js +0 -2
  92. package/lib/index.d.ts +20 -12
  93. package/lib/index.js +97 -34
  94. package/lib/reducer.d.ts +6 -0
  95. package/lib/reducer.js +6 -0
  96. package/lib/tsconfig.typecheck.tsbuildinfo +1 -0
  97. package/lib/view/arAgingView/arAgingReport/arAgingReducer.js +2 -2
  98. package/lib/view/arAgingView/arAgingReport/arAgingSelector.js +5 -5
  99. package/lib/view/expenseAutomationView/epics/missingReceipts/bulkUploadMatchResultToastEpic.d.ts +13 -0
  100. package/lib/view/expenseAutomationView/epics/missingReceipts/bulkUploadMatchResultToastEpic.js +37 -0
  101. package/lib/view/expenseAutomationView/epics/missingReceipts/bulkUploadReceiptsEpic.d.ts +18 -0
  102. package/lib/view/expenseAutomationView/epics/missingReceipts/bulkUploadReceiptsEpic.js +33 -0
  103. package/lib/view/expenseAutomationView/epics/missingReceipts/confirmBulkUploadMatchEpic.d.ts +7 -0
  104. package/lib/view/expenseAutomationView/epics/missingReceipts/confirmBulkUploadMatchEpic.js +53 -0
  105. package/lib/view/expenseAutomationView/epics/missingReceipts/fetchBulkUploadBatchDetailsEpic.d.ts +8 -0
  106. package/lib/view/expenseAutomationView/epics/missingReceipts/fetchBulkUploadBatchDetailsEpic.js +44 -0
  107. package/lib/view/expenseAutomationView/epics/missingReceipts/fetchBulkUploadBatchesEpic.d.ts +7 -0
  108. package/lib/view/expenseAutomationView/epics/missingReceipts/fetchBulkUploadBatchesEpic.js +53 -0
  109. package/lib/view/expenseAutomationView/epics/missingReceipts/fetchCompletedTransactionsEpic.d.ts +8 -0
  110. package/lib/view/expenseAutomationView/epics/missingReceipts/fetchCompletedTransactionsEpic.js +65 -0
  111. package/lib/view/expenseAutomationView/epics/missingReceipts/fetchMoreBatchDetailsEpic.d.ts +8 -0
  112. package/lib/view/expenseAutomationView/epics/missingReceipts/fetchMoreBatchDetailsEpic.js +37 -0
  113. package/lib/view/expenseAutomationView/epics/missingReceipts/fetchMultipleBatchDetailsEpic.d.ts +9 -0
  114. package/lib/view/expenseAutomationView/epics/missingReceipts/fetchMultipleBatchDetailsEpic.js +61 -0
  115. package/lib/view/expenseAutomationView/epics/missingReceipts/refetchCompletedTransactionsOnBulkUploadSortEpic.d.ts +10 -0
  116. package/lib/view/expenseAutomationView/epics/missingReceipts/refetchCompletedTransactionsOnBulkUploadSortEpic.js +16 -0
  117. package/lib/view/expenseAutomationView/epics/missingReceipts/restoreBulkUploadAutomatchingOnMountEpic.d.ts +18 -0
  118. package/lib/view/expenseAutomationView/epics/missingReceipts/restoreBulkUploadAutomatchingOnMountEpic.js +102 -0
  119. package/lib/view/expenseAutomationView/epics/missingReceipts/searchTransactionsForManualMatchEpic.d.ts +14 -0
  120. package/lib/view/expenseAutomationView/epics/missingReceipts/searchTransactionsForManualMatchEpic.js +75 -0
  121. package/lib/view/expenseAutomationView/epics/missingReceipts/uploadMissingReceiptSuccessEpic.js +7 -5
  122. package/lib/view/expenseAutomationView/epics/missingReceipts/watchBulkUploadBatchStatusEpic.d.ts +25 -0
  123. package/lib/view/expenseAutomationView/epics/missingReceipts/watchBulkUploadBatchStatusEpic.js +92 -0
  124. package/lib/view/expenseAutomationView/epics/transactionCategorization/uploadTransactionReceiptSuccessEpic.d.ts +7 -0
  125. package/lib/view/expenseAutomationView/epics/transactionCategorization/uploadTransactionReceiptSuccessEpic.js +28 -0
  126. package/lib/view/expenseAutomationView/helpers/bulkUploadTiming.d.ts +16 -0
  127. package/lib/view/expenseAutomationView/helpers/bulkUploadTiming.js +19 -0
  128. package/lib/view/expenseAutomationView/helpers/rollingCalendarDateRangeInclusive.d.ts +7 -0
  129. package/lib/view/expenseAutomationView/helpers/rollingCalendarDateRangeInclusive.js +16 -0
  130. package/lib/view/expenseAutomationView/helpers/transactionCategorizationLocalDataHelper.d.ts +1 -1
  131. package/lib/view/expenseAutomationView/payload/missingReceiptsPayload.d.ts +123 -0
  132. package/lib/view/expenseAutomationView/payload/missingReceiptsPayload.js +211 -0
  133. package/lib/view/expenseAutomationView/reducers/missingReceiptsViewReducer.d.ts +86 -3
  134. package/lib/view/expenseAutomationView/reducers/missingReceiptsViewReducer.js +457 -2
  135. package/lib/view/expenseAutomationView/reducers/transactionsViewReducer.d.ts +11 -3
  136. package/lib/view/expenseAutomationView/reducers/transactionsViewReducer.js +24 -2
  137. package/lib/view/expenseAutomationView/selectorTypes/missingReceiptsSelectorTypes.d.ts +70 -1
  138. package/lib/view/expenseAutomationView/selectorTypes/transactionsViewSelectorTypes.d.ts +1 -0
  139. package/lib/view/expenseAutomationView/selectors/missingReceiptsSelector.d.ts +1 -1
  140. package/lib/view/expenseAutomationView/selectors/missingReceiptsSelector.js +244 -0
  141. package/lib/view/expenseAutomationView/selectors/transactionCategorizationSelector.js +2 -1
  142. package/lib/view/expenseAutomationView/types/missingReceiptsViewState.d.ts +146 -0
  143. package/lib/view/expenseAutomationView/types/missingReceiptsViewState.js +54 -1
  144. package/lib/view/expenseAutomationView/types/transactionsViewState.d.ts +2 -1
  145. package/lib/view/expenseAutomationView/types/transactionsViewState.js +2 -1
  146. package/lib/view/financeStatement/financeStatementReducer.js +11 -8
  147. package/lib/view/financeStatement/financeStatementSelector.d.ts +2 -0
  148. package/lib/view/financeStatement/financeStatementSelector.js +24 -9
  149. package/lib/view/profitAndLossClassesView/horizontalSectionEmptyBalancesSlice.d.ts +9 -0
  150. package/lib/view/profitAndLossClassesView/horizontalSectionEmptyBalancesSlice.js +20 -0
  151. package/lib/view/profitAndLossClassesView/profitAndLossClassesByClassHorizontalSelector.d.ts +15 -0
  152. package/lib/view/profitAndLossClassesView/profitAndLossClassesByClassHorizontalSelector.js +335 -0
  153. package/lib/view/profitAndLossClassesView/profitAndLossClassesByClassHorizontalSelectorTypes.d.ts +111 -0
  154. package/lib/view/profitAndLossClassesView/profitAndLossClassesByClassHorizontalSelectorTypes.js +13 -0
  155. package/lib/view/profitAndLossClassesView/profitAndLossClassesViewReducer.d.ts +4 -2
  156. package/lib/view/profitAndLossClassesView/profitAndLossClassesViewReducer.js +7 -2
  157. package/lib/view/profitAndLossClassesView/profitAndLossClassesViewSelector.js +2 -29
  158. package/lib/view/profitAndLossClassesView/profitAndLossClassesViewState.d.ts +4 -0
  159. package/lib/view/profitAndLossClassesView/profitAndLossForTimeframeClassesViewEpic.js +7 -5
  160. package/lib/view/profitAndLossClassesView/profitAndLossHorizontalEnrichment.d.ts +6 -0
  161. package/lib/view/profitAndLossClassesView/profitAndLossHorizontalEnrichment.js +265 -0
  162. package/lib/view/reportUIOptions/updateReportUIOptionCOABalancesRangeEpic.js +8 -6
  163. package/lib/view/taskManager/taskDetailView/taskDetailSelector.js +1 -1
  164. package/lib/view/taskManager/taskListView/epics/bulkUpdateTaskListEpic.d.ts +1 -0
  165. package/lib/view/taskManager/taskListView/epics/bulkUpdateTaskListEpic.js +1 -0
  166. package/lib/view/taskManager/taskListView/taskListSelector.d.ts +2 -0
  167. package/lib/view/taskManager/taskListView/taskListSelector.js +3 -1
  168. package/lib/view/userGroupListView/userGroupListViewReducer.d.ts +23 -0
  169. package/lib/view/userGroupListView/userGroupListViewReducer.js +69 -0
  170. package/lib/view/userGroupListView/userGroupListViewSelector.d.ts +7 -0
  171. package/lib/view/userGroupListView/userGroupListViewSelector.js +20 -0
  172. package/lib/view/userGroupListView/userGroupListViewState.d.ts +8 -0
  173. package/lib/view/userGroupListView/userGroupListViewState.js +2 -0
  174. package/lib/view/userListView/fetchUserListByTypeEpic.d.ts +3 -1
  175. package/lib/view/userListView/fetchUserListByTypeEpic.js +11 -0
  176. package/lib/view/userListView/userListViewPayload.d.ts +2 -0
  177. package/lib/zeniAPI.js +0 -2
  178. package/package.json +3 -3
@@ -1,8 +1,11 @@
1
1
  import orderBy from 'lodash/orderBy';
2
2
  import { toMonthYearPeriodId } from '../../../commonStateTypes/timePeriod';
3
3
  import { getCurrentTenant } from '../../../entity/tenant/tenantSelector';
4
- import { getSupportedTransactionsByIds } from '../../../entity/transaction/transactionSelector';
4
+ import { getSupportedTransactionById, getSupportedTransactionsByIds, } from '../../../entity/transaction/transactionSelector';
5
+ import { isBatchListStatusCompleted } from '../payload/missingReceiptsPayload';
6
+ import { getCompletedTransactionsCacheKey } from '../reducers/missingReceiptsViewReducer';
5
7
  import { getAllSteps } from '../selectorTypes/expenseAutomationViewSelectorTypes';
8
+ import { isUnmatchedTabFileStatus, } from '../types/missingReceiptsViewState';
6
9
  export function getExpenseAutomationMissingReceiptsView(state) {
7
10
  const { expenseAutomationMissingReceiptsViewState, expenseAutomationViewState, transactionState, monthEndCloseChecksState, } = state;
8
11
  const currentTenant = getCurrentTenant(state);
@@ -47,6 +50,131 @@ export function getExpenseAutomationMissingReceiptsView(state) {
47
50
  const completionStatus = missingReceiptsCompletionStatus != null
48
51
  ? missingReceiptsCompletionStatus
49
52
  : 'incomplete';
53
+ const { bulkUpload, pendingMissingReceiptsTabNavigation } = expenseAutomationMissingReceiptsViewState;
54
+ const currentBatchStatus = bulkUpload.currentBatchId != null
55
+ ? bulkUpload.batchStatusById[bulkUpload.currentBatchId]
56
+ : undefined;
57
+ const batchList = monthYearPeriodId != null
58
+ ? (bulkUpload.batchListByPeriod[monthYearPeriodId] ?? [])
59
+ : [];
60
+ /**
61
+ * Primary "Unmatched" batch = first completed batch in platform list order, preferring the
62
+ * first batch that still has possible_matches/no_match work when any completed batch has such work.
63
+ * Batches without details yet: wait if the list row still reports unmatched work; otherwise skip
64
+ * so we do not block the whole tab while other rows are not fetched yet.
65
+ */
66
+ const primaryBatchIdFromDetailsOrder = getPrimaryBatchIdFromBatchListOrder(batchList, bulkUpload.batchDetailsById, bulkUpload.failedBatchDetailIds);
67
+ /**
68
+ * During upload/matching, prefer `currentBatchId` only when its batch details are loaded,
69
+ * completed, and still have unmatched-tab work. Otherwise keep `primaryBatchIdFromDetailsOrder`.
70
+ *
71
+ * If we pinned to `currentBatchId` as soon as it was absent from the batch list (in-flight
72
+ * upload), details are often not loaded yet — `unmatchedFiles` would empty while older batches
73
+ * moved to "past", so the primary "Unmatched Receipts" section vanished during auto-matching.
74
+ */
75
+ const unmatchedSectionBatchId = (() => {
76
+ const phase = bulkUpload.phase;
77
+ if (phase !== 'matching' && phase !== 'uploading') {
78
+ return primaryBatchIdFromDetailsOrder;
79
+ }
80
+ const currentId = bulkUpload.currentBatchId;
81
+ if (currentId == null) {
82
+ return primaryBatchIdFromDetailsOrder;
83
+ }
84
+ const currentDetails = bulkUpload.batchDetailsById[currentId];
85
+ if (currentDetails != null &&
86
+ currentDetails.status === 'completed' &&
87
+ batchDetailsHasUnmatchedWork(currentDetails)) {
88
+ return currentId;
89
+ }
90
+ return primaryBatchIdFromDetailsOrder;
91
+ })();
92
+ const completedBatchesInApiOrder = batchList.filter((batchListItem) => isBatchListStatusCompleted(batchListItem.status));
93
+ const currentBatchDetails = unmatchedSectionBatchId != null
94
+ ? bulkUpload.batchDetailsById[unmatchedSectionBatchId]
95
+ : undefined;
96
+ const resolvedFiles = currentBatchDetails?.files.map((file) => resolveBatchFile(file, currentBatchDetails.batchId, transactionState));
97
+ const failedIds = new Set(bulkUpload.failedBatchDetailIds);
98
+ const hasMoreBatchDetails = completedBatchesInApiOrder.some((batchListItem) => bulkUpload.batchDetailsById[batchListItem.batchId] == null &&
99
+ !failedIds.has(batchListItem.batchId));
100
+ const getSortValue = (file) => {
101
+ const matchedTransaction = file.matchedTransaction;
102
+ if (matchedTransaction == null) {
103
+ return null;
104
+ }
105
+ switch (bulkUpload.sortKey) {
106
+ case 'vendor':
107
+ return ((matchedTransaction.vendorName ?? matchedTransaction.description)?.toLowerCase() ?? null);
108
+ case 'category': {
109
+ const line = matchedTransaction.lines?.[0];
110
+ return line?.account?.accountName?.toLowerCase() ?? null;
111
+ }
112
+ case 'class': {
113
+ const line = matchedTransaction.lines?.[0];
114
+ return line?.class?.className?.toLowerCase() ?? null;
115
+ }
116
+ case 'date':
117
+ return matchedTransaction.date.valueOf();
118
+ case 'amount':
119
+ return matchedTransaction.amount.amount;
120
+ default:
121
+ return null;
122
+ }
123
+ };
124
+ const bulkUploadSortDirection = bulkUpload.sortOrder === 'ascending' ? 'asc' : 'desc';
125
+ const matchedFiles = orderBy(resolvedFiles?.filter((resolvedFile) => resolvedFile.status === 'exact_match') ?? [], getSortValue, bulkUploadSortDirection);
126
+ const unmatchedFiles = orderBy(resolvedFiles?.filter((resolvedFile) => isUnmatchedTabFileStatus(resolvedFile.status)) ?? [], getSortValue, bulkUploadSortDirection);
127
+ const progress = currentBatchStatus != null
128
+ ? {
129
+ ...currentBatchStatus.progress,
130
+ percentage: currentBatchStatus.progress.total > 0
131
+ ? Math.round((currentBatchStatus.progress.processed /
132
+ currentBatchStatus.progress.total) *
133
+ 100)
134
+ : 0,
135
+ }
136
+ : { total: 0, processed: 0, percentage: 0 };
137
+ const batchListFetchState = bulkUpload.batchListFetchState;
138
+ /**
139
+ * When primary batch is not resolved yet, still aggregate unmatched files from every batch
140
+ * that has details loaded (otherwise past is empty and receipts vanish from the UI).
141
+ */
142
+ const pastBatches = unmatchedSectionBatchId != null
143
+ ? batchList.filter((batchListItem) => batchListItem.batchId !== unmatchedSectionBatchId)
144
+ : batchList;
145
+ const pastUnmatchedFiles = pastBatches.flatMap((pastBatch) => {
146
+ const details = bulkUpload.batchDetailsById[pastBatch.batchId];
147
+ if (details == null) {
148
+ return [];
149
+ }
150
+ return details.files
151
+ .filter((batchFile) => isUnmatchedTabFileStatus(batchFile.status))
152
+ .map((batchFile) => resolveBatchFile(batchFile, pastBatch.batchId, transactionState));
153
+ });
154
+ const completedTransactionsCacheKey = monthYearPeriodId != null
155
+ ? getCompletedTransactionsCacheKey(monthYearPeriodId, bulkUpload.sortKey, bulkUpload.sortOrder, bulkUpload.completedSubTab)
156
+ : undefined;
157
+ const completedTransactionsForPeriod = completedTransactionsCacheKey != null
158
+ ? bulkUpload.completedTransactionsByPeriod[completedTransactionsCacheKey]
159
+ : undefined;
160
+ const completedTransactionIds = completedTransactionsForPeriod?.transactionIds ?? [];
161
+ const resolvedCompletedTransactions = getSupportedTransactionsByIds(transactionState, completedTransactionIds);
162
+ const resolvedBatchDetails = currentBatchDetails != null && resolvedFiles != null
163
+ ? {
164
+ batchId: currentBatchDetails.batchId,
165
+ createdAt: currentBatchDetails.createdAt,
166
+ files: resolvedFiles,
167
+ status: currentBatchDetails.status,
168
+ summary: currentBatchDetails.summary,
169
+ }
170
+ : undefined;
171
+ const manualSearchResults = bulkUpload.manualSearch.results.map((result) => {
172
+ const transaction = getSupportedTransactionById(transactionState, result.transactionId);
173
+ return {
174
+ ...result,
175
+ vendorLogo: result.vendorLogo ?? transaction?.logo?.href,
176
+ };
177
+ });
50
178
  return {
51
179
  fetchState,
52
180
  error,
@@ -55,5 +183,121 @@ export function getExpenseAutomationMissingReceiptsView(state) {
55
183
  refreshStatus,
56
184
  transactions: sortedTransactions,
57
185
  completionStatus,
186
+ pendingMissingReceiptsTabNavigation: pendingMissingReceiptsTabNavigation ?? null,
187
+ bulkUpload: {
188
+ phase: bulkUpload.phase,
189
+ uploadStatus: bulkUpload.uploadStatus,
190
+ batchStatus: currentBatchStatus,
191
+ batchDetails: resolvedBatchDetails,
192
+ completedSubTab: bulkUpload.completedSubTab,
193
+ completedTransactions: {
194
+ fetchState: completedTransactionsForPeriod?.fetchState ?? {
195
+ fetchState: 'Not-Started',
196
+ error: undefined,
197
+ },
198
+ nextPageToken: completedTransactionsForPeriod?.nextPageToken ?? null,
199
+ totalCount: completedTransactionsForPeriod?.totalCount ?? 0,
200
+ transactions: resolvedCompletedTransactions,
201
+ },
202
+ matchedFiles,
203
+ unmatchedFiles,
204
+ progress,
205
+ currentResultsTab: bulkUpload.currentResultsTab,
206
+ sortKey: bulkUpload.sortKey,
207
+ sortOrder: bulkUpload.sortOrder,
208
+ batchList,
209
+ batchListFetchState,
210
+ pastBatches,
211
+ pastUnmatchedFiles,
212
+ confirmMatchStatus: bulkUpload.confirmMatchStatus,
213
+ manualSearchUiResetKey: bulkUpload.manualSearchUiResetKey,
214
+ hasMoreBatchDetails,
215
+ batchDetailsPaginationState: bulkUpload.batchDetailsPaginationState,
216
+ manualSearch: {
217
+ ...bulkUpload.manualSearch,
218
+ results: manualSearchResults,
219
+ },
220
+ uploadedFileCount: bulkUpload.uploadedFileCount,
221
+ uploadProgress: bulkUpload.uploadProgress,
222
+ },
223
+ };
224
+ }
225
+ function batchDetailsHasUnmatchedWork(details) {
226
+ return details.files.some((file) => isUnmatchedTabFileStatus(file.status));
227
+ }
228
+ /** List row summary before batch details load — aligns with unmatched / possible-match work. */
229
+ function batchListItemHasUnmatchedWork(batch) {
230
+ return batch.noMatchCount > 0 || batch.possibleMatchesCount > 0;
231
+ }
232
+ /**
233
+ * `batchList` must match platform order (same sequence as the bulk-upload batches API). Picks the
234
+ * primary batch for the top "Unmatched" section. Skips failed detail fetches.
235
+ *
236
+ * If details are not loaded for an earlier list row but that row reports no unmatched work, we
237
+ * skip it so later rows with loaded details can be primary (avoids an empty tab when the first
238
+ * fetch page only loaded later batches). If the list reports unmatched work but details are still
239
+ * loading, we wait (undefined) so primary order stays correct once details arrive.
240
+ */
241
+ function getPrimaryBatchIdFromBatchListOrder(batchListInPlatformOrder, batchDetailsById, failedBatchDetailIds) {
242
+ const failedIds = new Set(failedBatchDetailIds);
243
+ const anyCompletedBatchHasUnmatchedWork = batchListInPlatformOrder.some((batch) => {
244
+ if (failedIds.has(batch.batchId)) {
245
+ return false;
246
+ }
247
+ const details = batchDetailsById[batch.batchId];
248
+ if (details != null &&
249
+ details.status === 'completed' &&
250
+ batchDetailsHasUnmatchedWork(details)) {
251
+ return true;
252
+ }
253
+ return details == null && batchListItemHasUnmatchedWork(batch);
254
+ });
255
+ for (const batch of batchListInPlatformOrder) {
256
+ if (failedIds.has(batch.batchId)) {
257
+ continue;
258
+ }
259
+ const details = batchDetailsById[batch.batchId];
260
+ if (details == null) {
261
+ if (batchListItemHasUnmatchedWork(batch)) {
262
+ return undefined;
263
+ }
264
+ continue;
265
+ }
266
+ if (details.status !== 'completed') {
267
+ continue;
268
+ }
269
+ if (anyCompletedBatchHasUnmatchedWork) {
270
+ if (batchDetailsHasUnmatchedWork(details)) {
271
+ return batch.batchId;
272
+ }
273
+ continue;
274
+ }
275
+ return batch.batchId;
276
+ }
277
+ return undefined;
278
+ }
279
+ function resolveBatchFile(file, batchId, transactionState) {
280
+ const matchedTransaction = file.matchedTransactionId != null
281
+ ? getSupportedTransactionById(transactionState, file.matchedTransactionId)
282
+ : undefined;
283
+ const candidates = file.candidates
284
+ ?.map((ref) => {
285
+ const transaction = getSupportedTransactionById(transactionState, ref.transactionId);
286
+ return transaction != null
287
+ ? { transaction, matchScore: ref.matchScore }
288
+ : undefined;
289
+ })
290
+ .filter((candidate) => candidate != null);
291
+ return {
292
+ attachmentId: file.attachmentId,
293
+ batchId,
294
+ fileDownloadUrl: file.fileDownloadUrl,
295
+ fileId: file.fileId,
296
+ filename: file.filename,
297
+ filePreviewUrl: file.filePreviewUrl,
298
+ status: file.status,
299
+ matchedTransaction,
300
+ candidates: candidates != null && candidates.length > 0 ? candidates : undefined,
301
+ matchSource: file.matchSource,
58
302
  };
59
303
  }
@@ -10,7 +10,7 @@ import { getAllSteps } from '../selectorTypes/expenseAutomationViewSelectorTypes
10
10
  export function getExpenseAutomationTransactionView(state) {
11
11
  const { accountState, classState, classListState, accountListState, expenseAutomationTransactionsViewState, expenseAutomationViewState, transactionState, monthEndCloseChecksState, } = state;
12
12
  const { selectedTransactionCategorizationTab } = expenseAutomationTransactionsViewState;
13
- const { uiState, refreshStatus, saveStatus, markAsNotMiscategorizedStatus, transactionIdsBySelectedPeriod, transactionReviewLocalDataById, selectedCheckBoxTransactionIds, transactionIdsWithUnsavedData, selectedTransactionId, selectedTransactionLineId, } = expenseAutomationTransactionsViewState.transactionCategorizationView[selectedTransactionCategorizationTab];
13
+ const { uiState, refreshStatus, saveStatus, markAsNotMiscategorizedStatus, transactionIdsBySelectedPeriod, transactionReviewLocalDataById, selectedCheckBoxTransactionIds, transactionIdsWithUnsavedData, uploadReceiptStatusById, selectedTransactionId, selectedTransactionLineId, } = expenseAutomationTransactionsViewState.transactionCategorizationView[selectedTransactionCategorizationTab];
14
14
  const uncategorizedIncomeExpense = getUncategorizedAccounts(accountState, accountListState, 'accountList');
15
15
  const accountList = getAccountList(accountState, accountListState, 'accountList');
16
16
  const classList = getClassList(classState, classListState);
@@ -94,6 +94,7 @@ export function getExpenseAutomationTransactionView(state) {
94
94
  completionStatus,
95
95
  totalCountByTab,
96
96
  fetchStateByTransactionTabs: fetchStateByTab,
97
+ uploadReceiptStatusById,
97
98
  selectedTransactionId,
98
99
  selectedTransactionLineId,
99
100
  };
@@ -7,3 +7,47 @@ const MISSING_RECEIPTS_SORT_KEYS = [
7
7
  'amount',
8
8
  ];
9
9
  export const toMissingReceiptsSortKey = (v) => stringToUnion(v, MISSING_RECEIPTS_SORT_KEYS);
10
+ // -- Bulk Receipt Upload Types --
11
+ /**
12
+ * Minimum trimmed query length before manual-match search calls the expense-automation API.
13
+ * Kept in sync with `ManualSearchInput` in web-components (fires search only when length ≥ this).
14
+ */
15
+ export const MIN_MANUAL_TRANSACTION_SEARCH_LENGTH = 2;
16
+ const BATCH_STATUS_VALUES = ['pending', 'processing', 'completed'];
17
+ export const toBatchStatusValue = (v) => stringToUnion(v.trim().toLowerCase(), BATCH_STATUS_VALUES);
18
+ /** Canonical batch file statuses (API ingress may use aliases; see `parseBatchFileStatus`). */
19
+ export const BATCH_FILE_STATUSES = [
20
+ 'exact_match',
21
+ 'possible_matches',
22
+ 'no_match',
23
+ 'failed',
24
+ ];
25
+ export const toBatchFileStatus = (v) => stringToUnion(v.trim().toLowerCase().replace(/-/g, '_'), BATCH_FILE_STATUSES);
26
+ const UNMATCHED_TAB_FILE_STATUSES = ['possible_matches', 'no_match'];
27
+ /** True for files that belong in the Unmatched tab (possible matches or no match). */
28
+ export function isUnmatchedTabFileStatus(status) {
29
+ return UNMATCHED_TAB_FILE_STATUSES.includes(status);
30
+ }
31
+ const BULK_UPLOAD_PHASES = [
32
+ 'idle',
33
+ 'uploading',
34
+ 'matching',
35
+ 'completed',
36
+ ];
37
+ export const toBulkUploadPhase = (v) => stringToUnion(v.trim().toLowerCase(), BULK_UPLOAD_PHASES);
38
+ const BULK_UPLOAD_RESULTS_TABS = ['completed', 'unmatched'];
39
+ export const toBulkUploadResultsTab = (v) => stringToUnion(v.trim().toLowerCase(), BULK_UPLOAD_RESULTS_TABS);
40
+ const MISSING_RECEIPTS_TABS = ['missing', 'completed', 'unmatched'];
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
+ const MATCH_SOURCES = ['ai', 'manual'];
45
+ export const toMatchSource = (v) => stringToUnion(v.trim().toLowerCase(), MATCH_SOURCES);
46
+ const BULK_UPLOAD_SORT_KEYS = [
47
+ 'amount',
48
+ 'category',
49
+ 'class',
50
+ 'date',
51
+ 'vendor',
52
+ ];
53
+ export const toBulkUploadSortKey = (v) => stringToUnion(v.trim().toLowerCase(), BULK_UPLOAD_SORT_KEYS);
@@ -4,10 +4,11 @@ const TRANSACTIONS_SORT_KEYS = [
4
4
  'vendor',
5
5
  'amount',
6
6
  'transaction_type',
7
+ 'payment_account',
7
8
  'date',
8
9
  'category',
9
10
  'class',
10
- 'memo',
11
+ 'memo_and_receipt',
11
12
  ];
12
13
  export const toTransactionsSortKey = (v) => stringToUnion(v, TRANSACTIONS_SORT_KEYS);
13
14
  export const TRANSACTIONS_TABS = ['review', 'autoCategorized'];
@@ -44,19 +44,20 @@ const financeStatement = createSlice({
44
44
  updateFinanceStatementThisPeriod(draft, action) {
45
45
  const { firstMonthOfFY, coaBalances, maxNumOfPeriodsToHighlight } = action.payload;
46
46
  const { timeframe, selectedCOABalancesRange } = draft;
47
- if (coaBalances.length > 0) {
48
- const prevThisPeriod = extractThisPeriod(action.payload.firstMonthOfFY, timeframe, selectedCOABalancesRange, coaBalances);
47
+ const safeCoaBalances = coaBalances != null ? coaBalances : [];
48
+ if (safeCoaBalances.length > 0) {
49
+ const prevThisPeriod = extractThisPeriod(action.payload.firstMonthOfFY, timeframe, selectedCOABalancesRange, safeCoaBalances);
49
50
  if (prevThisPeriod != null) {
50
51
  const selectedCoaBalancesRangeWithThisPeriod = {
51
52
  ...selectedCOABalancesRange,
52
53
  thisPeriod: prevThisPeriod,
53
54
  };
54
- const newThisPeriod = getMatchingThisPeriod(action.payload.firstMonthOfFY, timeframe, action.payload.thisPeriod, coaBalances);
55
+ const newThisPeriod = getMatchingThisPeriod(action.payload.firstMonthOfFY, timeframe, action.payload.thisPeriod, safeCoaBalances);
55
56
  if (newThisPeriod != null) {
56
57
  const selectionRanges = getSelectedAndHighlightedRangesForThisPeriod({
57
58
  firstMonthOfFY,
58
59
  thisPeriod: newThisPeriod,
59
- coaBalances,
60
+ coaBalances: safeCoaBalances,
60
61
  timeframe,
61
62
  maxNumOfPeriodsToSelect: maxNumOfPeriodsToHighlight,
62
63
  currentSelection: {
@@ -75,7 +76,9 @@ const financeStatement = createSlice({
75
76
  draft.selectedReportId = action.payload;
76
77
  },
77
78
  updateFinanceStatementAdditionalBalancesSelection(draft, action) {
78
- const { firstMonthOfFY, additionalBalances, coaBalances, maxNumOfPeriodsToHighlight, } = action.payload;
79
+ const { firstMonthOfFY, additionalBalances: additionalBalancesPayload, coaBalances, maxNumOfPeriodsToHighlight, } = action.payload;
80
+ const safeCoaBalances = coaBalances != null ? coaBalances : [];
81
+ const additionalBalances = additionalBalancesPayload ?? [];
79
82
  let tempAdditionalBalances = [...additionalBalances];
80
83
  if (additionalBalances.includes('this_period_vs_last_period') ||
81
84
  additionalBalances.includes('this_period_vs_last_period_percent')) {
@@ -91,8 +94,8 @@ const financeStatement = createSlice({
91
94
  draft.isAdditionalBalancesShown =
92
95
  additionalBalances.length != 0 ? true : false;
93
96
  const { timeframe, selectedCOABalancesRange } = draft;
94
- if (coaBalances.length > 0) {
95
- const thisPeriod = extractThisPeriod(action.payload.firstMonthOfFY, timeframe, selectedCOABalancesRange, coaBalances);
97
+ if (safeCoaBalances.length > 0) {
98
+ const thisPeriod = extractThisPeriod(action.payload.firstMonthOfFY, timeframe, selectedCOABalancesRange, safeCoaBalances);
96
99
  if (thisPeriod != null) {
97
100
  const selectedCoaBalancesRangeWithThisPeriod = {
98
101
  ...selectedCOABalancesRange,
@@ -101,7 +104,7 @@ const financeStatement = createSlice({
101
104
  const selectionRanges = getSelectedAndHighlightedRangesForThisPeriod({
102
105
  firstMonthOfFY,
103
106
  thisPeriod: thisPeriod,
104
- coaBalances,
107
+ coaBalances: safeCoaBalances,
105
108
  timeframe,
106
109
  maxNumOfPeriodsToHighlight: maxNumOfPeriodsToHighlight,
107
110
  currentSelection: {
@@ -1,4 +1,5 @@
1
1
  import { createSelector } from '@reduxjs/toolkit';
2
+ import { stripCompareAdditionalBalanceTypes } from '../../commonStateTypes/coaBalance/coaBalanceType';
2
3
  import { newBalancesFilter, newBalancesFilterForDateRange, } from '../../commonStateTypes/coaBalance/coaBalancesFilter';
3
4
  import { newBalancesFilterClassesView } from '../../commonStateTypes/coaBalance/coaBalancesFilterClassesView';
4
5
  import { getNumberOfPeriods, } from '../../commonStateTypes/dataAvailable';
@@ -8,6 +9,7 @@ import { extractThisPeriod } from '../../commonStateTypes/viewAndReport/thisPeri
8
9
  import { getBalanceSheet } from '../balanceSheet/balanceSheetSelector';
9
10
  import { getCashFlow } from '../cashFlow/cashFlowSelector';
10
11
  import { getProfitAndLossReport, } from '../profitAndLoss/profitAndLossSelector';
12
+ import { getProfitAndLossClassesHorizontalView } from '../profitAndLossClassesView/profitAndLossClassesByClassHorizontalSelector';
11
13
  import { getProfitAndLossClassesView } from '../profitAndLossClassesView/profitAndLossClassesViewSelector';
12
14
  function getDateRangeForFinanceReports(profitAndLossState, balanceSheetState, cashFlowState) {
13
15
  const datesSorted = [
@@ -42,11 +44,11 @@ export const getFinanceStatement = createSelector((state) => state.accountState,
42
44
  if (dataAvailable != null) {
43
45
  const noFilter = newBalancesFilterForDateRange(firstMonthOfFY, financeStatementState.timeframe, dataAvailable, 'ascending_date', []);
44
46
  const profitAndLossForTimeframeTicks = getProfitAndLossReport(noFilter, undefined, accountState, accountGroupState, sectionAccountsViewState, profitAndLossState, forecastState);
45
- if (profitAndLossForTimeframeTicks.sections.expenses != null) {
46
- balancesInTimeframe =
47
- profitAndLossForTimeframeTicks.sections.expenses.balances.length > 0
48
- ? profitAndLossForTimeframeTicks.sections['expenses'].balances
49
- : profitAndLossForTimeframeTicks.sections['income'].balances;
47
+ const expensesBalances = profitAndLossForTimeframeTicks.sections.expenses?.balances ?? [];
48
+ const incomeBalances = profitAndLossForTimeframeTicks.sections.income?.balances ?? [];
49
+ balancesInTimeframe =
50
+ expensesBalances.length > 0 ? expensesBalances : incomeBalances;
51
+ if (balancesInTimeframe.length > 0) {
50
52
  allTimeframeTicks = balancesInTimeframe.map((balance) => toTimeframeTick(balance));
51
53
  }
52
54
  }
@@ -54,12 +56,22 @@ export const getFinanceStatement = createSelector((state) => state.accountState,
54
56
  if (thisPeriod == null && balancesInTimeframe.length > 0) {
55
57
  thisPeriod = toTimeframeTick(balancesInTimeframe[balancesInTimeframe.length - 1]);
56
58
  }
57
- const filter = newBalancesFilter(firstMonthOfFY, financeStatementState.timeframe, Math.min(getNumberOfPeriods(firstMonthOfFY, financeStatementState.timeframe, dataAvailable) ?? financeStatementState.selectedCOABalancesRange.numberOfPeriods, financeStatementState.selectedCOABalancesRange.numberOfPeriods), thisPeriod, 'ascending_date', financeStatementState.isAdditionalBalancesShown === true
59
+ const rawAdditionalBalances = financeStatementState.isAdditionalBalancesShown === true
58
60
  ? financeStatementState.selectedAdditionalBalances
59
- : []);
61
+ : [];
62
+ const useHorizontalClassesLayout = profitAndLossClassesViewState.uiState.classViewLayout ===
63
+ 'horizontal' &&
64
+ financeStatementState.selectedReportId === 'profit_and_loss_by_classes';
65
+ const additionalBalancesForFilter = useHorizontalClassesLayout
66
+ ? stripCompareAdditionalBalanceTypes(rawAdditionalBalances)
67
+ : rawAdditionalBalances;
68
+ const filter = newBalancesFilter(firstMonthOfFY, financeStatementState.timeframe, Math.min(getNumberOfPeriods(firstMonthOfFY, financeStatementState.timeframe, dataAvailable) ?? financeStatementState.selectedCOABalancesRange.numberOfPeriods, financeStatementState.selectedCOABalancesRange.numberOfPeriods), thisPeriod, 'ascending_date', additionalBalancesForFilter);
60
69
  const profitAndLossReport = getProfitAndLossReport(filter, undefined, accountState, accountGroupState, sectionAccountsViewState, profitAndLossState, forecastState);
61
70
  const classFilter = newBalancesFilterClassesView(filter.firstMonthOfFY, filter.timeframe, filter.balancesRange.numberOfPeriods, filter.balancesRange.thisPeriod, filter.balancesRange.orderBy, filter.additionalBalances, profitAndLossClassesViewState.uiState.classesToFilterOut);
62
71
  const profitAndLossByClassReport = getProfitAndLossClassesView(classFilter, accountState, classState, sectionsState, profitAndLossClassesViewState);
72
+ const profitAndLossByClassHorizontalReport = getProfitAndLossClassesHorizontalView(classFilter, accountState, classState, sectionsState, profitAndLossClassesViewState, profitAndLossClassesViewState.uiState.classViewLayout === 'horizontal'
73
+ ? thisPeriod
74
+ : undefined);
63
75
  const balanceSheetReport = getBalanceSheet(filter, accountState, accountGroupState, sectionAccountsViewState, balanceSheetState);
64
76
  const cashFlowReport = getCashFlow(filter, accountState, accountGroupState, sectionAccountsViewState, cashFlowState);
65
77
  const allReports = [
@@ -107,12 +119,15 @@ export const getFinanceStatement = createSelector((state) => state.accountState,
107
119
  dataAvailable,
108
120
  profitAndLossReport,
109
121
  profitAndLossByClassReport,
122
+ profitAndLossByClassHorizontalReport,
110
123
  balanceSheetReport,
111
124
  cashFlowReport,
112
125
  filter,
113
126
  selectedReportId: financeStatementState.selectedReportId,
114
127
  downloadState: financeStatementState.downloadState,
115
- isCompareModeOn: financeStatementState.selectedAdditionalBalances.includes('this_period_vs_last_period') ||
116
- financeStatementState.selectedAdditionalBalances.includes('this_period_vs_last_period_percent'),
128
+ isCompareModeOn: useHorizontalClassesLayout
129
+ ? false
130
+ : financeStatementState.selectedAdditionalBalances.includes('this_period_vs_last_period') ||
131
+ financeStatementState.selectedAdditionalBalances.includes('this_period_vs_last_period_percent'),
117
132
  };
118
133
  });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Empty `COABalancesSlice` for horizontal P&L-by-class section / calculated headers when
3
+ * enrichment has not produced balances yet, or for UI mapping fallbacks.
4
+ * Structure matches {@link enrichProfitAndLossByClassHorizontalReport} output.
5
+ */
6
+ export function getEmptyHorizontalSectionBalancesSlice(firstMonthOfFY, timeframe = 'month') {
7
+ return {
8
+ balances: [],
9
+ additionalBalances: [],
10
+ filter: {
11
+ firstMonthOfFY,
12
+ timeframe,
13
+ balancesRange: { numberOfPeriods: 0, orderBy: 'ascending_date' },
14
+ additionalBalances: [],
15
+ },
16
+ };
17
+ }