@zeniai/client-epic-state 5.2.39 → 5.2.41

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 (25) hide show
  1. package/lib/entity/aiCfo/aiCfoReducer.js +78 -4
  2. package/lib/entity/aiCfo/aiCfoState.d.ts +16 -0
  3. package/lib/entity/file/fileReducer.d.ts +8 -0
  4. package/lib/entity/file/fileReducer.js +10 -2
  5. package/lib/entity/invoicing/invoicingCommonPayload.d.ts +14 -1
  6. package/lib/esm/entity/aiCfo/aiCfoReducer.js +79 -5
  7. package/lib/esm/entity/file/fileReducer.js +10 -2
  8. package/lib/esm/view/financeStatement/financeStatementReducer.js +17 -27
  9. package/lib/esm/view/invoicing/editInvoicingCustomerDetailView/editInvoicingCustomerDetailViewSelector.js +1 -1
  10. package/lib/esm/view/invoicing/settingsView/epics/fetchInvoicingSettingsEpic.js +6 -0
  11. package/lib/esm/view/invoicing/settingsView/epics/saveInvoicingSettingsEpic.js +7 -0
  12. package/lib/esm/view/invoicing/settingsView/invoicingBrandingFormConfig.js +1 -1
  13. package/lib/esm/view/invoicing/settingsView/settingsViewSelector.js +5 -0
  14. package/lib/esm/view/invoicing/settingsView/submitInvoicingBrandingFormEpic.js +9 -0
  15. package/lib/view/financeStatement/financeStatementReducer.js +17 -27
  16. package/lib/view/invoicing/editInvoicingCustomerDetailView/editInvoicingCustomerDetailViewSelector.js +1 -1
  17. package/lib/view/invoicing/settingsView/epics/fetchInvoicingSettingsEpic.js +6 -0
  18. package/lib/view/invoicing/settingsView/epics/saveInvoicingSettingsEpic.js +7 -0
  19. package/lib/view/invoicing/settingsView/epics/settingsViewActionType.d.ts +2 -1
  20. package/lib/view/invoicing/settingsView/invoicingBrandingFormConfig.js +1 -1
  21. package/lib/view/invoicing/settingsView/settingsViewSelector.d.ts +8 -0
  22. package/lib/view/invoicing/settingsView/settingsViewSelector.js +5 -0
  23. package/lib/view/invoicing/settingsView/submitInvoicingBrandingFormEpic.d.ts +2 -1
  24. package/lib/view/invoicing/settingsView/submitInvoicingBrandingFormEpic.js +9 -0
  25. package/package.json +1 -1
@@ -7,6 +7,7 @@ const rootActions_1 = require("../../rootActions");
7
7
  const zeniDayJS_1 = require("../../zeniDayJS");
8
8
  const aiCfoState_1 = require("./aiCfoState");
9
9
  exports.initialAiCfoState = {
10
+ deletedChatSessionIds: [],
10
11
  aiCfoByChatSessionId: {},
11
12
  partialQuestionAnswers: {},
12
13
  syntheticAnswersByChatSessionId: {},
@@ -474,6 +475,11 @@ const toResponseBlockType = (answer, userId) => {
474
475
  createdAt: (0, zeniDayJS_1.date)(timestamp),
475
476
  };
476
477
  };
