@zeniai/client-epic-state 5.2.52 → 5.2.53-beta0ND

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 (52) hide show
  1. package/lib/entity/notificationRegistry/notificationRegistryReducer.d.ts +2 -1
  2. package/lib/entity/notificationRegistry/notificationRegistryReducer.js +20 -9
  3. package/lib/entity/notificationRegistry/notificationRegistrySelector.d.ts +2 -1
  4. package/lib/entity/notificationRegistry/notificationRegistrySelector.js +7 -1
  5. package/lib/entity/notificationRegistry/notificationRegistryState.d.ts +5 -0
  6. package/lib/entity/notificationRegistry/notificationRegistryState.js +7 -1
  7. package/lib/entity/sectionProjectView/sectionProjectViewReducer.js +3 -1
  8. package/lib/epic.js +4 -4
  9. package/lib/esm/entity/notificationRegistry/notificationRegistryReducer.js +20 -9
  10. package/lib/esm/entity/notificationRegistry/notificationRegistrySelector.js +5 -0
  11. package/lib/esm/entity/notificationRegistry/notificationRegistryState.js +5 -0
  12. package/lib/esm/entity/sectionProjectView/sectionProjectViewReducer.js +3 -1
  13. package/lib/esm/epic.js +4 -4
  14. package/lib/esm/index.js +4 -4
  15. package/lib/esm/view/aiCfoView/aiCfoViewReducer.js +4 -2
  16. package/lib/esm/view/aiCfoView/epics/fetchRoutineRunsEpic.js +3 -1
  17. package/lib/esm/view/expenseAutomationView/epics/transactionCategorization/fetchTransactionCategorizationEpic.js +15 -4
  18. package/lib/esm/view/expenseAutomationView/reducers/transactionsViewReducer.js +10 -0
  19. package/lib/esm/view/expenseAutomationView/selectors/transactionCategorizationSelector.js +28 -11
  20. package/lib/esm/view/invoicing/editInvoicingCatalogItemDetailView/editInvoicingCatalogItemDetailViewSelector.js +19 -3
  21. package/lib/esm/view/invoicing/editInvoicingCouponDetailView/editInvoicingCouponDetailViewSelector.js +21 -21
  22. package/lib/esm/view/invoicing/invoicingQboView/invoicingQboViewSelector.js +57 -9
  23. package/lib/esm/view/notificationPreferencesView/epics/saveNotificationPreferencesEpic.js +20 -7
  24. package/lib/esm/view/notificationPreferencesView/notificationPreferencesViewReducer.js +43 -17
  25. package/lib/esm/view/notificationPreferencesView/notificationPreferencesViewSelector.js +24 -4
  26. package/lib/esm/view/settingsView/epic/fetchNotificationSettingsEpic.js +12 -2
  27. package/lib/index.d.ts +4 -4
  28. package/lib/index.js +21 -14
  29. package/lib/view/aiCfoView/aiCfoViewReducer.js +4 -2
  30. package/lib/view/aiCfoView/aiCfoViewSelector.d.ts +1 -1
  31. package/lib/view/aiCfoView/epics/fetchRoutineRunsEpic.js +3 -1
  32. package/lib/view/expenseAutomationView/epics/transactionCategorization/fetchTransactionCategorizationEpic.js +15 -4
  33. package/lib/view/expenseAutomationView/payload/transactionCategorizationPayload.d.ts +18 -7
  34. package/lib/view/expenseAutomationView/reducers/transactionsViewReducer.js +10 -0
  35. package/lib/view/expenseAutomationView/selectorTypes/transactionsViewSelectorTypes.d.ts +8 -4
  36. package/lib/view/expenseAutomationView/selectors/transactionCategorizationSelector.js +27 -10
  37. package/lib/view/expenseAutomationView/types/transactionsViewState.d.ts +8 -5
  38. package/lib/view/invoicing/editInvoicingCatalogItemDetailView/editInvoicingCatalogItemDetailViewSelector.d.ts +7 -0
  39. package/lib/view/invoicing/editInvoicingCatalogItemDetailView/editInvoicingCatalogItemDetailViewSelector.js +18 -2
  40. package/lib/view/invoicing/editInvoicingCouponDetailView/editInvoicingCouponDetailViewSelector.d.ts +3 -2
  41. package/lib/view/invoicing/editInvoicingCouponDetailView/editInvoicingCouponDetailViewSelector.js +19 -19
  42. package/lib/view/invoicing/invoicingQboView/invoicingQboViewSelector.d.ts +49 -0
  43. package/lib/view/invoicing/invoicingQboView/invoicingQboViewSelector.js +59 -10
  44. package/lib/view/notificationPreferencesView/epics/saveNotificationPreferencesEpic.js +19 -6
  45. package/lib/view/notificationPreferencesView/notificationPreferencesViewReducer.d.ts +8 -5
  46. package/lib/view/notificationPreferencesView/notificationPreferencesViewReducer.js +43 -17
  47. package/lib/view/notificationPreferencesView/notificationPreferencesViewSelector.d.ts +4 -0
  48. package/lib/view/notificationPreferencesView/notificationPreferencesViewSelector.js +30 -5
  49. package/lib/view/notificationPreferencesView/notificationPreferencesViewState.d.ts +4 -3
  50. package/lib/view/settingsView/epic/fetchNotificationSettingsEpic.js +12 -2
  51. package/lib/view/settingsView/settingsViewPayload.d.ts +2 -0
  52. package/package.json +1 -1
@@ -30,7 +30,9 @@ const fetchRoutineRunsEpic = (actions$, _state$, zeniAPI) => actions$.pipe((0, o
30
30
  // Filtered, not trusted: one malformed row must not take the
31
31
  // whole history down, and a run without an id is not addressable
32
32
  // by anything the UI does with it.
33
- runs: (data.runs ?? []).filter(toRoutineRun_1.isRoutineRunPayload).map(toRoutineRun_1.toRoutineRun),
33
+ runs: (data.runs ?? [])
34
+ .filter(toRoutineRun_1.isRoutineRunPayload)
35
+ .map(toRoutineRun_1.toRoutineRun),
34
36
  scheduleId,
35
37
  sessionId: data.session_id ?? null,
36
38
  });
