@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.
- package/lib/entity/aiCfo/aiCfoReducer.js +78 -4
- package/lib/entity/aiCfo/aiCfoState.d.ts +16 -0
- package/lib/entity/file/fileReducer.d.ts +8 -0
- package/lib/entity/file/fileReducer.js +10 -2
- package/lib/entity/invoicing/invoicingCommonPayload.d.ts +14 -1
- package/lib/esm/entity/aiCfo/aiCfoReducer.js +79 -5
- package/lib/esm/entity/file/fileReducer.js +10 -2
- package/lib/esm/view/financeStatement/financeStatementReducer.js +17 -27
- package/lib/esm/view/invoicing/editInvoicingCustomerDetailView/editInvoicingCustomerDetailViewSelector.js +1 -1
- package/lib/esm/view/invoicing/settingsView/epics/fetchInvoicingSettingsEpic.js +6 -0
- package/lib/esm/view/invoicing/settingsView/epics/saveInvoicingSettingsEpic.js +7 -0
- package/lib/esm/view/invoicing/settingsView/invoicingBrandingFormConfig.js +1 -1
- package/lib/esm/view/invoicing/settingsView/settingsViewSelector.js +5 -0
- package/lib/esm/view/invoicing/settingsView/submitInvoicingBrandingFormEpic.js +9 -0
- package/lib/view/financeStatement/financeStatementReducer.js +17 -27
- package/lib/view/invoicing/editInvoicingCustomerDetailView/editInvoicingCustomerDetailViewSelector.js +1 -1
- package/lib/view/invoicing/settingsView/epics/fetchInvoicingSettingsEpic.js +6 -0
- package/lib/view/invoicing/settingsView/epics/saveInvoicingSettingsEpic.js +7 -0
- package/lib/view/invoicing/settingsView/epics/settingsViewActionType.d.ts +2 -1
- package/lib/view/invoicing/settingsView/invoicingBrandingFormConfig.js +1 -1
- package/lib/view/invoicing/settingsView/settingsViewSelector.d.ts +8 -0
- package/lib/view/invoicing/settingsView/settingsViewSelector.js +5 -0
- package/lib/view/invoicing/settingsView/submitInvoicingBrandingFormEpic.d.ts +2 -1
- package/lib/view/invoicing/settingsView/submitInvoicingBrandingFormEpic.js +9 -0
- 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
|
-
|
|
499
|
-
|
|
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
|
-
|
|
513
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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
|
-
|
|
507
|
-
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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?.
|
|
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?.
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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?.
|
|
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?.
|
|
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.
|
|
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",
|