@zeniai/client-epic-state 5.3.7 → 5.3.8-beta0DI

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 (68) hide show
  1. package/lib/entity/invoicing/invoice/invoicePayload.d.ts +1 -0
  2. package/lib/entity/invoicing/invoice/invoicePayload.js +3 -0
  3. package/lib/entity/invoicing/invoice/invoiceState.d.ts +2 -0
  4. package/lib/entity/task/taskPayload.d.ts +1 -0
  5. package/lib/entity/task/taskPayload.js +1 -0
  6. package/lib/entity/task/taskReducer.d.ts +4 -1
  7. package/lib/entity/task/taskReducer.js +19 -2
  8. package/lib/entity/task/taskState.d.ts +3 -0
  9. package/lib/entity/timeEntry/timeEntryPayload.d.ts +26 -0
  10. package/lib/entity/timeEntry/timeEntryPayload.js +2 -0
  11. package/lib/entity/timeEntry/timeEntryReducer.d.ts +8 -0
  12. package/lib/entity/timeEntry/timeEntryReducer.js +76 -0
  13. package/lib/entity/timeEntry/timeEntrySelector.d.ts +10 -0
  14. package/lib/entity/timeEntry/timeEntrySelector.js +32 -0
  15. package/lib/entity/timeEntry/timeEntryState.d.ts +43 -0
  16. package/lib/entity/timeEntry/timeEntryState.js +13 -0
  17. package/lib/epic.d.ts +4 -1
  18. package/lib/epic.js +9 -0
  19. package/lib/esm/entity/invoicing/invoice/invoicePayload.js +3 -0
  20. package/lib/esm/entity/task/taskPayload.js +1 -0
  21. package/lib/esm/entity/task/taskReducer.js +18 -1
  22. package/lib/esm/entity/timeEntry/timeEntryPayload.js +1 -0
  23. package/lib/esm/entity/timeEntry/timeEntryReducer.js +70 -0
  24. package/lib/esm/entity/timeEntry/timeEntrySelector.js +23 -0
  25. package/lib/esm/entity/timeEntry/timeEntryState.js +8 -0
  26. package/lib/esm/epic.js +9 -0
  27. package/lib/esm/index.js +4 -1
  28. package/lib/esm/reducer.js +6 -0
  29. package/lib/esm/view/expenseAutomationView/epics/accountRecon/fetchReconciliationViewEpic.js +2 -1
  30. package/lib/esm/view/taskManager/taskDetailView/taskDetailSelector.js +3 -0
  31. package/lib/esm/view/taskManager/taskListView/taskList.js +5 -0
  32. package/lib/esm/view/taskManager/taskListView/taskListFilterHelpers.js +2 -0
  33. package/lib/esm/view/taskManager/taskListView/taskListViewHelpers.js +37 -0
  34. package/lib/esm/view/taskManager/timeEntryView/epic/fetchTaskCategoriesEpic.js +24 -0
  35. package/lib/esm/view/taskManager/timeEntryView/epic/fetchTimeEntriesEpic.js +73 -0
  36. package/lib/esm/view/taskManager/timeEntryView/epic/writeTimeEntryEpic.js +77 -0
  37. package/lib/esm/view/taskManager/timeEntryView/timeEntryViewPayload.js +1 -0
  38. package/lib/esm/view/taskManager/timeEntryView/timeEntryViewReducer.js +160 -0
  39. package/lib/esm/view/taskManager/timeEntryView/timeEntryViewSelector.js +41 -0
  40. package/lib/esm/view/taskManager/timeEntryView/timeEntryViewState.js +1 -0
  41. package/lib/index.d.ts +5 -1
  42. package/lib/index.js +31 -16
  43. package/lib/reducer.d.ts +2 -0
  44. package/lib/reducer.js +6 -0
  45. package/lib/rootStateTypes.d.ts +4 -0
  46. package/lib/view/expenseAutomationView/epics/accountRecon/fetchReconciliationViewEpic.js +2 -1
  47. package/lib/view/taskManager/taskDetailView/taskDetailSelector.d.ts +4 -0
  48. package/lib/view/taskManager/taskDetailView/taskDetailSelector.js +3 -0
  49. package/lib/view/taskManager/taskListView/taskList.d.ts +1 -1
  50. package/lib/view/taskManager/taskListView/taskList.js +5 -0
  51. package/lib/view/taskManager/taskListView/taskListFilterHelpers.js +2 -0
  52. package/lib/view/taskManager/taskListView/taskListViewHelpers.d.ts +1 -1
  53. package/lib/view/taskManager/taskListView/taskListViewHelpers.js +37 -0
  54. package/lib/view/taskManager/timeEntryView/epic/fetchTaskCategoriesEpic.d.ts +18 -0
  55. package/lib/view/taskManager/timeEntryView/epic/fetchTaskCategoriesEpic.js +28 -0
  56. package/lib/view/taskManager/timeEntryView/epic/fetchTimeEntriesEpic.d.ts +23 -0
  57. package/lib/view/taskManager/timeEntryView/epic/fetchTimeEntriesEpic.js +78 -0
  58. package/lib/view/taskManager/timeEntryView/epic/writeTimeEntryEpic.d.ts +23 -0
  59. package/lib/view/taskManager/timeEntryView/epic/writeTimeEntryEpic.js +83 -0
  60. package/lib/view/taskManager/timeEntryView/timeEntryViewPayload.d.ts +27 -0
  61. package/lib/view/taskManager/timeEntryView/timeEntryViewPayload.js +2 -0
  62. package/lib/view/taskManager/timeEntryView/timeEntryViewReducer.d.ts +48 -0
  63. package/lib/view/taskManager/timeEntryView/timeEntryViewReducer.js +164 -0
  64. package/lib/view/taskManager/timeEntryView/timeEntryViewSelector.d.ts +24 -0
  65. package/lib/view/taskManager/timeEntryView/timeEntryViewSelector.js +46 -0
  66. package/lib/view/taskManager/timeEntryView/timeEntryViewState.d.ts +21 -0
  67. package/lib/view/taskManager/timeEntryView/timeEntryViewState.js +5 -0
  68. package/package.json +1 -1
