Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38967fe75f | ||
|
|
6e80f90357 | ||
|
|
f0e9ffacd3 | ||
|
|
45a1a01a96 | ||
|
|
0e225d2f36 | ||
|
|
f45e82016d | ||
|
|
137f0e726b |
+2
-2
@@ -541,9 +541,9 @@ GEM
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
maxminddb (0.1.22)
|
||||
meta_request (0.8.5)
|
||||
meta_request (0.8.3)
|
||||
rack-contrib (>= 1.1, < 3)
|
||||
railties (>= 3.0.0, < 9)
|
||||
railties (>= 3.0.0, < 8)
|
||||
method_source (1.1.0)
|
||||
mime-types (3.4.1)
|
||||
mime-types-data (~> 3.2015)
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
class V2::Reports::OutgoingMessagesCountBuilder
|
||||
include DateRangeHelper
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account, params)
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def build
|
||||
send("build_by_#{params[:group_by]}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def base_messages
|
||||
account.messages.outgoing.unscope(:order).where(created_at: range)
|
||||
end
|
||||
|
||||
def build_by_agent
|
||||
counts = base_messages
|
||||
.where(sender_type: 'User')
|
||||
.where.not(sender_id: nil)
|
||||
.group(:sender_id)
|
||||
.count
|
||||
|
||||
user_names = account.users.where(id: counts.keys).index_by(&:id)
|
||||
|
||||
counts.map do |user_id, count|
|
||||
user = user_names[user_id]
|
||||
{ id: user_id, name: user&.name, outgoing_messages_count: count }
|
||||
end
|
||||
end
|
||||
|
||||
def build_by_team
|
||||
counts = base_messages
|
||||
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
|
||||
.where.not(conversations: { team_id: nil })
|
||||
.group('conversations.team_id')
|
||||
.count
|
||||
|
||||
team_names = account.teams.where(id: counts.keys).index_by(&:id)
|
||||
|
||||
counts.map do |team_id, count|
|
||||
team = team_names[team_id]
|
||||
{ id: team_id, name: team&.name, outgoing_messages_count: count }
|
||||
end
|
||||
end
|
||||
|
||||
def build_by_inbox
|
||||
counts = base_messages
|
||||
.group(:inbox_id)
|
||||
.count
|
||||
|
||||
inbox_names = account.inboxes.where(id: counts.keys).index_by(&:id)
|
||||
|
||||
counts.map do |inbox_id, count|
|
||||
inbox = inbox_names[inbox_id]
|
||||
{ id: inbox_id, name: inbox&.name, outgoing_messages_count: count }
|
||||
end
|
||||
end
|
||||
|
||||
def build_by_label
|
||||
counts = base_messages
|
||||
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
|
||||
.joins("INNER JOIN taggings ON taggings.taggable_id = conversations.id
|
||||
AND taggings.taggable_type = 'Conversation' AND taggings.context = 'labels'")
|
||||
.joins('INNER JOIN tags ON tags.id = taggings.tag_id')
|
||||
.group('tags.name')
|
||||
.count
|
||||
|
||||
label_ids = account.labels.where(title: counts.keys).index_by(&:title)
|
||||
|
||||
counts.map do |label_name, count|
|
||||
label = label_ids[label_name]
|
||||
{ id: label&.id, name: label_name, outgoing_messages_count: count }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -78,15 +78,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
OUTGOING_MESSAGES_ALLOWED_GROUP_BY = %w[agent team inbox label].freeze
|
||||
|
||||
def outgoing_messages_count
|
||||
return head :unprocessable_entity unless OUTGOING_MESSAGES_ALLOWED_GROUP_BY.include?(params[:group_by])
|
||||
|
||||
builder = V2::Reports::OutgoingMessagesCountBuilder.new(Current.account, outgoing_messages_count_params)
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_csv(filename, template)
|
||||
@@ -180,12 +171,4 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
until: params[:until]
|
||||
}
|
||||
end
|
||||
|
||||
def outgoing_messages_count_params
|
||||
{
|
||||
group_by: params[:group_by],
|
||||
since: params[:since],
|
||||
until: params[:until]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,14 +37,17 @@ class TasksAPI extends ApiClient {
|
||||
/**
|
||||
* Summarizes a conversation.
|
||||
* @param {string} conversationId - The conversation ID to summarize.
|
||||
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
||||
* @param {Object} [options] - Additional options.
|
||||
* @param {boolean} [options.forceRegenerate] - Force regeneration of cached summary.
|
||||
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise} A promise that resolves with the summary.
|
||||
*/
|
||||
summarize(conversationId, signal) {
|
||||
summarize(conversationId, { forceRegenerate = false, signal } = {}) {
|
||||
return axios.post(
|
||||
`${this.url}/summarize`,
|
||||
{
|
||||
conversation_display_id: conversationId,
|
||||
force_regenerate: forceRegenerate,
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ref, watch } from 'vue';
|
||||
import {
|
||||
getActiveDateRange,
|
||||
moveCalendarDate,
|
||||
DATE_RANGE_TYPES,
|
||||
CALENDAR_TYPES,
|
||||
CALENDAR_PERIODS,
|
||||
isNavigableRange,
|
||||
getRangeAtOffset,
|
||||
} from './helpers/DatePickerHelper';
|
||||
import {
|
||||
isValid,
|
||||
@@ -16,14 +13,14 @@ import {
|
||||
subDays,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
isBefore,
|
||||
subMonths,
|
||||
addMonths,
|
||||
isSameMonth,
|
||||
differenceInCalendarMonths,
|
||||
differenceInCalendarWeeks,
|
||||
setMonth,
|
||||
setYear,
|
||||
getWeek,
|
||||
isAfter,
|
||||
} from 'date-fns';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import DatePickerButton from './components/DatePickerButton.vue';
|
||||
@@ -35,187 +32,98 @@ import CalendarWeek from './components/CalendarWeek.vue';
|
||||
import CalendarFooter from './components/CalendarFooter.vue';
|
||||
|
||||
const emit = defineEmits(['dateRangeChanged']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const dateRange = defineModel('dateRange', {
|
||||
type: Array,
|
||||
default: undefined,
|
||||
});
|
||||
|
||||
const rangeType = defineModel('rangeType', {
|
||||
type: String,
|
||||
default: undefined,
|
||||
});
|
||||
const { LAST_7_DAYS, CUSTOM_RANGE } = DATE_RANGE_TYPES;
|
||||
const { LAST_7_DAYS, LAST_30_DAYS, CUSTOM_RANGE } = DATE_RANGE_TYPES;
|
||||
const { START_CALENDAR, END_CALENDAR } = CALENDAR_TYPES;
|
||||
const { WEEK, MONTH, YEAR } = CALENDAR_PERIODS;
|
||||
|
||||
const showDatePicker = ref(false);
|
||||
const calendarViews = ref({ start: WEEK, end: WEEK });
|
||||
const currentDate = ref(new Date());
|
||||
|
||||
// Use dates from v-model if provided, otherwise default to last 7 days
|
||||
const selectedStartDate = ref(
|
||||
dateRange.value?.[0]
|
||||
? startOfDay(dateRange.value[0])
|
||||
: startOfDay(subDays(currentDate.value, 6)) // LAST_7_DAYS
|
||||
);
|
||||
const selectedEndDate = ref(
|
||||
dateRange.value?.[1]
|
||||
? endOfDay(dateRange.value[1])
|
||||
: endOfDay(currentDate.value)
|
||||
);
|
||||
// Calendar month positioning (left and right calendars)
|
||||
// These control which months are displayed in the dual calendar view
|
||||
const startCurrentDate = ref(startOfMonth(selectedStartDate.value));
|
||||
const selectedStartDate = ref(startOfDay(subDays(currentDate.value, 6))); // LAST_7_DAYS
|
||||
const selectedEndDate = ref(endOfDay(currentDate.value));
|
||||
// Setting the start and end calendar
|
||||
const startCurrentDate = ref(startOfDay(selectedStartDate.value));
|
||||
const endCurrentDate = ref(
|
||||
isSameMonth(selectedStartDate.value, selectedEndDate.value)
|
||||
? startOfMonth(addMonths(selectedEndDate.value, 1)) // Same month: show next month on right (e.g., Jan 25-31 shows Jan + Feb)
|
||||
: startOfMonth(selectedEndDate.value) // Different months: show end month on right (e.g., Dec 5 - Jan 3 shows Dec + Jan)
|
||||
? startOfMonth(addMonths(selectedEndDate.value, 1)) // Moves to the start of the next month if dates are in the same month (Mounted case LAST_7_DAYS)
|
||||
: startOfMonth(selectedEndDate.value) // Always shows the month of the end date starting from the first (Mounted case LAST_7_DAYS)
|
||||
);
|
||||
const selectingEndDate = ref(false);
|
||||
const selectedRange = ref(rangeType.value || LAST_7_DAYS);
|
||||
const selectedRange = ref(LAST_7_DAYS);
|
||||
const hoveredEndDate = ref(null);
|
||||
const monthOffset = ref(0);
|
||||
|
||||
const showMonthNavigation = computed(() =>
|
||||
isNavigableRange(selectedRange.value)
|
||||
);
|
||||
const canNavigateNext = computed(() => {
|
||||
if (!isNavigableRange(selectedRange.value)) return false;
|
||||
// Compare selected start to the current period's start to determine if we're in the past
|
||||
const currentRange = getActiveDateRange(
|
||||
selectedRange.value,
|
||||
currentDate.value
|
||||
);
|
||||
return selectedStartDate.value < currentRange.start;
|
||||
});
|
||||
|
||||
const navigationLabel = computed(() => {
|
||||
const range = selectedRange.value;
|
||||
if (range === DATE_RANGE_TYPES.MONTH_TO_DATE) {
|
||||
return new Intl.DateTimeFormat(navigator.language, {
|
||||
month: 'long',
|
||||
}).format(selectedStartDate.value);
|
||||
}
|
||||
if (range === DATE_RANGE_TYPES.THIS_WEEK) {
|
||||
const currentWeekRange = getActiveDateRange(range, currentDate.value);
|
||||
const isCurrentWeek =
|
||||
selectedStartDate.value.getTime() === currentWeekRange.start.getTime();
|
||||
if (isCurrentWeek) return null;
|
||||
const weekNumber = getWeek(selectedStartDate.value, { weekStartsOn: 1 });
|
||||
return t('DATE_PICKER.WEEK_NUMBER', { weekNumber });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const manualStartDate = ref(selectedStartDate.value);
|
||||
const manualEndDate = ref(selectedEndDate.value);
|
||||
|
||||
// Watcher 1: Sync v-model props from parent component
|
||||
// Handles: URL params, parent component updates, rangeType changes
|
||||
watch(
|
||||
[rangeType, dateRange],
|
||||
([newRangeType, newDateRange]) => {
|
||||
if (newRangeType && newRangeType !== selectedRange.value) {
|
||||
selectedRange.value = newRangeType;
|
||||
monthOffset.value = 0;
|
||||
// Watcher will set the start and end dates based on the selected range
|
||||
watch(selectedRange, newRange => {
|
||||
if (newRange !== CUSTOM_RANGE) {
|
||||
// If selecting a range other than last 7 days or last 30 days, set the start and end dates to the selected start and end dates
|
||||
// If selecting last 7 days or last 30 days is, set the start date to the selected start date
|
||||
// and the end date to one month ahead of the start date if the start date and end date are in the same month
|
||||
// Otherwise set the end date to the selected end date
|
||||
const isLastSevenOrThirtyDays =
|
||||
newRange === LAST_7_DAYS || newRange === LAST_30_DAYS;
|
||||
startCurrentDate.value = selectedStartDate.value;
|
||||
endCurrentDate.value =
|
||||
isLastSevenOrThirtyDays &&
|
||||
isSameMonth(selectedStartDate.value, selectedEndDate.value)
|
||||
? startOfMonth(addMonths(selectedStartDate.value, 1))
|
||||
: selectedEndDate.value;
|
||||
selectingEndDate.value = false;
|
||||
} else if (!selectingEndDate.value) {
|
||||
// If selecting a custom range and not selecting an end date, set the start date to the selected start date
|
||||
startCurrentDate.value = startOfDay(currentDate.value);
|
||||
}
|
||||
});
|
||||
|
||||
// If rangeType changes without dateRange, recompute dates from the range
|
||||
if (!newDateRange && newRangeType !== CUSTOM_RANGE) {
|
||||
const activeDates = getActiveDateRange(newRangeType, currentDate.value);
|
||||
if (activeDates) {
|
||||
selectedStartDate.value = startOfDay(activeDates.start);
|
||||
selectedEndDate.value = endOfDay(activeDates.end);
|
||||
}
|
||||
}
|
||||
// Watcher will set the input values based on the selected start and end dates
|
||||
watch(
|
||||
[selectedStartDate, selectedEndDate],
|
||||
([newStart, newEnd]) => {
|
||||
if (isValid(newStart)) {
|
||||
manualStartDate.value = newStart;
|
||||
} else {
|
||||
manualStartDate.value = selectedStartDate.value;
|
||||
}
|
||||
|
||||
// When parent provides new dateRange (e.g., from URL params)
|
||||
// Skip if navigating with arrows — offset controls dates in that case
|
||||
if (newDateRange?.[0] && newDateRange?.[1] && monthOffset.value === 0) {
|
||||
selectedStartDate.value = startOfDay(newDateRange[0]);
|
||||
selectedEndDate.value = endOfDay(newDateRange[1]);
|
||||
|
||||
// Update calendar to show the months of the new date range
|
||||
startCurrentDate.value = startOfMonth(newDateRange[0]);
|
||||
endCurrentDate.value = isSameMonth(newDateRange[0], newDateRange[1])
|
||||
? startOfMonth(addMonths(newDateRange[1], 1))
|
||||
: startOfMonth(newDateRange[1]);
|
||||
|
||||
// Recalculate offset so arrow navigation is relative to restored range
|
||||
// TODO: When offset resolves to 0 (current period), the end date may be
|
||||
// stale if the URL was saved on a previous day. "This month" / "This week"
|
||||
// should show up-to-today dates for the current period. For now, the stale
|
||||
// end date is shown until the user clicks an arrow or re-selects the range.
|
||||
if (isNavigableRange(selectedRange.value)) {
|
||||
const current = getActiveDateRange(
|
||||
selectedRange.value,
|
||||
currentDate.value
|
||||
);
|
||||
if (selectedRange.value === DATE_RANGE_TYPES.THIS_WEEK) {
|
||||
monthOffset.value = differenceInCalendarWeeks(
|
||||
newDateRange[0],
|
||||
current.start,
|
||||
{ weekStartsOn: 1 }
|
||||
);
|
||||
} else {
|
||||
monthOffset.value = differenceInCalendarMonths(
|
||||
newDateRange[0],
|
||||
current.start
|
||||
);
|
||||
}
|
||||
}
|
||||
if (isValid(newEnd)) {
|
||||
manualEndDate.value = newEnd;
|
||||
} else {
|
||||
manualEndDate.value = selectedEndDate.value;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Watcher 2: Keep manual input fields in sync with selected dates
|
||||
// Updates the input field values when dates change programmatically
|
||||
// Watcher to ensure dates are always in logical order
|
||||
// This watch is will ensure that the start date is always before the end date
|
||||
watch(
|
||||
[selectedStartDate, selectedEndDate],
|
||||
([newStart, newEnd]) => {
|
||||
manualStartDate.value = isValid(newStart)
|
||||
? newStart
|
||||
: selectedStartDate.value;
|
||||
manualEndDate.value = isValid(newEnd) ? newEnd : selectedEndDate.value;
|
||||
[startCurrentDate, endCurrentDate],
|
||||
([newStart, newEnd], [oldStart, oldEnd]) => {
|
||||
const monthDifference = differenceInCalendarMonths(newEnd, newStart);
|
||||
|
||||
if (newStart !== oldStart) {
|
||||
if (isAfter(newStart, newEnd) || monthDifference === 0) {
|
||||
// Adjust the end date forward if the start date is adjusted and is after the end date or in the same month
|
||||
endCurrentDate.value = addMonths(newStart, 1);
|
||||
}
|
||||
}
|
||||
if (newEnd !== oldEnd) {
|
||||
if (isBefore(newEnd, newStart) || monthDifference === 0) {
|
||||
// Adjust the start date backward if the end date is adjusted and is before the start date or in the same month
|
||||
startCurrentDate.value = subMonths(newEnd, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
const setDateRange = range => {
|
||||
selectedRange.value = range.value;
|
||||
monthOffset.value = 0;
|
||||
const { start, end } = getActiveDateRange(range.value, currentDate.value);
|
||||
selectedStartDate.value = start;
|
||||
selectedEndDate.value = end;
|
||||
|
||||
// Position calendar to show the months of the selected range
|
||||
startCurrentDate.value = startOfMonth(start);
|
||||
endCurrentDate.value = isSameMonth(start, end)
|
||||
? startOfMonth(addMonths(start, 1))
|
||||
: startOfMonth(end);
|
||||
};
|
||||
|
||||
const navigateMonth = direction => {
|
||||
monthOffset.value += direction === 'prev' ? -1 : 1;
|
||||
if (monthOffset.value > 0) monthOffset.value = 0;
|
||||
|
||||
const { start, end } = getRangeAtOffset(
|
||||
selectedRange.value,
|
||||
monthOffset.value,
|
||||
currentDate.value
|
||||
);
|
||||
selectedStartDate.value = start;
|
||||
selectedEndDate.value = end;
|
||||
|
||||
startCurrentDate.value = startOfMonth(start);
|
||||
endCurrentDate.value = isSameMonth(start, end)
|
||||
? startOfMonth(addMonths(start, 1))
|
||||
: startOfMonth(end);
|
||||
|
||||
emit('dateRangeChanged', [start, end, selectedRange.value]);
|
||||
};
|
||||
|
||||
const moveCalendar = (calendar, direction, period = MONTH) => {
|
||||
@@ -226,27 +134,12 @@ const moveCalendar = (calendar, direction, period = MONTH) => {
|
||||
direction,
|
||||
period
|
||||
);
|
||||
|
||||
// Prevent calendar months from overlapping
|
||||
const monthDiff = differenceInCalendarMonths(end, start);
|
||||
if (monthDiff === 0) {
|
||||
// If they would be the same month, adjust the other calendar
|
||||
if (calendar === START_CALENDAR) {
|
||||
endCurrentDate.value = addMonths(start, 1);
|
||||
startCurrentDate.value = start;
|
||||
} else {
|
||||
startCurrentDate.value = subMonths(end, 1);
|
||||
endCurrentDate.value = end;
|
||||
}
|
||||
} else {
|
||||
startCurrentDate.value = start;
|
||||
endCurrentDate.value = end;
|
||||
}
|
||||
startCurrentDate.value = start;
|
||||
endCurrentDate.value = end;
|
||||
};
|
||||
|
||||
const selectDate = day => {
|
||||
selectedRange.value = CUSTOM_RANGE;
|
||||
monthOffset.value = 0;
|
||||
if (!selectingEndDate.value || day < selectedStartDate.value) {
|
||||
selectedStartDate.value = day;
|
||||
selectedEndDate.value = null;
|
||||
@@ -282,10 +175,10 @@ const openCalendar = (index, calendarType, period = MONTH) => {
|
||||
const updateManualInput = (newDate, calendarType) => {
|
||||
if (calendarType === START_CALENDAR) {
|
||||
selectedStartDate.value = newDate;
|
||||
startCurrentDate.value = startOfMonth(newDate);
|
||||
startCurrentDate.value = newDate;
|
||||
} else {
|
||||
selectedEndDate.value = newDate;
|
||||
endCurrentDate.value = startOfMonth(newDate);
|
||||
endCurrentDate.value = newDate;
|
||||
}
|
||||
selectingEndDate.value = false;
|
||||
};
|
||||
@@ -295,22 +188,13 @@ const handleManualInputError = message => {
|
||||
};
|
||||
|
||||
const resetDatePicker = () => {
|
||||
// Calculate Last 7 days from today
|
||||
const startDate = startOfDay(subDays(currentDate.value, 6));
|
||||
const endDate = endOfDay(currentDate.value);
|
||||
|
||||
selectedStartDate.value = startDate;
|
||||
selectedEndDate.value = endDate;
|
||||
|
||||
// Position calendar to show the months of Last 7 days
|
||||
// Example: If today is Feb 5, Last 7 days = Jan 30 - Feb 5, so show Jan + Feb
|
||||
startCurrentDate.value = startOfMonth(startDate);
|
||||
endCurrentDate.value = isSameMonth(startDate, endDate)
|
||||
? startOfMonth(addMonths(startDate, 1))
|
||||
: startOfMonth(endDate);
|
||||
startCurrentDate.value = startOfDay(currentDate.value); // Resets to today at start of the day
|
||||
endCurrentDate.value = addMonths(startOfDay(currentDate.value), 1); // Resets to one month ahead
|
||||
selectedStartDate.value = startOfDay(subDays(currentDate.value, 6));
|
||||
selectedEndDate.value = endOfDay(currentDate.value);
|
||||
selectingEndDate.value = false;
|
||||
selectedRange.value = LAST_7_DAYS;
|
||||
monthOffset.value = 0;
|
||||
// Reset view modes if they are being used to toggle between different calendar views
|
||||
calendarViews.value = { start: WEEK, end: WEEK };
|
||||
};
|
||||
|
||||
@@ -319,58 +203,26 @@ const emitDateRange = () => {
|
||||
useAlert('Please select a valid time range');
|
||||
} else {
|
||||
showDatePicker.value = false;
|
||||
emit('dateRangeChanged', [
|
||||
selectedStartDate.value,
|
||||
selectedEndDate.value,
|
||||
selectedRange.value,
|
||||
]);
|
||||
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);
|
||||
}
|
||||
};
|
||||
|
||||
// Called when picker opens - positions calendar to show selected date range
|
||||
// Fixes issue where calendar showed wrong months when loaded from URL params
|
||||
const initializeCalendarMonths = () => {
|
||||
if (selectedStartDate.value && selectedEndDate.value) {
|
||||
startCurrentDate.value = startOfMonth(selectedStartDate.value);
|
||||
endCurrentDate.value = isSameMonth(
|
||||
selectedStartDate.value,
|
||||
selectedEndDate.value
|
||||
)
|
||||
? startOfMonth(addMonths(selectedEndDate.value, 1))
|
||||
: startOfMonth(selectedEndDate.value);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDatePicker = () => {
|
||||
showDatePicker.value = !showDatePicker.value;
|
||||
if (showDatePicker.value) initializeCalendarMonths();
|
||||
};
|
||||
|
||||
const closeDatePicker = () => {
|
||||
if (isValid(selectedStartDate.value) && isValid(selectedEndDate.value)) {
|
||||
emitDateRange();
|
||||
} else {
|
||||
showDatePicker.value = false;
|
||||
}
|
||||
showDatePicker.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex-shrink-0 font-inter">
|
||||
<div v-on-clickaway="closeDatePicker" class="relative font-inter">
|
||||
<DatePickerButton
|
||||
:selected-start-date="selectedStartDate"
|
||||
:selected-end-date="selectedEndDate"
|
||||
:selected-range="selectedRange"
|
||||
:show-month-navigation="showMonthNavigation"
|
||||
:can-navigate-next="canNavigateNext"
|
||||
:navigation-label="navigationLabel"
|
||||
@open="toggleDatePicker"
|
||||
@navigate-month="navigateMonth"
|
||||
@open="showDatePicker = !showDatePicker"
|
||||
/>
|
||||
<div
|
||||
v-if="showDatePicker"
|
||||
v-on-clickaway="closeDatePicker"
|
||||
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] rounded-2xl bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container"
|
||||
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container"
|
||||
>
|
||||
<CalendarDateRange
|
||||
:selected-range="selectedRange"
|
||||
|
||||
@@ -46,13 +46,13 @@ const onClickSetView = (type, mode) => {
|
||||
xs
|
||||
icon="i-lucide-chevron-left"
|
||||
class="rtl:rotate-180"
|
||||
@click.stop="onClickPrev(calendarType)"
|
||||
@click="onClickPrev(calendarType)"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-if="firstButtonLabel"
|
||||
class="p-0 text-sm font-medium text-center text-n-slate-12 hover:text-n-brand"
|
||||
@click.stop="onClickSetView(calendarType, viewMode)"
|
||||
@click="onClickSetView(calendarType, viewMode)"
|
||||
>
|
||||
{{ firstButtonLabel }}
|
||||
</button>
|
||||
@@ -60,7 +60,7 @@ const onClickSetView = (type, mode) => {
|
||||
v-if="buttonLabel"
|
||||
class="p-0 text-sm font-medium text-center text-n-slate-12"
|
||||
:class="{ 'hover:text-n-brand': viewMode }"
|
||||
@click.stop="onClickSetView(calendarType, YEAR)"
|
||||
@click="onClickSetView(calendarType, YEAR)"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
</button>
|
||||
@@ -71,7 +71,7 @@ const onClickSetView = (type, mode) => {
|
||||
xs
|
||||
icon="i-lucide-chevron-right"
|
||||
class="rtl:rotate-180"
|
||||
@click.stop="onClickNext(calendarType)"
|
||||
@click="onClickNext(calendarType)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+14
-15
@@ -18,25 +18,24 @@ const setDateRange = range => {
|
||||
<template>
|
||||
<div class="w-[200px] flex flex-col items-start">
|
||||
<h4
|
||||
class="w-full px-5 py-4 text-xs font-bold capitalize text-start text-n-slate-10"
|
||||
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-n-slate-12"
|
||||
>
|
||||
{{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
|
||||
</h4>
|
||||
<div class="flex flex-col items-start w-full">
|
||||
<template v-for="range in dateRanges" :key="range.label">
|
||||
<div v-if="range.separator" class="w-full border-t border-n-strong" />
|
||||
<button
|
||||
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-n-alpha-2 dark:hover:bg-n-solid-3"
|
||||
:class="
|
||||
range.value === selectedRange
|
||||
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
|
||||
: 'text-n-slate-12'
|
||||
"
|
||||
@click="setDateRange(range)"
|
||||
>
|
||||
{{ $t(range.label) }}
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
v-for="range in dateRanges"
|
||||
:key="range.label"
|
||||
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-n-alpha-2 dark:hover:bg-n-solid-3"
|
||||
:class="
|
||||
range.value === selectedRange
|
||||
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
|
||||
: 'text-n-slate-12'
|
||||
"
|
||||
@click="setDateRange(range)"
|
||||
>
|
||||
{{ $t(range.label) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -23,6 +23,7 @@ const onClickApply = () => {
|
||||
/>
|
||||
<NextButton
|
||||
sm
|
||||
ghost
|
||||
:label="$t('DATE_PICKER.APPLY_BUTTON')"
|
||||
@click="onClickApply"
|
||||
/>
|
||||
|
||||
@@ -78,7 +78,7 @@ const selectMonth = index => {
|
||||
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3':
|
||||
index !== activeMonthIndex,
|
||||
}"
|
||||
@click.stop="selectMonth(index)"
|
||||
@click="selectMonth(index)"
|
||||
>
|
||||
{{ month }}
|
||||
</button>
|
||||
|
||||
@@ -77,7 +77,7 @@ const selectYear = year => {
|
||||
'bg-n-brand text-white hover:bg-n-blue-10': year === activeYear,
|
||||
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3': year !== activeYear,
|
||||
}"
|
||||
@click.stop="selectYear(year)"
|
||||
@click="selectYear(year)"
|
||||
>
|
||||
{{ year }}
|
||||
</button>
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
import { computed } from 'vue';
|
||||
import { dateRanges } from '../helpers/DatePickerHelper';
|
||||
import { format, isSameYear, isValid } from 'date-fns';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedStartDate: Date,
|
||||
@@ -12,21 +10,9 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showMonthNavigation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
canNavigateNext: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
navigationLabel: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['open', 'navigateMonth']);
|
||||
const emit = defineEmits(['open']);
|
||||
|
||||
const formatDateRange = computed(() => {
|
||||
const startDate = props.selectedStartDate;
|
||||
@@ -36,15 +22,19 @@ const formatDateRange = computed(() => {
|
||||
return 'Select a date range';
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const bothCurrentYear =
|
||||
isSameYear(startDate, now) && isSameYear(endDate, now);
|
||||
const formatString = isSameYear(startDate, endDate)
|
||||
? 'MMM d' // Same year: "Apr 1"
|
||||
: 'MMM d yyyy'; // Different years: "Apr 1 2025"
|
||||
|
||||
if (bothCurrentYear) {
|
||||
if (isSameYear(startDate, new Date()) && isSameYear(endDate, new Date())) {
|
||||
// Both dates are in the current year
|
||||
return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`;
|
||||
}
|
||||
|
||||
return `${format(startDate, 'MMM d yyyy')} - ${format(endDate, 'MMM d yyyy')}`;
|
||||
// At least one date is not in the current year
|
||||
return `${format(startDate, formatString)} - ${format(
|
||||
endDate,
|
||||
formatString
|
||||
)}`;
|
||||
});
|
||||
|
||||
const activeDateRange = computed(
|
||||
@@ -57,46 +47,17 @@ const openDatePicker = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<button
|
||||
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-n-alpha-2 hover:bg-n-alpha-1 active:bg-n-alpha-1 flex-shrink-0"
|
||||
@click="openDatePicker"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-calendar-range"
|
||||
class="text-n-slate-11 size-3.5 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12 truncate">
|
||||
{{ navigationLabel || $t(activeDateRange) }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-n-slate-11 truncate">
|
||||
{{ formatDateRange }}
|
||||
</span>
|
||||
<Icon
|
||||
icon="i-lucide-chevron-down"
|
||||
class="text-n-slate-12 size-4 flex-shrink-0"
|
||||
/>
|
||||
</button>
|
||||
<NextButton
|
||||
v-if="showMonthNavigation"
|
||||
v-tooltip.top="$t('DATE_PICKER.PREVIOUS_PERIOD')"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
icon="i-lucide-chevron-left"
|
||||
class="rtl:rotate-180"
|
||||
@click="emit('navigateMonth', 'prev')"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showMonthNavigation"
|
||||
v-tooltip.top="$t('DATE_PICKER.NEXT_PERIOD')"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
icon="i-lucide-chevron-right"
|
||||
class="rtl:rotate-180"
|
||||
:disabled="!canNavigateNext"
|
||||
@click="emit('navigateMonth', 'next')"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-n-alpha-2 hover:bg-n-alpha-1 active:bg-n-alpha-1"
|
||||
@click="openDatePicker"
|
||||
>
|
||||
<fluent-icon class="text-n-slate-12" icon="calendar" size="16" />
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t(activeDateRange) }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-n-slate-11">
|
||||
{{ formatDateRange }}
|
||||
</span>
|
||||
<fluent-icon class="text-n-slate-12" icon="chevron-down" size="14" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
isSameMonth,
|
||||
format,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
addWeeks,
|
||||
addDays,
|
||||
eachDayOfInterval,
|
||||
endOfMonth,
|
||||
@@ -36,27 +34,13 @@ export const dateRanges = [
|
||||
{
|
||||
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_3_MONTHS',
|
||||
value: 'last3months',
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_6_MONTHS',
|
||||
value: 'last6months',
|
||||
},
|
||||
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_YEAR', value: 'lastYear' },
|
||||
{
|
||||
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.THIS_WEEK',
|
||||
value: 'thisWeek',
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.MONTH_TO_DATE',
|
||||
value: 'monthToDate',
|
||||
},
|
||||
{
|
||||
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.CUSTOM_RANGE',
|
||||
value: 'custom',
|
||||
separator: true,
|
||||
},
|
||||
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.CUSTOM_RANGE', value: 'custom' },
|
||||
];
|
||||
|
||||
export const DATE_RANGE_TYPES = {
|
||||
@@ -65,8 +49,6 @@ export const DATE_RANGE_TYPES = {
|
||||
LAST_3_MONTHS: 'last3months',
|
||||
LAST_6_MONTHS: 'last6months',
|
||||
LAST_YEAR: 'lastYear',
|
||||
THIS_WEEK: 'thisWeek',
|
||||
MONTH_TO_DATE: 'monthToDate',
|
||||
CUSTOM_RANGE: 'custom',
|
||||
};
|
||||
|
||||
@@ -228,14 +210,6 @@ export const getActiveDateRange = (range, currentDate) => {
|
||||
start: startOfDay(subMonths(currentDate, 12)),
|
||||
end: endOfDay(currentDate),
|
||||
}),
|
||||
thisWeek: () => ({
|
||||
start: startOfDay(startOfWeek(currentDate, { weekStartsOn: 1 })),
|
||||
end: endOfDay(currentDate),
|
||||
}),
|
||||
monthToDate: () => ({
|
||||
start: startOfDay(startOfMonth(currentDate)),
|
||||
end: endOfDay(currentDate),
|
||||
}),
|
||||
custom: () => ({ start: currentDate, end: currentDate }),
|
||||
};
|
||||
|
||||
@@ -243,48 +217,3 @@ export const getActiveDateRange = (range, currentDate) => {
|
||||
ranges[range] || (() => ({ start: currentDate, end: currentDate }))
|
||||
)();
|
||||
};
|
||||
|
||||
export const isNavigableRange = rangeType =>
|
||||
rangeType === DATE_RANGE_TYPES.MONTH_TO_DATE ||
|
||||
rangeType === DATE_RANGE_TYPES.THIS_WEEK;
|
||||
|
||||
const WEEK_START = 1; // Monday
|
||||
|
||||
const getWeekRangeAtOffset = (offset, currentDate) => {
|
||||
if (offset === 0) {
|
||||
return {
|
||||
start: startOfDay(startOfWeek(currentDate, { weekStartsOn: WEEK_START })),
|
||||
end: endOfDay(currentDate),
|
||||
};
|
||||
}
|
||||
const targetWeek = addWeeks(currentDate, offset);
|
||||
return {
|
||||
start: startOfDay(startOfWeek(targetWeek, { weekStartsOn: WEEK_START })),
|
||||
end: endOfDay(endOfWeek(targetWeek, { weekStartsOn: WEEK_START })),
|
||||
};
|
||||
};
|
||||
|
||||
const getMonthRangeAtOffset = (offset, currentDate) => {
|
||||
if (offset === 0) {
|
||||
return {
|
||||
start: startOfDay(startOfMonth(currentDate)),
|
||||
end: endOfDay(currentDate),
|
||||
};
|
||||
}
|
||||
const targetMonth = addMonths(currentDate, offset);
|
||||
return {
|
||||
start: startOfDay(startOfMonth(targetMonth)),
|
||||
end: endOfDay(endOfMonth(targetMonth)),
|
||||
};
|
||||
};
|
||||
|
||||
export const getRangeAtOffset = (
|
||||
rangeType,
|
||||
offset,
|
||||
currentDate = new Date()
|
||||
) => {
|
||||
if (rangeType === DATE_RANGE_TYPES.THIS_WEEK) {
|
||||
return getWeekRangeAtOffset(offset, currentDate);
|
||||
}
|
||||
return getMonthRangeAtOffset(offset, currentDate);
|
||||
};
|
||||
|
||||
@@ -34,8 +34,8 @@ const value = defineModel({
|
||||
<input
|
||||
v-model="value"
|
||||
:placeholder="inputPlaceholder"
|
||||
type="search"
|
||||
class="w-full mb-0 text-sm !outline-0 !outline-none bg-transparent text-n-slate-12 placeholder:text-n-slate-10 reset-base"
|
||||
type="text"
|
||||
class="w-full mb-0 text-sm !outline-0 bg-transparent text-n-slate-12 placeholder:text-n-slate-10 reset-base"
|
||||
/>
|
||||
</div>
|
||||
<!-- Clear filter button -->
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
@@ -14,6 +15,8 @@ import PriorityMark from './PriorityMark.vue';
|
||||
import SLACardLabel from './components/SLACardLabel.vue';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||
import ConversationSummary from './ConversationSummary.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activeLabel: { type: String, default: '' },
|
||||
@@ -46,8 +49,10 @@ const emit = defineEmits([
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const hovered = ref(false);
|
||||
const summaryRef = ref(null);
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenu = ref({
|
||||
x: null,
|
||||
@@ -344,12 +349,15 @@ const deleteConversation = () => {
|
||||
class="absolute flex flex-col ltr:right-3 rtl:left-3"
|
||||
:class="showMetaSection ? 'top-8' : 'top-4'"
|
||||
>
|
||||
<span class="ml-auto font-normal leading-4 text-xxs">
|
||||
<TimeAgo
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
/>
|
||||
</span>
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
<ConversationSummary ref="summaryRef" :chat="chat" />
|
||||
<span class="font-normal leading-4 text-xxs">
|
||||
<TimeAgo
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
|
||||
:class="hasUnread ? 'block' : 'hidden'"
|
||||
@@ -366,6 +374,26 @@ const deleteConversation = () => {
|
||||
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
|
||||
</template>
|
||||
</CardLabels>
|
||||
<!-- Expanded Summary Section -->
|
||||
<div
|
||||
v-if="summaryRef?.isExpanded"
|
||||
class="mt-2 mx-2 mb-1 p-3 bg-n-alpha-1 dark:bg-n-alpha-2 rounded-lg"
|
||||
>
|
||||
<div v-if="summaryRef?.isLoading" class="flex items-center gap-2">
|
||||
<Spinner :size="16" class="text-n-slate-10" />
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ t('CHAT_LIST.SUMMARY.LOADING') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="summaryRef?.error" class="text-xs text-n-ruby-11">
|
||||
{{ summaryRef.error }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="summaryRef?.formattedSummary"
|
||||
class="text-xs text-n-slate-11 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:my-0.5 [&_p]:my-1 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0 [&_strong]:text-n-slate-12"
|
||||
v-html="summaryRef.formattedSummary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ContextMenu
|
||||
v-if="showContextMenu"
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import TasksAPI from 'dashboard/api/captain/tasks';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
const props = defineProps({
|
||||
chat: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
const isExpanded = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const captainTasksEnabled = computed(() => {
|
||||
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
|
||||
});
|
||||
|
||||
const cachedSummary = computed(() => props.chat?.cached_summary || '');
|
||||
const cachedSummaryAt = computed(() => props.chat?.cached_summary_at || 0);
|
||||
const lastActivityAt = computed(() => props.chat?.last_activity_at || 0);
|
||||
|
||||
const isStale = computed(() => {
|
||||
if (!cachedSummaryAt.value) return true;
|
||||
return lastActivityAt.value > cachedSummaryAt.value;
|
||||
});
|
||||
|
||||
const formattedSummary = computed(() => {
|
||||
return cachedSummary.value ? formatMessage(cachedSummary.value) : '';
|
||||
});
|
||||
|
||||
const fetchSummary = async () => {
|
||||
isLoading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const result = await TasksAPI.summarize(props.chat.id, {
|
||||
forceRegenerate: false,
|
||||
});
|
||||
const {
|
||||
data: { message: generatedSummary },
|
||||
} = result;
|
||||
|
||||
if (generatedSummary) {
|
||||
store.dispatch('updateConversationCachedSummary', {
|
||||
conversationId: props.chat.id,
|
||||
cachedSummary: generatedSummary,
|
||||
cachedSummaryAt: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError' && e.name !== 'CanceledError') {
|
||||
error.value = e.response?.data?.error || t('CHAT_LIST.SUMMARY.ERROR');
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSummary = async () => {
|
||||
if (isExpanded.value) {
|
||||
isExpanded.value = false;
|
||||
return;
|
||||
}
|
||||
isExpanded.value = true;
|
||||
useTrack(CAPTAIN_EVENTS.SUMMARIZE_USED, {
|
||||
conversationId: props.chat.id,
|
||||
uiFrom: 'conversation_list',
|
||||
});
|
||||
// Only fetch if no cached summary
|
||||
if (!cachedSummary.value && !error.value) {
|
||||
await fetchSummary();
|
||||
}
|
||||
};
|
||||
|
||||
const onButtonClick = e => {
|
||||
e.stopPropagation();
|
||||
toggleSummary();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
isExpanded,
|
||||
isLoading,
|
||||
error,
|
||||
formattedSummary,
|
||||
captainTasksEnabled,
|
||||
isStale,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
v-if="captainTasksEnabled"
|
||||
icon="i-material-symbols-auto-awesome"
|
||||
slate
|
||||
ghost
|
||||
xs
|
||||
:title="t('CHAT_LIST.SUMMARY.TITLE')"
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
:class="{ '!opacity-100': isExpanded }"
|
||||
@click="onButtonClick"
|
||||
/>
|
||||
</template>
|
||||
@@ -34,11 +34,21 @@ function getEventPrefix(action) {
|
||||
* @param {string} action - The action type
|
||||
* @param {number} conversationId - The conversation ID
|
||||
* @param {number} [followUpCount] - Optional follow-up count
|
||||
* @param {string} [uiFrom] - Optional UI source identifier
|
||||
* @returns {Object} The payload object
|
||||
*/
|
||||
function buildPayload(action, conversationId, followUpCount = undefined) {
|
||||
function buildPayload(
|
||||
action,
|
||||
conversationId,
|
||||
followUpCount = undefined,
|
||||
uiFrom = undefined
|
||||
) {
|
||||
const payload = { conversationId };
|
||||
|
||||
if (uiFrom) {
|
||||
payload.uiFrom = uiFrom;
|
||||
}
|
||||
|
||||
// Add operation for rewrite actions
|
||||
if (REWRITE_ACTIONS.includes(action)) {
|
||||
payload.operation = action;
|
||||
@@ -75,6 +85,7 @@ export function useCopilotReply() {
|
||||
const trackedConversationId = ref(null);
|
||||
|
||||
const conversationId = computed(() => currentChat.value?.id);
|
||||
const uiFrom = 'editor';
|
||||
|
||||
const isActive = computed(() => showEditor.value || isGenerating.value);
|
||||
const isButtonDisabled = computed(
|
||||
@@ -97,7 +108,8 @@ export function useCopilotReply() {
|
||||
buildPayload(
|
||||
currentAction.value,
|
||||
trackedConversationId.value,
|
||||
followUpCount.value
|
||||
followUpCount.value,
|
||||
uiFrom
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -168,7 +180,7 @@ export function useCopilotReply() {
|
||||
const eventKey = `${getEventPrefix(action)}_USED`;
|
||||
useTrack(
|
||||
CAPTAIN_EVENTS[eventKey],
|
||||
buildPayload(action, trackedConversationId.value)
|
||||
buildPayload(action, trackedConversationId.value, undefined, uiFrom)
|
||||
);
|
||||
}
|
||||
isGenerating.value = false;
|
||||
@@ -194,6 +206,7 @@ export function useCopilotReply() {
|
||||
// Track follow-up sent event
|
||||
useTrack(CAPTAIN_EVENTS.FOLLOW_UP_SENT, {
|
||||
conversationId: trackedConversationId.value,
|
||||
uiFrom,
|
||||
});
|
||||
followUpCount.value += 1;
|
||||
|
||||
@@ -237,7 +250,8 @@ export function useCopilotReply() {
|
||||
buildPayload(
|
||||
currentAction.value,
|
||||
trackedConversationId.value,
|
||||
followUpCount.value
|
||||
followUpCount.value,
|
||||
uiFrom
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed } from 'vue';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
|
||||
export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
|
||||
{ name: 'conversation_summary' },
|
||||
{ name: 'conversation_actions' },
|
||||
{ name: 'macros' },
|
||||
{ name: 'conversation_info' },
|
||||
|
||||
@@ -137,6 +137,11 @@
|
||||
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
|
||||
"SHOW_QUOTED_TEXT": "Show Quoted Text",
|
||||
"MESSAGE_READ": "Read",
|
||||
"SENDING": "Sending"
|
||||
"SENDING": "Sending",
|
||||
"SUMMARY": {
|
||||
"TITLE": "Generate Summary",
|
||||
"LOADING": "Generating summary...",
|
||||
"ERROR": "Failed to generate summary"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +349,7 @@
|
||||
"CONVERSATION_ACTIONS": "Conversation Actions",
|
||||
"CONVERSATION_LABELS": "Conversation Labels",
|
||||
"CONVERSATION_INFO": "Conversation Information",
|
||||
"CONVERSATION_SUMMARY": "AI Summary",
|
||||
"CONTACT_NOTES": "Contact Notes",
|
||||
"CONTACT_ATTRIBUTES": "Contact Attributes",
|
||||
"PREVIOUS_CONVERSATION": "Previous Conversations",
|
||||
@@ -356,6 +357,16 @@
|
||||
"LINEAR_ISSUES": "Linked Linear Issues",
|
||||
"SHOPIFY_ORDERS": "Shopify Orders"
|
||||
},
|
||||
"SUMMARY": {
|
||||
"DESCRIPTION": "Generate an AI-powered summary of this conversation",
|
||||
"GENERATE": "Generate Summary",
|
||||
"REGENERATE": "Regenerate",
|
||||
"REFRESH": "Refresh",
|
||||
"RETRY": "Retry",
|
||||
"STALE": "Summary may be outdated",
|
||||
"ERROR": "Failed to generate summary",
|
||||
"EMPTY": "No summary available"
|
||||
},
|
||||
"SHOPIFY": {
|
||||
"ORDER_ID": "Order #{id}",
|
||||
"ERROR": "Error loading orders",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
{
|
||||
"DATE_PICKER": {
|
||||
"PREVIOUS_PERIOD": "Previous period",
|
||||
"NEXT_PERIOD": "Next period",
|
||||
"WEEK_NUMBER": "Week #{weekNumber}",
|
||||
"APPLY_BUTTON": "Apply",
|
||||
"CLEAR_BUTTON": "Clear",
|
||||
"DATE_RANGE_INPUT": {
|
||||
@@ -16,8 +13,6 @@
|
||||
"LAST_3_MONTHS": "Last 3 months",
|
||||
"LAST_6_MONTHS": "Last 6 months",
|
||||
"LAST_YEAR": "Last year",
|
||||
"THIS_WEEK": "This week",
|
||||
"MONTH_TO_DATE": "This month",
|
||||
"CUSTOM_RANGE": "Custom date range"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,16 +128,11 @@
|
||||
},
|
||||
"AGENT_REPORTS": {
|
||||
"HEADER": "Agents Overview",
|
||||
"DESCRIPTION": "Easily track agent performance with key metrics such as conversations, response times, resolution times, and resolved cases. Click an agent's name to learn more.",
|
||||
"DESCRIPTION": "Easily track agent performance with key metrics such as conversations, response times, resolution times, and resolved cases. Click an agent’s name to learn more.",
|
||||
"LOADING_CHART": "Loading chart data...",
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_AGENT_REPORTS": "Download agent reports",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Agent",
|
||||
"FILTERS": {
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"AGENTS": "Search agents"
|
||||
}
|
||||
},
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
@@ -206,11 +201,6 @@
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_LABEL_REPORTS": "Download label reports",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Label",
|
||||
"FILTERS": {
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"LABELS": "Search labels"
|
||||
}
|
||||
},
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
@@ -281,11 +271,6 @@
|
||||
"FILTER_DROPDOWN_LABEL": "Select Inbox",
|
||||
"ALL_INBOXES": "All Inboxes",
|
||||
"SEARCH_INBOX": "Search Inbox",
|
||||
"FILTERS": {
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"INBOXES": "Search inboxes"
|
||||
}
|
||||
},
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
@@ -349,19 +334,11 @@
|
||||
},
|
||||
"TEAM_REPORTS": {
|
||||
"HEADER": "Team Overview",
|
||||
"DESCRIPTION": "Get a snapshot of your team's performance with essential metrics, including conversations, response times, resolution times, and resolved cases. Click a team name for more details.",
|
||||
"DESCRIPTION": "Get a snapshot of your team’s performance with essential metrics, including conversations, response times, resolution times, and resolved cases. Click a team name for more details.",
|
||||
"LOADING_CHART": "Loading chart data...",
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_TEAM_REPORTS": "Download team reports",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Team",
|
||||
"FILTERS": {
|
||||
"ADD_FILTER": "Add filter",
|
||||
"CLEAR_ALL": "Clear all",
|
||||
"NO_FILTER": "No filters available",
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"TEAMS": "Search teams"
|
||||
}
|
||||
},
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
|
||||
@@ -13,6 +13,7 @@ import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
|
||||
import ContactConversations from './ContactConversations.vue';
|
||||
import ConversationAction from './ConversationAction.vue';
|
||||
import ConversationParticipant from './ConversationParticipant.vue';
|
||||
import ConversationSummary from './ConversationSummary.vue';
|
||||
import ContactInfo from './contact/ContactInfo.vue';
|
||||
import ContactNotes from './contact/ContactNotes.vue';
|
||||
import ConversationInfo from './ConversationInfo.vue';
|
||||
@@ -44,6 +45,7 @@ const {
|
||||
|
||||
const dragging = ref(false);
|
||||
const conversationSidebarItems = ref([]);
|
||||
const summaryRef = ref(null);
|
||||
|
||||
const shopifyIntegration = useFunctionGetter(
|
||||
'integrations/getIntegration',
|
||||
@@ -60,6 +62,10 @@ const isLinearFeatureEnabled = computed(() =>
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.LINEAR)
|
||||
);
|
||||
|
||||
const isCaptainTasksEnabled = computed(() =>
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS)
|
||||
);
|
||||
|
||||
const linearIntegration = useFunctionGetter(
|
||||
'integrations/getIntegration',
|
||||
'linear'
|
||||
@@ -150,7 +156,31 @@ onMounted(() => {
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<div
|
||||
v-if="element.name === 'conversation_actions'"
|
||||
v-if="
|
||||
element.name === 'conversation_summary' && isCaptainTasksEnabled
|
||||
"
|
||||
>
|
||||
<AccordionItem
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONVERSATION_SUMMARY')"
|
||||
:is-open="isContactSidebarItemOpen('is_conv_summary_open')"
|
||||
compact
|
||||
@toggle="
|
||||
value => {
|
||||
toggleSidebarUIState('is_conv_summary_open', value);
|
||||
if (value && summaryRef) {
|
||||
summaryRef.fetchSummary();
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<ConversationSummary
|
||||
ref="summaryRef"
|
||||
:conversation-id="conversationId"
|
||||
/>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="element.name === 'conversation_actions'"
|
||||
class="conversation--actions"
|
||||
>
|
||||
<AccordionItem
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import TasksAPI from 'dashboard/api/captain/tasks';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const isLoading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const captainTasksEnabled = computed(() => {
|
||||
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
|
||||
});
|
||||
|
||||
const cachedSummary = computed(() => currentChat.value?.cached_summary || '');
|
||||
const cachedSummaryAt = computed(
|
||||
() => currentChat.value?.cached_summary_at || 0
|
||||
);
|
||||
const lastActivityAt = computed(() => currentChat.value?.last_activity_at || 0);
|
||||
const uiFrom = 'conversation_sidebar';
|
||||
|
||||
const isStale = computed(() => {
|
||||
if (!cachedSummaryAt.value) return true;
|
||||
return lastActivityAt.value > cachedSummaryAt.value;
|
||||
});
|
||||
|
||||
const hasSummary = computed(() => !!cachedSummary.value);
|
||||
|
||||
const formattedSummary = computed(() => {
|
||||
return cachedSummary.value ? formatMessage(cachedSummary.value) : '';
|
||||
});
|
||||
|
||||
const fetchSummary = async (forceRegenerate = false) => {
|
||||
if (!captainTasksEnabled.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
const result = await TasksAPI.summarize(props.conversationId, {
|
||||
forceRegenerate,
|
||||
});
|
||||
const {
|
||||
data: { message: generatedSummary },
|
||||
} = result;
|
||||
|
||||
if (generatedSummary) {
|
||||
store.dispatch('updateConversationCachedSummary', {
|
||||
conversationId: currentChat.value.id,
|
||||
cachedSummary: generatedSummary,
|
||||
cachedSummaryAt: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError' && e.name !== 'CanceledError') {
|
||||
error.value =
|
||||
e.response?.data?.error || t('CONVERSATION_SIDEBAR.SUMMARY.ERROR');
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const trackSummary = action => {
|
||||
useTrack(CAPTAIN_EVENTS.SUMMARIZE_USED, {
|
||||
conversationId: props.conversationId,
|
||||
uiFrom,
|
||||
action,
|
||||
});
|
||||
};
|
||||
|
||||
const generateSummary = () => {
|
||||
trackSummary('generate');
|
||||
return fetchSummary(false);
|
||||
};
|
||||
|
||||
const regenerate = () => {
|
||||
trackSummary('regenerate');
|
||||
return fetchSummary(true);
|
||||
};
|
||||
|
||||
const retryGenerate = () => {
|
||||
trackSummary('retry');
|
||||
return fetchSummary(true);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.conversationId,
|
||||
() => {
|
||||
error.value = '';
|
||||
}
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
fetchSummary,
|
||||
captainTasksEnabled,
|
||||
cachedSummary,
|
||||
isStale,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="captainTasksEnabled" class="p-3">
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-4">
|
||||
<Spinner :size="20" class="text-n-slate-10" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="text-sm text-n-ruby-11">
|
||||
{{ error }}
|
||||
<Button
|
||||
:label="t('CONVERSATION_SIDEBAR.SUMMARY.RETRY')"
|
||||
size="sm"
|
||||
variant="link"
|
||||
class="ml-2"
|
||||
@click="retryGenerate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!hasSummary" class="flex flex-col items-center gap-3 py-2">
|
||||
<p class="text-sm text-n-slate-11 text-center mb-0">
|
||||
{{ t('CONVERSATION_SIDEBAR.SUMMARY.DESCRIPTION') }}
|
||||
</p>
|
||||
<Button
|
||||
:label="t('CONVERSATION_SIDEBAR.SUMMARY.GENERATE')"
|
||||
icon="i-material-symbols-auto-awesome"
|
||||
size="sm"
|
||||
@click="generateSummary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="isStale"
|
||||
class="flex items-center gap-2 mb-2 text-xs text-n-amber-11"
|
||||
>
|
||||
<span>{{ t('CONVERSATION_SIDEBAR.SUMMARY.STALE') }}</span>
|
||||
<Button
|
||||
:label="t('CONVERSATION_SIDEBAR.SUMMARY.REFRESH')"
|
||||
size="xs"
|
||||
variant="link"
|
||||
@click="regenerate"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="summary-content text-sm text-n-slate-11 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:my-1 [&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0 [&_strong]:text-n-slate-12"
|
||||
v-html="formattedSummary"
|
||||
/>
|
||||
<div class="mt-3 pt-3 border-t border-n-weak">
|
||||
<Button
|
||||
:label="t('CONVERSATION_SIDEBAR.SUMMARY.REGENERATE')"
|
||||
icon="i-lucide-refresh-cw"
|
||||
size="sm"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
@click="regenerate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import BotMetrics from './components/BotMetrics.vue';
|
||||
import ReportFilters from './components/ReportFilters.vue';
|
||||
import ReportFilterSelector from './components/FilterSelector.vue';
|
||||
import { GROUP_BY_FILTER } from './constants';
|
||||
import ReportContainer from './ReportContainer.vue';
|
||||
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
@@ -12,7 +12,7 @@ export default {
|
||||
components: {
|
||||
BotMetrics,
|
||||
ReportHeader,
|
||||
ReportFilters,
|
||||
ReportFilterSelector,
|
||||
ReportContainer,
|
||||
},
|
||||
data() {
|
||||
@@ -88,10 +88,10 @@ export default {
|
||||
<template>
|
||||
<ReportHeader :header-title="$t('BOT_REPORTS.HEADER')" />
|
||||
<div class="flex flex-col gap-4">
|
||||
<ReportFilters
|
||||
:show-entity-filter="false"
|
||||
show-group-by
|
||||
:show-business-hours="false"
|
||||
<ReportFilterSelector
|
||||
:show-agents-filter="false"
|
||||
show-group-by-filter
|
||||
:show-business-hours-switch="false"
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
|
||||
|
||||
@@ -52,9 +52,6 @@ export default {
|
||||
);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('agents/get');
|
||||
},
|
||||
methods: {
|
||||
getAllData() {
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import V4Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import ReportFilters from './components/ReportFilters.vue';
|
||||
import ReportFilterSelector from './components/FilterSelector.vue';
|
||||
import { GROUP_BY_FILTER } from './constants';
|
||||
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
import { generateFileName } from 'dashboard/helper/downloadHelper';
|
||||
@@ -22,7 +22,7 @@ export default {
|
||||
name: 'ConversationReports',
|
||||
components: {
|
||||
ReportHeader,
|
||||
ReportFilters,
|
||||
ReportFilterSelector,
|
||||
ReportContainer,
|
||||
V4Button,
|
||||
},
|
||||
@@ -115,10 +115,10 @@ export default {
|
||||
@click="downloadConversationReports"
|
||||
/>
|
||||
</ReportHeader>
|
||||
<div class="flex flex-col">
|
||||
<ReportFilters
|
||||
:show-entity-filter="false"
|
||||
show-group-by
|
||||
<div class="flex flex-col gap-3">
|
||||
<ReportFilterSelector
|
||||
:show-agents-filter="false"
|
||||
show-group-by-filter
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
<ReportContainer :group-by="groupBy" />
|
||||
|
||||
@@ -145,7 +145,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 px-6 py-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 mt-4"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 px-6 py-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2"
|
||||
>
|
||||
<div
|
||||
v-for="metric in metrics"
|
||||
|
||||
+9
-56
@@ -2,8 +2,6 @@
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { subDays, fromUnixTime } from 'date-fns';
|
||||
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
|
||||
import {
|
||||
buildFilterList,
|
||||
@@ -15,12 +13,6 @@ import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
|
||||
import ActiveFilterChip from '../Filters/v3/ActiveFilterChip.vue';
|
||||
import AddFilterChip from '../Filters/v3/AddFilterChip.vue';
|
||||
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
|
||||
import {
|
||||
parseReportURLParams,
|
||||
parseFilterURLParams,
|
||||
generateCompleteURLParams,
|
||||
} from '../../helpers/reportFilterHelper';
|
||||
import { DATE_RANGE_TYPES } from 'dashboard/components/ui/DatePicker/helpers/DatePickerHelper';
|
||||
|
||||
const props = defineProps({
|
||||
showTeamFilter: {
|
||||
@@ -33,29 +25,16 @@ const emit = defineEmits(['filterChange']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// Initialize from URL params immediately
|
||||
const urlParams = parseReportURLParams(route.query);
|
||||
const urlFilters = parseFilterURLParams(route.query);
|
||||
|
||||
const initialDateRange =
|
||||
urlParams.from && urlParams.to
|
||||
? [fromUnixTime(urlParams.from), fromUnixTime(urlParams.to)]
|
||||
: [subDays(new Date(), 6), new Date()];
|
||||
|
||||
const showDropdownMenu = ref(false);
|
||||
const showSubDropdownMenu = ref(false);
|
||||
const activeFilterType = ref('');
|
||||
const customDateRange = ref(initialDateRange);
|
||||
const selectedDateRange = ref(urlParams.range || DATE_RANGE_TYPES.LAST_7_DAYS);
|
||||
|
||||
const customDateRange = ref([new Date(), new Date()]);
|
||||
const appliedFilters = ref({
|
||||
user_ids: urlFilters.agent_id,
|
||||
inbox_id: urlFilters.inbox_id,
|
||||
team_id: urlFilters.team_id,
|
||||
rating: urlFilters.rating,
|
||||
user_ids: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
rating: null,
|
||||
});
|
||||
|
||||
const agents = computed(() => store.getters['agents/getAgents']);
|
||||
@@ -132,17 +111,13 @@ const activeFilters = computed(() => {
|
||||
const activeKeys = Object.keys(appliedFilters.value).filter(
|
||||
key => appliedFilters.value[key]
|
||||
);
|
||||
|
||||
return activeKeys.map(key => {
|
||||
const filterType = getFilterType(key, 'keyToType');
|
||||
const items = getFilterSource(filterType);
|
||||
const item = getActiveFilter(items, filterType, appliedFilters.value[key]);
|
||||
const displayName =
|
||||
item?.name || item?.title || `ID: ${appliedFilters.value[key]}`;
|
||||
|
||||
return {
|
||||
id: item?.id || appliedFilters.value[key],
|
||||
name: displayName,
|
||||
id: item?.id,
|
||||
name: item?.name || '',
|
||||
type: filterType,
|
||||
options: getFilterOptions(filterType),
|
||||
};
|
||||
@@ -155,23 +130,7 @@ const hasActiveFilters = computed(() =>
|
||||
|
||||
const isAllFilterSelected = computed(() => !filterListMenuItems.value.length);
|
||||
|
||||
const updateURLParams = () => {
|
||||
const params = generateCompleteURLParams({
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
range: selectedDateRange.value,
|
||||
filters: {
|
||||
agent_id: appliedFilters.value.user_ids,
|
||||
inbox_id: appliedFilters.value.inbox_id,
|
||||
team_id: appliedFilters.value.team_id,
|
||||
rating: appliedFilters.value.rating,
|
||||
},
|
||||
});
|
||||
router.replace({ query: params });
|
||||
};
|
||||
|
||||
const emitChange = () => {
|
||||
updateURLParams();
|
||||
emit('filterChange', {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
@@ -241,9 +200,7 @@ const openActiveFilterDropdown = filterType => {
|
||||
};
|
||||
|
||||
const onDateRangeChange = value => {
|
||||
const [startDate, endDate, rangeType] = value;
|
||||
customDateRange.value = [startDate, endDate];
|
||||
selectedDateRange.value = rangeType || DATE_RANGE_TYPES.CUSTOM_RANGE;
|
||||
customDateRange.value = value;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
@@ -254,11 +211,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col flex-wrap w-full gap-3 md:flex-row">
|
||||
<WootDatePicker
|
||||
v-model:date-range="customDateRange"
|
||||
v-model:range-type="selectedDateRange"
|
||||
@date-range-changed="onDateRangeChange"
|
||||
/>
|
||||
<WootDatePicker @date-range-changed="onDateRangeChange" />
|
||||
|
||||
<div
|
||||
class="flex flex-col flex-wrap items-start gap-2 md:items-center md:flex-nowrap md:flex-row"
|
||||
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
<script>
|
||||
import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue';
|
||||
import ReportsFiltersDateRange from './Filters/DateRange.vue';
|
||||
import ReportsFiltersDateGroupBy from './Filters/DateGroupBy.vue';
|
||||
import ReportsFiltersAgents from './Filters/Agents.vue';
|
||||
import ReportsFiltersLabels from './Filters/Labels.vue';
|
||||
import ReportsFiltersInboxes from './Filters/Inboxes.vue';
|
||||
import ReportsFiltersTeams from './Filters/Teams.vue';
|
||||
import ReportsFiltersRatings from './Filters/Ratings.vue';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import { DATE_RANGE_OPTIONS } from '../constants';
|
||||
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WootDateRangePicker,
|
||||
ReportsFiltersDateRange,
|
||||
ReportsFiltersDateGroupBy,
|
||||
ReportsFiltersAgents,
|
||||
ReportsFiltersLabels,
|
||||
ReportsFiltersInboxes,
|
||||
ReportsFiltersTeams,
|
||||
ReportsFiltersRatings,
|
||||
ToggleSwitch,
|
||||
},
|
||||
props: {
|
||||
showGroupByFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showAgentsFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showLabelsFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showInboxFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showRatingFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showTeamFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showBusinessHoursSwitch: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ['filterChange'],
|
||||
data() {
|
||||
return {
|
||||
// default value, need not be translated
|
||||
selectedDateRange: DATE_RANGE_OPTIONS.LAST_7_DAYS,
|
||||
selectedGroupByFilter: null,
|
||||
selectedLabel: null,
|
||||
selectedInbox: null,
|
||||
selectedTeam: null,
|
||||
selectedRating: null,
|
||||
selectedAgents: [],
|
||||
customDateRange: [new Date(), new Date()],
|
||||
businessHoursSelected: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isDateRangeSelected() {
|
||||
return (
|
||||
this.selectedDateRange.id === DATE_RANGE_OPTIONS.CUSTOM_DATE_RANGE.id
|
||||
);
|
||||
},
|
||||
isGroupByPossible() {
|
||||
return this.selectedDateRange.id !== DATE_RANGE_OPTIONS.LAST_7_DAYS.id;
|
||||
},
|
||||
to() {
|
||||
if (this.isDateRangeSelected) {
|
||||
return getUnixEndOfDay(this.customDateRange[1]);
|
||||
}
|
||||
return getUnixEndOfDay(new Date());
|
||||
},
|
||||
from() {
|
||||
if (this.isDateRangeSelected) {
|
||||
return getUnixStartOfDay(this.customDateRange[0]);
|
||||
}
|
||||
|
||||
const { offset } = this.selectedDateRange;
|
||||
const fromDate = subDays(new Date(), offset);
|
||||
return getUnixStartOfDay(fromDate);
|
||||
},
|
||||
validGroupOptions() {
|
||||
return this.selectedDateRange.groupByOptions;
|
||||
},
|
||||
validGroupBy() {
|
||||
if (!this.selectedGroupByFilter) {
|
||||
return this.validGroupOptions[0];
|
||||
}
|
||||
|
||||
const validIds = this.validGroupOptions.map(opt => opt.id);
|
||||
if (validIds.includes(this.selectedGroupByFilter.id)) {
|
||||
return this.selectedGroupByFilter;
|
||||
}
|
||||
return this.validGroupOptions[0];
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.emitChange();
|
||||
},
|
||||
methods: {
|
||||
emitChange() {
|
||||
const {
|
||||
from,
|
||||
to,
|
||||
selectedGroupByFilter: groupBy,
|
||||
businessHoursSelected: businessHours,
|
||||
selectedAgents,
|
||||
selectedLabel,
|
||||
selectedInbox,
|
||||
selectedTeam,
|
||||
selectedRating,
|
||||
} = this;
|
||||
this.$emit('filterChange', {
|
||||
from,
|
||||
to,
|
||||
groupBy,
|
||||
businessHours,
|
||||
selectedAgents,
|
||||
selectedLabel,
|
||||
selectedInbox,
|
||||
selectedTeam,
|
||||
selectedRating,
|
||||
});
|
||||
},
|
||||
onDateRangeChange(selectedRange) {
|
||||
this.selectedDateRange = selectedRange;
|
||||
this.selectedGroupByFilter = this.validGroupBy;
|
||||
this.emitChange();
|
||||
},
|
||||
onCustomDateRangeChange(value) {
|
||||
this.customDateRange = value;
|
||||
this.selectedGroupByFilter = this.validGroupBy;
|
||||
this.emitChange();
|
||||
},
|
||||
onGroupingChange(payload) {
|
||||
this.selectedGroupByFilter = payload;
|
||||
this.emitChange();
|
||||
},
|
||||
handleAgentsFilterSelection(selectedAgents) {
|
||||
this.selectedAgents = selectedAgents;
|
||||
this.emitChange();
|
||||
},
|
||||
handleLabelsFilterSelection(selectedLabel) {
|
||||
this.selectedLabel = selectedLabel;
|
||||
this.emitChange();
|
||||
},
|
||||
handleInboxFilterSelection(selectedInbox) {
|
||||
this.selectedInbox = selectedInbox;
|
||||
this.emitChange();
|
||||
},
|
||||
handleTeamFilterSelection(selectedTeam) {
|
||||
this.selectedTeam = selectedTeam;
|
||||
this.emitChange();
|
||||
},
|
||||
handleRatingFilterSelection(selectedRating) {
|
||||
this.selectedRating = selectedRating;
|
||||
this.emitChange();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col justify-between gap-3 md:flex-row">
|
||||
<div
|
||||
class="w-full grid gap-y-2 gap-x-1.5 grid-cols-[repeat(auto-fill,minmax(250px,1fr))]"
|
||||
>
|
||||
<ReportsFiltersDateRange @on-range-change="onDateRangeChange" />
|
||||
<WootDateRangePicker
|
||||
v-if="isDateRangeSelected"
|
||||
show-range
|
||||
class="no-margin auto-width"
|
||||
:value="customDateRange"
|
||||
:confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')"
|
||||
:placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')"
|
||||
@change="onCustomDateRangeChange"
|
||||
/>
|
||||
<ReportsFiltersDateGroupBy
|
||||
v-if="showGroupByFilter && isGroupByPossible"
|
||||
:valid-group-options="validGroupOptions"
|
||||
:selected-option="selectedGroupByFilter"
|
||||
@on-grouping-change="onGroupingChange"
|
||||
/>
|
||||
<ReportsFiltersAgents
|
||||
v-if="showAgentsFilter"
|
||||
@agents-filter-selection="handleAgentsFilterSelection"
|
||||
/>
|
||||
<ReportsFiltersLabels
|
||||
v-if="showLabelsFilter"
|
||||
@labels-filter-selection="handleLabelsFilterSelection"
|
||||
/>
|
||||
<ReportsFiltersTeams
|
||||
v-if="showTeamFilter"
|
||||
@team-filter-selection="handleTeamFilterSelection"
|
||||
/>
|
||||
<ReportsFiltersInboxes
|
||||
v-if="showInboxFilter"
|
||||
@inbox-filter-selection="handleInboxFilterSelection"
|
||||
/>
|
||||
<ReportsFiltersRatings
|
||||
v-if="showRatingFilter"
|
||||
@rating-filter-selection="handleRatingFilterSelection"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showBusinessHoursSwitch" class="flex items-center">
|
||||
<span class="mx-2 text-sm whitespace-nowrap">
|
||||
{{ $t('REPORT.BUSINESS_HOURS') }}
|
||||
</span>
|
||||
<span>
|
||||
<ToggleSwitch v-model="businessHoursSelected" @change="emitChange" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'ReportsFiltersAgents',
|
||||
emits: ['agentsFilterSelection'],
|
||||
data() {
|
||||
return {
|
||||
selectedOptions: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
options: 'agents/getAgents',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('agents/get');
|
||||
},
|
||||
methods: {
|
||||
handleInput() {
|
||||
this.$emit('agentsFilterSelection', this.selectedOptions);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOptions"
|
||||
class="no-margin"
|
||||
:options="options"
|
||||
track-by="id"
|
||||
label="name"
|
||||
multiple
|
||||
:close-on-select="false"
|
||||
:clear-on-select="false"
|
||||
hide-selected
|
||||
:placeholder="$t('CSAT_REPORTS.FILTERS.AGENTS.PLACEHOLDER')"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
|
||||
@update:model-value="handleInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
<script>
|
||||
import { GROUP_BY_OPTIONS } from '../../constants';
|
||||
|
||||
export default {
|
||||
name: 'ReportsFiltersDateGroupBy',
|
||||
props: {
|
||||
validGroupOptions: {
|
||||
type: Array,
|
||||
default: () => [GROUP_BY_OPTIONS.DAY],
|
||||
},
|
||||
selectedOption: {
|
||||
type: Object,
|
||||
default: () => GROUP_BY_OPTIONS.DAY,
|
||||
},
|
||||
},
|
||||
emits: ['onGroupingChange'],
|
||||
data() {
|
||||
return {
|
||||
currentSelectedFilter: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
translatedOptions() {
|
||||
return this.validGroupOptions.map(option => ({
|
||||
...option,
|
||||
groupBy: this.$t(option.translationKey),
|
||||
}));
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectedOption: {
|
||||
handler() {
|
||||
this.currentSelectedFilter = {
|
||||
...this.selectedOption,
|
||||
groupBy: this.$t(this.selectedOption.translationKey),
|
||||
};
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
changeFilterSelection(selectedFilter) {
|
||||
this.groupByOptions = this.$emit('onGroupingChange', selectedFilter);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<p aria-hidden="true" class="hidden">
|
||||
{{ $t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL') }}
|
||||
</p>
|
||||
<multiselect
|
||||
v-model="currentSelectedFilter"
|
||||
class="no-margin"
|
||||
track-by="id"
|
||||
label="groupBy"
|
||||
:placeholder="$t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')"
|
||||
:options="translatedOptions"
|
||||
:allow-empty="false"
|
||||
:show-labels="false"
|
||||
@select="changeFilterSelection"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { DATE_RANGE_OPTIONS } from '../../constants';
|
||||
|
||||
const emit = defineEmits(['onRangeChange']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const options = computed(() =>
|
||||
Object.values(DATE_RANGE_OPTIONS).map(option => ({
|
||||
...option,
|
||||
name: t(option.translationKey),
|
||||
}))
|
||||
);
|
||||
|
||||
const selectedId = ref(Object.values(DATE_RANGE_OPTIONS)[0].id);
|
||||
|
||||
const selectedOption = computed({
|
||||
get() {
|
||||
return options.value.find(o => o.id === selectedId.value);
|
||||
},
|
||||
set(val) {
|
||||
selectedId.value = val.id;
|
||||
},
|
||||
});
|
||||
|
||||
const updateRange = range => {
|
||||
selectedOption.value = range;
|
||||
emit('onRangeChange', range);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOption"
|
||||
class="no-margin"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT_ONE')"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
:options="options"
|
||||
:searchable="false"
|
||||
:allow-empty="false"
|
||||
@select="updateRange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'ReportsFiltersInboxes',
|
||||
emits: ['inboxFilterSelection'],
|
||||
data() {
|
||||
return {
|
||||
selectedOption: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
options: 'inboxes/getInboxes',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('inboxes/get');
|
||||
},
|
||||
methods: {
|
||||
handleInput() {
|
||||
this.$emit('inboxFilterSelection', this.selectedOption);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOption"
|
||||
class="no-margin"
|
||||
:placeholder="$t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL')"
|
||||
label="name"
|
||||
track-by="id"
|
||||
:options="options"
|
||||
:option-height="24"
|
||||
:show-labels="false"
|
||||
@update:model-value="handleInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'ReportsFiltersLabels',
|
||||
emits: ['labelsFilterSelection'],
|
||||
data() {
|
||||
return {
|
||||
selectedOption: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
options: 'labels/getLabels',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('labels/get');
|
||||
},
|
||||
methods: {
|
||||
handleInput() {
|
||||
this.$emit('labelsFilterSelection', this.selectedOption);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOption"
|
||||
class="no-margin"
|
||||
:placeholder="$t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL')"
|
||||
label="title"
|
||||
track-by="id"
|
||||
:options="options"
|
||||
:option-height="24"
|
||||
:show-labels="false"
|
||||
@update:model-value="handleInput"
|
||||
>
|
||||
<template #singleLabel="props">
|
||||
<div class="flex items-center min-w-0 gap-2">
|
||||
<div
|
||||
:style="{ backgroundColor: props.option.color }"
|
||||
class="w-5 h-5 rounded-full"
|
||||
/>
|
||||
<span class="my-0 text-n-slate-12">
|
||||
{{ props.option.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #option="props">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
:style="{ backgroundColor: props.option.color }"
|
||||
class="flex-shrink-0 w-5 h-5 border border-solid rounded-full border-n-weak"
|
||||
/>
|
||||
|
||||
<span class="my-0 text-n-slate-12 truncate min-w-0">
|
||||
{{ props.option.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
</template>
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<script>
|
||||
import { CSAT_RATINGS } from 'shared/constants/messages';
|
||||
|
||||
export default {
|
||||
name: 'ReportFiltersRatings',
|
||||
emits: ['ratingFilterSelection'],
|
||||
data() {
|
||||
const translatedOptions = CSAT_RATINGS.reverse().map(option => ({
|
||||
...option,
|
||||
label: this.$t(option.translationKey),
|
||||
}));
|
||||
|
||||
return {
|
||||
selectedOption: null,
|
||||
options: translatedOptions,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
handleInput(selectedRating) {
|
||||
this.$emit('ratingFilterSelection', selectedRating);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOption"
|
||||
class="no-margin"
|
||||
:option-height="24"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT_ONE')"
|
||||
:options="options"
|
||||
:show-labels="false"
|
||||
track-by="value"
|
||||
label="label"
|
||||
@update:model-value="handleInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'ReportsFiltersTeams',
|
||||
emits: ['teamFilterSelection'],
|
||||
data() {
|
||||
return {
|
||||
selectedOption: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
options: 'teams/getTeams',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('teams/get');
|
||||
},
|
||||
methods: {
|
||||
handleInput() {
|
||||
this.$emit('teamFilterSelection', this.selectedOption);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOption"
|
||||
class="no-margin"
|
||||
:placeholder="$t('TEAM_REPORTS.FILTER_DROPDOWN_LABEL')"
|
||||
label="name"
|
||||
track-by="id"
|
||||
:options="options"
|
||||
:option-height="24"
|
||||
:show-labels="false"
|
||||
@update:model-value="handleInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+3
-7
@@ -8,8 +8,8 @@ const props = defineProps({
|
||||
required: true,
|
||||
},
|
||||
id: {
|
||||
type: [Number, null],
|
||||
default: null,
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
@@ -35,10 +35,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showClearFilter: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -64,7 +60,7 @@ const closeDropdown = () => emit('closeDropdown');
|
||||
<FilterListDropdown
|
||||
v-if="options"
|
||||
v-on-clickaway="closeDropdown"
|
||||
:show-clear-filter="showClearFilter"
|
||||
show-clear-filter
|
||||
:list-items="options"
|
||||
:active-filter-id="id"
|
||||
:input-placeholder="placeholder"
|
||||
|
||||
-102
@@ -1,102 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import {
|
||||
generateReportURLParams,
|
||||
parseReportURLParams,
|
||||
} from '../helpers/reportFilterHelper';
|
||||
import { DATE_RANGE_TYPES } from 'dashboard/components/ui/DatePicker/helpers/DatePickerHelper';
|
||||
|
||||
const emit = defineEmits(['filterChange']);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const customDateRange = ref([subDays(new Date(), 6), new Date()]);
|
||||
const selectedDateRange = ref(DATE_RANGE_TYPES.LAST_7_DAYS);
|
||||
const businessHoursSelected = ref(false);
|
||||
|
||||
const updateURLParams = () => {
|
||||
const params = generateReportURLParams({
|
||||
from: getUnixStartOfDay(customDateRange.value[0]),
|
||||
to: getUnixEndOfDay(customDateRange.value[1]),
|
||||
businessHours: businessHoursSelected.value,
|
||||
range: selectedDateRange.value,
|
||||
});
|
||||
|
||||
router.replace({ query: { ...params } });
|
||||
};
|
||||
|
||||
const emitChange = () => {
|
||||
updateURLParams();
|
||||
emit('filterChange', {
|
||||
from: getUnixStartOfDay(customDateRange.value[0]),
|
||||
to: getUnixEndOfDay(customDateRange.value[1]),
|
||||
businessHours: businessHoursSelected.value,
|
||||
});
|
||||
};
|
||||
|
||||
const onDateRangeChange = value => {
|
||||
const [startDate, endDate, rangeType] = value;
|
||||
customDateRange.value = [startDate, endDate];
|
||||
selectedDateRange.value = rangeType || DATE_RANGE_TYPES.CUSTOM_RANGE;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const onBusinessHoursToggle = () => {
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const initializeFromURL = () => {
|
||||
const urlParams = parseReportURLParams(route.query);
|
||||
|
||||
// Set the range type first
|
||||
if (urlParams.range) {
|
||||
selectedDateRange.value = urlParams.range;
|
||||
}
|
||||
|
||||
// Restore dates from URL if available
|
||||
if (urlParams.from && urlParams.to) {
|
||||
customDateRange.value = [
|
||||
new Date(urlParams.from * 1000),
|
||||
new Date(urlParams.to * 1000),
|
||||
];
|
||||
}
|
||||
|
||||
if (urlParams.businessHours) {
|
||||
businessHoursSelected.value = urlParams.businessHours;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeFromURL();
|
||||
emitChange();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col justify-between gap-3 md:flex-row">
|
||||
<div class="flex flex-col flex-wrap items-start gap-2 md:flex-row">
|
||||
<WootDatePicker
|
||||
v-model:date-range="customDateRange"
|
||||
v-model:range-type="selectedDateRange"
|
||||
@date-range-changed="onDateRangeChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="mx-2 text-sm whitespace-nowrap">
|
||||
{{ $t('REPORT.BUSINESS_HOURS') }}
|
||||
</span>
|
||||
<span>
|
||||
<ToggleSwitch
|
||||
v-model="businessHoursSelected"
|
||||
@change="onBusinessHoursToggle"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+334
-367
@@ -1,382 +1,349 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
|
||||
<script>
|
||||
import endOfDay from 'date-fns/endOfDay';
|
||||
import getUnixTime from 'date-fns/getUnixTime';
|
||||
import startOfDay from 'date-fns/startOfDay';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import differenceInDays from 'date-fns/differenceInDays';
|
||||
import ActiveFilterChip from './Filters/v3/ActiveFilterChip.vue';
|
||||
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
import { GROUP_BY_FILTER } from '../constants';
|
||||
import { DATE_RANGE_TYPES } from 'dashboard/components/ui/DatePicker/helpers/DatePickerHelper';
|
||||
import {
|
||||
generateReportURLParams,
|
||||
parseReportURLParams,
|
||||
} from '../helpers/reportFilterHelper';
|
||||
const CUSTOM_DATE_RANGE_ID = 5;
|
||||
|
||||
const props = defineProps({
|
||||
filterType: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
validator: value =>
|
||||
['teams', 'inboxes', 'labels', 'agents', ''].includes(value),
|
||||
export default {
|
||||
components: {
|
||||
WootDateRangePicker,
|
||||
Avatar,
|
||||
ToggleSwitch,
|
||||
},
|
||||
selectedItem: {
|
||||
type: Object,
|
||||
default: null,
|
||||
props: {
|
||||
currentFilter: {
|
||||
type: Object,
|
||||
default: () => null,
|
||||
},
|
||||
filterItemsList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
groupByFilterItemsList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'agent',
|
||||
},
|
||||
selectedGroupByFilter: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
showGroupBy: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showBusinessHours: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showEntityFilter: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['filterChange']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const buildReportFilterList = (items, type) => {
|
||||
if (!Array.isArray(items)) return [];
|
||||
|
||||
return items.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name || item.title,
|
||||
type,
|
||||
}));
|
||||
};
|
||||
|
||||
const getReportFilterKey = filterType => {
|
||||
const keyMap = {
|
||||
teams: 'team_id',
|
||||
inboxes: 'inbox_id',
|
||||
labels: 'label_id',
|
||||
agents: 'agent_id',
|
||||
};
|
||||
return keyMap[filterType] || '';
|
||||
};
|
||||
|
||||
const getFilterKey = () => getReportFilterKey(props.filterType);
|
||||
|
||||
const showSubDropdownMenu = ref(false);
|
||||
const showGroupByDropdown = ref(false);
|
||||
const activeFilterType = ref('');
|
||||
const customDateRange = ref([subDays(new Date(), 6), new Date()]);
|
||||
const selectedDateRange = ref(DATE_RANGE_TYPES.LAST_7_DAYS);
|
||||
const businessHoursSelected = ref(false);
|
||||
const groupBy = ref(GROUP_BY_FILTER[1]);
|
||||
const groupByfilterItemsList = ref([{ id: 1, name: 'Day' }]);
|
||||
|
||||
const appliedFilters = ref(
|
||||
props.showEntityFilter
|
||||
? { [getFilterKey()]: props.selectedItem?.id || null }
|
||||
: {}
|
||||
);
|
||||
|
||||
const filterSource = computed(() => {
|
||||
const sources = {
|
||||
teams: store.getters['teams/getTeams'],
|
||||
inboxes: store.getters['inboxes/getInboxes'],
|
||||
labels: store.getters['labels/getLabels'],
|
||||
agents: store.getters['agents/getAgents'],
|
||||
};
|
||||
return sources[props.filterType] || [];
|
||||
});
|
||||
|
||||
const from = computed(() => getUnixStartOfDay(customDateRange.value[0]));
|
||||
const to = computed(() => getUnixEndOfDay(customDateRange.value[1]));
|
||||
|
||||
const daysDifference = computed(() => {
|
||||
return differenceInDays(customDateRange.value[1], customDateRange.value[0]);
|
||||
});
|
||||
|
||||
const isGroupByPossible = computed(() => {
|
||||
return props.showGroupBy && daysDifference.value >= 29;
|
||||
});
|
||||
|
||||
const GROUP_BY_OPTIONS = computed(() => ({
|
||||
WEEK: [
|
||||
{ id: 1, name: t('REPORT.GROUPING_OPTIONS.DAY') },
|
||||
{ id: 2, name: t('REPORT.GROUPING_OPTIONS.WEEK') },
|
||||
emits: [
|
||||
'businessHoursToggle',
|
||||
'dateRangeChange',
|
||||
'filterChange',
|
||||
'groupByFilterChange',
|
||||
],
|
||||
MONTH: [
|
||||
{ id: 1, name: t('REPORT.GROUPING_OPTIONS.DAY') },
|
||||
{ id: 2, name: t('REPORT.GROUPING_OPTIONS.WEEK') },
|
||||
{ id: 3, name: t('REPORT.GROUPING_OPTIONS.MONTH') },
|
||||
],
|
||||
YEAR: [
|
||||
{ id: 2, name: t('REPORT.GROUPING_OPTIONS.WEEK') },
|
||||
{ id: 3, name: t('REPORT.GROUPING_OPTIONS.MONTH') },
|
||||
{ id: 4, name: t('REPORT.GROUPING_OPTIONS.YEAR') },
|
||||
],
|
||||
}));
|
||||
|
||||
const fetchFilterItems = () => {
|
||||
const days = daysDifference.value;
|
||||
if (days >= 364) return GROUP_BY_OPTIONS.value.YEAR;
|
||||
if (days >= 90) return GROUP_BY_OPTIONS.value.MONTH;
|
||||
if (days >= 29) return GROUP_BY_OPTIONS.value.WEEK;
|
||||
return GROUP_BY_OPTIONS.value.WEEK;
|
||||
data() {
|
||||
return {
|
||||
currentSelectedFilter: this.currentFilter || null,
|
||||
currentDateRangeSelection: {
|
||||
id: 0,
|
||||
name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
|
||||
},
|
||||
customDateRange: [new Date(), new Date()],
|
||||
currentSelectedGroupByFilter: null,
|
||||
businessHoursSelected: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
dateRange() {
|
||||
return [
|
||||
{ id: 0, name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS') },
|
||||
{ id: 1, name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS') },
|
||||
{ id: 2, name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_3_MONTHS') },
|
||||
{ id: 3, name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_6_MONTHS') },
|
||||
{ id: 4, name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_YEAR') },
|
||||
{ id: 5, name: this.$t('REPORT.DATE_RANGE_OPTIONS.CUSTOM_DATE_RANGE') },
|
||||
];
|
||||
},
|
||||
isDateRangeSelected() {
|
||||
return this.currentDateRangeSelection.id === CUSTOM_DATE_RANGE_ID;
|
||||
},
|
||||
to() {
|
||||
if (this.isDateRangeSelected) {
|
||||
return this.toCustomDate(this.customDateRange[1]);
|
||||
}
|
||||
return this.toCustomDate(new Date());
|
||||
},
|
||||
from() {
|
||||
if (this.isDateRangeSelected) {
|
||||
return this.fromCustomDate(this.customDateRange[0]);
|
||||
}
|
||||
const dateRange = {
|
||||
0: 6,
|
||||
1: 29,
|
||||
2: 89,
|
||||
3: 179,
|
||||
4: 364,
|
||||
};
|
||||
const diff = dateRange[this.currentDateRangeSelection.id];
|
||||
const fromDate = subDays(new Date(), diff);
|
||||
return this.fromCustomDate(fromDate);
|
||||
},
|
||||
multiselectLabel() {
|
||||
const typeLabels = {
|
||||
agent: this.$t('AGENT_REPORTS.FILTER_DROPDOWN_LABEL'),
|
||||
label: this.$t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL'),
|
||||
inbox: this.$t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL'),
|
||||
team: this.$t('TEAM_REPORTS.FILTER_DROPDOWN_LABEL'),
|
||||
};
|
||||
return typeLabels[this.type] || this.$t('FORMS.MULTISELECT.SELECT_ONE');
|
||||
},
|
||||
groupBy() {
|
||||
if (this.isDateRangeSelected) {
|
||||
return GROUP_BY_FILTER[4].period;
|
||||
}
|
||||
const groupRange = {
|
||||
0: GROUP_BY_FILTER[1].period,
|
||||
1: GROUP_BY_FILTER[2].period,
|
||||
2: GROUP_BY_FILTER[3].period,
|
||||
3: GROUP_BY_FILTER[3].period,
|
||||
4: GROUP_BY_FILTER[4].period,
|
||||
};
|
||||
return groupRange[this.currentDateRangeSelection.id];
|
||||
},
|
||||
notLast7Days() {
|
||||
return this.groupBy !== GROUP_BY_FILTER[1].period;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
filterItemsList(val) {
|
||||
this.currentSelectedFilter = !this.currentFilter
|
||||
? val[0]
|
||||
: this.currentFilter;
|
||||
this.changeFilterSelection();
|
||||
},
|
||||
groupByFilterItemsList() {
|
||||
this.currentSelectedGroupByFilter = this.selectedGroupByFilter;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.onDateRangeChange();
|
||||
},
|
||||
methods: {
|
||||
onDateRangeChange() {
|
||||
this.$emit('dateRangeChange', {
|
||||
from: this.from,
|
||||
to: this.to,
|
||||
groupBy: this.groupBy,
|
||||
});
|
||||
},
|
||||
onBusinessHoursToggle() {
|
||||
this.$emit('businessHoursToggle', this.businessHoursSelected);
|
||||
},
|
||||
fromCustomDate(date) {
|
||||
return getUnixTime(startOfDay(date));
|
||||
},
|
||||
toCustomDate(date) {
|
||||
return getUnixTime(endOfDay(date));
|
||||
},
|
||||
changeDateSelection(selectedRange) {
|
||||
this.currentDateRangeSelection = selectedRange;
|
||||
this.onDateRangeChange();
|
||||
},
|
||||
changeFilterSelection() {
|
||||
this.$emit('filterChange', this.currentSelectedFilter);
|
||||
},
|
||||
onChange(value) {
|
||||
this.customDateRange = value;
|
||||
this.onDateRangeChange();
|
||||
},
|
||||
changeGroupByFilterSelection() {
|
||||
this.$emit('groupByFilterChange', this.currentSelectedGroupByFilter);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const filterOptions = computed(() =>
|
||||
buildReportFilterList(filterSource.value, props.filterType)
|
||||
);
|
||||
|
||||
const filterPlaceholder = computed(() => {
|
||||
const placeholders = {
|
||||
teams: 'TEAM_REPORTS.FILTERS.INPUT_PLACEHOLDER.TEAMS',
|
||||
inboxes: 'INBOX_REPORTS.FILTERS.INPUT_PLACEHOLDER.INBOXES',
|
||||
labels: 'LABEL_REPORTS.FILTERS.INPUT_PLACEHOLDER.LABELS',
|
||||
agents: 'AGENT_REPORTS.FILTERS.INPUT_PLACEHOLDER.AGENTS',
|
||||
};
|
||||
return t(placeholders[props.filterType] || '');
|
||||
});
|
||||
|
||||
const defaultFilterLabel = computed(() => {
|
||||
const labelKeys = {
|
||||
teams: 'TEAM_REPORTS.FILTER_DROPDOWN_LABEL',
|
||||
inboxes: 'INBOX_REPORTS.FILTER_DROPDOWN_LABEL',
|
||||
labels: 'LABEL_REPORTS.FILTER_DROPDOWN_LABEL',
|
||||
agents: 'AGENT_REPORTS.FILTER_DROPDOWN_LABEL',
|
||||
};
|
||||
return t(labelKeys[props.filterType] || 'FORMS.MULTISELECT.SELECT_ONE');
|
||||
});
|
||||
|
||||
const selectedFilterName = computed(() => {
|
||||
const filterKey = getFilterKey();
|
||||
const selectedId = appliedFilters.value[filterKey];
|
||||
|
||||
if (!selectedId) {
|
||||
return defaultFilterLabel.value;
|
||||
}
|
||||
|
||||
const selectedItem = filterOptions.value.find(item => item.id === selectedId);
|
||||
return selectedItem?.name || defaultFilterLabel.value;
|
||||
});
|
||||
|
||||
const updateURLParams = () => {
|
||||
const params = generateReportURLParams({
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
businessHours: businessHoursSelected.value,
|
||||
groupBy: isGroupByPossible.value ? groupBy.value.id : null,
|
||||
range: selectedDateRange.value,
|
||||
});
|
||||
|
||||
router.replace({ query: { ...params } });
|
||||
};
|
||||
|
||||
const emitChange = () => {
|
||||
const payload = {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
businessHours: businessHoursSelected.value,
|
||||
};
|
||||
|
||||
if (props.showGroupBy) {
|
||||
// Always emit groupBy, default to day when range is too short
|
||||
payload.groupBy = isGroupByPossible.value
|
||||
? groupBy.value
|
||||
: GROUP_BY_FILTER[1];
|
||||
}
|
||||
|
||||
if (props.showEntityFilter) {
|
||||
const filterKey = getFilterKey();
|
||||
const selectedValue = appliedFilters.value[filterKey];
|
||||
|
||||
if (selectedValue) {
|
||||
payload[props.filterType] =
|
||||
props.filterType === 'agents'
|
||||
? [{ id: selectedValue }]
|
||||
: { id: selectedValue };
|
||||
}
|
||||
}
|
||||
|
||||
updateURLParams();
|
||||
emit('filterChange', payload);
|
||||
};
|
||||
|
||||
const closeActiveFilterDropdown = () => {
|
||||
showSubDropdownMenu.value = false;
|
||||
activeFilterType.value = '';
|
||||
};
|
||||
|
||||
const openActiveFilterDropdown = filterType => {
|
||||
showGroupByDropdown.value = false;
|
||||
activeFilterType.value = filterType;
|
||||
showSubDropdownMenu.value = !showSubDropdownMenu.value;
|
||||
};
|
||||
|
||||
const addFilter = item => {
|
||||
const filterKey = getFilterKey();
|
||||
appliedFilters.value[filterKey] = item.id;
|
||||
closeActiveFilterDropdown();
|
||||
emitChange();
|
||||
|
||||
// Navigate to the new entity's route
|
||||
const routeNameMap = {
|
||||
teams: 'team_reports_show',
|
||||
inboxes: 'inbox_reports_show',
|
||||
labels: 'label_reports_show',
|
||||
agents: 'agent_reports_show',
|
||||
};
|
||||
|
||||
const routeName = routeNameMap[props.filterType];
|
||||
if (routeName) {
|
||||
router.push({
|
||||
name: routeName,
|
||||
params: { ...route.params, id: item.id },
|
||||
query: route.query,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onDateRangeChange = value => {
|
||||
const [startDate, endDate, rangeType] = value;
|
||||
customDateRange.value = [startDate, endDate];
|
||||
selectedDateRange.value = rangeType || DATE_RANGE_TYPES.CUSTOM_RANGE;
|
||||
groupByfilterItemsList.value = fetchFilterItems();
|
||||
const filterItems = groupByfilterItemsList.value.filter(
|
||||
item => item.id === groupBy.value.id
|
||||
);
|
||||
if (filterItems.length === 0) {
|
||||
groupBy.value = GROUP_BY_FILTER[groupByfilterItemsList.value[0].id];
|
||||
}
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const onBusinessHoursToggle = () => {
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const onGroupByFilterChange = payload => {
|
||||
groupBy.value = GROUP_BY_FILTER[payload.id];
|
||||
showGroupByDropdown.value = false;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const toggleGroupByDropdown = () => {
|
||||
showGroupByDropdown.value = !showGroupByDropdown.value;
|
||||
};
|
||||
|
||||
const closeGroupByDropdown = () => {
|
||||
showGroupByDropdown.value = false;
|
||||
};
|
||||
|
||||
const initializeFromURL = () => {
|
||||
const urlParams = parseReportURLParams(route.query);
|
||||
|
||||
// Set the range type first
|
||||
if (urlParams.range) {
|
||||
selectedDateRange.value = urlParams.range;
|
||||
}
|
||||
|
||||
// Restore dates from URL if available
|
||||
if (urlParams.from && urlParams.to) {
|
||||
customDateRange.value = [
|
||||
new Date(urlParams.from * 1000),
|
||||
new Date(urlParams.to * 1000),
|
||||
];
|
||||
}
|
||||
|
||||
if (urlParams.businessHours) {
|
||||
businessHoursSelected.value = urlParams.businessHours;
|
||||
}
|
||||
|
||||
if (urlParams.groupBy) {
|
||||
const groupByValue = GROUP_BY_FILTER[urlParams.groupBy];
|
||||
if (groupByValue) {
|
||||
groupBy.value = groupByValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize entity filter from route params (not URL query)
|
||||
if (props.showEntityFilter && route.params.id) {
|
||||
const filterKey = getFilterKey();
|
||||
appliedFilters.value[filterKey] = Number(route.params.id);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeFromURL();
|
||||
groupByfilterItemsList.value = fetchFilterItems();
|
||||
emitChange();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col w-full gap-3 lg:flex-row">
|
||||
<WootDatePicker
|
||||
v-model:date-range="customDateRange"
|
||||
v-model:range-type="selectedDateRange"
|
||||
@date-range-changed="onDateRangeChange"
|
||||
/>
|
||||
|
||||
<div class="flex gap-2 items-center w-full">
|
||||
<ActiveFilterChip
|
||||
v-if="showEntityFilter"
|
||||
:id="appliedFilters[getFilterKey()]"
|
||||
:name="selectedFilterName"
|
||||
:type="filterType"
|
||||
:options="filterOptions"
|
||||
:active-filter-type="activeFilterType"
|
||||
:show-menu="showSubDropdownMenu"
|
||||
:placeholder="filterPlaceholder"
|
||||
:show-clear-filter="false"
|
||||
enable-search
|
||||
@toggle-dropdown="openActiveFilterDropdown"
|
||||
@close-dropdown="closeActiveFilterDropdown"
|
||||
@add-filter="addFilter"
|
||||
/>
|
||||
|
||||
<ActiveFilterChip
|
||||
v-if="isGroupByPossible"
|
||||
:id="groupBy?.id"
|
||||
:name="
|
||||
groupByfilterItemsList.find(item => item.id === groupBy?.id)?.name ||
|
||||
$t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')
|
||||
"
|
||||
type="groupBy"
|
||||
:options="groupByfilterItemsList"
|
||||
:active-filter-type="showGroupByDropdown ? 'groupBy' : ''"
|
||||
:show-menu="showGroupByDropdown"
|
||||
:placeholder="$t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')"
|
||||
:enable-search="false"
|
||||
:show-clear-filter="false"
|
||||
@toggle-dropdown="toggleGroupByDropdown"
|
||||
@close-dropdown="closeGroupByDropdown"
|
||||
@add-filter="onGroupByFilterChange"
|
||||
@remove-filter="() => {}"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="showBusinessHours"
|
||||
class="flex items-center flex-shrink-0 ltr:ml-auto rtl:mr-auto"
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-y-0.5 gap-x-2">
|
||||
<div v-if="type === 'agent'" class="multiselect-wrap--small">
|
||||
<p class="mb-2 text-xs font-medium">
|
||||
{{ $t('AGENT_REPORTS.FILTER_DROPDOWN_LABEL') }}
|
||||
</p>
|
||||
<multiselect
|
||||
v-model="currentSelectedFilter"
|
||||
:placeholder="multiselectLabel"
|
||||
label="name"
|
||||
track-by="id"
|
||||
:options="filterItemsList"
|
||||
:option-height="24"
|
||||
:show-labels="false"
|
||||
@update:model-value="changeFilterSelection"
|
||||
>
|
||||
<span class="mx-2 text-sm whitespace-nowrap">
|
||||
{{ $t('REPORT.BUSINESS_HOURS') }}
|
||||
</span>
|
||||
<span>
|
||||
<ToggleSwitch
|
||||
v-model="businessHoursSelected"
|
||||
@change="onBusinessHoursToggle"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<template #singleLabel="props">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Avatar
|
||||
:src="props.option.thumbnail"
|
||||
:status="props.option.availability_status"
|
||||
:name="props.option.name"
|
||||
:size="22"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<span class="my-0 text-n-slate-12 truncate">{{
|
||||
props.option.name
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #options="props">
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar
|
||||
:src="props.option.thumbnail"
|
||||
:status="props.option.availability_status"
|
||||
:name="props.option.name"
|
||||
:size="22"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<p class="my-0 text-n-slate-12">
|
||||
{{ props.option.name }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
|
||||
<div v-else-if="type === 'label'" class="multiselect-wrap--small">
|
||||
<p class="mb-2 text-xs font-medium">
|
||||
{{ $t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL') }}
|
||||
</p>
|
||||
<multiselect
|
||||
v-model="currentSelectedFilter"
|
||||
:placeholder="multiselectLabel"
|
||||
label="title"
|
||||
track-by="id"
|
||||
:options="filterItemsList"
|
||||
:option-height="24"
|
||||
:show-labels="false"
|
||||
@update:model-value="changeFilterSelection"
|
||||
>
|
||||
<template #singleLabel="props">
|
||||
<div class="flex items-center min-w-0 gap-2">
|
||||
<div
|
||||
:style="{ backgroundColor: props.option.color }"
|
||||
class="w-5 h-5 rounded-full"
|
||||
/>
|
||||
|
||||
<span class="my-0 text-n-slate-12 truncate">
|
||||
{{ props.option.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #option="props">
|
||||
<div class="flex items-center min-w-0 gap-2">
|
||||
<div
|
||||
:style="{ backgroundColor: props.option.color }"
|
||||
class="flex-shrink-0 w-5 h-5 border border-solid rounded-full border-n-weak"
|
||||
/>
|
||||
|
||||
<span class="my-0 text-n-slate-12 truncate">
|
||||
{{ props.option.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
|
||||
<div v-else class="multiselect-wrap--small">
|
||||
<p class="mb-2 text-xs font-medium">
|
||||
<template v-if="type === 'inbox'">
|
||||
{{ $t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL') }}
|
||||
</template>
|
||||
<template v-else-if="type === 'team'">
|
||||
{{ $t('TEAM_REPORTS.FILTER_DROPDOWN_LABEL') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('FORMS.MULTISELECT.SELECT_ONE') }}
|
||||
</template>
|
||||
</p>
|
||||
<multiselect
|
||||
v-model="currentSelectedFilter"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:placeholder="multiselectLabel"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
:options="filterItemsList"
|
||||
:searchable="false"
|
||||
:allow-empty="false"
|
||||
@update:model-value="changeFilterSelection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="multiselect-wrap--small">
|
||||
<p class="mb-2 text-xs font-medium">
|
||||
{{ $t('REPORT.DURATION_FILTER_LABEL') }}
|
||||
</p>
|
||||
<multiselect
|
||||
v-model="currentDateRangeSelection"
|
||||
track-by="name"
|
||||
label="name"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT_ONE')"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
:options="dateRange"
|
||||
:searchable="false"
|
||||
:allow-empty="false"
|
||||
@select="changeDateSelection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center h-10 self-center order-5 md:order-2 md:justify-self-end"
|
||||
>
|
||||
<span class="mr-2 text-sm whitespace-nowrap">
|
||||
{{ $t('REPORT.BUSINESS_HOURS') }}
|
||||
</span>
|
||||
<span>
|
||||
<ToggleSwitch
|
||||
v-model="businessHoursSelected"
|
||||
@change="onBusinessHoursToggle"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isDateRangeSelected" class="order-3 md:order-4">
|
||||
<p class="mb-2 text-xs font-medium">
|
||||
{{ $t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER') }}
|
||||
</p>
|
||||
<WootDateRangePicker
|
||||
show-range
|
||||
:value="customDateRange"
|
||||
:confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')"
|
||||
:placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')"
|
||||
class="auto-width"
|
||||
@change="onChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="notLast7Days" class="multiselect-wrap--small order-4 md:order-5">
|
||||
<p class="mb-2 text-xs font-medium">
|
||||
{{ $t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL') }}
|
||||
</p>
|
||||
<multiselect
|
||||
v-model="currentSelectedGroupByFilter"
|
||||
track-by="id"
|
||||
label="groupBy"
|
||||
:placeholder="$t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')"
|
||||
:options="groupByFilterItemsList"
|
||||
:allow-empty="false"
|
||||
:show-labels="false"
|
||||
@update:model-value="changeGroupByFilterSelection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+139
-185
@@ -1,201 +1,155 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import {
|
||||
buildFilterList,
|
||||
getActiveFilter,
|
||||
getFilterType,
|
||||
} from './helpers/SLAFilterHelpers';
|
||||
import {
|
||||
parseFilterURLParams,
|
||||
generateCompleteURLParams,
|
||||
} from '../../helpers/reportFilterHelper';
|
||||
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
|
||||
import ActiveFilterChip from '../Filters/v3/ActiveFilterChip.vue';
|
||||
import AddFilterChip from '../Filters/v3/AddFilterChip.vue';
|
||||
|
||||
const emit = defineEmits(['filterChange']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const showDropdownMenu = ref(false);
|
||||
const showSubDropdownMenu = ref(false);
|
||||
const activeFilterType = ref('');
|
||||
const appliedFilters = ref({
|
||||
assigned_agent_id: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
sla_policy_id: null,
|
||||
label_list: null,
|
||||
});
|
||||
|
||||
const agents = computed(() => store.getters['agents/getAgents']);
|
||||
const inboxes = computed(() => store.getters['inboxes/getInboxes']);
|
||||
const teams = computed(() => store.getters['teams/getTeams']);
|
||||
const labels = computed(() => store.getters['labels/getLabels']);
|
||||
const sla = computed(() => store.getters['sla/getSLA']);
|
||||
|
||||
const filterListMenuItems = computed(() => {
|
||||
const filterTypes = [
|
||||
{ id: '1', name: t('SLA_REPORTS.DROPDOWN.SLA'), type: 'sla' },
|
||||
{ id: '2', name: t('SLA_REPORTS.DROPDOWN.INBOXES'), type: 'inboxes' },
|
||||
{ id: '3', name: t('SLA_REPORTS.DROPDOWN.AGENTS'), type: 'agents' },
|
||||
{ id: '4', name: t('SLA_REPORTS.DROPDOWN.TEAMS'), type: 'teams' },
|
||||
{ id: '5', name: t('SLA_REPORTS.DROPDOWN.LABELS'), type: 'labels' },
|
||||
];
|
||||
|
||||
const activeFilterKeys = Object.keys(appliedFilters.value).filter(
|
||||
key => appliedFilters.value[key]
|
||||
);
|
||||
const activeFilterTypes = activeFilterKeys.map(key =>
|
||||
getFilterType(key, 'keyToType')
|
||||
);
|
||||
|
||||
const sources = {
|
||||
agents: agents.value,
|
||||
inboxes: inboxes.value,
|
||||
teams: teams.value,
|
||||
labels: labels.value,
|
||||
sla: sla.value,
|
||||
};
|
||||
|
||||
return filterTypes
|
||||
.filter(({ type }) => !activeFilterTypes.includes(type))
|
||||
.map(({ id, name, type }) => ({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
options: buildFilterList(sources[type], type),
|
||||
}));
|
||||
});
|
||||
|
||||
const activeFilters = computed(() => {
|
||||
const activeKeys = Object.keys(appliedFilters.value).filter(
|
||||
key => appliedFilters.value[key]
|
||||
);
|
||||
|
||||
const sources = {
|
||||
agents: agents.value,
|
||||
inboxes: inboxes.value,
|
||||
teams: teams.value,
|
||||
labels: labels.value,
|
||||
sla: sla.value,
|
||||
};
|
||||
|
||||
return activeKeys.map(key => {
|
||||
const filterType = getFilterType(key, 'keyToType');
|
||||
const item = getActiveFilter(
|
||||
sources[filterType],
|
||||
filterType,
|
||||
appliedFilters.value[key]
|
||||
);
|
||||
export default {
|
||||
components: {
|
||||
FilterButton,
|
||||
ActiveFilterChip,
|
||||
AddFilterChip,
|
||||
},
|
||||
emits: ['filterChange'],
|
||||
data() {
|
||||
return {
|
||||
id: item.id,
|
||||
name: filterType === 'labels' ? item.title : item.name,
|
||||
type: filterType,
|
||||
options: buildFilterList(sources[filterType], filterType),
|
||||
showDropdownMenu: false,
|
||||
showSubDropdownMenu: false,
|
||||
activeFilterType: '',
|
||||
appliedFilters: {
|
||||
assigned_agent_id: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
sla_policy_id: null,
|
||||
label_list: null,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
Object.values(appliedFilters.value).some(value => value !== null)
|
||||
);
|
||||
|
||||
const isAllFilterSelected = computed(() => !filterListMenuItems.value.length);
|
||||
|
||||
const updateURLParams = () => {
|
||||
const params = generateCompleteURLParams({
|
||||
from: route.query.from,
|
||||
to: route.query.to,
|
||||
range: route.query.range,
|
||||
filters: {
|
||||
agent_id: appliedFilters.value.assigned_agent_id,
|
||||
inbox_id: appliedFilters.value.inbox_id,
|
||||
team_id: appliedFilters.value.team_id,
|
||||
sla_policy_id: appliedFilters.value.sla_policy_id,
|
||||
label: appliedFilters.value.label_list,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
agents: 'agents/getAgents',
|
||||
inboxes: 'inboxes/getInboxes',
|
||||
teams: 'teams/getTeams',
|
||||
labels: 'labels/getLabels',
|
||||
sla: 'sla/getSLA',
|
||||
}),
|
||||
filterListMenuItems() {
|
||||
const filterTypes = [
|
||||
{ id: '1', name: this.$t('SLA_REPORTS.DROPDOWN.SLA'), type: 'sla' },
|
||||
{
|
||||
id: '2',
|
||||
name: this.$t('SLA_REPORTS.DROPDOWN.INBOXES'),
|
||||
type: 'inboxes',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: this.$t('SLA_REPORTS.DROPDOWN.AGENTS'),
|
||||
type: 'agents',
|
||||
},
|
||||
{ id: '4', name: this.$t('SLA_REPORTS.DROPDOWN.TEAMS'), type: 'teams' },
|
||||
{
|
||||
id: '5',
|
||||
name: this.$t('SLA_REPORTS.DROPDOWN.LABELS'),
|
||||
type: 'labels',
|
||||
},
|
||||
];
|
||||
// Filter out the active filters from the filter list
|
||||
// We only want to show the filters that are not already applied
|
||||
// In the add filter dropdown
|
||||
const activeFilters = Object.keys(this.appliedFilters).filter(
|
||||
key => this.appliedFilters[key]
|
||||
);
|
||||
const activeFilterTypes = activeFilters.map(key =>
|
||||
getFilterType(key, 'keyToType')
|
||||
);
|
||||
return filterTypes
|
||||
.filter(({ type }) => !activeFilterTypes.includes(type))
|
||||
.map(({ id, name, type }) => ({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
options: buildFilterList(this[type], type),
|
||||
}));
|
||||
},
|
||||
});
|
||||
router.replace({ query: params });
|
||||
activeFilters() {
|
||||
// Get the active filters from the applied filters
|
||||
// and return the filter name, type and options
|
||||
const activeKey = Object.keys(this.appliedFilters).filter(
|
||||
key => this.appliedFilters[key]
|
||||
);
|
||||
return activeKey.map(key => {
|
||||
const filterType = getFilterType(key, 'keyToType');
|
||||
const item = getActiveFilter(
|
||||
this[filterType],
|
||||
filterType,
|
||||
this.appliedFilters[key]
|
||||
);
|
||||
return {
|
||||
id: item.id,
|
||||
name: filterType === 'labels' ? item.title : item.name,
|
||||
type: filterType,
|
||||
options: buildFilterList(this[filterType], filterType),
|
||||
};
|
||||
});
|
||||
},
|
||||
hasActiveFilters() {
|
||||
return Object.values(this.appliedFilters).some(value => value !== null);
|
||||
},
|
||||
isAllFilterSelected() {
|
||||
return !this.filterListMenuItems.length;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addFilter(item) {
|
||||
const { type, id, name } = item;
|
||||
const filterKey = getFilterType(type, 'typeToKey');
|
||||
this.appliedFilters[filterKey] = type === 'labels' ? name : id;
|
||||
this.$emit('filterChange', this.appliedFilters);
|
||||
this.resetDropdown();
|
||||
},
|
||||
removeFilter(type) {
|
||||
const filterKey = getFilterType(type, 'typeToKey');
|
||||
this.appliedFilters[filterKey] = null;
|
||||
this.$emit('filterChange', this.appliedFilters);
|
||||
},
|
||||
clearAllFilters() {
|
||||
this.appliedFilters = {
|
||||
assigned_agent_id: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
sla_policy_id: null,
|
||||
label_list: null,
|
||||
};
|
||||
this.$emit('filterChange', this.appliedFilters);
|
||||
this.resetDropdown();
|
||||
},
|
||||
showDropdown() {
|
||||
this.showSubDropdownMenu = false;
|
||||
this.showDropdownMenu = !this.showDropdownMenu;
|
||||
},
|
||||
closeDropdown() {
|
||||
this.showDropdownMenu = false;
|
||||
},
|
||||
openActiveFilterDropdown(filterType) {
|
||||
this.closeDropdown();
|
||||
this.activeFilterType = filterType;
|
||||
this.showSubDropdownMenu = !this.showSubDropdownMenu;
|
||||
},
|
||||
closeActiveFilterDropdown() {
|
||||
this.activeFilterType = '';
|
||||
this.showSubDropdownMenu = false;
|
||||
},
|
||||
resetDropdown() {
|
||||
this.closeDropdown();
|
||||
this.closeActiveFilterDropdown();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const emitChange = () => {
|
||||
updateURLParams();
|
||||
emit('filterChange', appliedFilters.value);
|
||||
};
|
||||
|
||||
const showDropdown = () => {
|
||||
showSubDropdownMenu.value = false;
|
||||
showDropdownMenu.value = !showDropdownMenu.value;
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
showDropdownMenu.value = false;
|
||||
};
|
||||
|
||||
const openActiveFilterDropdown = filterType => {
|
||||
closeDropdown();
|
||||
activeFilterType.value = filterType;
|
||||
showSubDropdownMenu.value = !showSubDropdownMenu.value;
|
||||
};
|
||||
|
||||
const closeActiveFilterDropdown = () => {
|
||||
activeFilterType.value = '';
|
||||
showSubDropdownMenu.value = false;
|
||||
};
|
||||
|
||||
const resetDropdown = () => {
|
||||
closeDropdown();
|
||||
closeActiveFilterDropdown();
|
||||
};
|
||||
|
||||
const addFilter = item => {
|
||||
const { type, id, name } = item;
|
||||
const filterKey = getFilterType(type, 'typeToKey');
|
||||
appliedFilters.value[filterKey] = type === 'labels' ? name : id;
|
||||
emitChange();
|
||||
resetDropdown();
|
||||
};
|
||||
|
||||
const removeFilter = type => {
|
||||
const filterKey = getFilterType(type, 'typeToKey');
|
||||
appliedFilters.value[filterKey] = null;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
appliedFilters.value = {
|
||||
assigned_agent_id: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
sla_policy_id: null,
|
||||
label_list: null,
|
||||
};
|
||||
emitChange();
|
||||
resetDropdown();
|
||||
};
|
||||
|
||||
const initializeFromURL = () => {
|
||||
const urlFilters = parseFilterURLParams(route.query);
|
||||
appliedFilters.value.assigned_agent_id = urlFilters.agent_id;
|
||||
appliedFilters.value.inbox_id = urlFilters.inbox_id;
|
||||
appliedFilters.value.team_id = urlFilters.team_id;
|
||||
appliedFilters.value.sla_policy_id = urlFilters.sla_policy_id;
|
||||
appliedFilters.value.label_list = urlFilters.label;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeFromURL();
|
||||
if (hasActiveFilters.value) {
|
||||
emitChange();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
+61
-86
@@ -1,98 +1,73 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
<script>
|
||||
import SLAFilter from '../SLA/SLAFilter.vue';
|
||||
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
|
||||
import { subDays, fromUnixTime } from 'date-fns';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import { DATE_RANGE_OPTIONS } from '../../constants';
|
||||
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
|
||||
import {
|
||||
generateReportURLParams,
|
||||
parseReportURLParams,
|
||||
parseFilterURLParams,
|
||||
} from '../../helpers/reportFilterHelper';
|
||||
|
||||
const emit = defineEmits(['filterChange']);
|
||||
export default {
|
||||
components: {
|
||||
SLAFilter,
|
||||
},
|
||||
emits: ['filterChange'],
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// Initialize from URL params immediately
|
||||
const urlParams = parseReportURLParams(route.query);
|
||||
const initialDateRange =
|
||||
urlParams.from && urlParams.to
|
||||
? [fromUnixTime(urlParams.from), fromUnixTime(urlParams.to)]
|
||||
: [subDays(new Date(), 6), new Date()];
|
||||
|
||||
const selectedDateRange = ref(urlParams.range || 'last7days');
|
||||
const selectedGroupByFilter = ref(null);
|
||||
const customDateRange = ref(initialDateRange);
|
||||
|
||||
const to = computed(() => getUnixEndOfDay(customDateRange.value[1]));
|
||||
const from = computed(() => getUnixStartOfDay(customDateRange.value[0]));
|
||||
|
||||
const updateURLParams = () => {
|
||||
const dateParams = generateReportURLParams({
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
range: selectedDateRange.value,
|
||||
});
|
||||
|
||||
const filterParams = parseFilterURLParams(route.query);
|
||||
const params = { ...dateParams };
|
||||
|
||||
if (filterParams.agent_id) params.agent_id = filterParams.agent_id;
|
||||
if (filterParams.inbox_id) params.inbox_id = filterParams.inbox_id;
|
||||
if (filterParams.team_id) params.team_id = filterParams.team_id;
|
||||
if (filterParams.sla_policy_id)
|
||||
params.sla_policy_id = filterParams.sla_policy_id;
|
||||
if (filterParams.label) params.label = filterParams.label;
|
||||
|
||||
router.replace({ query: params });
|
||||
data() {
|
||||
return {
|
||||
selectedDateRange: DATE_RANGE_OPTIONS.LAST_7_DAYS,
|
||||
selectedGroupByFilter: null,
|
||||
customDateRange: [new Date(), new Date()],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
to() {
|
||||
return getUnixEndOfDay(this.customDateRange[1]);
|
||||
},
|
||||
from() {
|
||||
return getUnixStartOfDay(this.customDateRange[0]);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
businessHoursSelected() {
|
||||
this.emitChange();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setInitialRange();
|
||||
},
|
||||
methods: {
|
||||
setInitialRange() {
|
||||
const { offset } = this.selectedDateRange;
|
||||
const fromDate = subDays(new Date(), offset);
|
||||
const from = getUnixStartOfDay(fromDate);
|
||||
const to = getUnixEndOfDay(new Date());
|
||||
this.$emit('filterChange', {
|
||||
from,
|
||||
to,
|
||||
...this.selectedGroupByFilter,
|
||||
});
|
||||
},
|
||||
emitChange() {
|
||||
const { from, to } = this;
|
||||
this.$emit('filterChange', {
|
||||
from,
|
||||
to,
|
||||
...this.selectedGroupByFilter,
|
||||
});
|
||||
},
|
||||
emitFilterChange(params) {
|
||||
this.selectedGroupByFilter = params;
|
||||
this.emitChange();
|
||||
},
|
||||
onDateRangeChange(value) {
|
||||
this.customDateRange = value;
|
||||
this.emitChange();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const emitChange = () => {
|
||||
updateURLParams();
|
||||
emit('filterChange', {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
...selectedGroupByFilter.value,
|
||||
});
|
||||
};
|
||||
|
||||
const emitFilterChange = params => {
|
||||
selectedGroupByFilter.value = params;
|
||||
emit('filterChange', {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
...selectedGroupByFilter.value,
|
||||
});
|
||||
};
|
||||
|
||||
const onDateRangeChange = ([startDate, endDate, rangeType]) => {
|
||||
customDateRange.value = [startDate, endDate];
|
||||
selectedDateRange.value = rangeType;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const setInitialRange = () => {
|
||||
customDateRange.value = [subDays(new Date(), 6), new Date()];
|
||||
emitChange();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (!route.query.from || !route.query.to) {
|
||||
setInitialRange();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col flex-wrap w-full gap-3 md:flex-row">
|
||||
<WootDatePicker
|
||||
v-model:date-range="customDateRange"
|
||||
v-model:range-type="selectedDateRange"
|
||||
@date-range-changed="onDateRangeChange"
|
||||
/>
|
||||
<woot-date-picker @date-range-changed="onDateRangeChange" />
|
||||
<SLAFilter @filter-change="emitFilterChange" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import OverviewReportFilters from './OverviewReportFilters.vue';
|
||||
import ReportFilterSelector from './FilterSelector.vue';
|
||||
import { formatTime } from '@chatwoot/utils';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import Table from 'dashboard/components/table/Table.vue';
|
||||
@@ -178,7 +178,7 @@ defineExpose({ downloadReports });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OverviewReportFilters @filter-change="onFilterChange" />
|
||||
<ReportFilterSelector @filter-change="onFilterChange" />
|
||||
<div
|
||||
class="flex-1 overflow-auto px-2 py-2 mt-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2"
|
||||
>
|
||||
|
||||
+88
-31
@@ -1,12 +1,31 @@
|
||||
<script>
|
||||
import V4Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import ReportFilters from './ReportFilters.vue';
|
||||
import ReportContainer from '../ReportContainer.vue';
|
||||
import { GROUP_BY_FILTER } from '../constants';
|
||||
import { generateFileName } from '../../../../../helper/downloadHelper';
|
||||
import { REPORTS_EVENTS } from '../../../../../helper/AnalyticsHelper/events';
|
||||
import ReportHeader from './ReportHeader.vue';
|
||||
|
||||
const GROUP_BY_OPTIONS = {
|
||||
DAY: [{ id: 1, groupByKey: 'REPORT.GROUPING_OPTIONS.DAY' }],
|
||||
WEEK: [
|
||||
{ id: 1, groupByKey: 'REPORT.GROUPING_OPTIONS.DAY' },
|
||||
{ id: 2, groupByKey: 'REPORT.GROUPING_OPTIONS.WEEK' },
|
||||
],
|
||||
MONTH: [
|
||||
{ id: 1, groupByKey: 'REPORT.GROUPING_OPTIONS.DAY' },
|
||||
{ id: 2, groupByKey: 'REPORT.GROUPING_OPTIONS.WEEK' },
|
||||
{ id: 3, groupByKey: 'REPORT.GROUPING_OPTIONS.MONTH' },
|
||||
],
|
||||
YEAR: [
|
||||
{ id: 2, groupByKey: 'REPORT.GROUPING_OPTIONS.WEEK' },
|
||||
{ id: 3, groupByKey: 'REPORT.GROUPING_OPTIONS.MONTH' },
|
||||
{ id: 4, groupByKey: 'REPORT.GROUPING_OPTIONS.YEAR' },
|
||||
],
|
||||
};
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ReportHeader,
|
||||
@@ -50,19 +69,12 @@ export default {
|
||||
to: 0,
|
||||
selectedFilter: this.selectedItem,
|
||||
groupBy: GROUP_BY_FILTER[1],
|
||||
groupByfilterItemsList: GROUP_BY_OPTIONS.DAY.map(this.translateOptions),
|
||||
selectedGroupByFilter: null,
|
||||
businessHours: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
filterType() {
|
||||
const pluralMap = {
|
||||
agent: 'agents',
|
||||
team: 'teams',
|
||||
inbox: 'inboxes',
|
||||
label: 'labels',
|
||||
};
|
||||
return pluralMap[this.type] || this.type;
|
||||
},
|
||||
filterItemsList() {
|
||||
return this.$store.getters[this.getterKey] || [];
|
||||
},
|
||||
@@ -133,30 +145,70 @@ export default {
|
||||
this.$store.dispatch(dispatchMethods[type], params);
|
||||
}
|
||||
},
|
||||
onFilterChange(payload) {
|
||||
const { from, to, businessHours, groupBy } = payload;
|
||||
onDateRangeChange({ from, to, groupBy }) {
|
||||
// do not track filter change on inital load
|
||||
if (this.from !== 0 && this.to !== 0) {
|
||||
useTrack(REPORTS_EVENTS.FILTER_REPORT, {
|
||||
filterType: 'date',
|
||||
reportType: this.type,
|
||||
});
|
||||
}
|
||||
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.businessHours = businessHours;
|
||||
|
||||
if (groupBy) {
|
||||
this.groupBy = groupBy;
|
||||
this.groupByfilterItemsList = this.fetchFilterItems(groupBy);
|
||||
const filterItems = this.groupByfilterItemsList.filter(
|
||||
item => item.id === this.groupBy.id
|
||||
);
|
||||
if (filterItems.length > 0) {
|
||||
this.selectedGroupByFilter = filterItems[0];
|
||||
} else {
|
||||
this.groupBy = GROUP_BY_FILTER[1];
|
||||
this.selectedGroupByFilter = this.groupByfilterItemsList[0];
|
||||
this.groupBy = GROUP_BY_FILTER[this.selectedGroupByFilter.id];
|
||||
}
|
||||
|
||||
// Get filter value directly from filterType key
|
||||
const filterValue = payload[this.filterType];
|
||||
if (filterValue) {
|
||||
this.selectedFilter = Array.isArray(filterValue)
|
||||
? filterValue[0]
|
||||
: filterValue;
|
||||
} else {
|
||||
this.selectedFilter = null;
|
||||
}
|
||||
|
||||
this.fetchAllData();
|
||||
},
|
||||
onFilterChange(payload) {
|
||||
if (payload) {
|
||||
this.selectedFilter = payload;
|
||||
this.fetchAllData();
|
||||
}
|
||||
},
|
||||
onGroupByFilterChange(payload) {
|
||||
this.groupBy = GROUP_BY_FILTER[payload.id];
|
||||
this.fetchAllData();
|
||||
|
||||
useTrack(REPORTS_EVENTS.FILTER_REPORT, {
|
||||
filterType: 'groupBy',
|
||||
filterValue: this.groupBy?.period,
|
||||
reportType: this.type,
|
||||
});
|
||||
},
|
||||
fetchFilterItems(groupBy) {
|
||||
switch (groupBy) {
|
||||
case GROUP_BY_FILTER[2].period:
|
||||
return GROUP_BY_OPTIONS.WEEK.map(this.translateOptions);
|
||||
case GROUP_BY_FILTER[3].period:
|
||||
return GROUP_BY_OPTIONS.MONTH.map(this.translateOptions);
|
||||
case GROUP_BY_FILTER[4].period:
|
||||
return GROUP_BY_OPTIONS.YEAR.map(this.translateOptions);
|
||||
default:
|
||||
return GROUP_BY_OPTIONS.DAY.map(this.translateOptions);
|
||||
}
|
||||
},
|
||||
translateOptions(opts) {
|
||||
return { id: opts.id, groupBy: this.$t(opts.groupByKey) };
|
||||
},
|
||||
onBusinessHoursToggle(value) {
|
||||
this.businessHours = value;
|
||||
this.fetchAllData();
|
||||
|
||||
useTrack(REPORTS_EVENTS.FILTER_REPORT, {
|
||||
filterType: 'businessHours',
|
||||
filterValue: value,
|
||||
reportType: this.type,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -170,12 +222,17 @@ export default {
|
||||
@click="downloadReports"
|
||||
/>
|
||||
</ReportHeader>
|
||||
|
||||
<ReportFilters
|
||||
v-if="filterItemsList"
|
||||
:filter-type="filterType"
|
||||
:selected-item="selectedFilter"
|
||||
:type="type"
|
||||
:filter-items-list="filterItemsList"
|
||||
:group-by-filter-items-list="groupByfilterItemsList"
|
||||
:selected-group-by-filter="selectedGroupByFilter"
|
||||
:current-filter="selectedFilter"
|
||||
@date-range-change="onDateRangeChange"
|
||||
@filter-change="onFilterChange"
|
||||
@group-by-filter-change="onGroupByFilterChange"
|
||||
@business-hours-toggle="onBusinessHoursToggle"
|
||||
/>
|
||||
<ReportContainer
|
||||
v-if="filterItemsList.length"
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import ReportsFiltersAgents from '../../Filters/Agents.vue';
|
||||
|
||||
const mockStore = createStore({
|
||||
modules: {
|
||||
agents: {
|
||||
namespaced: true,
|
||||
state: {
|
||||
agents: [],
|
||||
},
|
||||
getters: {
|
||||
getAgents: state => state.agents,
|
||||
},
|
||||
actions: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mountParams = {
|
||||
global: {
|
||||
plugins: [mockStore],
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ReportsFiltersAgents.vue', () => {
|
||||
it('emits "agents-filter-selection" event when handleInput is called', async () => {
|
||||
const wrapper = shallowMount(ReportsFiltersAgents, mountParams);
|
||||
|
||||
const selectedAgents = [
|
||||
{ id: 1, name: 'Agent 1' },
|
||||
{ id: 2, name: 'Agent 2' },
|
||||
];
|
||||
await wrapper.setData({ selectedOptions: selectedAgents });
|
||||
|
||||
await wrapper.vm.handleInput();
|
||||
|
||||
expect(wrapper.emitted('agentsFilterSelection')).toBeTruthy();
|
||||
expect(wrapper.emitted('agentsFilterSelection')[0]).toEqual([
|
||||
selectedAgents,
|
||||
]);
|
||||
});
|
||||
|
||||
it('dispatches the "agents/get" action when the component is mounted', () => {
|
||||
const dispatchSpy = vi.spyOn(mockStore, 'dispatch');
|
||||
|
||||
shallowMount(ReportsFiltersAgents, mountParams);
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith('agents/get');
|
||||
});
|
||||
});
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import ReportsFiltersDateGroupBy from '../../Filters/DateGroupBy.vue';
|
||||
import { GROUP_BY_OPTIONS } from '../../../constants';
|
||||
|
||||
const mountParams = {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ReportsFiltersDateGroupBy.vue', () => {
|
||||
it('emits "on-grouping-change" event when changeFilterSelection is called', () => {
|
||||
const wrapper = shallowMount(ReportsFiltersDateGroupBy, mountParams);
|
||||
|
||||
const selectedFilter = GROUP_BY_OPTIONS.DAY;
|
||||
wrapper.vm.changeFilterSelection(selectedFilter);
|
||||
|
||||
expect(wrapper.emitted('onGroupingChange')).toBeTruthy();
|
||||
expect(wrapper.emitted('onGroupingChange')[0]).toEqual([selectedFilter]);
|
||||
});
|
||||
|
||||
it('updates currentSelectedFilter when selectedOption is changed', async () => {
|
||||
const wrapper = shallowMount(ReportsFiltersDateGroupBy, mountParams);
|
||||
|
||||
const newSelectedOption = GROUP_BY_OPTIONS.MONTH;
|
||||
await wrapper.setProps({ selectedOption: newSelectedOption });
|
||||
|
||||
expect(wrapper.vm.currentSelectedFilter).toEqual({
|
||||
...newSelectedOption,
|
||||
groupBy: newSelectedOption.translationKey,
|
||||
});
|
||||
});
|
||||
|
||||
it('initializes translatedOptions correctly', () => {
|
||||
const wrapper = shallowMount(ReportsFiltersDateGroupBy, mountParams);
|
||||
|
||||
const expectedOptions = wrapper.vm.validGroupOptions.map(option => ({
|
||||
...option,
|
||||
groupBy: option.translationKey,
|
||||
}));
|
||||
|
||||
expect(wrapper.vm.translatedOptions).toEqual(expectedOptions);
|
||||
});
|
||||
});
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import ReportFiltersDateRange from '../../Filters/DateRange.vue';
|
||||
import { DATE_RANGE_OPTIONS } from '../../../constants';
|
||||
|
||||
const mountParams = {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ReportFiltersDateRange.vue', () => {
|
||||
it('emits "onRangeChange" event when updateRange is called', () => {
|
||||
const wrapper = shallowMount(ReportFiltersDateRange, mountParams);
|
||||
|
||||
const selectedRange = DATE_RANGE_OPTIONS.LAST_7_DAYS;
|
||||
wrapper.vm.updateRange(selectedRange);
|
||||
|
||||
expect(wrapper.emitted('onRangeChange')).toBeTruthy();
|
||||
expect(wrapper.emitted('onRangeChange')[0]).toEqual([selectedRange]);
|
||||
});
|
||||
|
||||
it('initializes options correctly', () => {
|
||||
const wrapper = shallowMount(ReportFiltersDateRange, mountParams);
|
||||
|
||||
const expectedIds = Object.values(DATE_RANGE_OPTIONS).map(
|
||||
option => option.id
|
||||
);
|
||||
const receivedIds = wrapper.vm.options.map(option => option.id);
|
||||
|
||||
expect(receivedIds).toEqual(expectedIds);
|
||||
});
|
||||
|
||||
it('initializes selectedOption correctly', () => {
|
||||
const wrapper = shallowMount(ReportFiltersDateRange, mountParams);
|
||||
const expectedId = Object.values(DATE_RANGE_OPTIONS)[0].id;
|
||||
expect(wrapper.vm.selectedOption.id).toBe(expectedId);
|
||||
});
|
||||
});
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import ReportsFiltersInboxes from '../../Filters/Inboxes.vue';
|
||||
|
||||
const mountParams = {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ReportsFiltersInboxes.vue', () => {
|
||||
let store;
|
||||
let inboxesModule;
|
||||
|
||||
beforeEach(() => {
|
||||
inboxesModule = {
|
||||
namespaced: true,
|
||||
getters: {
|
||||
getInboxes: () => () => [
|
||||
{ id: 1, name: 'Inbox 1' },
|
||||
{ id: 2, name: 'Inbox 2' },
|
||||
],
|
||||
},
|
||||
actions: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
store = createStore({
|
||||
modules: {
|
||||
inboxes: inboxesModule,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches "inboxes/get" action when component is mounted', () => {
|
||||
shallowMount(ReportsFiltersInboxes, {
|
||||
global: {
|
||||
plugins: [store],
|
||||
...mountParams.global,
|
||||
},
|
||||
});
|
||||
expect(inboxesModule.actions.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits "inbox-filter-selection" event when handleInput is called', async () => {
|
||||
const wrapper = shallowMount(ReportsFiltersInboxes, {
|
||||
global: {
|
||||
plugins: [store],
|
||||
...mountParams.global,
|
||||
},
|
||||
});
|
||||
|
||||
const selectedInbox = { id: 1, name: 'Inbox 1' };
|
||||
await wrapper.setData({ selectedOption: selectedInbox });
|
||||
|
||||
await wrapper.vm.handleInput();
|
||||
|
||||
expect(wrapper.emitted('inboxFilterSelection')).toBeTruthy();
|
||||
expect(wrapper.emitted('inboxFilterSelection')[0]).toEqual([selectedInbox]);
|
||||
});
|
||||
});
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import ReportsFiltersLabels from '../../Filters/Labels.vue';
|
||||
|
||||
const mountParams = {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ReportsFiltersLabels.vue', () => {
|
||||
let store;
|
||||
let labelsModule;
|
||||
|
||||
beforeEach(() => {
|
||||
labelsModule = {
|
||||
namespaced: true,
|
||||
getters: {
|
||||
getLabels: () => () => [
|
||||
{ id: 1, title: 'Label 1', color: 'red' },
|
||||
{ id: 2, title: 'Label 2', color: 'blue' },
|
||||
],
|
||||
},
|
||||
actions: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
store = createStore({
|
||||
modules: {
|
||||
labels: labelsModule,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches "labels/get" action when component is mounted', () => {
|
||||
shallowMount(ReportsFiltersLabels, {
|
||||
global: {
|
||||
plugins: [store],
|
||||
...mountParams.global,
|
||||
},
|
||||
});
|
||||
expect(labelsModule.actions.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits "labels-filter-selection" event when handleInput is called', async () => {
|
||||
const wrapper = shallowMount(ReportsFiltersLabels, {
|
||||
global: {
|
||||
plugins: [store],
|
||||
...mountParams.global,
|
||||
},
|
||||
});
|
||||
|
||||
const selectedLabel = { id: 1, title: 'Label 1', color: 'red' };
|
||||
await wrapper.setData({ selectedOption: selectedLabel });
|
||||
|
||||
await wrapper.vm.handleInput();
|
||||
|
||||
expect(wrapper.emitted('labelsFilterSelection')).toBeTruthy();
|
||||
expect(wrapper.emitted('labelsFilterSelection')[0]).toEqual([
|
||||
selectedLabel,
|
||||
]);
|
||||
});
|
||||
});
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import ReportFiltersRatings from '../../Filters/Ratings.vue';
|
||||
import { CSAT_RATINGS } from 'shared/constants/messages';
|
||||
|
||||
const mountParams = {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ReportFiltersRatings.vue', () => {
|
||||
it('emits "rating-filter-selection" event when handleInput is called', async () => {
|
||||
const wrapper = shallowMount(ReportFiltersRatings, {
|
||||
...mountParams,
|
||||
});
|
||||
|
||||
const selectedRating = { value: 1, label: 'Rating 1' };
|
||||
await wrapper.setData({ selectedOption: selectedRating });
|
||||
|
||||
await wrapper.vm.handleInput(selectedRating);
|
||||
|
||||
expect(wrapper.emitted('ratingFilterSelection')).toBeTruthy();
|
||||
expect(wrapper.emitted('ratingFilterSelection')[0]).toEqual([
|
||||
selectedRating,
|
||||
]);
|
||||
});
|
||||
|
||||
it('initializes options correctly', () => {
|
||||
const wrapper = shallowMount(ReportFiltersRatings, {
|
||||
...mountParams,
|
||||
});
|
||||
|
||||
const expectedOptions = CSAT_RATINGS.map(option => ({
|
||||
...option,
|
||||
label: option.translationKey,
|
||||
}));
|
||||
|
||||
expect(wrapper.vm.options).toEqual(expectedOptions);
|
||||
});
|
||||
});
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import ReportsFiltersTeams from '../../Filters/Teams.vue';
|
||||
|
||||
const mountParams = {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ReportsFiltersTeams.vue', () => {
|
||||
let store;
|
||||
let teamsModule;
|
||||
|
||||
beforeEach(() => {
|
||||
teamsModule = {
|
||||
namespaced: true,
|
||||
getters: {
|
||||
getTeams: () => () => [
|
||||
{ id: 1, name: 'Team 1' },
|
||||
{ id: 2, name: 'Team 2' },
|
||||
],
|
||||
},
|
||||
actions: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
store = createStore({
|
||||
modules: {
|
||||
teams: teamsModule,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches "teams/get" action when component is mounted', () => {
|
||||
shallowMount(ReportsFiltersTeams, {
|
||||
global: {
|
||||
plugins: [store],
|
||||
...mountParams,
|
||||
},
|
||||
});
|
||||
expect(teamsModule.actions.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits "team-filter-selection" event when handleInput is called', async () => {
|
||||
const wrapper = shallowMount(ReportsFiltersTeams, {
|
||||
global: {
|
||||
plugins: [store],
|
||||
...mountParams,
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.setData({ selectedOption: { id: 1, name: 'Team 1' } });
|
||||
await wrapper.vm.handleInput();
|
||||
|
||||
expect(wrapper.emitted('teamFilterSelection')).toBeTruthy();
|
||||
expect(wrapper.emitted('teamFilterSelection')[0]).toEqual([
|
||||
{ id: 1, name: 'Team 1' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
export const generateReportURLParams = ({
|
||||
from,
|
||||
to,
|
||||
businessHours,
|
||||
groupBy,
|
||||
range,
|
||||
}) => {
|
||||
const params = {};
|
||||
|
||||
// Always include from/to dates
|
||||
if (from) params.from = from;
|
||||
if (to) params.to = to;
|
||||
|
||||
if (businessHours) params.business_hours = 'true';
|
||||
if (groupBy) params.group_by = groupBy;
|
||||
|
||||
// Include range type (last7days, last3months, custom, etc.)
|
||||
if (range) params.range = range;
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
export const parseReportURLParams = query => {
|
||||
const { from, to, business_hours, group_by, range } = query;
|
||||
|
||||
return {
|
||||
from: from ? Number(from) : null,
|
||||
to: to ? Number(to) : null,
|
||||
businessHours: business_hours === 'true',
|
||||
groupBy: group_by ? Number(group_by) : null,
|
||||
range: range || null,
|
||||
};
|
||||
};
|
||||
|
||||
// Parse filter params from URL (agent_id, inbox_id, team_id, sla_policy_id, label, rating)
|
||||
export const parseFilterURLParams = query => {
|
||||
return {
|
||||
agent_id: query.agent_id ? Number(query.agent_id) : null,
|
||||
inbox_id: query.inbox_id ? Number(query.inbox_id) : null,
|
||||
team_id: query.team_id ? Number(query.team_id) : null,
|
||||
sla_policy_id: query.sla_policy_id ? Number(query.sla_policy_id) : null,
|
||||
label: query.label || null,
|
||||
rating: query.rating ? Number(query.rating) : null,
|
||||
};
|
||||
};
|
||||
|
||||
// Generate filter URL params (only include non-null values)
|
||||
export const generateFilterURLParams = filters => {
|
||||
const params = {};
|
||||
if (filters.agent_id) params.agent_id = filters.agent_id;
|
||||
if (filters.inbox_id) params.inbox_id = filters.inbox_id;
|
||||
if (filters.team_id) params.team_id = filters.team_id;
|
||||
if (filters.sla_policy_id) params.sla_policy_id = filters.sla_policy_id;
|
||||
if (filters.label) params.label = filters.label;
|
||||
if (filters.rating) params.rating = filters.rating;
|
||||
return params;
|
||||
};
|
||||
|
||||
// Merge date range and filter params for complete URL
|
||||
export const generateCompleteURLParams = ({
|
||||
from,
|
||||
to,
|
||||
range,
|
||||
filters = {},
|
||||
}) => {
|
||||
const dateParams = generateReportURLParams({ from, to, range });
|
||||
const filterParams = generateFilterURLParams(filters);
|
||||
return { ...dateParams, ...filterParams };
|
||||
};
|
||||
-350
@@ -1,350 +0,0 @@
|
||||
import {
|
||||
generateReportURLParams,
|
||||
parseReportURLParams,
|
||||
parseFilterURLParams,
|
||||
generateFilterURLParams,
|
||||
generateCompleteURLParams,
|
||||
} from './reportFilterHelper';
|
||||
|
||||
describe('reportFilterHelper', () => {
|
||||
describe('generateReportURLParams', () => {
|
||||
it('generates URL params with from and to dates', () => {
|
||||
const params = generateReportURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes business hours when true', () => {
|
||||
const params = generateReportURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
businessHours: true,
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
business_hours: 'true',
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes business hours when false', () => {
|
||||
const params = generateReportURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
businessHours: false,
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes group by parameter', () => {
|
||||
const params = generateReportURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
groupBy: 3,
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
group_by: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes range type', () => {
|
||||
const params = generateReportURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
range: 'last7days',
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
range: 'last7days',
|
||||
});
|
||||
});
|
||||
|
||||
it('generates complete URL params with all options', () => {
|
||||
const params = generateReportURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
businessHours: true,
|
||||
groupBy: 3,
|
||||
range: 'lastYear',
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
business_hours: 'true',
|
||||
group_by: 3,
|
||||
range: 'lastYear',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseReportURLParams', () => {
|
||||
it('parses from and to dates as numbers', () => {
|
||||
const result = parseReportURLParams({
|
||||
from: '1738607400',
|
||||
to: '1770229799',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
businessHours: false,
|
||||
groupBy: null,
|
||||
range: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses business hours as boolean', () => {
|
||||
const result = parseReportURLParams({
|
||||
from: '1738607400',
|
||||
to: '1770229799',
|
||||
business_hours: 'true',
|
||||
});
|
||||
|
||||
expect(result.businessHours).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for business hours when not "true"', () => {
|
||||
const result = parseReportURLParams({
|
||||
from: '1738607400',
|
||||
to: '1770229799',
|
||||
business_hours: 'false',
|
||||
});
|
||||
|
||||
expect(result.businessHours).toBe(false);
|
||||
});
|
||||
|
||||
it('parses group by as number', () => {
|
||||
const result = parseReportURLParams({
|
||||
from: '1738607400',
|
||||
to: '1770229799',
|
||||
group_by: '3',
|
||||
});
|
||||
|
||||
expect(result.groupBy).toBe(3);
|
||||
});
|
||||
|
||||
it('parses range type', () => {
|
||||
const result = parseReportURLParams({
|
||||
from: '1738607400',
|
||||
to: '1770229799',
|
||||
range: 'last7days',
|
||||
});
|
||||
|
||||
expect(result.range).toBe('last7days');
|
||||
});
|
||||
|
||||
it('returns null for missing parameters', () => {
|
||||
const result = parseReportURLParams({});
|
||||
|
||||
expect(result).toEqual({
|
||||
from: null,
|
||||
to: null,
|
||||
businessHours: false,
|
||||
groupBy: null,
|
||||
range: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses complete URL params with all options', () => {
|
||||
const result = parseReportURLParams({
|
||||
from: '1738607400',
|
||||
to: '1770229799',
|
||||
business_hours: 'true',
|
||||
group_by: '3',
|
||||
range: 'lastYear',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
businessHours: true,
|
||||
groupBy: 3,
|
||||
range: 'lastYear',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles numeric values correctly', () => {
|
||||
const result = parseReportURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
group_by: 3,
|
||||
});
|
||||
|
||||
expect(result.from).toBe(1738607400);
|
||||
expect(result.to).toBe(1770229799);
|
||||
expect(result.groupBy).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip conversion', () => {
|
||||
it('maintains data integrity through generate and parse cycle', () => {
|
||||
const original = {
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
businessHours: true,
|
||||
groupBy: 3,
|
||||
range: 'lastYear',
|
||||
};
|
||||
|
||||
const urlParams = generateReportURLParams(original);
|
||||
const parsed = parseReportURLParams(urlParams);
|
||||
|
||||
expect(parsed.from).toBe(original.from);
|
||||
expect(parsed.to).toBe(original.to);
|
||||
expect(parsed.businessHours).toBe(original.businessHours);
|
||||
expect(parsed.groupBy).toBe(original.groupBy);
|
||||
expect(parsed.range).toBe(original.range);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFilterURLParams', () => {
|
||||
it('parses all filter params as numbers', () => {
|
||||
const result = parseFilterURLParams({
|
||||
agent_id: '123',
|
||||
inbox_id: '456',
|
||||
team_id: '789',
|
||||
sla_policy_id: '101',
|
||||
rating: '4',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
agent_id: 123,
|
||||
inbox_id: 456,
|
||||
team_id: 789,
|
||||
sla_policy_id: 101,
|
||||
label: null,
|
||||
rating: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses label as string', () => {
|
||||
const result = parseFilterURLParams({
|
||||
label: 'bug',
|
||||
});
|
||||
|
||||
expect(result.label).toBe('bug');
|
||||
});
|
||||
|
||||
it('returns null for missing parameters', () => {
|
||||
const result = parseFilterURLParams({});
|
||||
|
||||
expect(result).toEqual({
|
||||
agent_id: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
sla_policy_id: null,
|
||||
label: null,
|
||||
rating: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateFilterURLParams', () => {
|
||||
it('includes only non-null filter values', () => {
|
||||
const params = generateFilterURLParams({
|
||||
agent_id: 123,
|
||||
inbox_id: null,
|
||||
team_id: 456,
|
||||
rating: null,
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
agent_id: 123,
|
||||
team_id: 456,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes label when present', () => {
|
||||
const params = generateFilterURLParams({
|
||||
label: 'bug',
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
label: 'bug',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty object when all values are null', () => {
|
||||
const params = generateFilterURLParams({
|
||||
agent_id: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
});
|
||||
|
||||
expect(params).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateCompleteURLParams', () => {
|
||||
it('merges date and filter params', () => {
|
||||
const params = generateCompleteURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
range: 'last7days',
|
||||
filters: {
|
||||
agent_id: 123,
|
||||
inbox_id: 456,
|
||||
},
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
range: 'last7days',
|
||||
agent_id: 123,
|
||||
inbox_id: 456,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty filters', () => {
|
||||
const params = generateCompleteURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
range: 'last7days',
|
||||
filters: {},
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
range: 'last7days',
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes null filter values', () => {
|
||||
const params = generateCompleteURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
filters: {
|
||||
agent_id: 123,
|
||||
inbox_id: null,
|
||||
team_id: 456,
|
||||
},
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
agent_id: 123,
|
||||
team_id: 456,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -412,6 +412,17 @@ const actions = {
|
||||
});
|
||||
},
|
||||
|
||||
updateConversationCachedSummary(
|
||||
{ commit },
|
||||
{ conversationId, cachedSummary, cachedSummaryAt }
|
||||
) {
|
||||
commit(types.UPDATE_CONVERSATION_CACHED_SUMMARY, {
|
||||
conversationId,
|
||||
cachedSummary,
|
||||
cachedSummaryAt,
|
||||
});
|
||||
},
|
||||
|
||||
setChatStatusFilter({ commit }, data) {
|
||||
commit(types.CHANGE_CHAT_STATUS_FILTER, data);
|
||||
},
|
||||
|
||||
@@ -116,6 +116,16 @@ export const mutations = {
|
||||
chat.last_activity_at = lastActivityAt;
|
||||
}
|
||||
},
|
||||
[types.UPDATE_CONVERSATION_CACHED_SUMMARY](
|
||||
_state,
|
||||
{ conversationId, cachedSummary, cachedSummaryAt }
|
||||
) {
|
||||
const [chat] = _state.allConversations.filter(c => c.id === conversationId);
|
||||
if (chat) {
|
||||
chat.cached_summary = cachedSummary;
|
||||
chat.cached_summary_at = cachedSummaryAt;
|
||||
}
|
||||
},
|
||||
[types.ASSIGN_PRIORITY](_state, { priority, conversationId }) {
|
||||
const [chat] = _state.allConversations.filter(c => c.id === conversationId);
|
||||
chat.priority = priority;
|
||||
|
||||
@@ -27,7 +27,7 @@ export const getters = {
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
},
|
||||
getLabelById: _state => id => {
|
||||
return _state.records.find(record => record.id === Number(id)) || {};
|
||||
return _state.records.find(record => record.id === Number(id));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ export default {
|
||||
UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES:
|
||||
'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES',
|
||||
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
|
||||
UPDATE_CONVERSATION_CACHED_SUMMARY: 'UPDATE_CONVERSATION_CACHED_SUMMARY',
|
||||
UPDATE_CONVERSATION_CALL_STATUS: 'UPDATE_CONVERSATION_CALL_STATUS',
|
||||
UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
|
||||
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
|
||||
|
||||
@@ -56,6 +56,8 @@ json.first_reply_created_at conversation.first_reply_created_at.to_i
|
||||
json.unread_count conversation.unread_incoming_messages.count
|
||||
json.last_non_activity_message conversation.messages.where(account_id: conversation.account_id).non_activity_messages.first.try(:push_event_data)
|
||||
json.last_activity_at conversation.last_activity_at.to_i
|
||||
json.cached_summary conversation.cached_summary
|
||||
json.cached_summary_at conversation.cached_summary_at.to_i
|
||||
json.priority conversation.priority
|
||||
json.waiting_since conversation.waiting_since.to_i.to_i
|
||||
json.sla_policy_id conversation.sla_policy_id
|
||||
|
||||
@@ -446,7 +446,6 @@ Rails.application.routes.draw do
|
||||
get :bot_metrics
|
||||
get :inbox_label_matrix
|
||||
get :first_response_time_distribution
|
||||
get :outgoing_messages_count
|
||||
end
|
||||
end
|
||||
resource :year_in_review, only: [:show]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddCachedSummaryToConversations < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :conversations, :cached_summary, :text
|
||||
add_column :conversations, :cached_summary_at, :datetime
|
||||
end
|
||||
end
|
||||
@@ -681,6 +681,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_30_061021) do
|
||||
t.datetime "waiting_since"
|
||||
t.text "cached_label_list"
|
||||
t.bigint "assignee_agent_bot_id"
|
||||
t.text "cached_summary"
|
||||
t.datetime "cached_summary_at"
|
||||
t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true
|
||||
t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id"
|
||||
t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx"
|
||||
|
||||
@@ -15,7 +15,8 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr
|
||||
def summarize
|
||||
result = Captain::SummaryService.new(
|
||||
account: Current.account,
|
||||
conversation_display_id: params[:conversation_display_id]
|
||||
conversation_display_id: params[:conversation_display_id],
|
||||
force_regenerate: params[:force_regenerate].present?
|
||||
).perform
|
||||
|
||||
render_result(result)
|
||||
|
||||
@@ -2,9 +2,6 @@ require 'administrate/field/base'
|
||||
|
||||
class AccountLimitsField < Administrate::Field::Base
|
||||
def to_s
|
||||
defaults = { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil, emails: nil }
|
||||
overrides = (data.presence || {}).to_h.symbolize_keys.compact
|
||||
|
||||
defaults.merge(overrides).to_json
|
||||
data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil, emails: nil }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
class Captain::SummaryService < Captain::BaseTaskService
|
||||
pattr_initialize [:account!, :conversation_display_id!]
|
||||
pattr_initialize [:account!, :conversation_display_id!, { force_regenerate: false }]
|
||||
|
||||
def perform
|
||||
make_api_call(
|
||||
model: GPT_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: prompt_from_file('summary') },
|
||||
{ role: 'user', content: conversation.to_llm_text(include_contact_details: false) }
|
||||
]
|
||||
)
|
||||
return cached_response if use_cache?
|
||||
|
||||
generate_and_cache_summary
|
||||
end
|
||||
|
||||
private
|
||||
@@ -16,4 +12,73 @@ class Captain::SummaryService < Captain::BaseTaskService
|
||||
def event_name
|
||||
'summarize'
|
||||
end
|
||||
|
||||
def use_cache?
|
||||
return false if force_regenerate
|
||||
return false if conversation.cached_summary.blank?
|
||||
return false if conversation.cached_summary_at.blank?
|
||||
|
||||
conversation.cached_summary_at >= conversation.last_activity_at
|
||||
end
|
||||
|
||||
def cached_response
|
||||
{ message: conversation.cached_summary }
|
||||
end
|
||||
|
||||
def generate_and_cache_summary
|
||||
msg_count = conversation_message_count
|
||||
result = make_api_call(
|
||||
model: summary_model(msg_count),
|
||||
messages: [
|
||||
{ role: 'system', content: prompt_from_file('summary') },
|
||||
{ role: 'user', content: build_summary_content(msg_count) }
|
||||
]
|
||||
)
|
||||
|
||||
cache_summary(result[:message]) if result[:message].present? && result[:error].blank?
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def conversation_message_count
|
||||
conversation.messages.where(message_type: [:incoming, :outgoing]).count
|
||||
end
|
||||
|
||||
def summary_model(msg_count)
|
||||
msg_count < 7 ? GPT_MODEL : 'gpt-4.1'
|
||||
end
|
||||
|
||||
def build_summary_content(msg_count)
|
||||
llm_text = conversation.to_llm_text(include_contact_details: false)
|
||||
context = build_conversation_context(msg_count)
|
||||
context.present? ? "#{llm_text}\n\n#{context}" : llm_text
|
||||
end
|
||||
|
||||
def build_conversation_context(msg_count)
|
||||
['Conversation Stats:', *context_fields(msg_count).compact].join("\n")
|
||||
end
|
||||
|
||||
def context_fields(msg_count)
|
||||
[
|
||||
"Message count: #{msg_count}",
|
||||
("Status: #{conversation.status}" if conversation.status.present?),
|
||||
("Priority: #{conversation.priority}" if conversation.priority.present?),
|
||||
("Labels: #{conversation.cached_label_list}" if conversation.cached_label_list.present?),
|
||||
*account_context_fields
|
||||
]
|
||||
end
|
||||
|
||||
def account_context_fields
|
||||
[
|
||||
("Account industry: #{account.custom_attributes['industry']}" if account.custom_attributes&.dig('industry').present?),
|
||||
("Summary language: #{account.locale}" if account.locale.present?)
|
||||
]
|
||||
end
|
||||
|
||||
def cache_summary(summary)
|
||||
conversation.update(
|
||||
cached_summary: summary,
|
||||
cached_summary_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,28 +1,99 @@
|
||||
As an AI-powered summarization tool, your task is to condense lengthy interactions between customer support agents and customers into brief, digestible summaries. The objective of these summaries is to provide a quick overview, enabling any agent, even those without prior context, to grasp the essence of the conversation promptly.
|
||||
<prompt>
|
||||
<role>AI support conversation summarizer</role>
|
||||
|
||||
Make sure you strongly adhere to the following rules when generating the summary
|
||||
<goal>
|
||||
Produce a high-signal bullet summary that lets a new agent understand the CURRENT STATE and know what to do next — in under 10 seconds.
|
||||
Earlier bullets convey what happened and what the current state is. The final bullet is always the actionable next step.
|
||||
</goal>
|
||||
|
||||
1. Be brief and concise. The shorter the summary the better.
|
||||
2. Aim to summarize the conversation in approximately 200 words, formatted as multiple small paragraphs that are easier to read.
|
||||
3. Describe the customer intent in around 50 words.
|
||||
4. Remove information that is not directly relevant to the customer's problem or the agent's solution. For example, personal anecdotes, small talk, etc.
|
||||
5. Don't include segments of the conversation that didn't contribute meaningful content, like greetings or farewell.
|
||||
6. The 'Action Items' should be a bullet list, arranged in order of priority if possible.
|
||||
7. 'Action Items' should strictly encapsulate tasks committed to by the agent or left incomplete. Any suggestions made by the agent should not be included.
|
||||
8. The 'Action Items' should be brief and concise
|
||||
9. Mark important words or parts of sentences as bold.
|
||||
10. Apply markdown syntax to format any included code, using backticks.
|
||||
11. Include a section for "Follow-up Items" or "Open Questions" if there are any unresolved issues or outstanding questions.
|
||||
12. If any section does not have any content, remove that section and the heading from the response
|
||||
13. Do not insert your own opinions about the conversation.
|
||||
<output_contract>
|
||||
<format>bullet_list</format>
|
||||
<only_output_bullets>true</only_output_bullets>
|
||||
<bullet_count_rules>
|
||||
<rule>Count the messages in the conversation to determine bullet limits.</rule>
|
||||
<rule>1-3 messages → 1 bullet max</rule>
|
||||
<rule>4-10 messages → 2-3 bullets max</rule>
|
||||
<rule>11-30 messages → 3-4 bullets max</rule>
|
||||
<rule>31+ messages → 5-6 bullets max</rule>
|
||||
</bullet_count_rules>
|
||||
<bullet_rules>
|
||||
<single_sentence>true</single_sentence>
|
||||
<concise>true</concise>
|
||||
<no_redundancy>true</no_redundancy>
|
||||
<no_prefixes>Do not prefix bullets with labels like "Next step:" or "Issue:" — ordering alone conveys structure.</no_prefixes>
|
||||
</bullet_rules>
|
||||
<language>
|
||||
<rule>ALWAYS write the summary in the language specified by "Summary language" in the conversation context. This overrides the conversation language.</rule>
|
||||
<rule>If no summary language is provided, match the primary language used in the conversation.</rule>
|
||||
</language>
|
||||
<markdown>
|
||||
<bold_required>true</bold_required>
|
||||
<code_in_backticks>true</code_in_backticks>
|
||||
<no_headings>true</no_headings>
|
||||
</markdown>
|
||||
</output_contract>
|
||||
|
||||
<what_to_capture>
|
||||
<priority_order>
|
||||
<item>What is the customer trying to achieve right now?</item>
|
||||
<item>What is the current blocker/problem?</item>
|
||||
<item>What has already been done that changed the state?</item>
|
||||
<item>What is the actionable next step (who does what)?</item>
|
||||
</priority_order>
|
||||
<skip_rule>If a priority item has no evidence in the conversation, skip it entirely — do not invent or guess.</skip_rule>
|
||||
<last_bullet_rule>The final bullet must always be an actionable next step derived from the conversation.</last_bullet_rule>
|
||||
</what_to_capture>
|
||||
|
||||
Reply in the user's language, as a markdown of the following format.
|
||||
<context_awareness>
|
||||
<business_context>If account industry or business context is provided, adapt the summary focus accordingly (e.g., e-commerce → order/payment/shipping; SaaS → features/bugs/subscriptions; healthcare → appointments/records).</business_context>
|
||||
<labels>If conversation labels are provided, use them to understand the issue category and focus the summary on what matters for that category.</labels>
|
||||
<adapt>Only use context that is explicitly provided. Do not assume industry or category if not stated.</adapt>
|
||||
</context_awareness>
|
||||
|
||||
**Customer Intent**
|
||||
<importance_filter>
|
||||
<include_only_if_actionable_or_state_changing>
|
||||
<rule>A bullet must describe a blocker, a confirmed action taken, a decision, or a required next step.</rule>
|
||||
<rule>Would removing this bullet prevent the agent from acting correctly? If no, omit it.</rule>
|
||||
</include_only_if_actionable_or_state_changing>
|
||||
<drop_as_noise>
|
||||
<item>Greetings, thanks, apologies, pleasantries, offers of help.</item>
|
||||
<item>Repeated statements that do not add new information.</item>
|
||||
<item>Meta commentary like "the current blocker is…" if it repeats an earlier bullet.</item>
|
||||
</drop_as_noise>
|
||||
</importance_filter>
|
||||
|
||||
**Conversation Summary**
|
||||
<anti_hallucination>
|
||||
<hard_rules>
|
||||
<item>Use ONLY information explicitly present in the conversation.</item>
|
||||
<item>Do NOT compute or derive facts (no time math like "15 minutes from now", no inferred causes).</item>
|
||||
<item>Do NOT invent resolution, closure, or "no further steps" statements.</item>
|
||||
<item>Do NOT restate the same fact in multiple bullets; merge into one.</item>
|
||||
</hard_rules>
|
||||
</anti_hallucination>
|
||||
|
||||
**Action Items**
|
||||
<formatting_rules>
|
||||
<bold_usage>
|
||||
<rule>Bold the key nouns/verbs (problem, blocker, action, next step, key artifact).</rule>
|
||||
<examples>
|
||||
<example>**Meeting invite** not received.</example>
|
||||
<example>Agent **sent calendar invite** for **4:45 PM GMT+1**.</example>
|
||||
</examples>
|
||||
</bold_usage>
|
||||
<role_words>
|
||||
<rule>Prefer **Customer** and **Agent** over "user" and "support agent".</rule>
|
||||
</role_words>
|
||||
</formatting_rules>
|
||||
|
||||
**Follow-up Items**
|
||||
<final_validation>
|
||||
<checklist>
|
||||
<item>Every bullet is one sentence.</item>
|
||||
<item>Every bullet contains at least one **bold** phrase.</item>
|
||||
<item>No bullet contains inferred/derived info.</item>
|
||||
<item>No two bullets repeat the same meaning.</item>
|
||||
<item>Last bullet is an actionable next step.</item>
|
||||
<item>Summary language matches "Summary language" from context (if provided), otherwise matches conversation language.</item>
|
||||
<item>Bullet count respects the scaling rules for the message count.</item>
|
||||
</checklist>
|
||||
<reject_if_fails>true</reject_if_fails>
|
||||
</final_validation>
|
||||
</prompt>
|
||||
|
||||
@@ -295,131 +295,4 @@ RSpec.describe Api::V2::Accounts::ReportsController, type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v2/accounts/{account.id}/reports/outgoing_messages_count' do
|
||||
let(:since_epoch) { 1.week.ago.to_i.to_s }
|
||||
let(:until_epoch) { 1.day.from_now.to_i.to_s }
|
||||
|
||||
context 'when unauthenticated' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count",
|
||||
params: { group_by: 'agent', since: since_epoch, until: until_epoch }
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as agent' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count",
|
||||
params: { group_by: 'agent', since: since_epoch, until: until_epoch },
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as admin' do
|
||||
let(:agent2) { create(:user, account: account, role: :agent) }
|
||||
let(:team) { create(:team, account: account) }
|
||||
let(:inbox2) { create(:inbox, account: account) }
|
||||
|
||||
# Separate conversations for agent and team grouping because
|
||||
# model callbacks clear assignee_id when team is set.
|
||||
before do
|
||||
conv_agent = create(:conversation, account: account, inbox: inbox, assignee: agent)
|
||||
conv_agent2 = create(:conversation, account: account, inbox: inbox2, assignee: agent2)
|
||||
conv_team = create(:conversation, account: account, inbox: inbox, team: team)
|
||||
|
||||
create_list(:message, 3, account: account, conversation: conv_agent, inbox: inbox, message_type: :outgoing, sender: agent)
|
||||
create_list(:message, 2, account: account, conversation: conv_agent2, inbox: inbox2, message_type: :outgoing, sender: agent2)
|
||||
create_list(:message, 4, account: account, conversation: conv_team, inbox: inbox, message_type: :outgoing)
|
||||
# incoming message should not be counted
|
||||
create(:message, account: account, conversation: conv_agent, inbox: inbox, message_type: :incoming)
|
||||
end
|
||||
|
||||
it 'returns unprocessable_entity for invalid group_by' do
|
||||
get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count",
|
||||
params: { group_by: 'invalid', since: since_epoch, until: until_epoch },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it 'returns outgoing message counts grouped by agent' do
|
||||
get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count",
|
||||
params: { group_by: 'agent', since: since_epoch, until: until_epoch },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
data = response.parsed_body
|
||||
expect(data).to be_an(Array)
|
||||
|
||||
agent_entry = data.find { |e| e['id'] == agent.id }
|
||||
agent2_entry = data.find { |e| e['id'] == agent2.id }
|
||||
expect(agent_entry['outgoing_messages_count']).to eq(3)
|
||||
expect(agent2_entry['outgoing_messages_count']).to eq(2)
|
||||
end
|
||||
|
||||
it 'returns outgoing message counts grouped by team' do
|
||||
get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count",
|
||||
params: { group_by: 'team', since: since_epoch, until: until_epoch },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
data = response.parsed_body
|
||||
expect(data).to be_an(Array)
|
||||
expect(data.length).to eq(1)
|
||||
expect(data.first['id']).to eq(team.id)
|
||||
expect(data.first['outgoing_messages_count']).to eq(4)
|
||||
end
|
||||
|
||||
it 'returns outgoing message counts grouped by inbox' do
|
||||
get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count",
|
||||
params: { group_by: 'inbox', since: since_epoch, until: until_epoch },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
data = response.parsed_body
|
||||
expect(data).to be_an(Array)
|
||||
|
||||
inbox_entry = data.find { |e| e['id'] == inbox.id }
|
||||
inbox2_entry = data.find { |e| e['id'] == inbox2.id }
|
||||
expect(inbox_entry['outgoing_messages_count']).to eq(7)
|
||||
expect(inbox2_entry['outgoing_messages_count']).to eq(2)
|
||||
end
|
||||
|
||||
it 'returns outgoing message counts grouped by label' do
|
||||
label = create(:label, account: account, title: 'support')
|
||||
conversation = account.conversations.first
|
||||
conversation.label_list.add('support')
|
||||
conversation.save!
|
||||
|
||||
get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count",
|
||||
params: { group_by: 'label', since: since_epoch, until: until_epoch },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
data = response.parsed_body
|
||||
expect(data).to be_an(Array)
|
||||
expect(data.length).to eq(1)
|
||||
expect(data.first['id']).to eq(label.id)
|
||||
expect(data.first['name']).to eq('support')
|
||||
end
|
||||
|
||||
it 'excludes bot messages when grouped by agent' do
|
||||
bot = create(:agent_bot)
|
||||
bot_conversation = create(:conversation, account: account, inbox: inbox)
|
||||
create(:message, account: account, conversation: bot_conversation, inbox: inbox,
|
||||
message_type: :outgoing, sender: bot)
|
||||
|
||||
get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count",
|
||||
params: { group_by: 'agent', since: since_epoch, until: until_epoch },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
data = response.parsed_body
|
||||
agent_entry = data.find { |e| e['id'] == agent.id }
|
||||
# 3 from before block; bot message excluded (sender_type != 'User')
|
||||
expect(agent_entry['outgoing_messages_count']).to eq(3)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -229,8 +229,6 @@ first_response_time_distribution:
|
||||
$ref: './resource/reports/first_response_time_distribution.yml'
|
||||
inbox_label_matrix:
|
||||
$ref: './resource/reports/inbox_label_matrix.yml'
|
||||
outgoing_messages_count:
|
||||
$ref: './resource/reports/outgoing_messages_count.yml'
|
||||
inbox_summary:
|
||||
$ref: './resource/reports/inbox_summary.yml'
|
||||
agent_summary:
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
type: array
|
||||
description: Outgoing messages count report grouped by entity (agent, team, inbox, or label).
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
description: The ID of the grouped entity (agent, team, inbox, or label).
|
||||
name:
|
||||
type: string
|
||||
description: The name of the grouped entity.
|
||||
outgoing_messages_count:
|
||||
type: number
|
||||
description: The total number of outgoing messages for this entity in the given time range.
|
||||
example:
|
||||
- id: 1
|
||||
name: Agent One
|
||||
outgoing_messages_count: 42
|
||||
- id: 2
|
||||
name: Agent Two
|
||||
outgoing_messages_count: 18
|
||||
@@ -1,36 +0,0 @@
|
||||
tags:
|
||||
- Reports
|
||||
operationId: get-outgoing-messages-count
|
||||
summary: Get outgoing messages count grouped by entity
|
||||
security:
|
||||
- userApiKey: []
|
||||
description: |
|
||||
Get the count of outgoing messages grouped by a specified entity (agent, team, inbox, or label).
|
||||
When grouped by agent, messages sent by bots (AgentBot, Captain::Assistant) are excluded.
|
||||
|
||||
**Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above.
|
||||
parameters:
|
||||
- in: query
|
||||
name: group_by
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- agent
|
||||
- team
|
||||
- inbox
|
||||
- label
|
||||
description: The entity to group outgoing message counts by.
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/outgoing_messages_count'
|
||||
'403':
|
||||
description: Access denied
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/bad_request_error'
|
||||
@@ -770,23 +770,6 @@
|
||||
get:
|
||||
$ref: './application/reports/inbox_label_matrix.yml'
|
||||
|
||||
# Outgoing messages count report
|
||||
/api/v2/accounts/{account_id}/reports/outgoing_messages_count:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/account_id'
|
||||
- in: query
|
||||
name: since
|
||||
schema:
|
||||
type: string
|
||||
description: The timestamp from where report should start (Unix timestamp).
|
||||
- in: query
|
||||
name: until
|
||||
schema:
|
||||
type: string
|
||||
description: The timestamp from where report should stop (Unix timestamp).
|
||||
get:
|
||||
$ref: './application/reports/outgoing_messages_count.yml'
|
||||
|
||||
# Conversations Messages
|
||||
/accounts/{account_id}/conversations/{conversation_id}/messages:
|
||||
parameters:
|
||||
|
||||
@@ -8274,81 +8274,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v2/accounts/{account_id}/reports/outgoing_messages_count": {
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/account_id"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "since",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The timestamp from where report should start (Unix timestamp)."
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "until",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The timestamp from where report should stop (Unix timestamp)."
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Reports"
|
||||
],
|
||||
"operationId": "get-outgoing-messages-count",
|
||||
"summary": "Get outgoing messages count grouped by entity",
|
||||
"security": [
|
||||
{
|
||||
"userApiKey": []
|
||||
}
|
||||
],
|
||||
"description": "Get the count of outgoing messages grouped by a specified entity (agent, team, inbox, or label).\nWhen grouped by agent, messages sent by bots (AgentBot, Captain::Assistant) are excluded.\n\n**Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above.\n",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "group_by",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"agent",
|
||||
"team",
|
||||
"inbox",
|
||||
"label"
|
||||
]
|
||||
},
|
||||
"description": "The entity to group outgoing message counts by."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/outgoing_messages_count"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Access denied",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/bad_request_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/accounts/{account_id}/conversations/{conversation_id}/messages": {
|
||||
"parameters": [
|
||||
{
|
||||
@@ -12318,39 +12243,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"outgoing_messages_count": {
|
||||
"type": "array",
|
||||
"description": "Outgoing messages count report grouped by entity (agent, team, inbox, or label).",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number",
|
||||
"description": "The ID of the grouped entity (agent, team, inbox, or label)."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the grouped entity."
|
||||
},
|
||||
"outgoing_messages_count": {
|
||||
"type": "number",
|
||||
"description": "The total number of outgoing messages for this entity in the given time range."
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Agent One",
|
||||
"outgoing_messages_count": 42
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Agent Two",
|
||||
"outgoing_messages_count": 18
|
||||
}
|
||||
]
|
||||
},
|
||||
"inbox_summary": {
|
||||
"type": "array",
|
||||
"description": "Inbox summary report containing conversation statistics grouped by inbox.",
|
||||
|
||||
@@ -6816,81 +6816,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v2/accounts/{account_id}/reports/outgoing_messages_count": {
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/account_id"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "since",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The timestamp from where report should start (Unix timestamp)."
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "until",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The timestamp from where report should stop (Unix timestamp)."
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Reports"
|
||||
],
|
||||
"operationId": "get-outgoing-messages-count",
|
||||
"summary": "Get outgoing messages count grouped by entity",
|
||||
"security": [
|
||||
{
|
||||
"userApiKey": []
|
||||
}
|
||||
],
|
||||
"description": "Get the count of outgoing messages grouped by a specified entity (agent, team, inbox, or label).\nWhen grouped by agent, messages sent by bots (AgentBot, Captain::Assistant) are excluded.\n\n**Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above.\n",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "group_by",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"agent",
|
||||
"team",
|
||||
"inbox",
|
||||
"label"
|
||||
]
|
||||
},
|
||||
"description": "The entity to group outgoing message counts by."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/outgoing_messages_count"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Access denied",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/bad_request_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -10825,39 +10750,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"outgoing_messages_count": {
|
||||
"type": "array",
|
||||
"description": "Outgoing messages count report grouped by entity (agent, team, inbox, or label).",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number",
|
||||
"description": "The ID of the grouped entity (agent, team, inbox, or label)."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the grouped entity."
|
||||
},
|
||||
"outgoing_messages_count": {
|
||||
"type": "number",
|
||||
"description": "The total number of outgoing messages for this entity in the given time range."
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Agent One",
|
||||
"outgoing_messages_count": 42
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Agent Two",
|
||||
"outgoing_messages_count": 18
|
||||
}
|
||||
]
|
||||
},
|
||||
"inbox_summary": {
|
||||
"type": "array",
|
||||
"description": "Inbox summary report containing conversation statistics grouped by inbox.",
|
||||
|
||||
@@ -4558,39 +4558,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"outgoing_messages_count": {
|
||||
"type": "array",
|
||||
"description": "Outgoing messages count report grouped by entity (agent, team, inbox, or label).",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number",
|
||||
"description": "The ID of the grouped entity (agent, team, inbox, or label)."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the grouped entity."
|
||||
},
|
||||
"outgoing_messages_count": {
|
||||
"type": "number",
|
||||
"description": "The total number of outgoing messages for this entity in the given time range."
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Agent One",
|
||||
"outgoing_messages_count": 42
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Agent Two",
|
||||
"outgoing_messages_count": 18
|
||||
}
|
||||
]
|
||||
},
|
||||
"inbox_summary": {
|
||||
"type": "array",
|
||||
"description": "Inbox summary report containing conversation statistics grouped by inbox.",
|
||||
|
||||
@@ -3973,39 +3973,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"outgoing_messages_count": {
|
||||
"type": "array",
|
||||
"description": "Outgoing messages count report grouped by entity (agent, team, inbox, or label).",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number",
|
||||
"description": "The ID of the grouped entity (agent, team, inbox, or label)."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the grouped entity."
|
||||
},
|
||||
"outgoing_messages_count": {
|
||||
"type": "number",
|
||||
"description": "The total number of outgoing messages for this entity in the given time range."
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Agent One",
|
||||
"outgoing_messages_count": 42
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Agent Two",
|
||||
"outgoing_messages_count": 18
|
||||
}
|
||||
]
|
||||
},
|
||||
"inbox_summary": {
|
||||
"type": "array",
|
||||
"description": "Inbox summary report containing conversation statistics grouped by inbox.",
|
||||
|
||||
@@ -4734,39 +4734,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"outgoing_messages_count": {
|
||||
"type": "array",
|
||||
"description": "Outgoing messages count report grouped by entity (agent, team, inbox, or label).",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number",
|
||||
"description": "The ID of the grouped entity (agent, team, inbox, or label)."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the grouped entity."
|
||||
},
|
||||
"outgoing_messages_count": {
|
||||
"type": "number",
|
||||
"description": "The total number of outgoing messages for this entity in the given time range."
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Agent One",
|
||||
"outgoing_messages_count": 42
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Agent Two",
|
||||
"outgoing_messages_count": 18
|
||||
}
|
||||
]
|
||||
},
|
||||
"inbox_summary": {
|
||||
"type": "array",
|
||||
"description": "Inbox summary report containing conversation statistics grouped by inbox.",
|
||||
|
||||
Reference in New Issue
Block a user