diff --git a/package-lock.json b/package-lock.json index 768ca8a9b..29630d587 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4006,9 +4006,9 @@ } }, "node_modules/@webitel/api-services": { - "version": "26.8.67", - "resolved": "https://registry.npmjs.org/@webitel/api-services/-/api-services-26.8.67.tgz", - "integrity": "sha512-SyslVNA4beyveqSfWySE7yuNPX8k29fX06Sll+5dPoq2kQHhv4lBoBFa7aASoF1EkR83qu2in8843WP50JQhyw==", + "version": "26.8.68", + "resolved": "https://registry.npmjs.org/@webitel/api-services/-/api-services-26.8.68.tgz", + "integrity": "sha512-X86BZSsLBBwnXs65Q4QBFE8414OZL1uysNKFD1KoookDxKHGT3QGgu+l8IOBuX0P3/YBPfGqidcyJGufeRzKOQ==", "dependencies": { "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", diff --git a/src/app/locale/en/en.js b/src/app/locale/en/en.js index d48d8b561..0150ad18b 100644 --- a/src/app/locale/en/en.js +++ b/src/app/locale/en/en.js @@ -897,6 +897,7 @@ export default { allMembers: 'All members', addMember: 'Add member', destination: 'Destination', + communications: 'Communications', display: 'Display number', dtmf: 'DTMF', priority: ({ linked }) => linked('objects.memberPriority'), diff --git a/src/app/locale/es/es.js b/src/app/locale/es/es.js index bae2b4470..5b5041371 100644 --- a/src/app/locale/es/es.js +++ b/src/app/locale/es/es.js @@ -906,6 +906,7 @@ export default { allMembers: 'Todos los miembros', addMember: 'Añadir miembro', destination: 'Destino', + communications: 'Comunicaciones', display: 'Número de visualización', dtmf: 'DTMF', priority: ({ linked }) => linked('objects.memberPriority'), diff --git a/src/app/locale/kz/kz.js b/src/app/locale/kz/kz.js index 65972a007..ec8012055 100644 --- a/src/app/locale/kz/kz.js +++ b/src/app/locale/kz/kz.js @@ -898,6 +898,7 @@ export default { allMembers: 'Барлық меншіктер', addMember: 'Меншікті қосу', destination: 'Мақсат', + communications: 'Коммуникациялар', display: 'Көрсету нөмірі', dtmf: 'DTMF', priority: ({ linked }) => linked('objects.memberPriority'), diff --git a/src/app/locale/pl/pl.js b/src/app/locale/pl/pl.js index 49bd000c9..a1c8da1aa 100644 --- a/src/app/locale/pl/pl.js +++ b/src/app/locale/pl/pl.js @@ -901,6 +901,7 @@ export default { allMembers: 'Wszystkie członkowie', addMember: 'Dodaj członka', destination: 'Miejsce docelowe', + communications: 'Komunikacja', display: 'Numer wyświetlany', dtmf: 'DTMF', priority: ({ linked }) => linked('objects.memberPriority'), diff --git a/src/app/locale/ro/ro.js b/src/app/locale/ro/ro.js index bd9bbe7b5..6cb34d878 100644 --- a/src/app/locale/ro/ro.js +++ b/src/app/locale/ro/ro.js @@ -903,6 +903,7 @@ export default { allMembers: 'Toți membrii', addMember: 'Adaugă membru', destination: 'Destinație', + communications: 'Comunicații', display: 'Număr de afișare', dtmf: 'DTMF', priority: ({ linked }) => linked('objects.memberPriority'), diff --git a/src/app/locale/ru/ru.js b/src/app/locale/ru/ru.js index d54110c1b..ee38d24f3 100644 --- a/src/app/locale/ru/ru.js +++ b/src/app/locale/ru/ru.js @@ -908,6 +908,7 @@ export default { allMembers: 'Все абоненты', addMember: 'добавить абонента', destination: 'Назначение', + communications: 'Коммуникации', display: 'Отображение номера', dtmf: 'DTMF', priority: ({ linked }) => linked('objects.memberPriority'), diff --git a/src/app/locale/uk/uk.js b/src/app/locale/uk/uk.js index 2c66b0df0..3162f2013 100644 --- a/src/app/locale/uk/uk.js +++ b/src/app/locale/uk/uk.js @@ -911,6 +911,7 @@ export default { allMembers: 'Всі абоненти', addMember: 'Додати абонента', destination: 'Призначення', + communications: 'Комунікації', display: 'Відображення номера', dtmf: 'DTMF', priority: ({ linked }) => linked('objects.memberPriority'), diff --git a/src/app/locale/uz/uz.js b/src/app/locale/uz/uz.js index 2219888ee..8e8823721 100644 --- a/src/app/locale/uz/uz.js +++ b/src/app/locale/uz/uz.js @@ -905,6 +905,7 @@ export default { allMembers: "Barcha a'zolchalar", addMember: "A'zolchaga qo'shish", destination: "Ma'lumotnoma", + communications: 'Aloqalar', display: "Ko'rsatish raqami", dtmf: 'DTMF', priority: ({ linked }) => linked('objects.memberPriority'), diff --git a/src/app/locale/vi/vi.js b/src/app/locale/vi/vi.js index 27baba9c2..05ef927e6 100644 --- a/src/app/locale/vi/vi.js +++ b/src/app/locale/vi/vi.js @@ -898,6 +898,7 @@ export default { allMembers: 'Tất cả thành viên', addMember: 'Thêm thành viên', destination: 'Đích', + communications: 'Liên lạc', display: 'Số hiển thị', dtmf: 'DTMF', priority: ({ linked }) => linked('objects.memberPriority'), diff --git a/src/modules/contact-center/modules/queues/modules/members/components/communications/opened-queue-member-destinations-popup.vue b/src/modules/contact-center/modules/queues/modules/members/components/communications/opened-queue-member-destinations-popup.vue deleted file mode 100644 index 8d81fb083..000000000 --- a/src/modules/contact-center/modules/queues/modules/members/components/communications/opened-queue-member-destinations-popup.vue +++ /dev/null @@ -1,75 +0,0 @@ - - - - {{ t('objects.ccenter.queues.destination', 2) }} - - - - - - {{ item.destination }} - - - - {{ item.type.name }} - - - - {{ item.priority }} - - - - - - - {{ t('objects.ok') }} - - - {{ t('objects.close') }} - - - - - - - - diff --git a/src/modules/contact-center/modules/queues/modules/members/components/communications/queue-member-communications-popup.vue b/src/modules/contact-center/modules/queues/modules/members/components/communications/queue-member-communications-popup.vue new file mode 100644 index 000000000..65dada236 --- /dev/null +++ b/src/modules/contact-center/modules/queues/modules/members/components/communications/queue-member-communications-popup.vue @@ -0,0 +1,124 @@ + + + + {{ t('objects.ccenter.members.communications') }} + + + + + + + + + {{ item.destination }} + + + + {{ item.type.name }} + + + + {{ item.priority }} + + + + + + + + {{ t('objects.close') }} + + + + + + + + diff --git a/src/modules/contact-center/modules/queues/modules/members/components/the-queue-members.vue b/src/modules/contact-center/modules/queues/modules/members/components/the-queue-members.vue index c24ba26ee..a80dbad43 100644 --- a/src/modules/contact-center/modules/queues/modules/members/components/the-queue-members.vue +++ b/src/modules/contact-center/modules/queues/modules/members/components/the-queue-members.vue @@ -49,10 +49,11 @@ - - - {{ item.communications[0].destination }} - - +{{ item.communications.length - 1 }} - - + + + +{{ item.communications.length - 1 }} + + + {{ item.attempts || 0 }} @@ -219,6 +220,15 @@ {{ item.agent.name }} + + {{ item.bucket?.name }} + + + {{ asDate(item.expireAt) }} + + + {{ item.timezone?.name }} + @@ -278,6 +288,7 @@ import { DynamicFilterSearchComponent as DynamicFilterSearch, TableFiltersPanelComponent as TableFiltersPanel, } from '@webitel/ui-datalist/filters'; +import { WtDisplayChipItems } from '@webitel/ui-sdk/components'; import { FormatDateMode, IconAction } from '@webitel/ui-sdk/enums'; import DeleteConfirmationPopup from '@webitel/ui-sdk/src/modules/DeleteConfirmationPopup/components/delete-confirmation-popup.vue'; import { useDeleteConfirmationPopup } from '@webitel/ui-sdk/src/modules/DeleteConfirmationPopup/composables/useDeleteConfirmationPopup'; @@ -309,7 +320,7 @@ import { import { filterConfigs, filtersOptions } from '../configs/filtersOptions'; import { useQueueMembersDatalistStore } from '../stores/datalist/queueMembersDatalistStore'; import { ActionOptions } from '../types/ActionOptions'; -import DestinationsPopup from './communications/opened-queue-member-destinations-popup.vue'; +import CommunicationsPopup from './communications/queue-member-communications-popup.vue'; import ExportPopup from './export-members-popup.vue'; import QueueMembersColumnFilter from './queue-members-column-filter.vue'; import ResetPopup from './reset-members-popup.vue'; @@ -378,7 +389,7 @@ const { const isFiltersPanelShown = ref(false); const isExportPopup = ref(false); const csvFile = ref(null); -const destinationsOnPopup = ref(null); +const communicationsMemberId = ref(null); const fileInput = useTemplateRef('fileInput'); const path = computed(() => { @@ -398,6 +409,11 @@ const path = computed(() => { ]; }); +const communicationValues = (communications?: EngineMemberCommunication[]) => + (communications ?? []).map(({ destination }) => ({ + name: destination, + })); + const asDate = (value?: number | string) => formatDate(value, FormatDateMode.DATETIME); @@ -657,17 +673,8 @@ onMounted(() => > @use '@webitel/ui-sdk/src/css/main' as *; -.members__destinations-wrapper { - display: flex; - align-items: flex-start; - gap: var(--spacing-xs); -} - -.members__destinations-num { - display: flex; - align-items: center; +.the-queue-members__communications-counter { cursor: pointer; - user-select: none; } .upload-file-input { diff --git a/src/modules/contact-center/modules/queues/modules/members/stores/datalist/__tests__/queueMemberCommunicationsDatalistStore.spec.ts b/src/modules/contact-center/modules/queues/modules/members/stores/datalist/__tests__/queueMemberCommunicationsDatalistStore.spec.ts new file mode 100644 index 000000000..3c984611d --- /dev/null +++ b/src/modules/contact-center/modules/queues/modules/members/stores/datalist/__tests__/queueMemberCommunicationsDatalistStore.spec.ts @@ -0,0 +1,138 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { unref } from 'vue'; + +const getCommunications = vi.fn(); + +vi.mock('@webitel/api-services/api', () => ({ + QueueMembersAPI: { + getCommunications, + }, +})); + +const { + getMemberCommunicationsList, + useQueueMemberCommunicationsDatalistStore, +} = await import('../queueMemberCommunicationsDatalistStore'); + +const lastRequest = () => getCommunications.mock.lastCall?.[0]; + +/** what the popup does on open: the queue as a filter, the member as the parent */ +const open = ( + store: ReturnType, + memberId = '42', +) => { + const queueFilter = { + name: 'queueId', + value: '7', + }; + if (store.hasFilter(queueFilter.name)) store.updateFilter(queueFilter); + else store.addFilter(queueFilter); + return store.initialize({ + parentId: memberId, + }); +}; + +describe('queue member communications datalist store', () => { + beforeEach(() => { + setActivePinia(createPinia()); + getCommunications.mockReset().mockResolvedValue({ + items: [ + { + id: '1', + }, + ], + next: true, + }); + }); + + it('requests the member of the queue', async () => { + const store = useQueueMemberCommunicationsDatalistStore(); + + await open(store); + + expect(lastRequest()).toMatchObject({ + parentId: '7', + memberId: '42', + page: 1, + }); + expect(store.dataList).toHaveLength(1); + }); + + it('passes the sort and the page through to the api', async () => { + await getMemberCommunicationsList({ + parentId: '42', + queueId: '7', + page: 2, + size: 10, + sort: '-destination', + }); + + expect(lastRequest()).toEqual({ + parentId: '7', + memberId: '42', + page: 2, + size: 10, + sort: '-destination', + }); + }); + + /** `field` goes out as the sort key; engine sorts by exactly these three */ + it('sorts by destination, type and priority', () => { + const store = useQueueMemberCommunicationsDatalistStore(); + + expect(unref(store.headers).map((header) => header.field)).toEqual([ + 'destination', + 'type', + 'priority', + ]); + }); + + it('appends the next page', async () => { + const store = useQueueMemberCommunicationsDatalistStore(); + await open(store); + getCommunications.mockResolvedValue({ + items: [ + { + id: '2', + }, + ], + next: false, + }); + + await store.appendToDataList(); + + expect(lastRequest()).toMatchObject({ + page: 2, + }); + expect(unref(store.dataList).map(({ id }) => id)).toEqual([ + '1', + '2', + ]); + expect(store.next).toBe(false); + }); + + /** the store outlives the popup, so another member must not see these rows */ + it('drops the rows on reset', async () => { + const store = useQueueMemberCommunicationsDatalistStore(); + await open(store); + + store.$reset(); + + expect(store.dataList).toEqual([]); + }); + + /** a reopened popup must ask for the new member, still in the same queue */ + it('requests the next member after a reset', async () => { + const store = useQueueMemberCommunicationsDatalistStore(); + await open(store); + store.$reset(); + + await open(store, '43'); + + expect(lastRequest()).toMatchObject({ + parentId: '7', + memberId: '43', + }); + }); +}); diff --git a/src/modules/contact-center/modules/queues/modules/members/stores/datalist/_internals/__tests__/headers.spec.ts b/src/modules/contact-center/modules/queues/modules/members/stores/datalist/_internals/__tests__/headers.spec.ts new file mode 100644 index 000000000..f34e91356 --- /dev/null +++ b/src/modules/contact-center/modules/queues/modules/members/stores/datalist/_internals/__tests__/headers.spec.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; + +import en from '../../../../../../../../../../app/locale/en/en.js'; +import { headers } from '../headers'; + +const byValue = (value: string) => { + const header = headers.find((h) => h.value === value); + if (!header) throw new Error(`no header ${value}`); + return header; +}; + +/** engine has no column to order these two by; see headers.ts */ +const notSortable = [ + 'bucket', + 'timezone', +]; + +const shownValues = () => + headers.filter((h) => h.show).map((h) => h.value as string); + +/** walks a dotted i18n path; every key used here holds a plain string */ +const localeText = (path: string) => + path + .split('.') + .reduce( + (node, key) => (node as Record | undefined)?.[key], + en, + ); + +describe('queue members table headers', () => { + /** both the set and the order matter, so the whole list is asserted */ + it('shows exactly the nine default columns, in order', () => { + expect(shownValues()).toEqual([ + 'name', + 'createdAt', + 'offeringAt', + 'destination', + 'priority', + 'endCause', + 'attempts', + 'bucket', + 'agent', + ]); + }); + + it('offers all eleven columns to the column select', () => { + expect(headers.map((h) => h.value)).toEqual([ + 'name', + 'createdAt', + 'offeringAt', + 'destination', + 'priority', + 'endCause', + 'attempts', + 'bucket', + 'agent', + 'expireAt', + 'timezone', + ]); + }); + + /** the column is labelled Communications while the api field stays as it was */ + it('labels the communications column Communications', () => { + const header = byValue('destination'); + + expect(header.locale).toBe('objects.ccenter.members.communications'); + expect(localeText(header.locale as string)).toBe('Communications'); + }); + + it('asks the api for the fields the new columns render from', () => { + expect(byValue('destination').field).toBe('communications'); + expect(byValue('bucket').field).toBe('bucket'); + expect(byValue('expireAt').field).toBe('expire_at'); + expect(byValue('timezone').field).toBe('timezone'); + }); + + /** only an absent `sort` blocks the click; `SortSymbols.NONE` is still clickable */ + it('offers no sorting by bucket or timezone', () => { + for (const value of notSortable) { + expect(byValue(value).sort, `${value} is sortable`).toBeUndefined(); + } + }); +}); diff --git a/src/modules/contact-center/modules/queues/modules/members/stores/datalist/_internals/communicationsHeaders.ts b/src/modules/contact-center/modules/queues/modules/members/stores/datalist/_internals/communicationsHeaders.ts new file mode 100644 index 000000000..e5b3d9190 --- /dev/null +++ b/src/modules/contact-center/modules/queues/modules/members/stores/datalist/_internals/communicationsHeaders.ts @@ -0,0 +1,26 @@ +import type { DatalistTableHeader } from '@webitel/ui-datalist'; +import { SortSymbols } from '@webitel/ui-sdk/src/scripts/sortQueryAdapters'; + +export const communicationsHeaders: DatalistTableHeader[] = [ + { + value: 'destination', + locale: 'objects.name', + field: 'destination', + show: true, + sort: SortSymbols.NONE, + }, + { + value: 'type', + locale: 'objects.ccenter.queues.type', + field: 'type', + show: true, + sort: SortSymbols.NONE, + }, + { + value: 'priority', + locale: 'objects.ccenter.queues.priority', + field: 'priority', + show: true, + sort: SortSymbols.NONE, + }, +]; diff --git a/src/modules/contact-center/modules/queues/modules/members/stores/datalist/_internals/headers.ts b/src/modules/contact-center/modules/queues/modules/members/stores/datalist/_internals/headers.ts index 50a27876d..543b49bab 100644 --- a/src/modules/contact-center/modules/queues/modules/members/stores/datalist/_internals/headers.ts +++ b/src/modules/contact-center/modules/queues/modules/members/stores/datalist/_internals/headers.ts @@ -29,10 +29,7 @@ export const headers: DatalistTableHeader[] = [ }, { value: 'destination', - locale: [ - 'objects.ccenter.queues.destination', - 2, - ], + locale: 'objects.ccenter.members.communications', field: 'communications', show: true, sort: SortSymbols.NONE, @@ -60,6 +57,15 @@ export const headers: DatalistTableHeader[] = [ show: true, sort: SortSymbols.NONE, }, + { + value: 'bucket', + locale: [ + 'objects.lookups.buckets.buckets', + 1, + ], + field: 'bucket', + show: true, + }, { value: 'agent', locale: [ @@ -71,4 +77,17 @@ export const headers: DatalistTableHeader[] = [ sort: SortSymbols.NONE, filter: filterConfigs[FilterOption.Agent], }, + { + value: 'expireAt', + locale: 'objects.ccenter.queues.expire', + field: 'expire_at', + show: false, + sort: SortSymbols.NONE, + }, + { + value: 'timezone', + locale: 'objects.ccenter.queues.timezone', + field: 'timezone', + show: false, + }, ]; diff --git a/src/modules/contact-center/modules/queues/modules/members/stores/datalist/queueMemberCommunicationsDatalistStore.ts b/src/modules/contact-center/modules/queues/modules/members/stores/datalist/queueMemberCommunicationsDatalistStore.ts new file mode 100644 index 000000000..8861eda18 --- /dev/null +++ b/src/modules/contact-center/modules/queues/modules/members/stores/datalist/queueMemberCommunicationsDatalistStore.ts @@ -0,0 +1,30 @@ +import { QueueMembersAPI } from '@webitel/api-services/api'; +import type { EngineMemberCommunication } from '@webitel/api-services/gen/models'; +import { createTableStore } from '@webitel/ui-datalist'; + +import { QueueMembersNamespace } from '../namespace'; +import { communicationsHeaders } from './_internals/communicationsHeaders'; + +export const getMemberCommunicationsList = ({ + parentId, + queueId, + ...params +}: Record) => + QueueMembersAPI.getCommunications({ + ...params, + parentId: queueId as string, + memberId: parentId as string, + }); + +export const useQueueMemberCommunicationsDatalistStore = + createTableStore( + `${QueueMembersNamespace}/communications/datalist`, + { + apiModule: { + getList: getMemberCommunicationsList, + }, + headers: communicationsHeaders, + disablePersistence: true, + isAppendDataList: true, + }, + ); diff --git a/src/modules/contact-center/modules/queues/stores/datalist/_internals/__tests__/headers.spec.ts b/src/modules/contact-center/modules/queues/stores/datalist/_internals/__tests__/headers.spec.ts index 3e7d5c833..89694dc06 100644 --- a/src/modules/contact-center/modules/queues/stores/datalist/_internals/__tests__/headers.spec.ts +++ b/src/modules/contact-center/modules/queues/stores/datalist/_internals/__tests__/headers.spec.ts @@ -21,14 +21,12 @@ const tables = { }; describe.each(Object.entries(tables))('%s headers', (_name, headers) => { - /** - * `shownHeaders` is `headers.filter(h => h.show)` and `fields` is derived - * from it, so a header without `show` is not merely hidden — its field is - * never requested, and the column can never be switched back on. - */ - it('marks every column shown', () => { + /** an omitted `show` drops the column; `false` only hides it by default */ + it('declares show on every column', () => { for (const header of headers) { - expect(header.show, `${header.value} is not shown`).toBe(true); + expect(typeof header.show, `${header.value} does not declare show`).toBe( + 'boolean', + ); } });