@@ -0,0 +1,70 @@
1
+ import { createSlice } from '@reduxjs/toolkit';
2
+ import { clearAll } from '../../rootActions';
3
+ import { date } from '../../zeniDayJS';
4
+ import { toBillingType, toTimeEntrySource, } from './timeEntryState';
5
+ export const initialState = {
6
+ timeEntriesById: {},
7
+ taskCategoriesById: {},
8
+ };
9
+ const timeEntry = createSlice({
10
+ name: 'timeEntry',
11
+ initialState,
12
+ reducers: {
13
+ updateTimeEntries(draft, action) {
14
+ action.payload.forEach((payload) => {
15
+ const latest = mapTimeEntryPayloadToTimeEntry(payload);
16
+ const inStore = draft.timeEntriesById[latest.timeEntryId];
17
+ // Last-write-wins by updateTime, same as `address`. A weekly fetch and
18
+ // a single-entry edit can land in either order.
19
+ if (inStore == null || latest.updateTime.isAfter(inStore.updateTime)) {
20
+ draft.timeEntriesById[latest.timeEntryId] = latest;
21
+ }
22
+ });
23
+ },
24
+ /** A soft delete comes back as a normal entry; the server has already
25
+ * dropped it from the totals, so the client drops it from the store
26
+ * rather than keeping a row it must then remember to filter. */
27
+ removeTimeEntry(draft, action) {
28
+ delete draft.timeEntriesById[action.payload];
29
+ },
30
+ updateTaskCategories(draft, action) {
31
+ action.payload.forEach((payload) => {
32
+ const category = mapTaskCategoryPayloadToTaskCategory(payload);
33
+ draft.taskCategoriesById[category.taskCategoryId] = category;
34
+ });
35
+ },
36
+ },
37
+ extraReducers: (builder) => {
38
+ builder.addCase(clearAll, (draft) => {
39
+ Object.assign(draft, initialState);
40
+ });
41
+ },
42
+ });
43
+ export const { updateTimeEntries, removeTimeEntry, updateTaskCategories } = timeEntry.actions;
44
+ export default timeEntry.reducer;
45
+ export function mapTimeEntryPayloadToTimeEntry(payload) {
46
+ return {
47
+ timeEntryId: payload.time_entry_id,
48
+ taskId: payload.task_id,
49
+ userId: payload.user_id,
50
+ minutes: payload.minutes,
51
+ entryDate: date(payload.entry_date),
52
+ source: toTimeEntrySource(payload.source),
53
+ createTime: date(payload.create_time),
54
+ updateTime: date(payload.update_time),
55
+ billingType: toBillingType(payload.billing_type),
56
+ createdBy: payload.created_by ?? undefined,
57
+ department: payload.department ?? undefined,
58
+ taskCategoryId: payload.task_category_id ?? undefined,
59
+ };
60
+ }
61
+ export function mapTaskCategoryPayloadToTaskCategory(payload) {
62
+ return {
63
+ taskCategoryId: payload.task_category_id,
64
+ name: payload.name,
65
+ isActive: payload.is_active,
66
+ billingType: toBillingType(payload.billing_type),
67
+ service: payload.service ?? undefined,
68
+ vertical: payload.vertical ?? undefined,
69
+ };
70
+ }
@@ -0,0 +1,23 @@
1
+ import recordGet from 'lodash/get';
2
+ export function getTimeEntryById(timeEntryState, timeEntryId) {
3
+ return recordGet(timeEntryState.timeEntriesById, timeEntryId, undefined);
4
+ }
5
+ export function getTimeEntriesByIds(timeEntryState, timeEntryIds) {
6
+ return timeEntryIds
7
+ .map((timeEntryId) => getTimeEntryById(timeEntryState, timeEntryId))
8
+ .filter((entry) => entry != null);
9
+ }
10
+ export function getTaskCategoryById(timeEntryState, taskCategoryId) {
11
+ return recordGet(timeEntryState.taskCategoriesById, taskCategoryId, undefined);
12
+ }
13
+ /**
14
+ * The catalog, ordered service → vertical → name, matching the order the
15
+ * server sends so the dropdown reads the same on both sides.
16
+ */
17
+ export function getTaskCategories(timeEntryState, includeInactive = false) {
18
+ return Object.values(timeEntryState.taskCategoriesById)
19
+ .filter((category) => includeInactive || category.isActive)
20
+ .sort((left, right) => (left.service ?? '').localeCompare(right.service ?? '') ||
21
+ (left.vertical ?? '').localeCompare(right.vertical ?? '') ||
22
+ left.name.localeCompare(right.name));
23
+ }
@@ -0,0 +1,8 @@
1
+ import { stringToUnionStrict } from '../../commonStateTypes/stringToUnion';
2
+ export const ALL_TIME_ENTRY_SOURCES = ['timer', 'manual', 'migration'];
3
+ export const ALL_BILLING_TYPES = ['billable', 'non_billable'];
4
+ /** Unknown values become `undefined` rather than being cast through.
5
+ * A server that gains a source we do not know about must not land an
6
+ * unmatched string in a union the UI switches on. */
7
+ export const toTimeEntrySource = (raw) => raw == null ? undefined : stringToUnionStrict(raw, ALL_TIME_ENTRY_SOURCES);
8
+ export const toBillingType = (raw) => raw == null ? undefined : stringToUnionStrict(raw, ALL_BILLING_TYPES);
package/lib/esm/epic.js CHANGED
@@ -729,6 +729,9 @@ import { dragNDropTasksEpic, } from './view/taskManager/taskListView/epics/dragN
729
729
  import { fetchTaskListEpic, } from './view/taskManager/taskListView/epics/fetchTaskListEpic';
