Compare commits

..
Author SHA1 Message Date
Aakash BakhleandGitHub 38967fe75f Merge branch 'develop' into summaries 2026-02-04 17:57:25 +05:30
aakashb95 6e80f90357 Merge branch 'develop' into summaries 2026-02-03 18:52:01 +05:30
aakashb95 f0e9ffacd3 fix: summary language generation 2026-02-03 17:01:32 +05:30
aakashb95 45a1a01a96 fix: add context to prompt, get actionable 2026-02-03 16:40:25 +05:30
aakashb95 0e225d2f36 add: events for analytics 2026-01-30 14:57:35 +05:30
Pranav f45e82016d cache the summary 2026-01-29 11:45:36 -08:00
Pranav 137f0e726b Summaries in the ChatList 2026-01-29 09:56:29 -08:00
74 changed files with 2346 additions and 2349 deletions
+2 -2
View File
@@ -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>
@@ -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"
@@ -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"
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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"
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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"
>
@@ -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"
@@ -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');
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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]);
});
});
@@ -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,
]);
});
});
@@ -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);
});
});
@@ -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' },
]);
});
});
@@ -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 };
};
@@ -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
-1
View File
@@ -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
+2
View File
@@ -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
+73 -8
View File
@@ -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
-2
View File
@@ -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'
-17
View File
@@ -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:
-108
View File
@@ -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.",
-108
View File
@@ -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.",
-33
View File
@@ -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.",
-33
View File
@@ -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.",
-33
View File
@@ -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.",