478
+ // How many deleted session ids to remember. The tombstone only has to outlive
479
+ // an in-flight history request, which is seconds, so this is generous. Capped
480
+ // because `clearSession` would otherwise grow the array for the life of the
481
+ // store and every `setChatHistory` scans it.
482
+ const DELETED_SESSION_MEMORY = 100;
477
483
  const aiCfo = (0, toolkit_1.createSlice)({
478
484
  name: 'aiCfo',
479
485
  initialState: exports.initialAiCfoState,
@@ -495,8 +501,25 @@ const aiCfo = (0, toolkit_1.createSlice)({
495
501
  setSessions(draft, action) {
496
502
  action.payload.forEach((chatSessionPayload) => {
497
503
  const { chat_session_id } = chatSessionPayload;
498
- // This is to prevent overwriting the session if it is already in state
499
- if (draft.aiCfoByChatSessionId[chat_session_id] == null) {
504
+ if (draft.aiCfoByChatSessionId[chat_session_id] != null) {
505
+ // The list is authoritative for a session's metadata, and a session
506
+ // seeded by `setChatHistory` has guessed metadata: no summary, and a
507
+ // `createdAt` taken from one page of a newest-first paginated
508
+ // history, which is later than the session really started.
509
+ // `questionAnswers` is left alone — the list must never wipe a
510
+ // thread that is on screen.
511
+ const entry = draft.aiCfoByChatSessionId[chat_session_id];
512
+ const incoming = toChatSession(chatSessionPayload);
513
+ entry.chatSession = {
514
+ ...incoming,
515
+ // Never downgrade a title already in state: a page of the list can
516
+ // carry a nullish summary, and assigning it wholesale would blank
517
+ // a header that was correct.
518
+ chatSessionSummary: incoming.chatSessionSummary ??
519
+ entry.chatSession.chatSessionSummary,
520
+ };
521
+ }
522
+ else {
500
523
  draft.aiCfoByChatSessionId[chat_session_id] = {
501
524
  chatSession: toChatSession(chatSessionPayload),
502
525
  questionAnswers: [],
@@ -509,8 +532,52 @@ const aiCfo = (0, toolkit_1.createSlice)({
509
532
  setChatHistory(draft, action) {
510
533
  const { chatSessionId, history, isPaginationComplete = false, } = action.payload;
511
534
  if (draft.aiCfoByChatSessionId[chatSessionId] == null) {
512
- console.warn(`session with id ${chatSessionId} not found in setChatHistory`);
513
- return;
535
+ // Sessions created server-side (a routine run) are opened by deep
536
+ // link, so their history can arrive before the sessions list has
537
+ // registered them. Dropping it here loses the messages for good: the
538
+ // response's null page token sets `hasMore: false`, which is the
539
+ // condition the refetch is guarded on. The response is itself proof
540
+ // the session exists, so seed the entry from the messages.
541
+ if (history.length === 0) {
542
+ return;
543
+ }
544
+ // A deleted session. Its history request is never cancelled
545
+ // (`mergeMap`), so seeding would resurrect the conversation into the
546
+ // rail, where clicking it 404s. Only this path is guarded:
547
+ // `clearSession` is optimistic, so if the server still lists the
548
+ // session, `setSessions` re-adding it is correct.
549
+ if (draft.deletedChatSessionIds?.includes(chatSessionId) === true) {
550
+ return;
551
+ }
552
+ // The human's id, taken off a message they actually sent. The API
553
+ // returns newest first, so `history[0]` is usually the agent's reply,
554
+ // and `chatSession.userId` is read as "whose conversation is this".
555
+ const owner = history.find((message) => message.sender === 'user') ?? history[0];
556
+ // Oldest message in the page, not `history[0]`: a session cannot
557
+ // start after its own messages, and the API returns newest first.
558
+ //
559
+ // Compared as instants, not strings. The wire format is an RFC 1123
560
+ // HTTP-date ("Thu, 03 Sep 2026 12:34:56 GMT") because `set_200`
561
+ // bypasses chat's isoformat encoder, and lexically "Mon…" precedes
562
+ // "Sat…" while being the later day. Unparseable values are filtered
563
+ // out, not compared: every comparison against NaN is false, so one
564
+ // used as the reduce's seed would carry through to an Invalid Date.
565
+ const oldest = history
566
+ .map((message) => ({ at: Date.parse(message.created_at), message }))
567
+ .filter(({ at }) => !Number.isNaN(at))
568
+ .reduce((min, entry) => (min == null || entry.at < min.at ? entry : min), undefined)?.message;
569
+ draft.aiCfoByChatSessionId[chatSessionId] = {
570
+ chatSession: {
571
+ chatSessionId,
572
+ userId: owner.user_id,
573
+ chatSessionSummary: undefined,
574
+ // Nothing in the page carried a usable timestamp. "Now" is a
575
+ // guess, but it is a sane one and it keeps the row out of the
576
+ // Invalid-Date behaviour above; the sessions list corrects it.
577
+ createdAt: oldest != null ? (0, zeniDayJS_1.date)(oldest.created_at) : (0, zeniDayJS_1.dateNow)(),
578
+ },
579
+ questionAnswers: [],
580
+ };
514
581
  }
515
582
  const session = draft.aiCfoByChatSessionId[chatSessionId];
516
583
  // Check if we have an existing partial Q&A pair for this session
@@ -790,6 +857,13 @@ const aiCfo = (0, toolkit_1.createSlice)({
790
857
  },
791
858
  clearSession(draft, action) {
792
859
  const sessionId = action.payload;
860
+ draft.deletedChatSessionIds ?? (draft.deletedChatSessionIds = []);
861
+ if (!draft.deletedChatSessionIds.includes(sessionId)) {
862
+ draft.deletedChatSessionIds.push(sessionId);
863
+ if (draft.deletedChatSessionIds.length > DELETED_SESSION_MEMORY) {
864
+ draft.deletedChatSessionIds.splice(0, draft.deletedChatSessionIds.length - DELETED_SESSION_MEMORY);
865
+ }
866
+ }
793
867
  delete draft.aiCfoByChatSessionId[sessionId];
794
868
  delete draft.syntheticAnswersByChatSessionId[sessionId];
795
869
  if (draft.partialQuestionAnswers?.[sessionId] != null) {
@@ -241,4 +241,20 @@ export interface AiCfoState {
241
241
  aiCfoByChatSessionId: Record<ID, ChatSessionWithMessages>;
242
242
  partialQuestionAnswers: Record<ID, AiCfoQuestionWithAnswer | undefined>;
243
243
  syntheticAnswersByChatSessionId: Record<ID, SyntheticAiCfoAnswer[]>;
244
+ /**
245
+ * Sessions this client has deleted. `setChatHistory` seeds an entry for a
246
+ * session it has not seen, so without a tombstone an in-flight history
247
+ * response arriving after the delete would recreate the whole conversation
248
+ * and put the deleted chat back in the rail. `fetchChatHistoryEpic` uses
249
+ * `mergeMap`, so that request is never cancelled. Same race, and the same
250
+ * reasoning, as `deletedScheduleIds` on the view slice. Bounded — it only
251
+ * needs to outlive an in-flight request.
252
+ *
253
+ * Optional because `AiCfoState` is public API of a package four apps
254
+ * consume: making it required turns a bug fix into a compile break for
255
+ * every literal of this type, in this repo and in theirs. The reducer's
256
+ * initial state always sets it, so it is only ever absent on a
257
+ * hand-built literal — which is exactly the case that must keep working.
258
+ */
259
+ deletedChatSessionIds?: ID[];
244
260
  }
@@ -11,6 +11,14 @@ export default _default;
11
11
  /**
12
12
  * Helper functions.
13
13
  */
14
+ /**
15
+ * `image_file_ids` and `image_files` are typed as required, but a service that
16
+ * has no renditions to report can leave them out — the invoicing settings
17
+ * `logo_file` did. Mapping an absent `image_files` threw, taking down whichever
18
+ * reducer was mapping the file rather than losing one field, so both default to
19
+ * empty here: an absent list means no renditions, which is what an empty list
20
+ * already says.
21
+ */
14
22
  export declare function mapFilePayloadToFile(filepayload: FilePayload): File;
15
23
  export declare function mapAttachmentFilePayloadToFile(filePayload: AttachmentFilePayload): AttachmentFile;
16
24
  export declare function mapAttachmentFileToAttachmentFilePayload(file: AttachmentFile | undefined): AttachmentFilePayload | undefined;
@@ -48,6 +48,14 @@ exports.default = user.reducer;
48
48
  /**
49
49
  * Helper functions.
50
50
  */
51
+ /**
52
+ * `image_file_ids` and `image_files` are typed as required, but a service that
53
+ * has no renditions to report can leave them out — the invoicing settings
54
+ * `logo_file` did. Mapping an absent `image_files` threw, taking down whichever
55
+ * reducer was mapping the file rather than losing one field, so both default to
56
+ * empty here: an absent list means no renditions, which is what an empty list
57
+ * already says.
58
+ */
51
59
  function mapFilePayloadToFile(filepayload) {
52
60
  return {
53
61
  fileSizeUnit: filepayload.file_size_unit,
@@ -55,9 +63,9 @@ function mapFilePayloadToFile(filepayload) {
55
63
  ? (0, zeniDayJS_1.date)(filepayload.update_time)
56
64
  : undefined,
57
65
  userId: filepayload.user_id ?? undefined,
58
- imageFileIds: filepayload.image_file_ids,
66
+ imageFileIds: filepayload.image_file_ids ?? [],
59
67
  documentSide: filepayload.document_side,
60
- imageFiles: filepayload.image_files.map((imagePayload) => ({
68
+ imageFiles: (filepayload.image_files ?? []).map((imagePayload) => ({
61
69
  fileLocation: imagePayload.file_location,
62
70
  height: imagePayload.height,
63
71
  signedUrl: (0, zeniUrl_1.toZeniUrl)(imagePayload.signed_url),
@@ -1,6 +1,7 @@
1
1
  import { AccountBasePayload } from '../account/accountPayload';
2
2
  import { AccountBase } from '../account/accountState';
3
3
  import { AddressPayload } from '../address/addressPayload';
4
+ import { FilePayload } from '../file/filePayload';
4
5
  export type InvoicingPriceType = 'tax_exclusive' | 'tax_inclusive';
5
6
  export declare const INVOICING_DUNNING_ACTIONS: readonly ["email_only", "email_and_retry", "do_nothing", "retry_only"];
6
7
  export type InvoicingDunningAction = (typeof INVOICING_DUNNING_ACTIONS)[number];
@@ -167,8 +168,20 @@ export interface InvoicingSettingsPayload {
167
168
  /** Empty string means hide the footer line; absent means use the default. */
168
169
  invoice_footer_text?: string;
169
170
  invoice_prefix?: string;
171
+ /**
172
+ * Read-only. The logo's Files record, whole, replacing the
173
+ * `logo_file_id` + `logo_url` pair the read used to return: those could
174
+ * disagree — most often an id whose file had been deleted — and neither
175
+ * carried the file's name. `null` means no logo, which is also how a stored
176
+ * id that no longer resolves comes back.
177
+ */
178
+ logo_file?: FilePayload | null;
179
+ /**
180
+ * Write-only. The save payload sets the logo by id; the read never returns it
181
+ * — see `logo_file`. Kept apart so a stale id cannot be mistaken for a logo
182
+ * that still exists.
183
+ */
170
184
  logo_file_id?: string;
171
- logo_url?: string;
172
185
  net_term_days?: number;
173
186
  payment_terms?: string;
174
187
  phone?: string;
@@ -1,8 +1,9 @@
1
1
  import { createSlice } from '@reduxjs/toolkit';
2
2
  import { clearAll } from '../../rootActions';
3
- import { date as zeniDate } from '../../zeniDayJS';
3
+ import { dateNow, date as zeniDate } from '../../zeniDayJS';
4
4
  import { ALL_AI_CFO_ANSWER_RESPONSE_TYPES, toAiCfoAnswerResponseType, toAiCfoAnswerResponseTypeStrict, toAiCfoAnswerStateType, toAiCfoVisualizationTypeStrict, toInteractiveFormTypeStrict, toMessageSender, toMessageType, toYFormatScaleStrict, toYFormatTypeStrict, toYFormatUnitStrict, } from './aiCfoState';
5
5
  export const initialAiCfoState = {
6
+ deletedChatSessionIds: [],
6
7
  aiCfoByChatSessionId: {},
7
8
  partialQuestionAnswers: {},
8
9
  syntheticAnswersByChatSessionId: {},
@@ -468,6 +469,11 @@ const toResponseBlockType = (answer, userId) => {
468
469
  createdAt: zeniDate(timestamp),
469
470
  };
470
471
  };
472
+ // How many deleted session ids to remember. The tombstone only has to outlive
473
+ // an in-flight history request, which is seconds, so this is generous. Capped
474
+ // because `clearSession` would otherwise grow the array for the life of the
475
+ // store and every `setChatHistory` scans it.
476
+ const DELETED_SESSION_MEMORY = 100;
471
477
  const aiCfo = createSlice({
472
478
  name: 'aiCfo',
473
479
  initialState: initialAiCfoState,
@@ -489,8 +495,25 @@ const aiCfo = createSlice({
489
495
  setSessions(draft, action) {
490
496
  action.payload.forEach((chatSessionPayload) => {
491
497
  const { chat_session_id } = chatSessionPayload;
492
- // This is to prevent overwriting the session if it is already in state
493
- if (draft.aiCfoByChatSessionId[chat_session_id] == null) {
498
+ if (draft.aiCfoByChatSessionId[chat_session_id] != null) {
499
+ // The list is authoritative for a session's metadata, and a session
500
+ // seeded by `setChatHistory` has guessed metadata: no summary, and a
501
+ // `createdAt` taken from one page of a newest-first paginated
502
+ // history, which is later than the session really started.
503
+ // `questionAnswers` is left alone — the list must never wipe a
504
+ // thread that is on screen.
505
+ const entry = draft.aiCfoByChatSessionId[chat_session_id];
506
+ const incoming = toChatSession(chatSessionPayload);
507
+ entry.chatSession = {
508
+ ...incoming,
509
+ // Never downgrade a title already in state: a page of the list can
510
+ // carry a nullish summary, and assigning it wholesale would blank
511
+ // a header that was correct.
512
+ chatSessionSummary: incoming.chatSessionSummary ??
513
+ entry.chatSession.chatSessionSummary,
514
+ };
515
+ }
516
+ else {
494
517
  draft.aiCfoByChatSessionId[chat_session_id] = {
495
518
  chatSession: toChatSession(chatSessionPayload),
496
519
  questionAnswers: [],
@@ -503,8 +526,52 @@ const aiCfo = createSlice({
503
526
  setChatHistory(draft, action) {
504
527
  const { chatSessionId, history, isPaginationComplete = false, } = action.payload;
505
528
  if (draft.aiCfoByChatSessionId[chatSessionId] == null) {
506
- console.warn(`session with id ${chatSessionId} not found in setChatHistory`);
507
- return;
529
+ // Sessions created server-side (a routine run) are opened by deep
530
+ // link, so their history can arrive before the sessions list has
531
+ // registered them. Dropping it here loses the messages for good: the
532
+ // response's null page token sets `hasMore: false`, which is the
533
+ // condition the refetch is guarded on. The response is itself proof
534
+ // the session exists, so seed the entry from the messages.
535
+ if (history.length === 0) {
536
+ return;
537
+ }
538
+ // A deleted session. Its history request is never cancelled
539
+ // (`mergeMap`), so seeding would resurrect the conversation into the
540
+ // rail, where clicking it 404s. Only this path is guarded:
541
+ // `clearSession` is optimistic, so if the server still lists the
542
+ // session, `setSessions` re-adding it is correct.
543
+ if (draft.deletedChatSessionIds?.includes(chatSessionId) === true) {
544
+ return;
545
+ }
546
+ // The human's id, taken off a message they actually sent. The API
547
+ // returns newest first, so `history[0]` is usually the agent's reply,
548
+ // and `chatSession.userId` is read as "whose conversation is this".
549
+ const owner = history.find((message) => message.sender === 'user') ?? history[0];
550
+ // Oldest message in the page, not `history[0]`: a session cannot
551
+ // start after its own messages, and the API returns newest first.
552
+ //
553
+ // Compared as instants, not strings. The wire format is an RFC 1123
554
+ // HTTP-date ("Thu, 03 Sep 2026 12:34:56 GMT") because `set_200`
555
+ // bypasses chat's isoformat encoder, and lexically "Mon…" precedes
556
+ // "Sat…" while being the later day. Unparseable values are filtered
557
+ // out, not compared: every comparison against NaN is false, so one
558
+ // used as the reduce's seed would carry through to an Invalid Date.
559
+ const oldest = history
560
+ .map((message) => ({ at: Date.parse(message.created_at), message }))
561
+ .filter(({ at }) => !Number.isNaN(at))
562
+ .reduce((min, entry) => (min == null || entry.at < min.at ? entry : min), undefined)?.message;
563
+ draft.aiCfoByChatSessionId[chatSessionId] = {
564
+ chatSession: {
565
+ chatSessionId,
566
+ userId: owner.user_id,
567
+ chatSessionSummary: undefined,
568
+ // Nothing in the page carried a usable timestamp. "Now" is a
569
+ // guess, but it is a sane one and it keeps the row out of the
570
+ // Invalid-Date behaviour above; the sessions list corrects it.
571
+ createdAt: oldest != null ? zeniDate(oldest.created_at) : dateNow(),
572
+ },
573
+ questionAnswers: [],
574
+ };
508
575
  }
509
576
  const session = draft.aiCfoByChatSessionId[chatSessionId];
510
577
  // Check if we have an existing partial Q&A pair for this session
@@ -784,6 +851,13 @@ const aiCfo = createSlice({
784
851
  },
785
852
  clearSession(draft, action) {
786
853
  const sessionId = action.payload;
854
+ draft.deletedChatSessionIds ?? (draft.deletedChatSessionIds = []);
855
+ if (!draft.deletedChatSessionIds.includes(sessionId)) {
856
+ draft.deletedChatSessionIds.push(sessionId);
857
+ if (draft.deletedChatSessionIds.length > DELETED_SESSION_MEMORY) {
858
+ draft.deletedChatSessionIds.splice(0, draft.deletedChatSessionIds.length - DELETED_SESSION_MEMORY);
859
+ }
860
+ }
787
861
  delete draft.aiCfoByChatSessionId[sessionId];
788
862
  delete draft.syntheticAnswersByChatSessionId[sessionId];
789
863
  if (draft.partialQuestionAnswers?.[sessionId] != null) {
@@ -41,6 +41,14 @@ export default user.reducer;
41
41
  /**
42
42
  * Helper functions.
43
43
  */
44
+ /**
45
+ * `image_file_ids` and `image_files` are typed as required, but a service that
46
+ * has no renditions to report can leave them out — the invoicing settings
47
+ * `logo_file` did. Mapping an absent `image_files` threw, taking down whichever
48
+ * reducer was mapping the file rather than losing one field, so both default to
49
+ * empty here: an absent list means no renditions, which is what an empty list
50
+ * already says.
51
+ */
44
52
  export function mapFilePayloadToFile(filepayload) {
45
53
  return {
46
54
  fileSizeUnit: filepayload.file_size_unit,
@@ -48,9 +56,9 @@ export function mapFilePayloadToFile(filepayload) {
48
56
  ? date(filepayload.update_time)
49
57
  : undefined,
50
58
  userId: filepayload.user_id ?? undefined,
51
- imageFileIds: filepayload.image_file_ids,
59
+ imageFileIds: filepayload.image_file_ids ?? [],
52
60
  documentSide: filepayload.document_side,
53
- imageFiles: filepayload.image_files.map((imagePayload) => ({
61
+ imageFiles: (filepayload.image_files ?? []).map((imagePayload) => ({
54
62
  fileLocation: imagePayload.file_location,
55
63
  height: imagePayload.height,
56
64
  signedUrl: toZeniUrl(imagePayload.signed_url),
@@ -33,9 +33,18 @@ const financeStatement = createSlice({
33
33
  },
34
34
  },
35
35
  updateFinanceStatementTimeframe(draft, action) {
36
+ // Unrelated UI re-dispatches the *current* timeframe as a side effect (the
37
+ // reports page hangs its table scroll reset off that callback); dropping the
38
+ // anchor there would silently reset the user's period to the latest one.
39
+ if (draft.timeframe === action.payload) {
40
+ return;
41
+ }
36
42
  draft.timeframe = action.payload;
43
+ // A month anchor is meaningless once the timeframe becomes quarter/year, so
44
+ // a real change drops it and returns the width to the default — 12 months
45
+ // must not become 12 quarters. Order is the user's, so it survives.
37
46
  draft.selectedCOABalancesRange = {
38
- numberOfPeriods: draft.selectedCOABalancesRange.numberOfPeriods,
47
+ numberOfPeriods: initialFinanceStatementState.selectedCOABalancesRange.numberOfPeriods,
39
48
  orderBy: draft.selectedCOABalancesRange.orderBy,
40
49
  };
41
50
  },
@@ -77,8 +86,7 @@ const financeStatement = createSlice({
77
86
  draft.selectedReportId = action.payload;
78
87
  },
79
88
  updateFinanceStatementAdditionalBalancesSelection(draft, action) {
80
- const { firstMonthOfFY, additionalBalances: additionalBalancesPayload, coaBalances, maxNumOfPeriodsToHighlight, } = action.payload;
81
- const safeCoaBalances = coaBalances != null ? coaBalances : [];
89
+ const { additionalBalances: additionalBalancesPayload, maxNumOfPeriodsToHighlight, } = action.payload;
82
90
  const additionalBalances = additionalBalancesPayload ?? [];
83
91
  let tempAdditionalBalances = [...additionalBalances];
84
92
  if (additionalBalances.includes('this_period_vs_last_period') ||
@@ -94,30 +102,12 @@ const financeStatement = createSlice({
94
102
  draft.maxNumOfPeriodsToHighlight = maxNumOfPeriodsToHighlight;
95
103
  draft.isAdditionalBalancesShown =
96
104
  additionalBalances.length != 0 ? true : false;
97
- const { timeframe, selectedCOABalancesRange } = draft;
98
- if (safeCoaBalances.length > 0) {
99
- const thisPeriod = extractThisPeriod(action.payload.firstMonthOfFY, timeframe, selectedCOABalancesRange, safeCoaBalances);
100
- if (thisPeriod != null) {
101
- const selectedCoaBalancesRangeWithThisPeriod = {
102
- ...selectedCOABalancesRange,
103
- thisPeriod,
104
- };
105
- const selectionRanges = getSelectedAndHighlightedRangesForThisPeriod({
106
- firstMonthOfFY,
107
- thisPeriod: thisPeriod,
108
- coaBalances: safeCoaBalances,
109
- timeframe,
110
- maxNumOfPeriodsToHighlight: maxNumOfPeriodsToHighlight,
111
- currentSelection: {
112
- selectedCOABalancesRange: selectedCoaBalancesRangeWithThisPeriod,
113
- },
114
- orderBy: 'ascending_date',
115
- maxNumOfPeriodsToSelect: maxNumOfPeriodsToHighlight,
116
- });
117
- draft.selectedCOABalancesRange =
118
- selectionRanges.selectedCOABalancesRange;
119
- }
120
- }
105
+ // Column metadata only must not touch selectedCOABalancesRange. The
106
+ // reports page fires this on load, on resize and on every fetch-state
107
+ // transition (so also on report switch and on remount after a drill-down)
108
+ // with a viewport-derived period count that knows nothing about the range
109
+ // the user picked. The width default comes from initial state, and from
110
+ // updateFinanceStatementTimeframe on a real timeframe change.
121
111
  },
122
112
  updateDownloadState(draft, action) {
123
113
  draft.downloadState = action.payload;
@@ -36,7 +36,7 @@ export const getInvoicingCustomerRecord = (state, invoicingCustomerID) => {
36
36
  brandingAccentColor: settings?.accent_color,
37
37
  brandingCompanyName: settings?.company_name,
38
38
  brandingEmail: settings?.email,
39
- brandingLogoUrl: settings?.logo_url,
39
+ brandingLogoUrl: settings?.logo_file?.signed_url,
40
40
  stripeConnected: isStripeConnectedFromSettings(settings),
41
41
  fetchState: invoicingCustomerDetail?.fetchState ?? 'Not-Started',
42
42
  error: invoicingCustomerDetail?.error,
@@ -1,6 +1,7 @@
1
1
  import { of } from 'rxjs';
2
2
  import { catchError, filter, switchMap } from 'rxjs/operators';
3
3
  import { updateAddresses } from '../../../../entity/address/addressReducer';
4
+ import { updateFiles } from '../../../../entity/file/fileReducer';
4
5
  import { createZeniAPIStatus, isSuccessResponse, } from '../../../../responsePayload';
5
6
  import { fetchInvoicingSettings, updateInvoicingSettings, updateInvoicingSettingsFailure, } from '../settingsViewReducer';
6
7
  export const fetchInvoicingSettingsEpic = (actions$, _state$, zeniAPI) => actions$.pipe(filter(fetchInvoicingSettings.match), switchMap(() => {
@@ -8,8 +9,13 @@ export const fetchInvoicingSettingsEpic = (actions$, _state$, zeniAPI) => action
8
9
  return zeniAPI.getJSON(apiUrl).pipe(switchMap((response) => {
9
10
  if (isSuccessResponse(response) && response.data != null) {
10
11
  const address = response.data.address;
12
+ // The logo record goes to the file entity, like the address goes to
13
+ // the address entity: the settings view keeps only its id and reads
14
+ // the file back through the file selector, so one copy is canonical.
15
+ const logoFile = response.data.logo_file;
11
16
  return [
12
17
  ...(address != null ? [updateAddresses([address])] : []),
18
+ ...(logoFile != null ? [updateFiles({ files: [logoFile] })] : []),
13
19
  updateInvoicingSettings(response.data),
14
20
  ];
15
21
  }
@@ -4,6 +4,7 @@ import { apiErrorSnackbarVariables } from '../../../../entity/snackbar/snackbarA
4
4
  import { openSnackbar } from '../../../../entity/snackbar/snackbarReducer';
5
5
  import { createZeniAPIStatus, isSuccessResponse, } from '../../../../responsePayload';
6
6
  import { saveInvoicingSettings, saveInvoicingSettingsFailure, saveInvoicingSettingsSuccess, } from '../settingsViewReducer';
7
+ import { updateFiles } from '../../../../entity/file/fileReducer';
7
8
  export const saveInvoicingSettingsEpic = (actions$, _state$, zeniAPI) => actions$.pipe(filter(saveInvoicingSettings.match), mergeMap((action) => {
8
9
  const apiUrl = `${zeniAPI.apiEndPoints.invoicingMicroServiceBaseUrl}/1.0/settings`;
9
10
  const { settings, snackbarSection } = action.payload;
@@ -22,7 +23,13 @@ export const saveInvoicingSettingsEpic = (actions$, _state$, zeniAPI) => actions
22
23
  .putAndGetJSON(apiUrl, { ...settings })
23
24
  .pipe(switchMap((response) => {
24
25
  if (isSuccessResponse(response) && response.data != null) {
26
+ // Keep the file entity in step with what was persisted — the
27
+ // settings view resolves the logo by id, not from the response.
28
+ const savedLogoFile = response.data.logo_file;
25
29
  return from([
30
+ ...(savedLogoFile != null
31
+ ? [updateFiles({ files: [savedLogoFile] })]
32
+ : []),
26
33
  saveInvoicingSettingsSuccess(response.data),
27
34
  ...snackbar('success'),
28
35
  ]);
@@ -22,7 +22,7 @@ export const entityToInvoicingBrandingFormLocalData = (settings) => ({
22
22
  email: settings?.email ?? '',
23
23
  invoiceFooterText: settings?.invoice_footer_text ?? '',
24
24
  invoicePrefix: settings?.invoice_prefix ?? '',
25
- logoFileId: settings?.logo_file_id ?? '',
25
+ logoFileId: settings?.logo_file?.file_id ?? '',
26
26
  phone: settings?.phone ?? '',
27
27
  taxId: settings?.tax_id ?? '',
28
28
  });
@@ -1,4 +1,5 @@
1
1
  import { getInvoicingQboAccountMappingId, } from '../../../entity/invoicing/invoicingCommonPayload';
2
+ import { getFileByFileId } from '../../../entity/file/fileSelector';
2
3
  import { getFormattedAddress } from '../../addressView/addressViewSelector';
3
4
  import { getInvoicingConfigView, invoicingFieldHelpForGroup, } from '../invoicingConfigView/invoicingConfigViewSelector';
4
5
  import { getInvoicingQboView, isInvoicingAccountingPickerDataLoading, } from '../invoicingQboView/invoicingQboViewSelector';
@@ -25,10 +26,14 @@ export const getInvoicingSettingsFetchState = (state) => ({
25
26
  */
26
27
  export const getInvoicingSettingsView = (state) => {
27
28
  const { error, fetchState, saveFetchState, settings, stripeFetchState } = state.invoicingSettingsViewState;
29
+ const logoFileId = settings?.logo_file?.file_id ?? '';
28
30
  return {
29
31
  fieldHelp: invoicingFieldHelpForGroup(state, 'settings'),
30
32
  error,
31
33
  fetchState,
34
+ logoFile: logoFileId !== ''
35
+ ? getFileByFileId(state.fileState, logoFileId)
36
+ : undefined,
32
37
  saveState: saveFetchState,
33
38
  settings,
34
39
  stripeConnectState: stripeFetchState,
@@ -1,6 +1,7 @@
1
1
  import { of } from 'rxjs';
2
2
  import { catchError, filter, mergeMap } from 'rxjs/operators';
3
3
  import { updateAddresses } from '../../../entity/address/addressReducer';
4
+ import { updateFiles } from '../../../entity/file/fileReducer';
4
5
  import { createZeniAPIStatus, isSuccessResponse } from '../../../responsePayload';
5
6
  import { resetNewAddressDataInLocalStore } from '../../addressView/addressViewReducer';
6
7
  import { INVOICING_BUSINESS_ADDRESS_TYPE, brandingLocalDataToSettingsPayload, entityToInvoicingBrandingFormLocalData, } from './invoicingBrandingFormConfig';
@@ -23,10 +24,18 @@ export const submitInvoicingBrandingFormEpic = (actions$, state$, zeniAPI) => ac
23
24
  .pipe(mergeMap((response) => {
24
25
  if (isSuccessResponse(response) && response.data != null) {
25
26
  const savedAddress = response.data.address;
27
+ // The saved logo goes to the file entity for the same reason the
28
+ // saved address goes to the address entity: the settings view
29
+ // resolves the file by id, so without this a save that changes
30
+ // the logo leaves the view resolving an id nothing holds.
31
+ const savedLogoFile = response.data.logo_file;
26
32
  return [
27
33
  ...(savedAddress != null
28
34
  ? [updateAddresses([savedAddress])]
29
35
  : []),
36
+ ...(savedLogoFile != null
37
+ ? [updateFiles({ files: [savedLogoFile] })]
38
+ : []),
30
39
  saveInvoicingSettingsSuccess(response.data),
31
40
  resetNewAddressDataInLocalStore(INVOICING_BUSINESS_ADDRESS_TYPE),
32
41
  ];
@@ -37,9 +37,18 @@ const financeStatement = (0, toolkit_1.createSlice)({
37
37
  },
38
38
  },
39
39
  updateFinanceStatementTimeframe(draft, action) {
40
+ // Unrelated UI re-dispatches the *current* timeframe as a side effect (the
41
+ // reports page hangs its table scroll reset off that callback); dropping the
42
+ // anchor there would silently reset the user's period to the latest one.
43
+ if (draft.timeframe === action.payload) {
44
+ return;
45
+ }
40
46
  draft.timeframe = action.payload;
47
+ // A month anchor is meaningless once the timeframe becomes quarter/year, so
48
+ // a real change drops it and returns the width to the default — 12 months
49
+ // must not become 12 quarters. Order is the user's, so it survives.
41
50
  draft.selectedCOABalancesRange = {
42
- numberOfPeriods: draft.selectedCOABalancesRange.numberOfPeriods,
51
+ numberOfPeriods: exports.initialFinanceStatementState.selectedCOABalancesRange.numberOfPeriods,
43
52
  orderBy: draft.selectedCOABalancesRange.orderBy,
44
53
  };
45
54
  },
@@ -81,8 +90,7 @@ const financeStatement = (0, toolkit_1.createSlice)({
81
90
  draft.selectedReportId = action.payload;
82
91
  },
83
92
  updateFinanceStatementAdditionalBalancesSelection(draft, action) {
84
- const { firstMonthOfFY, additionalBalances: additionalBalancesPayload, coaBalances, maxNumOfPeriodsToHighlight, } = action.payload;
85
- const safeCoaBalances = coaBalances != null ? coaBalances : [];
93
+ const { additionalBalances: additionalBalancesPayload, maxNumOfPeriodsToHighlight, } = action.payload;
86
94
  const additionalBalances = additionalBalancesPayload ?? [];
87
95
  let tempAdditionalBalances = [...additionalBalances];
88
96
  if (additionalBalances.includes('this_period_vs_last_period') ||
@@ -98,30 +106,12 @@ const financeStatement = (0, toolkit_1.createSlice)({
98
106
  draft.maxNumOfPeriodsToHighlight = maxNumOfPeriodsToHighlight;
99
107
  draft.isAdditionalBalancesShown =
100
108
  additionalBalances.length != 0 ? true : false;
101
- const { timeframe, selectedCOABalancesRange } = draft;
102
- if (safeCoaBalances.length > 0) {
103
- const thisPeriod = (0, thisPeriodHelpers_1.extractThisPeriod)(action.payload.firstMonthOfFY, timeframe, selectedCOABalancesRange, safeCoaBalances);
104
- if (thisPeriod != null) {
105
- const selectedCoaBalancesRangeWithThisPeriod = {
106
- ...selectedCOABalancesRange,
107
- thisPeriod,
108
- };
109
- const selectionRanges = (0, getSelectedAndHighlightedRanges_1.getSelectedAndHighlightedRangesForThisPeriod)({
110
- firstMonthOfFY,
111
- thisPeriod: thisPeriod,
112
- coaBalances: safeCoaBalances,
113
- timeframe,
114
- maxNumOfPeriodsToHighlight: maxNumOfPeriodsToHighlight,
115
- currentSelection: {
116
- selectedCOABalancesRange: selectedCoaBalancesRangeWithThisPeriod,
117
- },
118
- orderBy: 'ascending_date',
119
- maxNumOfPeriodsToSelect: maxNumOfPeriodsToHighlight,
120
- });
121
- draft.selectedCOABalancesRange =
122
- selectionRanges.selectedCOABalancesRange;
123
- }
124
- }
109
+ // Column metadata only must not touch selectedCOABalancesRange. The
110
+ // reports page fires this on load, on resize and on every fetch-state
111
+ // transition (so also on report switch and on remount after a drill-down)
112
+ // with a viewport-derived period count that knows nothing about the range
113
+ // the user picked. The width default comes from initial state, and from
114
+ // updateFinanceStatementTimeframe on a real timeframe change.
125
115
  },
126
116
  updateDownloadState(draft, action) {
127
117
  draft.downloadState = action.payload;
@@ -39,7 +39,7 @@ const getInvoicingCustomerRecord = (state, invoicingCustomerID) => {
39
39
  brandingAccentColor: settings?.accent_color,
40
40
  brandingCompanyName: settings?.company_name,
41
41
  brandingEmail: settings?.email,
42
- brandingLogoUrl: settings?.logo_url,
42
+ brandingLogoUrl: settings?.logo_file?.signed_url,
43
43
  stripeConnected: (0, invoicingSettingsHelpers_1.isStripeConnectedFromSettings)(settings),
44
44
  fetchState: invoicingCustomerDetail?.fetchState ?? 'Not-Started',
45
45
  error: invoicingCustomerDetail?.error,
@@ -4,6 +4,7 @@ exports.fetchInvoicingSettingsEpic = void 0;
4
4
  const rxjs_1 = require("rxjs");
5
5
  const operators_1 = require("rxjs/operators");
6
6
  const addressReducer_1 = require("../../../../entity/address/addressReducer");
7
+ const fileReducer_1 = require("../../../../entity/file/fileReducer");
7
8
  const responsePayload_1 = require("../../../../responsePayload");
8
9
  const settingsViewReducer_1 = require("../settingsViewReducer");
9
10
  const fetchInvoicingSettingsEpic = (actions$, _state$, zeniAPI) => actions$.pipe((0, operators_1.filter)(settingsViewReducer_1.fetchInvoicingSettings.match), (0, operators_1.switchMap)(() => {
@@ -11,8 +12,13 @@ const fetchInvoicingSettingsEpic = (actions$, _state$, zeniAPI) => actions$.pipe
11
12
  return zeniAPI.getJSON(apiUrl).pipe((0, operators_1.switchMap)((response) => {
12
13
  if ((0, responsePayload_1.isSuccessResponse)(response) && response.data != null) {
13
14
  const address = response.data.address;
15
+ // The logo record goes to the file entity, like the address goes to
16
+ // the address entity: the settings view keeps only its id and reads
17
+ // the file back through the file selector, so one copy is canonical.
18
+ const logoFile = response.data.logo_file;
14
19
  return [
15
20
  ...(address != null ? [(0, addressReducer_1.updateAddresses)([address])] : []),
21
+ ...(logoFile != null ? [(0, fileReducer_1.updateFiles)({ files: [logoFile] })] : []),
16
22
  (0, settingsViewReducer_1.updateInvoicingSettings)(response.data),
17
23
  ];
18
24
  }
@@ -7,6 +7,7 @@ const snackbarApiError_1 = require("../../../../entity/snackbar/snackbarApiError
7
7
  const snackbarReducer_1 = require("../../../../entity/snackbar/snackbarReducer");
8
8
  const responsePayload_1 = require("../../../../responsePayload");
9
9
  const settingsViewReducer_1 = require("../settingsViewReducer");
10
+ const fileReducer_1 = require("../../../../entity/file/fileReducer");
10
11
  const saveInvoicingSettingsEpic = (actions$, _state$, zeniAPI) => actions$.pipe((0, operators_1.filter)(settingsViewReducer_1.saveInvoicingSettings.match), (0, operators_1.mergeMap)((action) => {
11
12
  const apiUrl = `${zeniAPI.apiEndPoints.invoicingMicroServiceBaseUrl}/1.0/settings`;
12
13
  const { settings, snackbarSection } = action.payload;
@@ -25,7 +26,13 @@ const saveInvoicingSettingsEpic = (actions$, _state$, zeniAPI) => actions$.pipe(
25
26
  .putAndGetJSON(apiUrl, { ...settings })
26
27
  .pipe((0, operators_1.switchMap)((response) => {
27
28
  if ((0, responsePayload_1.isSuccessResponse)(response) && response.data != null) {
29
+ // Keep the file entity in step with what was persisted — the
30
+ // settings view resolves the logo by id, not from the response.
31
+ const savedLogoFile = response.data.logo_file;
28
32
  return (0, rxjs_1.from)([
33
+ ...(savedLogoFile != null
34
+ ? [(0, fileReducer_1.updateFiles)({ files: [savedLogoFile] })]
35
+ : []),
29
36
  (0, settingsViewReducer_1.saveInvoicingSettingsSuccess)(response.data),
30
37
  ...snackbar('success'),
31
38
  ]);
@@ -1,5 +1,6 @@
1
1
  import { updateAddresses } from '../../../../entity/address/addressReducer';
2
+ import { updateFiles } from '../../../../entity/file/fileReducer';
2
3
  import { openSnackbar } from '../../../../entity/snackbar/snackbarReducer';
3
4
  import { connectInvoicingStripe, disconnectInvoicingStripe, fetchInvoicingSettings, invoicingStripeConnectionFailure, saveInvoicingSettings, saveInvoicingSettingsFailure, saveInvoicingSettingsSuccess, submitInvoicingQboSettings, updateInvoicingSettings, updateInvoicingSettingsFailure, updateInvoicingStripeConnection } from '../settingsViewReducer';
4
5
  /** Every action the settings epics accept or emit. */
5
- export type InvoicingSettingsViewActionType = ReturnType<typeof fetchInvoicingSettings> | ReturnType<typeof updateInvoicingSettings> | ReturnType<typeof updateInvoicingSettingsFailure> | ReturnType<typeof saveInvoicingSettings> | ReturnType<typeof saveInvoicingSettingsSuccess> | ReturnType<typeof submitInvoicingQboSettings> | ReturnType<typeof saveInvoicingSettingsFailure> | ReturnType<typeof connectInvoicingStripe> | ReturnType<typeof disconnectInvoicingStripe> | ReturnType<typeof updateInvoicingStripeConnection> | ReturnType<typeof invoicingStripeConnectionFailure> | ReturnType<typeof updateAddresses> | ReturnType<typeof openSnackbar>;
6
+ export type InvoicingSettingsViewActionType = ReturnType<typeof fetchInvoicingSettings> | ReturnType<typeof updateInvoicingSettings> | ReturnType<typeof updateInvoicingSettingsFailure> | ReturnType<typeof saveInvoicingSettings> | ReturnType<typeof saveInvoicingSettingsSuccess> | ReturnType<typeof submitInvoicingQboSettings> | ReturnType<typeof saveInvoicingSettingsFailure> | ReturnType<typeof connectInvoicingStripe> | ReturnType<typeof disconnectInvoicingStripe> | ReturnType<typeof updateInvoicingStripeConnection> | ReturnType<typeof invoicingStripeConnectionFailure> | ReturnType<typeof updateAddresses> | ReturnType<typeof updateFiles> | ReturnType<typeof openSnackbar>;
@@ -25,7 +25,7 @@ const entityToInvoicingBrandingFormLocalData = (settings) => ({
25
25
  email: settings?.email ?? '',
26
26
  invoiceFooterText: settings?.invoice_footer_text ?? '',
27
27
  invoicePrefix: settings?.invoice_prefix ?? '',
28
- logoFileId: settings?.logo_file_id ?? '',
28
+ logoFileId: settings?.logo_file?.file_id ?? '',
29
29
  phone: settings?.phone ?? '',
30
30
  taxId: settings?.tax_id ?? '',
31
31
  });
@@ -1,6 +1,7 @@
1
1
  import { NestedAccountHierarchyForReport } from '../../../commonStateTypes/accountView/nestedAccountID';
2
2
  import { FetchState, FetchStateAndError } from '../../../commonStateTypes/common';
3
3
  import { InvoicingDunningActionOption, InvoicingFieldHelpByCode, InvoicingNamedOption, InvoicingQboAccountMappingKindOption, InvoicingSettingsPayload } from '../../../entity/invoicing/invoicingCommonPayload';
4
+ import { File } from '../../../entity/file/fileState';
4
5
  import { ZeniAPIStatus } from '../../../responsePayload';
5
6
  import { RootState } from '../../../rootStateTypes';
6
7
  import { InvoicingQboConnection, InvoicingQboSyncHealth } from '../invoicingQboView/invoicingQboViewPayload';
@@ -24,6 +25,13 @@ export interface InvoicingSettingsView {
24
25
  saveState: FetchStateAndError;
25
26
  stripeConnectState: FetchStateAndError;
26
27
  error?: ZeniAPIStatus;
28
+ /**
29
+ * The tenant's logo, read back out of the file entity the settings fetch
30
+ * stored it in. `undefined` when no logo is set — the settings response
31
+ * returns no record then, which is also how a logo deleted out from under a
32
+ * stale id reads, so the screen has nothing to show either way.
33
+ */
34
+ logoFile?: File;
27
35
  settings?: InvoicingSettingsPayload;
28
36
  }
29
37
  /**
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getInvoicingBrandingFormView = exports.getInvoicingQboSettingsFormView = exports.getInvoicingSettingsHubView = exports.getInvoicingSettingsView = exports.getInvoicingSettingsFetchState = exports.getInvoicingSettings = void 0;
4
4
  exports.deriveInvoicingEnablePrerequisites = deriveInvoicingEnablePrerequisites;
5
5
  const invoicingCommonPayload_1 = require("../../../entity/invoicing/invoicingCommonPayload");
6
+ const fileSelector_1 = require("../../../entity/file/fileSelector");
6
7
  const addressViewSelector_1 = require("../../addressView/addressViewSelector");
7
8
  const invoicingConfigViewSelector_1 = require("../invoicingConfigView/invoicingConfigViewSelector");
8
9
  const invoicingQboViewSelector_1 = require("../invoicingQboView/invoicingQboViewSelector");
@@ -31,10 +32,14 @@ exports.getInvoicingSettingsFetchState = getInvoicingSettingsFetchState;
31
32
  */
32
33
  const getInvoicingSettingsView = (state) => {
33
34
  const { error, fetchState, saveFetchState, settings, stripeFetchState } = state.invoicingSettingsViewState;
35
+ const logoFileId = settings?.logo_file?.file_id ?? '';
34
36
  return {
35
37
  fieldHelp: (0, invoicingConfigViewSelector_1.invoicingFieldHelpForGroup)(state, 'settings'),
36
38
  error,
37
39
  fetchState,
40
+ logoFile: logoFileId !== ''
41
+ ? (0, fileSelector_1.getFileByFileId)(state.fileState, logoFileId)
42
+ : undefined,
38
43
  saveState: saveFetchState,
39
44
  settings,
40
45
  stripeConnectState: stripeFetchState,
@@ -1,11 +1,12 @@
1
1
  import { ActionsObservable, StateObservable } from 'redux-observable';
2
2
  import { Observable } from 'rxjs';
3
3
  import { updateAddresses } from '../../../entity/address/addressReducer';
4
+ import { updateFiles } from '../../../entity/file/fileReducer';
4
5
  import { RootState } from '../../../rootStateTypes';
5
6
  import { ZeniAPI } from '../../../zeniAPI';
6
7
  import { resetNewAddressDataInLocalStore } from '../../addressView/addressViewReducer';
7
8
  import { saveInvoicingSettingsFailure, saveInvoicingSettingsSuccess, submitInvoicingBrandingForm } from './settingsViewReducer';
8
- export type SubmitActionType = ReturnType<typeof submitInvoicingBrandingForm> | ReturnType<typeof saveInvoicingSettingsSuccess> | ReturnType<typeof saveInvoicingSettingsFailure> | ReturnType<typeof resetNewAddressDataInLocalStore> | ReturnType<typeof updateAddresses>;
9
+ export type SubmitActionType = ReturnType<typeof submitInvoicingBrandingForm> | ReturnType<typeof saveInvoicingSettingsSuccess> | ReturnType<typeof saveInvoicingSettingsFailure> | ReturnType<typeof resetNewAddressDataInLocalStore> | ReturnType<typeof updateAddresses> | ReturnType<typeof updateFiles>;
9
10
  /**
10
11
  * On `submitInvoicingBrandingForm`, PUT the settings body built from the branding
11
12
  * draft + the business address captured via the shared Address screen. The
@@ -4,6 +4,7 @@ exports.submitInvoicingBrandingFormEpic = void 0;
4
4
  const rxjs_1 = require("rxjs");
5
5
  const operators_1 = require("rxjs/operators");
6
6
  const addressReducer_1 = require("../../../entity/address/addressReducer");
7
+ const fileReducer_1 = require("../../../entity/file/fileReducer");
7
8
  const responsePayload_1 = require("../../../responsePayload");
8
9
  const addressViewReducer_1 = require("../../addressView/addressViewReducer");
9
10
  const invoicingBrandingFormConfig_1 = require("./invoicingBrandingFormConfig");
@@ -26,10 +27,18 @@ const submitInvoicingBrandingFormEpic = (actions$, state$, zeniAPI) => actions$.
26
27
  .pipe((0, operators_1.mergeMap)((response) => {
27
28
  if ((0, responsePayload_1.isSuccessResponse)(response) && response.data != null) {
28
29
  const savedAddress = response.data.address;
30
+ // The saved logo goes to the file entity for the same reason the
31
+ // saved address goes to the address entity: the settings view
32
+ // resolves the file by id, so without this a save that changes
33
+ // the logo leaves the view resolving an id nothing holds.
34
+ const savedLogoFile = response.data.logo_file;
29
35
  return [
30
36
  ...(savedAddress != null
31
37
  ? [(0, addressReducer_1.updateAddresses)([savedAddress])]
32
38
  : []),
39
+ ...(savedLogoFile != null
40
+ ? [(0, fileReducer_1.updateFiles)({ files: [savedLogoFile] })]
41
+ : []),
33
42
  (0, settingsViewReducer_1.saveInvoicingSettingsSuccess)(response.data),
34
43
  (0, addressViewReducer_1.resetNewAddressDataInLocalStore)(invoicingBrandingFormConfig_1.INVOICING_BUSINESS_ADDRESS_TYPE),
35
44
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeniai/client-epic-state",
3
- "version": "5.2.39",
3
+ "version": "5.2.41",
4
4
  "description": "Shared module between Web & Mobile containing required abstractions for state management, async network communication. ",
5
5
  "main": "lib/index.js",
6
6
  "module": "lib/esm/index.js",