730
730
  import { fetchTaskListPageEpic, } from './view/taskManager/taskListView/epics/fetchTaskListPageEpic';
731
731
  import { updateTaskFromListViewEpic, } from './view/taskManager/taskListView/epics/updateTaskFromListViewEpic';
732
+ import { fetchTaskCategoriesEpic, } from './view/taskManager/timeEntryView/epic/fetchTaskCategoriesEpic';
733
+ import { fetchTimeEntriesForRangeEpic, fetchTimeEntriesForTaskEpic, } from './view/taskManager/timeEntryView/epic/fetchTimeEntriesEpic';
734
+ import { deleteTimeEntryEpic, logTimeEpic, updateTimeEntryEpic, } from './view/taskManager/timeEntryView/epic/writeTimeEntryEpic';
732
735
  import { fetchTasksCardEpic, } from './view/tasksCard/fetchTasksCardEpic';
733
736
  import { fetchTopExEpic, } from './view/topEx/topExEpic';
734
737
  import { fetchTransactionActivityLogEpic, } from './view/transactionActivityLogView/fetchTransactionActivityLogEpic';
@@ -880,6 +883,7 @@ const ALL_EPICS = [
880
883
  deleteTagEpic,
881
884
  deleteTaskEpic,
882
885
  deleteTaskGroupEpic,
886
+ deleteTimeEntryEpic,
883
887
  deleteTransactionAttachmentEpic,
884
888
  deleteUserBankAccountEpic,
885
889
  disableAccountingProjectsEpic,
@@ -1230,6 +1234,7 @@ const ALL_EPICS = [
1230
1234
  fetchSubscriptionViewEpic,
1231
1235
  fetchSubTasksEpic,
1232
1236
  fetchSuggestedQuestionsEpic,
1237
+ fetchTaskCategoriesEpic,
1233
1238
  fetchTaskDetailEpic,
1234
1239
  fetchTaskDetailPageEpic,
1235
1240
  fetchTaskGroupTemplatesEpic,
@@ -1238,6 +1243,8 @@ const ALL_EPICS = [
1238
1243
  fetchTaskListPageEpic,
1239
1244
  fetchTaskManagerMetricsEpic,
1240
1245
  fetchTasksCardEpic,
1246
+ fetchTimeEntriesForRangeEpic,
1247
+ fetchTimeEntriesForTaskEpic,
1241
1248
  fetchTopExEpic,
1242
1249
  fetchTransactionActivityLogEpic,
1243
1250
  fetchTransactionDetailEpic,
@@ -1330,6 +1337,7 @@ const ALL_EPICS = [
1330
1337
  issueCreditNoteEpic,
1331
1338
  lockChargeCardEpic,
1332
1339
  lockChargeCardsEpic,
1340
+ logTimeEpic,
1333
1341
  markAsCompleteScheduleDetailEpic,
1334
1342
  markTransactionAsNotMiscategorizedEpic,
1335
1343
  matchAndReverseAccrualsEpic,
@@ -1554,6 +1562,7 @@ const ALL_EPICS = [
1554
1562
  updateStatementInfoEpic,
1555
1563
  updateTaskFromListViewEpic,
1556
1564
  updateTaskGroupNameEpic,
1565
+ updateTimeEntryEpic,
1557
1566
  updateTransactionDetailEpic,
1558
1567
  updateVendorContactEpic,
1559
1568
  uploadAccountStatementEpic,
package/lib/esm/index.js CHANGED
@@ -75,6 +75,7 @@ import { getTaskGroupById } from './entity/taskGroup/taskGroupSelector';
75
75
  import { deleteConnection, doMagicLinkSignIn, doSignIn, doSignOut, fetchActiveTenant, fetchAllTenants, fetchExcludedResources as fetchExcludedResourcesForTenant, fetchExternalConnections as fetchExternalConnectionsForTenant, fetchSubscriptionSummaryForTenant, initEmailConnectOAuth, resendVerifyDeviceOTP, resetSignInState, saveAPIKeyConnection, saveConnectorCredentials, saveExternalConnection as saveExternalConnectionForTenant, saveOAuthConnection, sendEmailMagicLinkToUser, sendSessionHeartbeat, toExternalIntegrationType, updateCurrentTenant, updateSignInState, verifyDeviceWithTwoFA, } from './entity/tenant/tenantReducer';
76
76
  import { DEFAULT_RECEIPTS_EMAIL, getCurrentTenant, getIsAccountingClassesEnabled, getIsAccountingProjectsEnabled, getIsAutoReviewIncomeWithoutCustomerEnabled, getReceiptsEmail, getTenantBaseById, getTenantBaseViewForTenantId, getTenantBaseViewForTenantView, getTenantsBaseByCheckInDateSelector, getTenantsByCheckInDateSelector, getTenantsCoreDetailsByCheckInDateSelector, isOtherProductsEnabledForTenant, isTenantBankingOnly, isTenantBookkeepingEnabled, isTenantCardsOnly, isTenantUsingZeniCOA, isZeniDomainTenant, toTenantCoreDetailsView, } from './entity/tenant/tenantSelector';
77
77
  import { toExternalSupportedTool } from './entity/tenant/tenantTypes';
78
+ import { getTaskCategories, getTaskCategoryById, } from './entity/timeEntry/timeEntrySelector';
78
79
  import { pushToastNotification } from './entity/toastNotification/toastNotificationReducer';
79
80
  import { getLastNotificationTime, getNotifications, } from './entity/toastNotification/toastNotificationSelector';
80
81
  import { mapFileEntityToAttachment, toContentType, toContentTypeStrict, } from './entity/transaction/stateTypes/attachment';
@@ -421,6 +422,8 @@ import { ALL_TASK_LIST_TABS, TASK_LIST_FILTER_CATEGORIES, TASK_LIST_GROUP_BY_CAT
421
422
  import { bulkUpdateTaskList, dragNDropTasks, fetchTaskList, fetchTaskListPage, initiateTaskListLocalData, removeTaskFromList, updateTaskFilters, updateTaskFromListView, updateTaskListLocalData, updateTaskListSearchText, updateTaskListTab, updateTaskListUIState, } from './view/taskManager/taskListView/taskListReducer';
422
423
  import { getAllTasks, } from './view/taskManager/taskListView/taskListSelector';
423
424
  import { getDueDateValueFromDueDateGroupId, getTaskUpdates, sortSubtasks, } from './view/taskManager/taskListView/taskListViewHelpers';
425
+ import { deleteTimeEntry, fetchTaskCategories, fetchTimeEntriesForRange, fetchTimeEntriesForTask, logTime, updateTimeEntryMinutes, } from './view/taskManager/timeEntryView/timeEntryViewReducer';
426
+ import { getTimeEntriesForRange, getTimeEntriesForTask, getTimeEntrySaveState, } from './view/taskManager/timeEntryView/timeEntryViewSelector';
424
427
  import { fetchTasksCard } from './view/tasksCard/tasksCardReducer';
425
428
  import { fetchTopEx, updateTopExDownloadState, updateTopExTimeFrame, } from './view/topEx/topExReducer';
426
429
  import { getAllTopExpenses, getTop6Expenses, getTopExpenses, } from './view/topEx/topExSelector';
@@ -678,7 +681,7 @@ export { fetchGlobalMerchantRecommendation, createGlobalMerchant, updateCreateGl
678
681
  export { approveVendorGlobalReview, rejectVendorGlobalReview, fetchVendorGlobalReviewView, updateSelectedGlobalMerchant, updateVendorGlobalReviewViewUIState, getVendorGlobalReviewView, getTenantMerchantByMerchantId, toVendorGlobalReviewColumnSortKeyType, updateVendorGlobalReviewViewLocalData, };
679
682
  export { fetchArAging, updateArAgingUIState, fetchArAgingDetail, updateArAgingDetailUIState, getArAgingReport, getArAgingDetailForCustomer, updateArAgingNodeCollapseState, };
680
683
  export { toRecurringBillFrequency, toRecurringBillFrequencyStrict, };
681
- export { getTaskGroupById, getAllTasks, fetchTaskList, fetchTaskListPage, getTaskDetail, fetchTaskDetailPage, saveTaskUpdatesToLocalStore, saveTaskDetail, createSubTask, resetSubTaskCreateStatus, fetchSubTasks, archiveTask, fetchCannedResponses, saveCannedResponse, deleteCannedResponse, getCannedResponsesView, updateTaskListSearchText, updateTaskListUIState, allTaskStatus, allTaskPriority, updateTaskFilters, TASK_LIST_FILTER_CATEGORIES, TASK_LIST_GROUP_BY_CATEGORIES, deleteTask, discardTaskUpdatesInLocalStore, bulkUpdateTaskList, createNewTaskGroup, initiateTaskListLocalData, deleteTaskGroup, updateTaskListLocalData, dragNDropTasks, updateTaskGroupName, toTaskListGroupByKeyType, toTaskListGroupByKeyTypeStrict, getTaskUpdates, sortSubtasks, fetchAllTaskGroups, initialTaskDetail, getDueDateValueFromDueDateGroupId, toPriorityCodeType, toTaskStatusCodeType, toDueDateGroupKeyType, updateTaskFromListView, updateTaskListTab, removeTaskFromList, snoozeTask, unsnoozeTask, convertHHMMStrToMinutes, initialTaskDetailLocalData, ALL_TASK_LIST_TABS, };
684
+ export { getTaskGroupById, getAllTasks, getTaskCategories, getTaskCategoryById, getTimeEntriesForRange, getTimeEntriesForTask, getTimeEntrySaveState, fetchTimeEntriesForTask, fetchTimeEntriesForRange, fetchTaskCategories, logTime, updateTimeEntryMinutes, deleteTimeEntry, fetchTaskList, fetchTaskListPage, getTaskDetail, fetchTaskDetailPage, saveTaskUpdatesToLocalStore, saveTaskDetail, createSubTask, resetSubTaskCreateStatus, fetchSubTasks, archiveTask, fetchCannedResponses, saveCannedResponse, deleteCannedResponse, getCannedResponsesView, updateTaskListSearchText, updateTaskListUIState, allTaskStatus, allTaskPriority, updateTaskFilters, TASK_LIST_FILTER_CATEGORIES, TASK_LIST_GROUP_BY_CATEGORIES, deleteTask, discardTaskUpdatesInLocalStore, bulkUpdateTaskList, createNewTaskGroup, initiateTaskListLocalData, deleteTaskGroup, updateTaskListLocalData, dragNDropTasks, updateTaskGroupName, toTaskListGroupByKeyType, toTaskListGroupByKeyTypeStrict, getTaskUpdates, sortSubtasks, fetchAllTaskGroups, initialTaskDetail, getDueDateValueFromDueDateGroupId, toPriorityCodeType, toTaskStatusCodeType, toDueDateGroupKeyType, updateTaskFromListView, updateTaskListTab, removeTaskFromList, snoozeTask, unsnoozeTask, convertHHMMStrToMinutes, initialTaskDetailLocalData, ALL_TASK_LIST_TABS, };
682
685
  export { getAllTags, fetchTagList, createTag, deleteTag, };
683
686
  export { getAllCardsAndBankPaymentMethods, createCardSetupIntent, confirmCardSetupIntent, addCardPaymentSource, fetchPaymentSources, resetCardPaymentErrorStatuses, clearCardPaymentView, };
684
687
  export { getAuditReportGroupViewSelectorView, getAuditRuleGroupViewSelectorView, getUserFromAllUsers, fetchAuditRuleGroupView, fetchAuditReportGroupView, saveReasonForAuditRule, clearAuditReportGroupViewByCompanyId, };
@@ -70,6 +70,7 @@ import taskGroup, { initialState as initialTaskGroupState, } from './entity/task
70
70
  import taskGroupTemplate, { initialState as initialTaskGroupTemplateState, } from './entity/taskGroupTemplate/taskGroupTemplateReducer';
71
71
  import taskSummary, { initialState as initailTaskSummaryState, } from './entity/taskSummary/taskSummaryReducer';
72
72
  import tenant, { initialState as initialTenantState, } from './entity/tenant/tenantReducer';
73
+ import timeEntry, { initialState as initialTimeEntryState, } from './entity/timeEntry/timeEntryReducer';
73
74
  import toastNotification, { initialState as initialToastNotificationState, } from './entity/toastNotification/toastNotificationReducer';
74
75
  import transaction, { initialState as initialTransactionState, } from './entity/transaction/transactionReducer';
75
76
  import transactionActivityLog, { initialState as initialTransactionActivityLogState, } from './entity/transactionActivityLog/transactionActivityLogReducer';
@@ -255,6 +256,7 @@ import taskDetailView, { initialState as initialTaskDetailState, } from './view/
255
256
  import taskGroupTemplateView, { initialState as initialTaskGroupTemplateViewState, } from './view/taskManager/taskGroupTemplateView/taskGroupTemplateViewReducer';
256
257
  import taskGroupView, { initialState as initialTaskGroupViewState, } from './view/taskManager/taskGroupView/taskGroupViewReducer';
257
258
  import taskList, { initialState as initialTaskListState, } from './view/taskManager/taskListView/taskListReducer';
259
+ import timeEntryView, { initialState as initialTimeEntryViewState, } from './view/taskManager/timeEntryView/timeEntryViewReducer';
258
260
  import tasksCard, { initialState as initialTasksCardState, } from './view/tasksCard/tasksCardReducer';
259
261
  import topEx, { initialState as initialTopExState, } from './view/topEx/topExReducer';
260
262
  import transactionActivityLogView, { initialState as initialTransactionActivityLogViewState, } from './view/transactionActivityLogView/transactionActivityLogViewReducer';
@@ -343,6 +345,7 @@ const initialEntitiesState = {
343
345
  subscriptionState: initialSubscriptionState,
344
346
  subscriptionSummaryState: initialSubscriptionSummaryState,
345
347
  tagState: initialTagState,
348
+ timeEntryState: initialTimeEntryState,
346
349
  taskGroupState: initialTaskGroupState,
347
350
  taskGroupTemplateState: initialTaskGroupTemplateState,
348
351
  taskState: initialTaskState,
@@ -524,6 +527,7 @@ const initialViewsState = {
524
527
  taskGroupTemplateViewState: initialTaskGroupTemplateViewState,
525
528
  taskGroupViewState: initialTaskGroupViewState,
526
529
  taskListState: initialTaskListState,
530
+ timeEntryViewState: initialTimeEntryViewState,
527
531
  tasksCardState: initialTasksCardState,
528
532
  topExState: initialTopExState,
529
533
  transactionActivityLogViewState: initialTransactionActivityLogViewState,
@@ -632,6 +636,7 @@ const entityReducers = {
632
636
  subscriptionState: subscription,
633
637
  subscriptionSummaryState: subscriptionSummary,
634
638
  tagState: tag,
639
+ timeEntryState: timeEntry,
635
640
  taskGroupState: taskGroup,
636
641
  taskGroupTemplateState: taskGroupTemplate,
637
642
  taskState: task,
@@ -812,6 +817,7 @@ const viewReducers = {
812
817
  taskGroupTemplateViewState: taskGroupTemplateView,
813
818
  taskGroupViewState: taskGroupView,
814
819
  taskListState: taskList,
820
+ timeEntryViewState: timeEntryView,
815
821
  tagViewState: tagView,
816
822
  tasksCardState: tasksCard,
817
823
  topExState: topEx,
@@ -26,9 +26,10 @@ export const fetchReconciliationViewEpic = (actions$, state$, zeniAPI) => action
26
26
  }
27
27
  let fetchState;
28
28
  if (accountId != null) {
29
+ // Period bucket can be unhydrated during app-shell load.
29
30
  fetchState =
30
31
  accountReconciliationRecords[toMonthYearPeriodId(selectedPeriod)]
31
- .reconciliationByAccountID[accountId].transactionFetchState
32
+ ?.reconciliationByAccountID[accountId]?.transactionFetchState
32
33
  .fetchState;
33
34
  }
34
35
  else {
@@ -26,6 +26,7 @@ export const getTaskDetail = (state, taskId) => {
26
26
  let isDeleted = undefined;
27
27
  let depth = undefined;
28
28
  let recurringSourceTaskId = undefined;
29
+ let taskCategoryId = undefined;
29
30
  let snoozedUntil = undefined;
30
31
  let taskGroupId = undefined;
31
32
  let subtasks = [];
@@ -39,6 +40,7 @@ export const getTaskDetail = (state, taskId) => {
39
40
  if (taskEntity != null) {
40
41
  createdByUser = getUserAndUserRole(userState, userRoleState, addressState, taskEntity.createdBy);
41
42
  depth = taskEntity.depth;
43
+ taskCategoryId = taskEntity.taskCategoryId;
42
44
  isArchived = taskEntity.isArchived;
43
45
  isDeleted = taskEntity.isDeleted;
44
46
  recurringSourceTaskId = taskEntity.recurringSourceTaskId;
@@ -103,6 +105,7 @@ export const getTaskDetail = (state, taskId) => {
103
105
  version: 0,
104
106
  createdByUser,
105
107
  depth,
108
+ taskCategoryId,
106
109
  isArchived,
107
110
  isDeleted,
108
111
  recurringSourceTaskId,
@@ -47,6 +47,11 @@ export const TASK_LIST_FILTER_CATEGORIES = [
47
47
  type: 'user',
48
48
  multiple: true,
49
49
  },
50
+ {
51
+ value: 'createdBy',
52
+ type: 'user',
53
+ multiple: true,
54
+ },
50
55
  { value: 'creationDate', type: 'dateRange', multiple: true },
51
56
  ];
52
57
  export const TASK_LIST_GROUP_BY_CATEGORIES = [
@@ -12,6 +12,8 @@ export const getCategoryValueForTaskList = (key, task) => {
12
12
  return task.createTime;
13
13
  case 'assignee':
14
14
  return task.assignees;
15
+ case 'createdBy':
16
+ return task.createdBy;
15
17
  default:
16
18
  return undefined;
17
19
  }
@@ -1,3 +1,4 @@
1
+ import { convertHHMMStrToMinutes } from '../../../commonStateTypes/fiscalYearHelpers/formatMinutesToFromHHMM';
1
2
  import { dateNow } from '../../../zeniDayJS';
2
3
  import { allTaskPriority, allTaskStatus, } from '../taskDetailView/taskDetailSelector';
3
4
  import { toDueDateGroupKeyType } from './taskList';
@@ -63,6 +64,11 @@ export const sortSubtasks = (subtasks, sortKey, sortOrder, userList, tagList) =>
63
64
  return acc;
64
65
  }, {});
65
66
  const unknownStatusRank = allTaskStatus.length;
67
+ const priorityOrder = allTaskPriority.reduce((acc, priority, index) => {
68
+ acc[priority.code] = index;
69
+ return acc;
70
+ }, {});
71
+ const unknownPriorityRank = allTaskPriority.length;
66
72
  const cmpStr = (a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' });
67
73
  const keyFn = (task) => {
68
74
  if (sortKey === 'name') {
@@ -89,6 +95,37 @@ export const sortSubtasks = (subtasks, sortKey, sortOrder, userList, tagList) =>
89
95
  const tag = tagList.find((candidate) => candidate.tagId === firstTagId);
90
96
  return tag?.name ?? '';
91
97
  }
98
+ if (sortKey === 'priority') {
99
+ // Rank by the canonical order, not the code string: alphabetical would
100
+ // sort "high" before "urgent" and read as wrong to anyone scanning it.
101
+ return priorityOrder[task.priority?.code] ?? unknownPriorityRank;
102
+ }
103
+ if (sortKey === 'dueDate') {
104
+ // A task with no date is not "earliest", so undated rows belong at the
105
+ // bottom in BOTH directions. The comparator negates for 'desc', so one
106
+ // fixed sentinel would flip them to the top — the sentinel has to flip
107
+ // with the order to survive that negation.
108
+ // `dueDate` is already a ZeniDate (dayjs), so valueOf() is the epoch.
109
+ // An unparseable date yields NaN, and a NaN comparator leaves the array
110
+ // untouched -- the same silent failure the timeSpent branch guards.
111
+ // Treat it as undated rather than as a date.
112
+ const due = task.dueDate;
113
+ const epoch = due?.valueOf();
114
+ if (epoch == null || Number.isNaN(epoch)) {
115
+ return sortOrder === 'asc'
116
+ ? Number.MAX_SAFE_INTEGER
117
+ : Number.MIN_SAFE_INTEGER;
118
+ }
119
+ return epoch;
120
+ }
121
+ if (sortKey === 'timeSpent') {
122
+ // `timeSpent` is an "HHMM" STRING (no colon). Comparing it as text puts
123
+ // "1000" before "0900" only by luck of padding, and any malformed value
124
+ // makes the converter return NaN -- which a comparator turns into "no
125
+ // sort at all", silently. Coerce that to 0.
126
+ const minutes = convertHHMMStrToMinutes(task.timeSpent ?? '');
127
+ return Number.isFinite(minutes) ? minutes : 0;
128
+ }
92
129
  return '';
93
130
  };
94
131
  const copy = [...subtasks];
@@ -0,0 +1,24 @@
1
+ import { of } from 'rxjs';
2
+ import { catchError, filter, switchMap } from 'rxjs/operators';
3
+ import { mergeMap } from 'rxjs/operators';
4
+ import { updateTaskCategories } from '../../../../entity/timeEntry/timeEntryReducer';
5
+ import { createZeniAPIStatus, isSuccessResponse, } from '../../../../responsePayload';
6
+ import { fetchTaskCategories, fetchTaskCategoriesFailure, fetchTaskCategoriesSuccess, } from '../timeEntryViewReducer';
7
+ /**
8
+ * The 156-category catalog that fills the Category dropdown.
9
+ *
10
+ * `switchMap` here, unlike the entry epics: there is one catalog, so a second
11
+ * request supersedes the first rather than racing it.
12
+ *
13
+ * Zeni-only on the server (the billing marker is commercially sensitive), so
14
+ * a customer user gets a 401 — surfaced as Error rather than an empty list,
15
+ * because an empty catalog looks like "no categories exist".
16
+ */
17
+ export const fetchTaskCategoriesEpic = (actions$, _state$, zeniAPI) => actions$.pipe(filter(fetchTaskCategories.match), switchMap(() => zeniAPI
18
+ .getJSON(`${zeniAPI.apiEndPoints.taskMicroServiceBaseUrl}/1.0/task-manager/task-categories`)
19
+ .pipe(mergeMap((response) => {
20
+ if (isSuccessResponse(response) && response.data != null) {
21
+ return of(updateTaskCategories(response.data.task_categories ?? []), fetchTaskCategoriesSuccess());
22
+ }
23
+ return of(fetchTaskCategoriesFailure(response.status));
24
+ }), catchError((error) => of(fetchTaskCategoriesFailure(createZeniAPIStatus('Unexpected Error', 'Fetch task categories errored out' + JSON.stringify(error))))))));
@@ -0,0 +1,73 @@
1
+ import { of } from 'rxjs';
2
+ import { catchError, filter, mergeMap } from 'rxjs/operators';
3
+ import { updateTimeEntries } from '../../../../entity/timeEntry/timeEntryReducer';
4
+ import { createZeniAPIStatus, isSuccessResponse, } from '../../../../responsePayload';
5
+ import { fetchTimeEntriesForRange, fetchTimeEntriesForRangeFailure, fetchTimeEntriesForRangeSuccess, fetchTimeEntriesForTask, fetchTimeEntriesForTaskFailure, fetchTimeEntriesForTaskSuccess, } from '../timeEntryViewReducer';
6
+ import { toTimeEntryRangeKey } from '../timeEntryViewState';
7
+ /**
8
+ * Every entry on one task, all users — the task detail breakdown.
9
+ *
10
+ * `mergeMap`, not `switchMap`: a detail page and an expanded board row can
11
+ * ask for two different tasks at once, and cancelling the first would leave
12
+ * its breakdown stuck In-Progress forever.
13
+ */
14
+ export const fetchTimeEntriesForTaskEpic = (actions$, state$, zeniAPI) => actions$.pipe(filter(fetchTimeEntriesForTask.match), mergeMap((action) => {
15
+ const { taskId, cacheOverride } = action.payload;
16
+ const cached = state$.value.timeEntryViewState.timeEntryIdsByTaskId[taskId];
17
+ const status = state$.value.timeEntryViewState.fetchStateByTaskId[taskId];
18
+ // The reducer has already flipped this request to In-Progress, so the
19
+ // guard reads the COMPLETED cache, never the fetch state -- a fetch
20
+ // state is not an in-flight signal.
21
+ if (cacheOverride !== true &&
22
+ cached != null &&
23
+ status?.fetchState === 'In-Progress') {
24
+ return of(fetchTimeEntriesForTaskSuccess({ taskId, timeEntryIds: cached }));
25
+ }
26
+ return zeniAPI
27
+ .getJSON(`${zeniAPI.apiEndPoints.taskMicroServiceBaseUrl}/1.0/task-manager/tasks/${taskId}/time-entries`)
28
+ .pipe(mergeMap((response) => {
29
+ if (isSuccessResponse(response) && response.data != null) {
30
+ const entries = response.data.time_entries ?? [];
31
+ return of(updateTimeEntries(entries), fetchTimeEntriesForTaskSuccess({
32
+ taskId,
33
+ timeEntryIds: entries.map((entry) => entry.time_entry_id),
34
+ }));
35
+ }
36
+ return of(fetchTimeEntriesForTaskFailure({ taskId, status: response.status }));
37
+ }), catchError((error) => of(fetchTimeEntriesForTaskFailure({
38
+ taskId,
39
+ status: createZeniAPIStatus('Unexpected Error', 'Fetch time entries for task errored out' +
40
+ JSON.stringify(error)),
41
+ }))));
42
+ }));
43
+ /**
44
+ * One user's entries in an inclusive date range — the weekly timesheet.
45
+ *
46
+ * Needs the `is_deleted + user_id + entry_date` composite index (api #593).
47
+ * Without it the server returns 500, not an empty list, so the failure is
48
+ * surfaced rather than rendered as a blank week.
49
+ */
50
+ export const fetchTimeEntriesForRangeEpic = (actions$, _state$, zeniAPI) => actions$.pipe(filter(fetchTimeEntriesForRange.match), mergeMap((action) => {
51
+ const { userId, startDate, endDate } = action.payload;
52
+ const rangeKey = toTimeEntryRangeKey(userId, startDate, endDate);
53
+ const query = `user_id=${encodeURIComponent(userId)}&start_date=${encodeURIComponent(startDate)}&end_date=${encodeURIComponent(endDate)}`;
54
+ return zeniAPI
55
+ .getJSON(`${zeniAPI.apiEndPoints.taskMicroServiceBaseUrl}/1.0/task-manager/time-entries?${query}`)
56
+ .pipe(mergeMap((response) => {
57
+ if (isSuccessResponse(response) && response.data != null) {
58
+ const entries = response.data.time_entries ?? [];
59
+ return of(updateTimeEntries(entries), fetchTimeEntriesForRangeSuccess({
60
+ rangeKey,
61
+ timeEntryIds: entries.map((entry) => entry.time_entry_id),
62
+ }));
63
+ }
64
+ return of(fetchTimeEntriesForRangeFailure({
65
+ rangeKey,
66
+ status: response.status,
67
+ }));
68
+ }), catchError((error) => of(fetchTimeEntriesForRangeFailure({
69
+ rangeKey,
70
+ status: createZeniAPIStatus('Unexpected Error', 'Fetch time entries for range errored out' +
71
+ JSON.stringify(error)),
72
+ }))));
73
+ }));
@@ -0,0 +1,77 @@
1
+ import { of } from 'rxjs';
2
+ import { catchError, filter, mergeMap } from 'rxjs/operators';
3
+ import { updateTaskTimeSpentMinutes } from '../../../../entity/task/taskReducer';
4
+ import { removeTimeEntry, updateTimeEntries, } from '../../../../entity/timeEntry/timeEntryReducer';
5
+ import { getTimeEntryById } from '../../../../entity/timeEntry/timeEntrySelector';
6
+ import { createZeniAPIStatus, isSuccessResponse, } from '../../../../responsePayload';
7
+ import { deleteTimeEntry, logTime, logTimeFailure, logTimeSuccess, timeEntryWriteFailure, timeEntryWriteSuccess, updateTimeEntryMinutes, } from '../timeEntryViewReducer';
8
+ const unexpected = (what, error) => createZeniAPIStatus('Unexpected Error', `${what} errored out${JSON.stringify(error)}`);
9
+ /**
10
+ * Log time against a task.
11
+ *
12
+ * `mergeMap`, not `switchMap`: two rows on the board can be edited in quick
13
+ * succession and cancelling the first would silently drop that entry.
14
+ */
15
+ export const logTimeEpic = (actions$, _state$, zeniAPI) => actions$.pipe(filter(logTime.match), mergeMap((action) => {
16
+ const { taskId, minutes, entryDate, source, identifier } = action.payload;
17
+ return zeniAPI
18
+ .postAndGetJSON(`${zeniAPI.apiEndPoints.taskMicroServiceBaseUrl}/1.0/task-manager/tasks/${taskId}/time-entries`, {
19
+ minutes,
20
+ entry_date: entryDate,
21
+ source,
22
+ // Sent only when the caller has one. The server treats it as the
23
+ // entity key, which is what makes a retried timer stop idempotent.
24
+ ...(identifier != null ? { identifier } : {}),
25
+ })
26
+ .pipe(mergeMap((response) => {
27
+ if (isSuccessResponse(response) && response.data != null) {
28
+ const { time_entry, task_time_spent_minutes } = response.data;
29
+ return of(updateTimeEntries([time_entry]),
30
+ // The board reads `Task.time_spent` per row, so it has to be
31
+ // told the new total; otherwise the row keeps the old number
32
+ // until something refetches the whole list.
33
+ updateTaskTimeSpentMinutes({
34
+ taskId,
35
+ minutes: task_time_spent_minutes ?? 0,
36
+ }), logTimeSuccess({ taskId, timeEntryId: time_entry.time_entry_id }));
37
+ }
38
+ return of(logTimeFailure({ taskId, status: response.status }));
39
+ }), catchError((error) => of(logTimeFailure({ taskId, status: unexpected('Log time', error) }))));
40
+ }));
41
+ /** Correct an entry's minutes. Owner, or a Zeni user as the admin path. */
42
+ export const updateTimeEntryEpic = (actions$, _state$, zeniAPI) => actions$.pipe(filter(updateTimeEntryMinutes.match), mergeMap((action) => {
43
+ const { timeEntryId, minutes } = action.payload;
44
+ return zeniAPI
45
+ .putAndGetJSON(`${zeniAPI.apiEndPoints.taskMicroServiceBaseUrl}/1.0/task-manager/time-entries/${timeEntryId}`, { minutes })
46
+ .pipe(mergeMap((response) => {
47
+ if (isSuccessResponse(response) && response.data != null) {
48
+ return of(updateTimeEntries([response.data.time_entry]), timeEntryWriteSuccess({ timeEntryId }));
49
+ }
50
+ return of(timeEntryWriteFailure({ timeEntryId, status: response.status }));
51
+ }), catchError((error) => of(timeEntryWriteFailure({
52
+ timeEntryId,
53
+ status: unexpected('Update time entry', error),
54
+ }))));
55
+ }));
56
+ /**
57
+ * Soft delete. The row stays in Datastore for the audit trail but leaves the
58
+ * totals, so the client drops it from the store rather than keeping something
59
+ * every reader would then have to remember to filter.
60
+ */
61
+ export const deleteTimeEntryEpic = (actions$, state$, zeniAPI) => actions$.pipe(filter(deleteTimeEntry.match), mergeMap((action) => {
62
+ const timeEntryId = action.payload;
63
+ // Read the task id BEFORE the delete: the entry is about to leave the
64
+ // store, and the view slice needs to know which task's id list to prune.
65
+ const taskId = getTimeEntryById(state$.value.timeEntryState, timeEntryId)?.taskId;
66
+ return zeniAPI
67
+ .deleteAndGetJSON(`${zeniAPI.apiEndPoints.taskMicroServiceBaseUrl}/1.0/task-manager/time-entries/${timeEntryId}`)
68
+ .pipe(mergeMap((response) => {
69
+ if (isSuccessResponse(response)) {
70
+ return of(removeTimeEntry(timeEntryId), timeEntryWriteSuccess({ timeEntryId, removedFromTaskId: taskId }));
71
+ }
72
+ return of(timeEntryWriteFailure({ timeEntryId, status: response.status }));
73
+ }), catchError((error) => of(timeEntryWriteFailure({
74
+ timeEntryId,
75
+ status: unexpected('Delete time entry', error),
76
+ }))));
77
+ }));