@@ -9,7 +9,6 @@ const transactionReducer_1 = require("../../../../entity/transaction/transaction
9
9
  const responsePayload_1 = require("../../../../responsePayload");
10
10
  const transactionCategorizationLocalDataHelper_1 = require("../../helpers/transactionCategorizationLocalDataHelper");
11
11
  const transactionsViewReducer_1 = require("../../reducers/transactionsViewReducer");
12
- const completedSubTab_1 = require("../../types/completedSubTab");
13
12
  const transactionsViewState_1 = require("../../types/transactionsViewState");
14
13
  const fetchTransactionCategorizationEpic = (actions$, state$, zeniAPI) => actions$.pipe((0, operators_1.filter)(transactionsViewReducer_1.fetchTransactionCategorization.match),
15
14
  // Per-tab switchMap: cancels in-flight requests for the same tab on a new
@@ -36,9 +35,10 @@ const fetchTransactionCategorizationEpic = (actions$, state$, zeniAPI) => action
36
35
  // cross-status search (omit or true) is reserved for Receipts
37
36
  // manual-match.
38
37
  search_all_statuses: false,
39
- sub_tab: selectedTab === 'autoCategorized'
40
- ? expenseAutomationTransactionsViewState.selectedTransactionCategorizationCompletedSubTab
41
- : completedSubTab_1.DEFAULT_COMPLETED_SUB_TAB,
38
+ // Sent on both tabs: the backend ignores it for Review's own rows but uses
39
+ // it to scope the Completed badge it returns alongside them.
40
+ sub_tab: expenseAutomationTransactionsViewState
41
+ .selectedTransactionCategorizationCompletedSubTab,
42
42
  transaction_group: expenseAutomationTransactionsViewState.selectedTransactionGroup,
43
43
  };
44
44
  return zeniAPI
@@ -90,6 +90,17 @@ const fetchTransactionCategorizationEpic = (actions$, state$, zeniAPI) => action
90
90
  parentTotalCount: response.data.parent_tab_total_count ??
91
91
  response.data.total_count,
92
92
  }));
93
+ // The inactive tab's badge, counted under the same filters. Absent on
94
+ // pages after the first and on the cross-tab search path, where the
95
+ // last known value stays correct.
96
+ const otherTabTotalCount = response.data.other_tab_total_count;
97
+ if (otherTabTotalCount != null) {
98
+ updateActions.push((0, transactionsViewReducer_1.updateParentTotalCountForTab)({
99
+ selectedPeriod,
100
+ selectedTab: selectedTab === 'review' ? 'autoCategorized' : 'review',
101
+ parentTotalCount: otherTabTotalCount,
102
+ }));
103
+ }
93
104
  updateActions.push((0, transactionsViewReducer_1.resetOtherTabsFetchState)({
94
105
  refreshViewInBackground,
95
106
  tabs: transactionsViewState_1.TRANSACTIONS_TABS.filter((tab) => selectedTab !== tab),
@@ -15,9 +15,9 @@ export interface TransactionCategorizationQueryPayload {
15
15
  start_date: string | null;
16
16
  /**
17
17
  * Required. For the Completed (`auto_categorized=true`) listing, scopes the
18
- * returned page + `total_count` to one of the Completed sub-tabs. For the
19
- * Review listing it is forced to `'all'` to preserve parent-tab semantics
20
- * on the backend.
18
+ * returned page + `total_count` to one of the Completed sub-tabs. It is sent
19
+ * on the Review listing too: the backend ignores it for Review's own status
20
+ * set, but uses it to scope the Completed badge in `other_tab_total_count`.
21
21
  */
22
22
  sub_tab: CompletedSubTab;
23
23
  /**
@@ -44,10 +44,21 @@ export interface TransactionCategorizationPayload {
44
44
  total_count: number;
45
45
  transactions: SupportedTransactionPayload[];
46
46
  /**
47
- * Count of transactions for the entire parent tab (Review or Completed),
48
- * regardless of which sub-tab the listing was scoped to. Used solely to
49
- * drive the parent tab badge counts on the UI; pagination, list-level
50
- * counts, and sub-tab chips continue to consume {@link total_count}.
47
+ * Badge count for the OTHER tab (Completed when this response is Review, and
48
+ * vice versa) under the same filters, so both badges move together with the
49
+ * Transaction Group dropdown and the Completed sub-tab without having to
50
+ * fetch that tab's rows.
51
+ *
52
+ * `null`/absent when the backend computed no badge — pages after the first
53
+ * (a scroll cannot change either badge) and the cross-tab search path used by
54
+ * Receipts manual-match. Consumers must keep their last known value then.
55
+ */
56
+ other_tab_total_count?: number | null;
57
+ /**
58
+ * Badge count for the tab this response belongs to, taken under every filter
59
+ * the request carried (`transaction_group`, `sub_tab`, `search_text`) — so it
60
+ * matches the rows rendered below it and equals {@link total_count}. Retained
61
+ * as its own key for wire compatibility.
51
62
  *
52
63
  * Optional at the wire boundary so that any version-skew scenario
53
64
  * (delayed BE rollout, cached SW response, hotfix without the BE change)
@@ -380,6 +380,16 @@ const expenseAutomationTransactionsView = (0, toolkit_1.createSlice)({
380
380
  if (cached != null) {
381
381
  autoCat.uiState.pageToken = cached.pageToken;
382
382
  autoCat.uiState.totalCount = { ...cached.totalCount };
383
+ // Keep the stored badge in step with the restored sub-tab: the selector
384
+ // falls back to it for any period this sub-tab has not loaded. Merged,
385
+ // not replaced -- the snapshot only holds periods this sub-tab actually
386
+ // loaded, while the badge map also carries periods filled from a Review
387
+ // response's `other_tab_total_count`. Overwriting would drop those and
388
+ // leave the badge at 0 for any period this sub-tab never visited.
389
+ draft.parentTotalCountByTab.autoCategorized = {
390
+ ...draft.parentTotalCountByTab.autoCategorized,
391
+ ...cached.totalCount,
392
+ };
383
393
  autoCat.uiState.scrollPosition = cached.scrollPosition;
384
394
  autoCat.transactionIdsBySelectedPeriod = {
385
395
  ...cached.transactionIdsBySelectedPeriod,
@@ -28,10 +28,14 @@ export interface ExpenseAutomationTransactionViewSelector extends SelectorView {
28
28
  isAccountingProjectsEnabled: boolean;
29
29
  markAsNotMiscategorizedStatus: FetchStateAndError;
30
30
  /**
31
- * Parent tab badge counts, sourced from the listing response's
32
- * `parent_tab_total_count`. Used exclusively to drive the navbar / tab
33
- * strip badge labels — every other count consumer (pagination, in-list
34
- * "showing X of Y", save flows) keeps reading {@link totalCountByTab}.
31
+ * Tab badge counts under the filters currently applied to the list
32
+ * (transaction group + Completed sub-tab + search), so each badge matches
33
+ * the rows its tab renders. The active tab's entry is {@link totalCountByTab}
34
+ * itself, so it cannot drift when a cache hit skips the fetch; the inactive
35
+ * tab's comes from the listing response's `other_tab_total_count`. Used
36
+ * exclusively to drive the navbar / tab strip badge labels — every other
37
+ * count consumer (pagination, in-list "showing X of Y", save flows) keeps
38
+ * reading {@link totalCountByTab}.
35
39
  */
36
40
  parentTabTotalCountByTab: Record<TransactionsTab, number>;
37
41
  projectList: ProjectListSelectorView;
@@ -182,18 +182,35 @@ function getExpenseAutomationTransactionView(state) {
182
182
  review: monthYearPeriodId != null ? totalCountByReview[monthYearPeriodId] : 0,
183
183
  autoCategorized: monthYearPeriodId != null ? totalCountByAutoCat[monthYearPeriodId] : 0,
184
184
  };
185
- // Parent-tab badge counts. Sourced from `parent_tab_total_count` in the
186
- // listing response and used exclusively by the navbar / tab strip badges.
187
- // Independent of which Completed sub-tab is active so the badge stays
188
- // stable across sub-tab switches.
185
+ // Tab badge counts, used exclusively by the navbar / tab strip badges. Both
186
+ // tabs are counted under the filters applied to the list, so a badge always
187
+ // equals the number of rows that tab shows.
188
+ //
189
+ // The ACTIVE tab prefers its own list count: the two are the same number (the
190
+ // backend's parent_tab_total_count mirrors total_count), but `uiState.totalCount`
191
+ // is restored by every cache path that skips a fetch -- switching back to an
192
+ // already-loaded period, or to a cached Completed sub-tab -- whereas the stored
193
+ // badge is only written when a response lands, so on those paths it would keep
194
+ // showing whatever was fetched last.
195
+ //
196
+ // The `in` check is load-bearing, not defensive: `totalCount` is a per-period
197
+ // map typed as `Record<MonthYearPeriodId, number>`, so indexing it is typed
198
+ // `number` but is `undefined` for any period the active (sub-)tab has not
199
+ // loaded -- the window between switching to a fresh sub-tab and its response
200
+ // landing. Falling back to the stored badge there keeps a real number in the
201
+ // label instead of blanking it.
189
202
  const parentTotalCountByTab = expenseAutomationTransactionsViewState.parentTotalCountByTab;
203
+ const storedBadgeByTab = (tab) => monthYearPeriodId != null
204
+ ? (parentTotalCountByTab[tab][monthYearPeriodId] ?? 0)
205
+ : 0;
206
+ const badgeForTab = (tab, listCountByPeriod) => selectedTransactionCategorizationTab === tab &&
207
+ monthYearPeriodId != null &&
208
+ monthYearPeriodId in listCountByPeriod
209
+ ? listCountByPeriod[monthYearPeriodId]
210
+ : storedBadgeByTab(tab);
190
211
  const parentTabTotalCountByTab = {
191
- review: monthYearPeriodId != null
192
- ? (parentTotalCountByTab.review[monthYearPeriodId] ?? 0)
193
- : 0,
194
- autoCategorized: monthYearPeriodId != null
195
- ? (parentTotalCountByTab.autoCategorized[monthYearPeriodId] ?? 0)
196
- : 0,
212
+ review: badgeForTab('review', totalCountByReview),
213
+ autoCategorized: badgeForTab('autoCategorized', totalCountByAutoCat),
197
214
  };
198
215
  const fetchStateByTab = {
199
216
  review: {
@@ -139,11 +139,14 @@ export type TransactionsViewState = {
139
139
  */
140
140
  autoCategorizedSubTabCache: Partial<Record<CompletedSubTab, AutoCategorizedSubTabSnapshot>>;
141
141
  /**
142
- * Period-keyed parent tab counts, scoped per {@link TransactionsTab}.
143
- * Sourced from the listing response's `parent_tab_total_count` and used
144
- * exclusively to drive the parent tab badges on the UI — every other
145
- * count consumer (pagination, sub-tab chips, in-tab "showing X of Y")
146
- * keeps reading from {@link TransactionsViewUIState.totalCount}.
142
+ * Period-keyed tab badge counts, scoped per {@link TransactionsTab}. Every
143
+ * count here is taken under the filters currently applied to the list
144
+ * (`transaction_group` + Completed `sub_tab` + search), so a badge always
145
+ * matches the rows that tab renders. The active tab is filled from the
146
+ * listing response's `parent_tab_total_count` and the inactive one from its
147
+ * `other_tab_total_count`. Used exclusively to drive the tab badges — every
148
+ * other count consumer (pagination, in-tab "showing X of Y") keeps reading
149
+ * from {@link TransactionsViewUIState.totalCount}.
147
150
  */
148
151
  parentTotalCountByTab: Record<TransactionsTab, Record<MonthYearPeriodId, number>>;
149
152
  selectedTransactionCategorizationCompletedSubTab: CompletedSubTab;
@@ -79,6 +79,13 @@ export interface InvoicingCatalogItemFormView extends SelectorView {
79
79
  */
80
80
  qboAccountHierarchy: NestedAccountHierarchyForReport[];
81
81
  qboClassHierarchy: NestedClassHierarchyForReport[];
82
+ /**
83
+ * The ids within the account tree an income account may be picked from.
84
+ * QuickBooks only accepts an income account on a product, so a price point
85
+ * mapped to anything else — A/R, deferred revenue, a bank account — fails to
86
+ * sync once an invoice carrying it is pushed.
87
+ */
88
+ qboIncomeAccountIds: string[];
82
89
  /**
83
90
  * Loading the item, as distinct from this view's own `fetchState`, which
84
91
  * tracks the save.
@@ -50,6 +50,21 @@ const NOT_STARTED = {
50
50
  error: undefined,
51
51
  fetchState: 'Not-Started',
52
52
  };
53
+ /**
54
+ * The QuickBooks ids a price point's income account may be picked from.
55
+ *
56
+ * Memoized, and collapsing to one shared empty array while QBO is disconnected:
57
+ * the pages turn this into their picker options and re-seed their form defaults
58
+ * when those change, so a fresh array on every store change would reset the form
59
+ * under the user — including mid-save.
60
+ */
61
+ const getInvoicingCatalogIncomeAccountIds = (0, toolkit_1.createSelector)([
62
+ invoicingQboViewSelector_1.getInvoicingQboAccountOptions,
63
+ (state) => (0, invoicingConfigViewSelector_1.getInvoicingConfigView)(state).qboAccountMappingKindOptions,
64
+ invoicingQboViewSelector_1.isInvoicingQboConnected,
65
+ ], (accounts, qboAccountMappingKindOptions, isQboConnected) => isQboConnected
66
+ ? (0, invoicingQboViewSelector_1.invoicingQboAccountIdsForMappingSlot)(accounts, qboAccountMappingKindOptions, invoicingQboViewSelector_1.INVOICING_INCOME_ACCOUNT_MAPPING_SLOT, invoicingQboViewSelector_1.INVOICING_DEFAULT_INCOME_ACCOUNT_TYPES)
67
+ : invoicingQboViewSelector_1.NO_INVOICING_ACCOUNT_IDS);
53
68
  const getInvoicingCatalogItemFormView = (state, id) => {
54
69
  const { editDraftById, error, fetchState, newItemDraft, savedItemId, savedKind, } = state.editInvoicingCatalogItemDetailViewState;
55
70
  const plan = id != null ? (0, planSelector_1.getInvoicingPlanById)(state.invoicingPlanState, id) : undefined;
@@ -100,8 +115,9 @@ const getInvoicingCatalogItemFormView = (state, id) => {
100
115
  isFormLoading: (0, invoicingQboViewSelector_1.isInvoicingAccountingPickerDataLoading)(state) ||
101
116
  (0, invoicingFormDraft_1.isInvoicingFormDraftPending)(editDraftById, id),
102
117
  isQboPickerDataLoading: (0, invoicingQboViewSelector_1.isInvoicingAccountingPickerDataLoading)(state),
103
- qboAccountHierarchy: qboView?.nestedAccountHierarchy ?? [],
104
- qboClassHierarchy: qboView?.nestedClassHierarchy ?? [],
118
+ qboAccountHierarchy: qboView?.nestedAccountHierarchy ?? invoicingQboViewSelector_1.NO_INVOICING_ACCOUNT_HIERARCHY,
119
+ qboClassHierarchy: qboView?.nestedClassHierarchy ?? invoicingQboViewSelector_1.NO_INVOICING_CLASS_HIERARCHY,
120
+ qboIncomeAccountIds: getInvoicingCatalogIncomeAccountIds(state),
105
121
  applicabilityValues: invoicingCatalogItemFormHelper_1.INVOICING_CATALOG_APPLICABILITY_VALUES,
106
122
  applicableItemOptions,
107
123
  localData,
@@ -82,8 +82,9 @@ export interface InvoicingCouponFormView extends EditInvoicingCouponDetailView {
82
82
  /**
83
83
  * The account tree the discounts-account picker renders, and the QuickBooks
84
84
  * ids within it the discounts slot accepts. Two values rather than a filtered
85
- * tree: the picker groups by parent, and dropping a parent that is not itself
86
- * an income account would take its income children with it.
85
+ * tree: the picker flattens the tree into options by walking each node's
86
+ * children, so dropping a parent that is not itself an income account would
87
+ * drop its income descendants with it.
87
88
  *
88
89
  * Empty until QBO is connected — the form must not offer a mapping that
89
90
  * cannot be pushed.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getInvoicingCouponFormView = exports.getEditInvoicingCouponDetailViewState = void 0;
4
+ const toolkit_1 = require("@reduxjs/toolkit");
4
5
  const reduceFetchState_1 = require("../../../commonStateTypes/reduceFetchState");
5
6
  const couponSelector_1 = require("../../../entity/invoicing/coupon/couponSelector");
6
7
  const invoicingCommonPayload_1 = require("../../../entity/invoicing/invoicingCommonPayload");
@@ -14,6 +15,21 @@ const editInvoicingCouponDetailViewState_1 = require("./editInvoicingCouponDetai
14
15
  * the id of the coupon last saved, and — when a `couponId` is passed (edit
15
16
  * mode) — the existing coupon entity to prefill the form.
16
17
  */
18
+ /**
19
+ * The QuickBooks ids the discounts account may be picked from.
20
+ *
21
+ * Memoized, and collapsing to one shared empty array while QBO is disconnected:
22
+ * the form turns this into its picker options and re-seeds its defaults when
23
+ * those change, so a fresh array on every store change would reset the form
24
+ * under the user until their first edit.
25
+ */
26
+ const getInvoicingCouponDiscountsAccountIds = (0, toolkit_1.createSelector)([
27
+ invoicingQboViewSelector_1.getInvoicingQboAccountOptions,
28
+ (state) => (0, invoicingConfigViewSelector_1.getInvoicingConfigView)(state).qboAccountMappingKindOptions,
29
+ invoicingQboViewSelector_1.isInvoicingQboConnected,
30
+ ], (accounts, qboAccountMappingKindOptions, isQboConnected) => isQboConnected
31
+ ? (0, invoicingQboViewSelector_1.invoicingQboAccountIdsForMappingSlot)(accounts, qboAccountMappingKindOptions, invoicingQboViewSelector_1.INVOICING_DISCOUNTS_ACCOUNT_MAPPING_SLOT, invoicingQboViewSelector_1.INVOICING_DEFAULT_INCOME_ACCOUNT_TYPES)
32
+ : invoicingQboViewSelector_1.NO_INVOICING_ACCOUNT_IDS);
17
33
  const NOT_STARTED = {
18
34
  error: undefined,
19
35
  fetchState: 'Not-Started',
@@ -55,14 +71,6 @@ const getEditInvoicingCouponDetailViewState = (state, couponId) => {
55
71
  };
56
72
  };
57
73
  exports.getEditInvoicingCouponDetailViewState = getEditInvoicingCouponDetailViewState;
58
- /**
59
- * The account types the discounts slot falls back to when the config has no
60
- * `discounts_account` kind. A QuickBooks discount line posts to an income
61
- * account; offering every account instead would let a save be rejected.
62
- */
63
- const DEFAULT_DISCOUNTS_ACCOUNT_TYPES = 'Income';
64
- /** The account-mapping slot a coupon's discounts account shares with settings. */
65
- const DISCOUNTS_ACCOUNT_MAPPING_SLOT = 'discounts_account';
66
74
  /**
67
75
  * Bundled view for the discount form screen: the edit view (save state + the
68
76
  * coupon to prefill in edit mode), the draft `localData` (seeded from the
@@ -78,7 +86,7 @@ const getInvoicingCouponFormView = (state, couponId) => {
78
86
  const localData = couponId != null
79
87
  ? (0, invoicingFormDraft_1.getInvoicingFormDraftLocalData)(editDraftById, couponId, editInvoicingCouponDetailViewState_1.initialInvoicingCouponFormLocalData)
80
88
  : newCouponDraft;
81
- const { billingPeriodUnitOptions, discountApplyOnOptions, discountDurationTypeOptions, discountTypeOptions, qboAccountMappingKindOptions, } = (0, invoicingConfigViewSelector_1.getInvoicingConfigView)(state);
89
+ const { billingPeriodUnitOptions, discountApplyOnOptions, discountDurationTypeOptions, discountTypeOptions, } = (0, invoicingConfigViewSelector_1.getInvoicingConfigView)(state);
82
90
  const isQboConnected = (0, invoicingQboViewSelector_1.isInvoicingQboConnected)(state);
83
91
  const qboView = isQboConnected ? (0, invoicingQboViewSelector_1.getInvoicingQboView)(state) : undefined;
84
92
  const isQboPickerDataLoading = (0, invoicingQboViewSelector_1.isInvoicingAccountingPickerDataLoading)(state);
@@ -88,14 +96,6 @@ const getInvoicingCouponFormView = (state, couponId) => {
88
96
  const isCreateSeedPending = couponId == null &&
89
97
  (settingsFetchState === 'Not-Started' ||
90
98
  settingsFetchState === 'In-Progress');
91
- // The account types the discounts slot accepts come from the same config the
92
- // QBO settings page reads, so the two pickers offer the same accounts and
93
- // widening the slot stays a config change. Matched on the slot rather than
94
- // the code as written: a config that names the kind `discounts_account_id`
95
- // is the same slot, and missing it here would quietly fall back to the
96
- // default and offer a different set than the settings page does.
97
- const discountsAccountTypes = qboAccountMappingKindOptions.find((kindOption) => (0, invoicingCommonPayload_1.toInvoicingQboAccountMappingSlot)(kindOption.code) ===
98
- DISCOUNTS_ACCOUNT_MAPPING_SLOT)?.accountTypes ?? DEFAULT_DISCOUNTS_ACCOUNT_TYPES;
99
99
  return {
100
100
  ...base,
101
101
  applyOnOptions: discountApplyOnOptions.map(invoicingCommonPayload_1.toInvoicingFormOption),
@@ -134,8 +134,8 @@ const getInvoicingCouponFormView = (state, couponId) => {
134
134
  },
135
135
  mode: couponId != null ? 'edit' : 'create',
136
136
  periodUnitOptions: billingPeriodUnitOptions.map(invoicingCommonPayload_1.toInvoicingFormOption),
137
- qboAccountHierarchy: qboView?.nestedAccountHierarchy ?? [],
138
- qboIncomeAccountIds: (0, invoicingQboViewSelector_1.invoicingAccountsForQboAccountTypes)(qboView?.accounts ?? [], discountsAccountTypes).map((account) => account.id),
137
+ qboAccountHierarchy: qboView?.nestedAccountHierarchy ?? invoicingQboViewSelector_1.NO_INVOICING_ACCOUNT_HIERARCHY,
138
+ qboIncomeAccountIds: getInvoicingCouponDiscountsAccountIds(state),
139
139
  };
140
140
  };
141
141
  exports.getInvoicingCouponFormView = getInvoicingCouponFormView;
@@ -3,6 +3,7 @@ import { NestedClassHierarchyForReport } from '../../../commonStateTypes/classes
3
3
  import { FetchStateAndError } from '../../../commonStateTypes/common';
4
4
  import { AccountBase } from '../../../entity/account/accountState';
5
5
  import { ClassBase } from '../../../entity/class/classState';
6
+ import { InvoicingQboAccountMappingKindOption } from '../../../entity/invoicing/invoicingCommonPayload';
6
7
  import { RootState } from '../../../rootStateTypes';
7
8
  import { InvoicingQboConnection, InvoicingQboSyncHealth } from './invoicingQboViewPayload';
8
9
  export declare const INVOICING_ACCOUNT_LIST_KEY: "accountList";
@@ -60,6 +61,17 @@ export interface InvoicingQboView {
60
61
  connection?: InvoicingQboConnection;
61
62
  syncHealth?: InvoicingQboSyncHealth;
62
63
  }
64
+ /**
65
+ * Accounts as the invoicing pickers need them, from the shared
66
+ * accounting-service list, so whatever another screen already loaded is reused.
67
+ * `qboId` is the submitted value because that is what the invoicing API stores;
68
+ * an account without one cannot be posted, so it is dropped rather than offered.
69
+ *
70
+ * Memoized on the two slices it reads: the pickers and the lists derived from
71
+ * them are handed straight to forms, and a fresh array on every store change
72
+ * re-seeds a form the user is editing.
73
+ */
74
+ export declare const getInvoicingQboAccountOptions: (state: RootState) => InvoicingAccountingOption[];
63
75
  /**
64
76
  * The accounts valid for one QBO mapping kind, whose `accountTypes` is a
65
77
  * comma-separated list of QuickBooks type names. An unrecognised list yields no
@@ -67,6 +79,43 @@ export interface InvoicingQboView {
67
79
  * required would post a mapping QuickBooks rejects.
68
80
  */
69
81
  export declare const invoicingAccountsForQboAccountTypes: (accounts: InvoicingAccountingOption[], accountTypes: string) => InvoicingAccountingOption[];
82
+ /**
83
+ * The account-mapping slot a catalog price point's income account shares with
84
+ * the settings page, and the one a discount's account shares with it.
85
+ */
86
+ export declare const INVOICING_INCOME_ACCOUNT_MAPPING_SLOT = "default_income_account";
87
+ export declare const INVOICING_DISCOUNTS_ACCOUNT_MAPPING_SLOT = "discounts_account";
88
+ /**
89
+ * The account types a slot falls back to when the config carries no kind for
90
+ * it. QuickBooks posts a product, a price and a discount line to an income
91
+ * account; offering every account instead would let a save be rejected.
92
+ */
93
+ export declare const INVOICING_DEFAULT_INCOME_ACCOUNT_TYPES = "Income";
94
+ /**
95
+ * The QuickBooks ids a mapping slot's picker may offer, from the account types
96
+ * the config gives that slot — so every picker filling the same slot offers the
97
+ * same accounts, and widening one stays a config change.
98
+ *
99
+ * Ids rather than a filtered tree: the pickers flatten the tree into options by
100
+ * walking each node's children, so dropping a parent that is not itself of the
101
+ * wanted type would drop its matching descendants with it.
102
+ *
103
+ * Matched on the slot rather than the code as written, since a config naming
104
+ * the kind `discounts_account_id` means the same slot. A slot the config does
105
+ * not carry falls back to `defaultAccountTypes` rather than to every account.
106
+ */
107
+ export declare const invoicingQboAccountIdsForMappingSlot: (accounts: InvoicingAccountingOption[], accountMappingKindOptions: InvoicingQboAccountMappingKindOption[], mappingSlot: string, defaultAccountTypes: string) => string[];
108
+ /**
109
+ * Stable empties for a tenant with no QBO connection.
110
+ *
111
+ * `?? []` builds a fresh array per call, and the invoicing forms re-seed their
112
+ * fields whenever their option lists change identity — so a form open against a
113
+ * disconnected tenant would reset itself on every unrelated store change, until
114
+ * the first edit disarms the re-seed.
115
+ */
116
+ export declare const NO_INVOICING_ACCOUNT_HIERARCHY: NestedAccountHierarchyForReport[];
117
+ export declare const NO_INVOICING_CLASS_HIERARCHY: NestedClassHierarchyForReport[];
118
+ export declare const NO_INVOICING_ACCOUNT_IDS: string[];
70
119
  /**
71
120
  * Single bundled view for the QBO integration settings screen — the
72
121
  * connection, the account and class options, sync health, and the fetch/action
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getInvoicingQboView = exports.invoicingAccountsForQboAccountTypes = exports.getInvoicingCatalogAccountingLookup = exports.invoicingQboClassByQboId = exports.invoicingQboAccountByQboId = exports.getInvoicingQboConnectionFetchState = exports.isInvoicingAccountingPickerDataLoading = exports.isInvoicingQboConnected = exports.INVOICING_ACCOUNT_LIST_KEY = void 0;
3
+ exports.getInvoicingQboView = exports.NO_INVOICING_ACCOUNT_IDS = exports.NO_INVOICING_CLASS_HIERARCHY = exports.NO_INVOICING_ACCOUNT_HIERARCHY = exports.invoicingQboAccountIdsForMappingSlot = exports.INVOICING_DEFAULT_INCOME_ACCOUNT_TYPES = exports.INVOICING_DISCOUNTS_ACCOUNT_MAPPING_SLOT = exports.INVOICING_INCOME_ACCOUNT_MAPPING_SLOT = exports.invoicingAccountsForQboAccountTypes = exports.getInvoicingQboAccountOptions = exports.getInvoicingCatalogAccountingLookup = exports.invoicingQboClassByQboId = exports.invoicingQboAccountByQboId = exports.getInvoicingQboConnectionFetchState = exports.isInvoicingAccountingPickerDataLoading = exports.isInvoicingQboConnected = exports.INVOICING_ACCOUNT_LIST_KEY = void 0;
4
+ const toolkit_1 = require("@reduxjs/toolkit");
5
+ const invoicingCommonPayload_1 = require("../../../entity/invoicing/invoicingCommonPayload");
4
6
  const accountListSelector_1 = require("../../accountList/accountListSelector");
5
7
  const classListSelector_1 = require("../../classList/classListSelector");
6
8
  // The shared bucket, fetched with the same arguments as the other consumers so
@@ -91,14 +93,20 @@ const getInvoicingCatalogAccountingLookup = (state) => ({
91
93
  });
92
94
  exports.getInvoicingCatalogAccountingLookup = getInvoicingCatalogAccountingLookup;
93
95
  /**
94
- * Accounts and classes as the invoicing pickers need them. Both come from the
95
- * shared accounting-service lists, so whatever another screen already loaded
96
- * is reused. `qboId` is the submitted value because that is what the invoicing
97
- * API stores; an account without one cannot be posted, so it is dropped rather
98
- * than offered.
96
+ * Accounts as the invoicing pickers need them, from the shared
97
+ * accounting-service list, so whatever another screen already loaded is reused.
98
+ * `qboId` is the submitted value because that is what the invoicing API stores;
99
+ * an account without one cannot be posted, so it is dropped rather than offered.
100
+ *
101
+ * Memoized on the two slices it reads: the pickers and the lists derived from
102
+ * them are handed straight to forms, and a fresh array on every store change
103
+ * re-seeds a form the user is editing.
99
104
  */
100
- const invoicingAccountingOptions = (state) => {
101
- const { accounts } = (0, accountListSelector_1.getAccountList)(state.accountState, state.accountListState, exports.INVOICING_ACCOUNT_LIST_KEY);
105
+ exports.getInvoicingQboAccountOptions = (0, toolkit_1.createSelector)([
106
+ (state) => state.accountState,
107
+ (state) => state.accountListState,
108
+ ], (accountState, accountListState) => {
109
+ const { accounts } = (0, accountListSelector_1.getAccountList)(accountState, accountListState, exports.INVOICING_ACCOUNT_LIST_KEY);
102
110
  return accounts.flatMap((account) => {
103
111
  if (account.qboId == null || account.isDeleted === true) {
104
112
  return [];
@@ -111,7 +119,7 @@ const invoicingAccountingOptions = (state) => {
111
119
  },
112
120
  ];
113
121
  });
114
- };
122
+ });
115
123
  /**
116
124
  * Normalises an account-type name for comparison. The config's mapping kinds
117
125
  * name QuickBooks types the way QuickBooks displays them ("Other Current
@@ -148,6 +156,47 @@ const invoicingAccountsForQboAccountTypes = (accounts, accountTypes) => {
148
156
  wanted.has(normalizeAccountTypeName(account.accountType)));
149
157
  };
150
158
  exports.invoicingAccountsForQboAccountTypes = invoicingAccountsForQboAccountTypes;
159
+ /**
160
+ * The account-mapping slot a catalog price point's income account shares with
161
+ * the settings page, and the one a discount's account shares with it.
162
+ */
163
+ exports.INVOICING_INCOME_ACCOUNT_MAPPING_SLOT = 'default_income_account';
164
+ exports.INVOICING_DISCOUNTS_ACCOUNT_MAPPING_SLOT = 'discounts_account';
165
+ /**
166
+ * The account types a slot falls back to when the config carries no kind for
167
+ * it. QuickBooks posts a product, a price and a discount line to an income
168
+ * account; offering every account instead would let a save be rejected.
169
+ */
170
+ exports.INVOICING_DEFAULT_INCOME_ACCOUNT_TYPES = 'Income';
171
+ /**
172
+ * The QuickBooks ids a mapping slot's picker may offer, from the account types
173
+ * the config gives that slot — so every picker filling the same slot offers the
174
+ * same accounts, and widening one stays a config change.
175
+ *
176
+ * Ids rather than a filtered tree: the pickers flatten the tree into options by
177
+ * walking each node's children, so dropping a parent that is not itself of the
178
+ * wanted type would drop its matching descendants with it.
179
+ *
180
+ * Matched on the slot rather than the code as written, since a config naming
181
+ * the kind `discounts_account_id` means the same slot. A slot the config does
182
+ * not carry falls back to `defaultAccountTypes` rather than to every account.
183
+ */
184
+ const invoicingQboAccountIdsForMappingSlot = (accounts, accountMappingKindOptions, mappingSlot, defaultAccountTypes) => {
185
+ const accountTypes = accountMappingKindOptions.find((kindOption) => (0, invoicingCommonPayload_1.toInvoicingQboAccountMappingSlot)(kindOption.code) === mappingSlot)?.accountTypes ?? defaultAccountTypes;
186
+ return (0, exports.invoicingAccountsForQboAccountTypes)(accounts, accountTypes).map((account) => account.id);
187
+ };
188
+ exports.invoicingQboAccountIdsForMappingSlot = invoicingQboAccountIdsForMappingSlot;
189
+ /**
190
+ * Stable empties for a tenant with no QBO connection.
191
+ *
192
+ * `?? []` builds a fresh array per call, and the invoicing forms re-seed their
193
+ * fields whenever their option lists change identity — so a form open against a
194
+ * disconnected tenant would reset itself on every unrelated store change, until
195
+ * the first edit disarms the re-seed.
196
+ */
197
+ exports.NO_INVOICING_ACCOUNT_HIERARCHY = [];
198
+ exports.NO_INVOICING_CLASS_HIERARCHY = [];
199
+ exports.NO_INVOICING_ACCOUNT_IDS = [];
151
200
  /** Classes as the invoicing pickers need them, from the shared class list. */
152
201
  const invoicingAccountingClassOptions = (state) => {
153
202
  const { classes } = (0, classListSelector_1.getClassList)(state.classState, state.classListState);
@@ -169,7 +218,7 @@ const getInvoicingQboView = (state) => {
169
218
  const qboState = state.invoicingQboViewState;
170
219
  const accountList = state.accountListState.byReportId[exports.INVOICING_ACCOUNT_LIST_KEY];
171
220
  return {
172
- accounts: invoicingAccountingOptions(state),
221
+ accounts: (0, exports.getInvoicingQboAccountOptions)(state),
173
222
  accountsFetchState: {
174
223
  fetchState: accountList.fetchState,
175
224
  error: accountList.error,
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.saveNotificationPreferencesEpic = void 0;
4
4
  const rxjs_1 = require("rxjs");
5
5
  const operators_1 = require("rxjs/operators");
6
+ const notificationRegistryState_1 = require("../../../entity/notificationRegistry/notificationRegistryState");
6
7
  const snackbarReducer_1 = require("../../../entity/snackbar/snackbarReducer");
7
8
  const responsePayload_1 = require("../../../responsePayload");
8
9
  const notificationPreferencesEndpoint_1 = require("../notificationPreferencesEndpoint");
@@ -16,9 +17,19 @@ const saveNotificationPreferencesEpic = (actions$, state$, zeniAPI) => actions$.
16
17
  // ignored, so stale success/failure actions cannot clobber the newer
17
18
  // batch's state. Server-side ordering is arrival-order LWW.
18
19
  (0, operators_1.switchMap)(([, state]) => {
19
- const overrides = (0, notificationPreferencesViewSelector_1.getNotificationLocalOverrides)(state);
20
- const preferencesPatch = (0, notificationPreferencesViewPayload_1.mapPreferencesToPayload)(overrides);
21
- if (Object.keys(preferencesPatch).length === 0) {
20
+ // A clean domain is omitted entirely: an empty patch is still a write against it.
21
+ const overridesByDomain = {};
22
+ const body = {};
23
+ notificationRegistryState_1.NOTIFICATION_DOMAINS.forEach((domain) => {
24
+ const overrides = (0, notificationPreferencesViewSelector_1.getNotificationLocalOverridesForDomain)(state, domain);
25
+ const preferencesPatch = (0, notificationPreferencesViewPayload_1.mapPreferencesToPayload)(overrides);
26
+ if (Object.keys(preferencesPatch).length === 0) {
27
+ return;
28
+ }
29
+ overridesByDomain[domain] = overrides;
30
+ body[`${domain}_preferences`] = preferencesPatch;
31
+ });
32
+ if (Object.keys(body).length === 0) {
22
33
  // No diff → do not dispatch a synthetic success (that would wipe
23
34
  // stored preferences via the reducer). Simply no-op.
24
35
  return rxjs_1.EMPTY;
@@ -31,7 +42,6 @@ const saveNotificationPreferencesEpic = (actions$, state$, zeniAPI) => actions$.
31
42
  // in-flight save's response (Success/Failure never dispatched, so
32
43
  // the counter never drained and future saves got their merge dropped).
33
44
  const cancelEpochAtDispatch = state.notificationPreferencesViewState.cancelEpoch;
34
- const body = { task_checklist_preferences: preferencesPatch };
35
45
  // Suppress the error snackbar when the response lands AFTER the user
36
46
  // pressed Cancel — the Failure reducer already swallows the state
37
47
  // update, so the toast would be a stale error for a save the user
@@ -60,8 +70,11 @@ const saveNotificationPreferencesEpic = (actions$, state$, zeniAPI) => actions$.
60
70
  if ((0, responsePayload_1.isSuccessResponse)(response)) {
61
71
  return (0, rxjs_1.of)((0, notificationPreferencesViewReducer_1.saveNotificationPreferencesSuccess)({
62
72
  cancelEpochAtDispatch,
63
- savedOverrides: overrides,
64
- serverEcho: response.data?.task_checklist_preferences,
73
+ savedOverridesByDomain: overridesByDomain,
74
+ serverEchoByDomain: {
75
+ task_checklist: response.data?.task_checklist_preferences,
76
+ commenting: response.data?.commenting_preferences,
77
+ },
65
78
  }));
66
79
  }
67
80
  const actions = [
@@ -1,30 +1,33 @@
1
- import { NotificationChannel, NotificationFrequency } from '../../entity/notificationRegistry/notificationRegistryState';
1
+ import { NotificationChannel, NotificationDomain, NotificationFrequency } from '../../entity/notificationRegistry/notificationRegistryState';
2
2
  import { ZeniAPIStatus } from '../../responsePayload';
3
3
  import { NotificationPreferencesPayload } from './notificationPreferencesViewPayload';
4
- import { NotificationPreferences, NotificationPreferencesViewState } from './notificationPreferencesViewState';
4
+ import { NotificationPreferencesByDomain, NotificationPreferencesViewState } from './notificationPreferencesViewState';
5
5
  export declare const initialState: NotificationPreferencesViewState;
6
6
  export interface SetFrequencyPayload {
7
7
  frequency: NotificationFrequency;
8
8
  groupId: string;
9
+ domain?: NotificationDomain;
9
10
  }
10
11
  export interface TogglePreferencePayload {
11
12
  channel: NotificationChannel;
12
13
  enabled: boolean;
13
14
  eventId: string;
15
+ domain?: NotificationDomain;
14
16
  }
15
17
  export interface SetGroupChannelMasterPayload {
16
18
  channel: NotificationChannel;
17
19
  enabled: boolean;
18
20
  groupId: string;
21
+ domain?: NotificationDomain;
19
22
  }
20
23
  export interface FetchNotificationPreferencesSuccessPayload {
21
- preferences: NotificationPreferencesPayload | undefined;
24
+ preferencesByDomain: Partial<Record<NotificationDomain, NotificationPreferencesPayload | undefined>>;
22
25
  saveEpochAtDispatch: number;
23
26
  }
24
27
  export interface SaveNotificationPreferencesSuccessPayload {
25
28
  cancelEpochAtDispatch: number;
26
- savedOverrides: NotificationPreferences;
27
- serverEcho: NotificationPreferencesPayload | undefined;
29
+ savedOverridesByDomain: NotificationPreferencesByDomain;
30
+ serverEchoByDomain: Partial<Record<NotificationDomain, NotificationPreferencesPayload | undefined>>;
28
31
  }
29
32
  export interface SaveNotificationPreferencesFailurePayload {
30
33
  cancelEpochAtDispatch: number;