feat(ee): Review Notes for CSAT Reports (#13289)
CSAT scores are helpful, but on their own they rarely tell the full story. A drop in rating can come from delayed timelines, unclear expectations, or simple misunderstandings, even when the issue itself was handled correctly. Review Notes for CSAT let admins/report manager roles add internal-only context next to each CSAT response. This makes it easier to interpret scores properly and focus on patterns and root causes, not just numbers. <img width="2170" height="1680" alt="image" src="https://github.com/user-attachments/assets/56df7fab-d0a7-4a94-95b9-e4c459ad33d5" /> ### Why this matters * Capture the real context behind individual CSAT ratings * Clarify whether a low score points to a genuine service issue or a process gap * Spot recurring themes across conversations and teams * Make CSAT reviews more useful for leadership reviews and retrospectives ### How Review Notes work **View CSAT responses** Open the CSAT report to see overall metrics, rating distribution, and individual responses. **Add a Review Note** For any CSAT entry, managers can add a Review Note directly below the customer’s feedback. **Document internal insights** Use Review Notes to capture things like: * Why a score was lower or higher than expected * Patterns you are seeing across similar cases * Observations around communication, timelines, or customer expectations Review Notes are visible only to administrators and people with report access only. We may expand visibility to agents in the future based on feedback. However, customers never see them. Each note clearly shows who added it and when, making it easy to review context and changes over time.
This commit is contained in:
@@ -206,10 +206,14 @@ const emitDateRange = () => {
|
||||
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDatePicker = () => {
|
||||
showDatePicker.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative font-inter">
|
||||
<div v-on-clickaway="closeDatePicker" class="relative font-inter">
|
||||
<DatePickerButton
|
||||
:selected-start-date="selectedStartDate"
|
||||
:selected-end-date="selectedEndDate"
|
||||
|
||||
@@ -38,7 +38,7 @@ const toggleShowMore = () => {
|
||||
<button
|
||||
v-if="text.length > limit"
|
||||
class="text-n-brand !p-0 !border-0 align-top"
|
||||
@click="toggleShowMore"
|
||||
@click.stop="toggleShowMore"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
</button>
|
||||
|
||||
@@ -402,22 +402,48 @@
|
||||
},
|
||||
"CSAT_REPORTS": {
|
||||
"HEADER": "CSAT Reports",
|
||||
"NO_RECORDS": "There are no CSAT survey responses available.",
|
||||
"NO_RECORDS": "No responses yet",
|
||||
"NO_RECORDS_DESCRIPTION": "CSAT survey responses will appear here once customers start providing feedback.",
|
||||
"DOWNLOAD": "Download CSAT Reports",
|
||||
"DOWNLOAD_FAILED": "Failed to download CSAT Reports",
|
||||
"FILTERS": {
|
||||
"ADD_FILTER": "Add filter",
|
||||
"CLEAR_ALL": "Clear all",
|
||||
"NO_FILTER": "No filters available",
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"AGENTS": "Search agents",
|
||||
"INBOXES": "Search inboxes",
|
||||
"TEAMS": "Search teams",
|
||||
"RATINGS": "Search ratings"
|
||||
},
|
||||
"AGENTS": {
|
||||
"PLACEHOLDER": "Choose Agents"
|
||||
"LABEL": "Agent"
|
||||
},
|
||||
"INBOXES": {
|
||||
"LABEL": "Inbox"
|
||||
},
|
||||
"TEAMS": {
|
||||
"LABEL": "Team"
|
||||
},
|
||||
"RATINGS": {
|
||||
"LABEL": "Rating"
|
||||
}
|
||||
},
|
||||
"TABLE": {
|
||||
"HEADER": {
|
||||
"CONTACT_NAME": "Contact",
|
||||
"AGENT_NAME": "Assigned agent",
|
||||
"AGENT_NAME": "Agent",
|
||||
"RATING": "Rating",
|
||||
"FEEDBACK_TEXT": "Feedback comment"
|
||||
}
|
||||
"FEEDBACK_TEXT": "Feedback comment",
|
||||
"CONVERSATION": "Conversation",
|
||||
"CUSTOMER": "Customer",
|
||||
"RESPONSE": "Response",
|
||||
"HANDLED_BY": "Handled by"
|
||||
},
|
||||
"UNKNOWN_CUSTOMER": "Unknown customer"
|
||||
},
|
||||
"NO_AGENT": "No assigned agent",
|
||||
"NO_FEEDBACK": "No feedback provided",
|
||||
"METRIC": {
|
||||
"TOTAL_RESPONSES": {
|
||||
"LABEL": "Total responses",
|
||||
@@ -430,6 +456,25 @@
|
||||
"RESPONSE_RATE": {
|
||||
"LABEL": "Response rate",
|
||||
"TOOLTIP": "Total number of responses / Total number of CSAT survey messages sent * 100"
|
||||
},
|
||||
"RATING_DISTRIBUTION": "Rating distribution"
|
||||
},
|
||||
"REVIEW_NOTES": {
|
||||
"TITLE": "Review notes",
|
||||
"PLACEHOLDER": "Add review notes about this rating...",
|
||||
"SAVE": "Save",
|
||||
"CANCEL": "Cancel",
|
||||
"SAVING": "Saving...",
|
||||
"SAVED": "Notes saved successfully",
|
||||
"SAVE_ERROR": "Failed to save notes",
|
||||
"UPDATED_BY": "Updated by {name} {time}",
|
||||
"UPDATED_BY_LABEL": "Updated by",
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to add review notes",
|
||||
"AVAILABLE_ON": "The review notes feature is only available in the Business and Enterprise plans.",
|
||||
"UPGRADE_PROMPT": "Add internal context to every CSAT response with review notes. Capture what really happened, spot patterns faster, and make better decisions from your feedback.",
|
||||
"UPGRADE_NOW": "Upgrade now",
|
||||
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mapGetters } from 'vuex';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import CsatMetrics from './components/CsatMetrics.vue';
|
||||
import CsatTable from './components/CsatTable.vue';
|
||||
import ReportFilterSelector from './components/FilterSelector.vue';
|
||||
import CsatFilters from './components/Csat/CsatFilters.vue';
|
||||
import { generateFileName } from '../../../../helper/downloadHelper';
|
||||
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
@@ -15,7 +15,7 @@ export default {
|
||||
components: {
|
||||
CsatMetrics,
|
||||
CsatTable,
|
||||
ReportFilterSelector,
|
||||
CsatFilters,
|
||||
ReportHeader,
|
||||
V4Button,
|
||||
},
|
||||
@@ -90,7 +90,7 @@ export default {
|
||||
selectedTeam,
|
||||
selectedRating,
|
||||
}) {
|
||||
// do not track filter change on inital load
|
||||
// do not track filter change on initial load
|
||||
if (this.from !== 0 && this.to !== 0) {
|
||||
useTrack(REPORTS_EVENTS.FILTER_REPORT, {
|
||||
filterType: 'date',
|
||||
@@ -121,16 +121,11 @@ export default {
|
||||
/>
|
||||
</ReportHeader>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<ReportFilterSelector
|
||||
show-agents-filter
|
||||
show-inbox-filter
|
||||
show-rating-filter
|
||||
<div class="flex flex-col gap-6">
|
||||
<CsatFilters
|
||||
:show-team-filter="isTeamsEnabled"
|
||||
:show-business-hours-switch="false"
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
|
||||
<CsatMetrics :filters="requestPayload" />
|
||||
<CsatTable :page-index="pageIndex" @page-change="onPageNumberChange" />
|
||||
</div>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const { row } = defineProps({
|
||||
row: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const routerParams = computed(() => ({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: row.original.conversationId },
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-right">
|
||||
<router-link :to="routerParams" class="hover:underline">
|
||||
{{ `#${row.original.conversationId}` }}
|
||||
</router-link>
|
||||
<div v-tooltip="row.original.createdAt" class="text-n-slate-11 text-sm">
|
||||
{{ row.original.createdAgo }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { CSAT_RATINGS } from 'shared/constants/messages';
|
||||
|
||||
export const buildFilterList = (items, type) =>
|
||||
items.map(item => ({
|
||||
id: item.id,
|
||||
name: type === 'ratings' ? item.emoji : item.name,
|
||||
type,
|
||||
}));
|
||||
|
||||
export const buildRatingsList = t =>
|
||||
CSAT_RATINGS.map(rating => ({
|
||||
id: rating.value,
|
||||
name: `${t(rating.translationKey)}`,
|
||||
type: 'ratings',
|
||||
}));
|
||||
|
||||
export const getActiveFilter = (filters, type, key) =>
|
||||
filters.find(item => item.id.toString() === key.toString());
|
||||
|
||||
export const getFilterType = (input, direction) => {
|
||||
const filterMap = {
|
||||
keyToType: {
|
||||
user_ids: 'agents',
|
||||
inbox_id: 'inboxes',
|
||||
team_id: 'teams',
|
||||
rating: 'ratings',
|
||||
},
|
||||
typeToKey: {
|
||||
agents: 'user_ids',
|
||||
inboxes: 'inbox_id',
|
||||
teams: 'team_id',
|
||||
ratings: 'rating',
|
||||
},
|
||||
};
|
||||
return filterMap[direction][input];
|
||||
};
|
||||
@@ -0,0 +1,267 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
|
||||
import {
|
||||
buildFilterList,
|
||||
buildRatingsList,
|
||||
getActiveFilter,
|
||||
getFilterType,
|
||||
} from './CsatFilterHelpers';
|
||||
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';
|
||||
|
||||
const props = defineProps({
|
||||
showTeamFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['filterChange']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const showDropdownMenu = ref(false);
|
||||
const showSubDropdownMenu = ref(false);
|
||||
const activeFilterType = ref('');
|
||||
const customDateRange = ref([new Date(), new Date()]);
|
||||
const appliedFilters = ref({
|
||||
user_ids: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
rating: null,
|
||||
});
|
||||
|
||||
const agents = computed(() => store.getters['agents/getAgents']);
|
||||
const inboxes = computed(() => store.getters['inboxes/getInboxes']);
|
||||
const teams = computed(() => store.getters['teams/getTeams']);
|
||||
|
||||
const ratings = computed(() => buildRatingsList(t));
|
||||
|
||||
const from = computed(() => getUnixStartOfDay(customDateRange.value[0]));
|
||||
const to = computed(() => getUnixEndOfDay(customDateRange.value[1]));
|
||||
|
||||
const getFilterSource = type => {
|
||||
const sources = {
|
||||
agents: agents.value,
|
||||
inboxes: inboxes.value,
|
||||
teams: teams.value,
|
||||
ratings: ratings.value,
|
||||
};
|
||||
return sources[type] || [];
|
||||
};
|
||||
|
||||
const getFilterOptions = type => {
|
||||
if (type === 'ratings') {
|
||||
return ratings.value;
|
||||
}
|
||||
return buildFilterList(getFilterSource(type), type);
|
||||
};
|
||||
|
||||
const filterListMenuItems = computed(() => {
|
||||
const filterTypes = [
|
||||
{
|
||||
id: '1',
|
||||
name: t('CSAT_REPORTS.FILTERS.AGENTS.LABEL'),
|
||||
type: 'agents',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: t('CSAT_REPORTS.FILTERS.INBOXES.LABEL'),
|
||||
type: 'inboxes',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: t('CSAT_REPORTS.FILTERS.RATINGS.LABEL'),
|
||||
type: 'ratings',
|
||||
},
|
||||
];
|
||||
|
||||
if (props.showTeamFilter) {
|
||||
filterTypes.splice(2, 0, {
|
||||
id: '4',
|
||||
name: t('CSAT_REPORTS.FILTERS.TEAMS.LABEL'),
|
||||
type: 'teams',
|
||||
});
|
||||
}
|
||||
|
||||
const activeFilterKeys = Object.keys(appliedFilters.value).filter(
|
||||
key => appliedFilters.value[key]
|
||||
);
|
||||
const activeFilterTypes = activeFilterKeys.map(key =>
|
||||
getFilterType(key, 'keyToType')
|
||||
);
|
||||
|
||||
return filterTypes
|
||||
.filter(({ type }) => !activeFilterTypes.includes(type))
|
||||
.map(({ id, name, type }) => ({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
options: getFilterOptions(type),
|
||||
}));
|
||||
});
|
||||
|
||||
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]);
|
||||
return {
|
||||
id: item?.id,
|
||||
name: item?.name || '',
|
||||
type: filterType,
|
||||
options: getFilterOptions(filterType),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
Object.values(appliedFilters.value).some(value => value !== null)
|
||||
);
|
||||
|
||||
const isAllFilterSelected = computed(() => !filterListMenuItems.value.length);
|
||||
|
||||
const emitChange = () => {
|
||||
emit('filterChange', {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
selectedAgents: appliedFilters.value.user_ids
|
||||
? [{ id: appliedFilters.value.user_ids }]
|
||||
: [],
|
||||
selectedInbox: appliedFilters.value.inbox_id
|
||||
? { id: appliedFilters.value.inbox_id }
|
||||
: null,
|
||||
selectedTeam: appliedFilters.value.team_id
|
||||
? { id: appliedFilters.value.team_id }
|
||||
: null,
|
||||
selectedRating: appliedFilters.value.rating
|
||||
? { value: appliedFilters.value.rating }
|
||||
: null,
|
||||
});
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
showDropdownMenu.value = false;
|
||||
};
|
||||
|
||||
const closeActiveFilterDropdown = () => {
|
||||
activeFilterType.value = '';
|
||||
showSubDropdownMenu.value = false;
|
||||
};
|
||||
|
||||
const resetDropdown = () => {
|
||||
closeDropdown();
|
||||
closeActiveFilterDropdown();
|
||||
};
|
||||
|
||||
const addFilter = item => {
|
||||
const { type, id } = item;
|
||||
const filterKey = getFilterType(type, 'typeToKey');
|
||||
appliedFilters.value[filterKey] = id;
|
||||
emitChange();
|
||||
resetDropdown();
|
||||
};
|
||||
|
||||
const removeFilter = type => {
|
||||
const filterKey = getFilterType(type, 'typeToKey');
|
||||
appliedFilters.value[filterKey] = null;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
appliedFilters.value = {
|
||||
user_ids: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
rating: null,
|
||||
};
|
||||
emitChange();
|
||||
resetDropdown();
|
||||
};
|
||||
|
||||
const showDropdown = () => {
|
||||
showSubDropdownMenu.value = false;
|
||||
showDropdownMenu.value = !showDropdownMenu.value;
|
||||
};
|
||||
|
||||
const openActiveFilterDropdown = filterType => {
|
||||
closeDropdown();
|
||||
activeFilterType.value = filterType;
|
||||
showSubDropdownMenu.value = !showSubDropdownMenu.value;
|
||||
};
|
||||
|
||||
const onDateRangeChange = value => {
|
||||
customDateRange.value = value;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
emitChange();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col flex-wrap w-full gap-3 md:flex-row">
|
||||
<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"
|
||||
>
|
||||
<div v-if="hasActiveFilters" class="flex flex-wrap gap-2 md:flex-nowrap">
|
||||
<ActiveFilterChip
|
||||
v-for="filter in activeFilters"
|
||||
v-bind="filter"
|
||||
:key="filter.type"
|
||||
:placeholder="
|
||||
$t(
|
||||
`CSAT_REPORTS.FILTERS.INPUT_PLACEHOLDER.${filter.type.toUpperCase()}`
|
||||
)
|
||||
"
|
||||
:active-filter-type="activeFilterType"
|
||||
:show-menu="showSubDropdownMenu"
|
||||
enable-search
|
||||
@toggle-dropdown="openActiveFilterDropdown"
|
||||
@close-dropdown="closeActiveFilterDropdown"
|
||||
@add-filter="addFilter"
|
||||
@remove-filter="removeFilter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasActiveFilters && !isAllFilterSelected"
|
||||
class="w-full h-px border md:w-px md:h-5 border-n-weak"
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<AddFilterChip
|
||||
v-if="!isAllFilterSelected"
|
||||
placeholder-i18n-key="CSAT_REPORTS.FILTERS.INPUT_PLACEHOLDER"
|
||||
:name="$t('CSAT_REPORTS.FILTERS.ADD_FILTER')"
|
||||
:menu-option="filterListMenuItems"
|
||||
:show-menu="showDropdownMenu"
|
||||
:empty-state-message="$t('CSAT_REPORTS.FILTERS.NO_FILTER')"
|
||||
@toggle-dropdown="showDropdown"
|
||||
@close-dropdown="closeDropdown"
|
||||
@add-filter="addFilter"
|
||||
/>
|
||||
|
||||
<div v-if="hasActiveFilters" class="w-px h-5 border border-n-weak" />
|
||||
|
||||
<FilterButton
|
||||
v-if="hasActiveFilters"
|
||||
:button-text="$t('CSAT_REPORTS.FILTERS.CLEAR_ALL')"
|
||||
@click="clearAllFilters"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
defineProps({
|
||||
contact: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
conversationId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
createdAgo: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
createdAt: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar
|
||||
:src="contact?.thumbnail || ''"
|
||||
:name="contact?.name || ''"
|
||||
:size="32"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-sm text-n-slate-12 font-medium capitalize">
|
||||
{{ contact?.name || '—' }}
|
||||
</span>
|
||||
<div
|
||||
class="flex items-center gap-1 text-xs text-n-slate-10 whitespace-nowrap"
|
||||
>
|
||||
<a
|
||||
:href="`/app/accounts/${$route.params.accountId}/conversations/${conversationId}`"
|
||||
class="flex items-center text-xs gap-0.5 hover:text-n-brand hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<span>#{{ conversationId }}</span>
|
||||
</a>
|
||||
<span>·</span>
|
||||
<span :title="createdAt">{{ createdAgo }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: 'i-lucide-message-square-off',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center py-16 px-6 text-center">
|
||||
<div
|
||||
class="size-16 rounded-full bg-n-alpha-2 flex items-center justify-center mb-4"
|
||||
>
|
||||
<i :class="icon" class="size-8 text-n-slate-10" />
|
||||
</div>
|
||||
<h3 class="text-base font-medium text-n-slate-12 mb-1">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p v-if="description" class="text-sm text-n-slate-10 max-w-sm">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import CsatReviewNotesPaywall from './CsatReviewNotesPaywall.vue';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
|
||||
const props = defineProps({
|
||||
response: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { isCloudFeatureEnabled, isOnChatwootCloud } = useAccount();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
const isFeatureEnabled = computed(() =>
|
||||
isCloudFeatureEnabled('csat_review_notes')
|
||||
);
|
||||
const showPaywall = computed(
|
||||
() => !isFeatureEnabled.value && isOnChatwootCloud.value
|
||||
);
|
||||
|
||||
const reviewNotes = ref(props.response.csat_review_notes || '');
|
||||
const isEditing = ref(!props.response.csat_review_notes);
|
||||
const isSaving = ref(false);
|
||||
|
||||
const hasExistingReviewNotes = computed(
|
||||
() => !!props.response.csat_review_notes
|
||||
);
|
||||
|
||||
const hasChanges = computed(
|
||||
() => reviewNotes.value !== (props.response.csat_review_notes || '')
|
||||
);
|
||||
|
||||
const startEditing = () => {
|
||||
isEditing.value = true;
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
reviewNotes.value = props.response.csat_review_notes || '';
|
||||
if (hasExistingReviewNotes.value) {
|
||||
isEditing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveReviewNotes = async () => {
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await store.dispatch('csat/update', {
|
||||
id: props.response.id,
|
||||
reviewNotes: reviewNotes.value,
|
||||
});
|
||||
useAlert(t('CSAT_REPORTS.REVIEW_NOTES.SAVED'));
|
||||
isEditing.value = false;
|
||||
} catch {
|
||||
useAlert(t('CSAT_REPORTS.REVIEW_NOTES.SAVE_ERROR'));
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-4 px-5 border-t border-n-container bg-n-background">
|
||||
<CsatReviewNotesPaywall v-if="showPaywall" />
|
||||
<div v-else-if="isFeatureEnabled" class="flex flex-col gap-3">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex items-center gap-1.5 text-n-slate-11 shrink-0 w-36 pt-3"
|
||||
>
|
||||
<i class="i-lucide-notebook-pen size-4" />
|
||||
<span class="text-sm font-medium">
|
||||
{{ $t('CSAT_REPORTS.REVIEW_NOTES.TITLE') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 max-w-2xl">
|
||||
<div
|
||||
v-if="hasExistingReviewNotes && !isEditing"
|
||||
class="group flex items-start gap-2 py-2 px-3 rounded-lg hover:bg-n-slate-2 dark:hover:bg-n-solid-3 cursor-pointer transition-colors"
|
||||
@click.stop="startEditing"
|
||||
>
|
||||
<p
|
||||
v-dompurify-html="formatMessage(response.csat_review_notes || '')"
|
||||
class="flex-1 text-sm text-n-slate-12 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0"
|
||||
/>
|
||||
<i
|
||||
class="i-lucide-pencil size-4 text-n-slate-10 opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col gap-3 [&_.ProseMirror]:min-h-32 [&_.ProseMirror]:max-h-64 [&_.ProseMirror-menubar]:!mt-0"
|
||||
@click.stop
|
||||
>
|
||||
<Editor
|
||||
v-model="reviewNotes"
|
||||
:placeholder="$t('CSAT_REPORTS.REVIEW_NOTES.PLACEHOLDER')"
|
||||
:show-character-count="false"
|
||||
:enable-canned-responses="false"
|
||||
focus-on-mount
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-2 py-2">
|
||||
<Button
|
||||
v-if="hasExistingReviewNotes"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
:label="$t('CSAT_REPORTS.REVIEW_NOTES.CANCEL')"
|
||||
@click.stop="cancelEditing"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('CSAT_REPORTS.REVIEW_NOTES.SAVE')"
|
||||
:disabled="!hasChanges || isSaving"
|
||||
:loading="isSaving"
|
||||
size="xs"
|
||||
@click.stop="saveReviewNotes"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Editor>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
hasExistingReviewNotes &&
|
||||
!isEditing &&
|
||||
response.review_notes_updated_by
|
||||
"
|
||||
class="flex items-center gap-4"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 text-n-slate-11 shrink-0 w-36">
|
||||
<i class="i-lucide-user-pen size-4" />
|
||||
<span class="text-sm font-medium">
|
||||
{{ $t('CSAT_REPORTS.REVIEW_NOTES.UPDATED_BY_LABEL') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-1 max-w-2xl px-3">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ response.review_notes_updated_by.name }}
|
||||
</span>
|
||||
<span class="text-n-slate-10">·</span>
|
||||
<span class="text-sm text-n-slate-10">
|
||||
{{ dynamicTime(response.review_notes_updated_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup>
|
||||
import CsatMetricCard from './CsatMetricCard.vue';
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<!-- eslint-disable vue/no-undef-components -->
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Reports/CsatMetricCard"
|
||||
:layout="{ type: 'grid', width: '400px' }"
|
||||
>
|
||||
<Variant title="Default">
|
||||
<div class="p-4 bg-n-background">
|
||||
<CsatMetricCard
|
||||
label="Total Responses"
|
||||
tooltip="Total number of responses received"
|
||||
value="1,234"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Percentage Value">
|
||||
<div class="p-4 bg-n-background">
|
||||
<CsatMetricCard
|
||||
label="Satisfaction Score"
|
||||
tooltip="Percentage of positive responses"
|
||||
value="85%"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Loading State">
|
||||
<div class="p-4 bg-n-background">
|
||||
<CsatMetricCard
|
||||
label="Response Rate"
|
||||
tooltip="Percentage of conversations with responses"
|
||||
value="0"
|
||||
is-loading
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Zero Value">
|
||||
<div class="p-4 bg-n-background">
|
||||
<CsatMetricCard
|
||||
label="Total Responses"
|
||||
tooltip="Total number of responses received"
|
||||
value="0"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
tooltip: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 items-start justify-center min-w-[10rem]">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-n-slate-11"
|
||||
>
|
||||
{{ label }}
|
||||
<span
|
||||
v-tooltip.right="tooltip"
|
||||
class="i-lucide-info flex flex-shrink-0 text-n-slate-10 size-3.5"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="w-16 h-8 rounded-md bg-n-slate-3 animate-pulse"
|
||||
/>
|
||||
<span v-else class="text-2xl font-medium text-n-slate-12">
|
||||
{{ value }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,135 +1,63 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import CsatMetricCard from './ReportMetricCard.vue';
|
||||
import { CSAT_RATINGS } from 'shared/constants/messages';
|
||||
import BarChart from 'shared/components/charts/BarChart.vue';
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import CsatMetricCard from './CsatMetricCard.vue';
|
||||
import CsatRatingDistribution from './CsatRatingDistribution.vue';
|
||||
|
||||
export default {
|
||||
components: { BarChart, CsatMetricCard },
|
||||
props: {
|
||||
filters: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
csatRatings: CSAT_RATINGS,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
metrics: 'csat/getMetrics',
|
||||
ratingPercentage: 'csat/getRatingPercentage',
|
||||
satisfactionScore: 'csat/getSatisfactionScore',
|
||||
responseRate: 'csat/getResponseRate',
|
||||
}),
|
||||
ratingFilterEnabled() {
|
||||
return Boolean(this.filters.rating);
|
||||
},
|
||||
chartData() {
|
||||
const sortedRatings = [...CSAT_RATINGS].sort((a, b) => b.value - a.value);
|
||||
return {
|
||||
labels: ['Rating'],
|
||||
datasets: sortedRatings.map(rating => ({
|
||||
label: rating.emoji,
|
||||
data: [this.ratingPercentage[rating.value]],
|
||||
backgroundColor: rating.color,
|
||||
})),
|
||||
};
|
||||
},
|
||||
responseCount() {
|
||||
return this.metrics.totalResponseCount
|
||||
? this.metrics.totalResponseCount.toLocaleString()
|
||||
: '--';
|
||||
},
|
||||
chartOptions() {
|
||||
return {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: false,
|
||||
stacked: true,
|
||||
},
|
||||
y: {
|
||||
display: false,
|
||||
stacked: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
formatToPercent(value) {
|
||||
return value ? `${value}%` : '--';
|
||||
},
|
||||
ratingToEmoji(value) {
|
||||
return CSAT_RATINGS.find(rating => rating.value === Number(value)).emoji;
|
||||
},
|
||||
},
|
||||
};
|
||||
const metrics = useMapGetter('csat/getMetrics');
|
||||
const ratingPercentage = useMapGetter('csat/getRatingPercentage');
|
||||
const ratingCount = useMapGetter('csat/getRatingCount');
|
||||
const satisfactionScore = useMapGetter('csat/getSatisfactionScore');
|
||||
const responseRate = useMapGetter('csat/getResponseRate');
|
||||
const uiFlags = useMapGetter('csat/getUIFlags');
|
||||
|
||||
const isLoading = computed(() => uiFlags.value.isFetchingMetrics);
|
||||
|
||||
const responseCount = computed(() =>
|
||||
metrics.value.totalResponseCount
|
||||
? metrics.value.totalResponseCount.toLocaleString()
|
||||
: '0'
|
||||
);
|
||||
|
||||
const formatPercent = value => (value ? `${value}%` : '0%');
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-unused-refs -->
|
||||
<!-- Added ref for writing specs -->
|
||||
<template>
|
||||
<div
|
||||
class="flex-col lg:flex-row flex flex-wrap mx-0 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-8 gap-4"
|
||||
>
|
||||
<CsatMetricCard
|
||||
:label="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
|
||||
:info-text="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP')"
|
||||
:value="responseCount"
|
||||
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"
|
||||
/>
|
||||
<CsatMetricCard
|
||||
:disabled="ratingFilterEnabled"
|
||||
:label="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL')"
|
||||
:info-text="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP')"
|
||||
:value="ratingFilterEnabled ? '--' : formatToPercent(satisfactionScore)"
|
||||
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"
|
||||
/>
|
||||
<CsatMetricCard
|
||||
:label="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL')"
|
||||
:info-text="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP')"
|
||||
:value="formatToPercent(responseRate)"
|
||||
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-if="metrics.totalResponseCount && !ratingFilterEnabled"
|
||||
ref="csatBarChart"
|
||||
class="w-full md:w-1/2 md:max-w-[50%] flex-1 rtl:[direction:initial]"
|
||||
class="flex sm:flex-row flex-col w-full gap-4 sm:gap-14 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
|
||||
>
|
||||
<h3
|
||||
class="flex items-center m-0 text-xs font-medium md:text-sm text-n-slate-12"
|
||||
>
|
||||
<div class="flex flex-row-reverse justify-end">
|
||||
<div
|
||||
v-for="(rating, key, index) in ratingPercentage"
|
||||
:key="rating + key + index"
|
||||
class="ltr:pr-4 rtl:pl-4"
|
||||
>
|
||||
<span class="my-0 mx-0.5">{{ ratingToEmoji(key) }}</span>
|
||||
<span>{{ formatToPercent(rating) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</h3>
|
||||
<div class="mt-2 h-6">
|
||||
<BarChart :collection="chartData" :chart-options="chartOptions" />
|
||||
</div>
|
||||
<CsatMetricCard
|
||||
:label="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
|
||||
:tooltip="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP')"
|
||||
:value="responseCount"
|
||||
:is-loading="isLoading"
|
||||
/>
|
||||
|
||||
<div class="w-full sm:w-px bg-n-strong" />
|
||||
|
||||
<CsatMetricCard
|
||||
:label="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL')"
|
||||
:tooltip="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP')"
|
||||
:value="formatPercent(satisfactionScore)"
|
||||
:is-loading="isLoading"
|
||||
/>
|
||||
|
||||
<div class="w-full sm:w-px bg-n-strong" />
|
||||
|
||||
<CsatMetricCard
|
||||
:label="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL')"
|
||||
:tooltip="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP')"
|
||||
:value="formatPercent(responseRate)"
|
||||
:is-loading="isLoading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CsatRatingDistribution
|
||||
:rating-percentage="ratingPercentage"
|
||||
:rating-count="ratingCount"
|
||||
:total-response-count="metrics.totalResponseCount"
|
||||
:is-loading="isLoading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup>
|
||||
import CsatRatingDistribution from './CsatRatingDistribution.vue';
|
||||
|
||||
const sampleRatingPercentage = {
|
||||
1: 5,
|
||||
2: 10,
|
||||
3: 15,
|
||||
4: 25,
|
||||
5: 45,
|
||||
};
|
||||
|
||||
const sampleRatingCount = {
|
||||
1: 50,
|
||||
2: 100,
|
||||
3: 150,
|
||||
4: 250,
|
||||
5: 450,
|
||||
};
|
||||
|
||||
const emptyRatingPercentage = {
|
||||
1: 0,
|
||||
2: 0,
|
||||
3: 0,
|
||||
4: 0,
|
||||
5: 0,
|
||||
};
|
||||
|
||||
const emptyRatingCount = {
|
||||
1: 0,
|
||||
2: 0,
|
||||
3: 0,
|
||||
4: 0,
|
||||
5: 0,
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<!-- eslint-disable vue/no-undef-components -->
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Reports/CsatRatingDistribution"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="With Data">
|
||||
<div class="p-4 bg-n-background">
|
||||
<CsatRatingDistribution
|
||||
:rating-percentage="sampleRatingPercentage"
|
||||
:rating-count="sampleRatingCount"
|
||||
:total-response-count="1000"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Empty State">
|
||||
<div class="p-4 bg-n-background">
|
||||
<CsatRatingDistribution
|
||||
:rating-percentage="emptyRatingPercentage"
|
||||
:rating-count="emptyRatingCount"
|
||||
:total-response-count="0"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Loading State">
|
||||
<div class="p-4 bg-n-background">
|
||||
<CsatRatingDistribution
|
||||
:rating-percentage="emptyRatingPercentage"
|
||||
:rating-count="emptyRatingCount"
|
||||
:total-response-count="0"
|
||||
is-loading
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { CSAT_RATINGS } from 'shared/constants/messages';
|
||||
|
||||
const props = defineProps({
|
||||
ratingPercentage: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
ratingCount: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
totalResponseCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const sortedRatings = computed(() =>
|
||||
[...CSAT_RATINGS].sort((a, b) => b.value - a.value)
|
||||
);
|
||||
|
||||
const formatPercent = value => (value ? `${value}%` : '0%');
|
||||
|
||||
const getRatingLabel = value => {
|
||||
const rating = CSAT_RATINGS.find(r => r.value === value);
|
||||
return rating ? t(rating.translationKey) : '';
|
||||
};
|
||||
|
||||
const getRatingCount = value => {
|
||||
return props.ratingCount[value] || 0;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
|
||||
>
|
||||
<span class="text-sm font-medium text-n-slate-11">
|
||||
{{ $t('CSAT_REPORTS.METRIC.RATING_DISTRIBUTION') }}
|
||||
</span>
|
||||
|
||||
<div v-if="isLoading" class="mt-4">
|
||||
<div class="h-6 w-full rounded-full bg-n-slate-3 animate-pulse" />
|
||||
<div class="flex gap-6 mt-4">
|
||||
<div
|
||||
v-for="n in 5"
|
||||
:key="n"
|
||||
class="h-4 w-20 rounded bg-n-slate-3 animate-pulse"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4">
|
||||
<div
|
||||
v-if="totalResponseCount"
|
||||
class="flex h-6 w-full rounded-full overflow-hidden bg-n-alpha-2"
|
||||
>
|
||||
<div
|
||||
v-for="rating in sortedRatings"
|
||||
:key="rating.value"
|
||||
v-tooltip="
|
||||
`${getRatingLabel(rating.value)}: ${formatPercent(ratingPercentage[rating.value])} (${getRatingCount(rating.value)})`
|
||||
"
|
||||
:style="{
|
||||
width: `${ratingPercentage[rating.value]}%`,
|
||||
backgroundColor: rating.color,
|
||||
}"
|
||||
class="h-full transition-all duration-300 first:rounded-s-full last:rounded-e-full cursor-default"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="h-6 w-full rounded-full bg-n-alpha-2" />
|
||||
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-2 mt-4">
|
||||
<div
|
||||
v-for="rating in sortedRatings"
|
||||
:key="rating.value"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ getRatingLabel(rating.value) }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ formatPercent(ratingPercentage[rating.value]) }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-10">
|
||||
({{ getRatingCount(rating.value) }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
|
||||
const goToBillingSettings = () => {
|
||||
router.push({
|
||||
name: 'billing_settings_index',
|
||||
params: { accountId: currentAccountId.value },
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-4 px-5 bg-n-background">
|
||||
<div class="flex justify-center">
|
||||
<BasePaywallModal
|
||||
feature-prefix="CSAT_REPORTS.REVIEW_NOTES"
|
||||
i18n-key="PAYWALL"
|
||||
is-on-chatwoot-cloud
|
||||
@upgrade="goToBillingSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,19 +1,19 @@
|
||||
<script setup>
|
||||
import { defineEmits, computed, h } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
// [TODO] Instead of converting the values to their reprentation when building the tableData
|
||||
// We should do the change in the cell
|
||||
import { messageStamp, dynamicTime } from 'shared/helpers/timeHelper';
|
||||
|
||||
// components
|
||||
import Table from 'dashboard/components/table/Table.vue';
|
||||
import Pagination from 'dashboard/components/table/Pagination.vue';
|
||||
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
|
||||
import ConversationCell from './ConversationCell.vue';
|
||||
import ShowMore from 'dashboard/components/widgets/ShowMore.vue';
|
||||
import CsatContactCell from './CsatContactCell.vue';
|
||||
import CsatExpandedRow from './CsatExpandedRow.vue';
|
||||
import CsatEmptyState from './CsatEmptyState.vue';
|
||||
import CsatTableLoader from './CsatTableLoader.vue';
|
||||
|
||||
// constants
|
||||
import { CSAT_RATINGS } from 'shared/constants/messages';
|
||||
|
||||
import {
|
||||
@@ -31,97 +31,82 @@ const { pageIndex } = defineProps({
|
||||
|
||||
const emit = defineEmits(['pageChange']);
|
||||
const { t } = useI18n();
|
||||
// const isRTL = useMapGetter('accounts/isRTL');
|
||||
const { isCloudFeatureEnabled, isOnChatwootCloud } = useAccount();
|
||||
const csatResponses = useMapGetter('csat/getCSATResponses');
|
||||
|
||||
const isFeatureEnabled = computed(() =>
|
||||
isCloudFeatureEnabled('csat_review_notes')
|
||||
);
|
||||
const showExpandableRows = computed(
|
||||
() => isFeatureEnabled.value || isOnChatwootCloud.value
|
||||
);
|
||||
const metrics = useMapGetter('csat/getMetrics');
|
||||
const uiFlags = useMapGetter('csat/getUIFlags');
|
||||
|
||||
const isLoading = computed(() => uiFlags.value.isFetching);
|
||||
|
||||
const expandedRows = ref({});
|
||||
|
||||
const toggleRow = id => {
|
||||
expandedRows.value = {
|
||||
...expandedRows.value,
|
||||
[id]: !expandedRows.value[id],
|
||||
};
|
||||
};
|
||||
|
||||
const isRowExpanded = id => !!expandedRows.value[id];
|
||||
|
||||
const tableData = computed(() => {
|
||||
return csatResponses.value.map(response => ({
|
||||
id: response.id,
|
||||
contact: response.contact,
|
||||
assignedAgent: response.assigned_agent,
|
||||
rating: response.rating,
|
||||
feedbackText: response.feedback_message || '---',
|
||||
feedbackText: response.feedback_message || '',
|
||||
conversationId: response.conversation_id,
|
||||
csatReviewNotes: response.csat_review_notes,
|
||||
createdAgo: dynamicTime(response.created_at),
|
||||
createdAt: messageStamp(response.created_at, 'LLL d yyyy, h:mm a'),
|
||||
_original: response,
|
||||
}));
|
||||
});
|
||||
|
||||
const defaultSpanRender = cellProps => {
|
||||
const value = cellProps.getValue() || '---';
|
||||
return h(
|
||||
'span',
|
||||
{
|
||||
class: 'line-clamp-5 break-words max-w-full text-n-slate-12',
|
||||
title: value,
|
||||
},
|
||||
value
|
||||
);
|
||||
const getRatingData = rating => {
|
||||
return CSAT_RATINGS.find(r => r.value === rating) || {};
|
||||
};
|
||||
|
||||
const columnHelper = createColumnHelper();
|
||||
|
||||
const columns = computed(() => [
|
||||
columnHelper.accessor('contact', {
|
||||
header: t('CSAT_REPORTS.TABLE.HEADER.CONTACT_NAME'),
|
||||
width: 200,
|
||||
cell: cellProps => {
|
||||
const { contact } = cellProps.row.original;
|
||||
if (contact) {
|
||||
return h(UserAvatarWithName, {
|
||||
user: contact,
|
||||
class: 'max-w-[200px] overflow-hidden',
|
||||
});
|
||||
}
|
||||
return '--';
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor('assignedAgent', {
|
||||
header: t('CSAT_REPORTS.TABLE.HEADER.AGENT_NAME'),
|
||||
width: 200,
|
||||
cell: cellProps => {
|
||||
const { assignedAgent } = cellProps.row.original;
|
||||
if (assignedAgent) {
|
||||
return h(UserAvatarWithName, {
|
||||
user: assignedAgent,
|
||||
class: 'max-w-[200px] overflow-hidden',
|
||||
});
|
||||
}
|
||||
return '--';
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor('rating', {
|
||||
header: t('CSAT_REPORTS.TABLE.HEADER.RATING'),
|
||||
align: 'center',
|
||||
width: 80,
|
||||
cell: cellProps => {
|
||||
const { rating: giveRating } = cellProps.row.original;
|
||||
const [ratingObject = {}] = CSAT_RATINGS.filter(
|
||||
rating => rating.value === giveRating
|
||||
);
|
||||
const columns = computed(() => {
|
||||
const baseColumns = [
|
||||
columnHelper.accessor('contact', {
|
||||
header: t('CSAT_REPORTS.TABLE.HEADER.CONTACT_NAME'),
|
||||
}),
|
||||
columnHelper.accessor('rating', {
|
||||
header: t('CSAT_REPORTS.TABLE.HEADER.RATING'),
|
||||
size: 120,
|
||||
}),
|
||||
columnHelper.accessor('feedbackText', {
|
||||
header: t('CSAT_REPORTS.TABLE.HEADER.FEEDBACK_TEXT'),
|
||||
size: 500,
|
||||
}),
|
||||
columnHelper.accessor('assignedAgent', {
|
||||
header: t('CSAT_REPORTS.TABLE.HEADER.HANDLED_BY'),
|
||||
size: 160,
|
||||
}),
|
||||
];
|
||||
|
||||
return h(
|
||||
'span',
|
||||
{
|
||||
class: ratingObject.emoji
|
||||
? 'emoji-response text-lg'
|
||||
: 'text-n-slate-10',
|
||||
},
|
||||
ratingObject.emoji || '---'
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor('feedbackText', {
|
||||
header: t('CSAT_REPORTS.TABLE.HEADER.FEEDBACK_TEXT'),
|
||||
width: 400,
|
||||
cell: defaultSpanRender,
|
||||
}),
|
||||
columnHelper.accessor('conversationId', {
|
||||
header: '',
|
||||
width: 100,
|
||||
cell: cellProps => h(ConversationCell, cellProps),
|
||||
}),
|
||||
]);
|
||||
if (showExpandableRows.value) {
|
||||
baseColumns.push(
|
||||
columnHelper.accessor('actions', {
|
||||
header: '',
|
||||
size: 50,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return baseColumns;
|
||||
});
|
||||
|
||||
const paginationParams = computed(() => {
|
||||
return {
|
||||
@@ -149,25 +134,124 @@ const table = useVueTable({
|
||||
},
|
||||
},
|
||||
onPaginationChange: updater => {
|
||||
const newPagintaion = updater(paginationParams.value);
|
||||
emit('pageChange', newPagintaion.pageIndex);
|
||||
const newPagination = updater(paginationParams.value);
|
||||
emit('pageChange', newPagination.pageIndex);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
|
||||
class="shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 overflow-hidden"
|
||||
>
|
||||
<Table :table="table" class="max-h-[calc(100vh-21.875rem)]" />
|
||||
<div
|
||||
v-show="!tableData.length"
|
||||
class="h-48 flex items-center justify-center text-n-slate-12 text-sm"
|
||||
>
|
||||
{{ $t('CSAT_REPORTS.NO_RECORDS') }}
|
||||
<CsatTableLoader v-if="isLoading" />
|
||||
|
||||
<div v-else-if="tableData.length" class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-n-solid-2 border-b border-n-container">
|
||||
<tr>
|
||||
<th
|
||||
v-for="header in table.getFlatHeaders()"
|
||||
:key="header.id"
|
||||
:style="{
|
||||
width: header.getSize() ? `${header.getSize()}px` : 'auto',
|
||||
}"
|
||||
class="text-left py-3 px-5 font-medium text-sm text-n-slate-12"
|
||||
>
|
||||
{{ header.column.columnDef.header }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-n-container">
|
||||
<template v-for="row in tableData" :key="row.id">
|
||||
<tr
|
||||
class="group hover:bg-n-slate-2 dark:hover:bg-n-solid-3 transition-colors"
|
||||
:class="{
|
||||
'bg-n-slate-2 dark:bg-n-solid-3': isRowExpanded(row.id),
|
||||
'cursor-pointer': showExpandableRows,
|
||||
}"
|
||||
@click="showExpandableRows && toggleRow(row.id)"
|
||||
>
|
||||
<td class="py-4 px-5">
|
||||
<CsatContactCell
|
||||
:contact="row.contact"
|
||||
:conversation-id="row.conversationId"
|
||||
:created-ago="row.createdAgo"
|
||||
:created-at="row.createdAt"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-4 px-5">
|
||||
<div
|
||||
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-lg"
|
||||
:style="{
|
||||
backgroundColor: `${getRatingData(row.rating).color}20`,
|
||||
}"
|
||||
>
|
||||
<span class="text-sm font-medium text-n-slate-12 truncate">
|
||||
{{ $t(getRatingData(row.rating).translationKey) }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4 px-5">
|
||||
<span
|
||||
v-if="!row.feedbackText"
|
||||
class="text-n-slate-10 italic text-sm"
|
||||
>
|
||||
{{ $t('CSAT_REPORTS.NO_FEEDBACK') }}
|
||||
</span>
|
||||
<div v-else class="text-sm text-n-slate-12">
|
||||
<ShowMore :text="row.feedbackText" :limit="100" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4 px-5">
|
||||
<UserAvatarWithName
|
||||
v-if="row.assignedAgent"
|
||||
:user="row.assignedAgent"
|
||||
:size="28"
|
||||
/>
|
||||
<span v-else class="text-n-slate-10 text-sm italic">
|
||||
{{ $t('CSAT_REPORTS.NO_AGENT') }}
|
||||
</span>
|
||||
</td>
|
||||
<td v-if="showExpandableRows" class="py-4 px-5">
|
||||
<div
|
||||
class="p-1.5 rounded-md text-n-slate-10 group-hover:text-n-slate-12 transition-colors"
|
||||
>
|
||||
<i
|
||||
class="size-4 block transition-transform duration-200"
|
||||
:class="
|
||||
isRowExpanded(row.id)
|
||||
? 'i-lucide-chevron-up'
|
||||
: 'i-lucide-chevron-down'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-if="showExpandableRows && isRowExpanded(row.id)"
|
||||
class="!border-t-0"
|
||||
>
|
||||
<td colspan="5" class="p-0 !border-t-0">
|
||||
<CsatExpandedRow :response="row._original" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="metrics.totalResponseCount" class="table-pagination">
|
||||
<Pagination class="mt-2" :table="table" />
|
||||
|
||||
<CsatEmptyState
|
||||
v-else
|
||||
:title="$t('CSAT_REPORTS.NO_RECORDS')"
|
||||
:description="$t('CSAT_REPORTS.NO_RECORDS_DESCRIPTION')"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="metrics.totalResponseCount"
|
||||
class="px-6 py-4 border-t border-n-weak"
|
||||
>
|
||||
<Pagination :table="table" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
<div class="flex gap-4 pb-3 border-b border-n-weak">
|
||||
<div class="h-4 w-32 rounded bg-n-slate-3 animate-pulse" />
|
||||
<div class="h-4 w-28 rounded bg-n-slate-3 animate-pulse" />
|
||||
<div class="h-4 w-20 rounded bg-n-slate-3 animate-pulse" />
|
||||
<div class="h-4 flex-1 rounded bg-n-slate-3 animate-pulse" />
|
||||
<div class="h-4 w-20 rounded bg-n-slate-3 animate-pulse" />
|
||||
</div>
|
||||
|
||||
<div v-for="n in rows" :key="n" class="flex items-center gap-4 py-3">
|
||||
<div class="flex items-center gap-2 w-48">
|
||||
<div class="size-8 rounded-full bg-n-slate-3 animate-pulse" />
|
||||
<div class="h-4 w-24 rounded bg-n-slate-3 animate-pulse" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 w-44">
|
||||
<div class="size-8 rounded-full bg-n-slate-3 animate-pulse" />
|
||||
<div class="h-4 w-20 rounded bg-n-slate-3 animate-pulse" />
|
||||
</div>
|
||||
<div class="h-7 w-24 rounded-lg bg-n-slate-3 animate-pulse" />
|
||||
<div class="h-4 flex-1 rounded bg-n-slate-3 animate-pulse" />
|
||||
<div class="h-4 w-16 rounded bg-n-slate-3 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -6,7 +6,6 @@ describe('CsatMetrics.vue', () => {
|
||||
let getters;
|
||||
let store;
|
||||
let wrapper;
|
||||
const filters = { rating: 3 };
|
||||
|
||||
beforeEach(() => {
|
||||
getters = {
|
||||
@@ -18,8 +17,16 @@ describe('CsatMetrics.vue', () => {
|
||||
4: 30,
|
||||
5: 10,
|
||||
}),
|
||||
'csat/getRatingCount': () => ({
|
||||
1: 10,
|
||||
2: 20,
|
||||
3: 30,
|
||||
4: 30,
|
||||
5: 10,
|
||||
}),
|
||||
'csat/getSatisfactionScore': () => 85,
|
||||
'csat/getResponseRate': () => 90,
|
||||
'csat/getUIFlags': () => ({ isFetchingMetrics: false }),
|
||||
};
|
||||
|
||||
store = createStore({
|
||||
@@ -28,40 +35,31 @@ describe('CsatMetrics.vue', () => {
|
||||
|
||||
wrapper = shallowMount(CsatMetrics, {
|
||||
global: {
|
||||
plugins: [store], // Ensure the store is injected here
|
||||
plugins: [store],
|
||||
mocks: {
|
||||
$t: msg => msg, // mock translation function
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: {
|
||||
CsatMetricCard: '<csat-metric-card/>',
|
||||
BarChart: '<woot-horizontal-bar/>',
|
||||
CsatMetricCard: true,
|
||||
CsatRatingDistribution: true,
|
||||
},
|
||||
},
|
||||
props: { filters },
|
||||
});
|
||||
});
|
||||
|
||||
it('computes response count correctly', () => {
|
||||
expect(wrapper.vm.responseCount).toBe('100');
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('formats values to percent correctly', () => {
|
||||
expect(wrapper.vm.formatToPercent(85)).toBe('85%');
|
||||
expect(wrapper.vm.formatToPercent(null)).toBe('--');
|
||||
it('renders metric cards with correct values', () => {
|
||||
const metricCards = wrapper.findAllComponents({ name: 'CsatMetricCard' });
|
||||
expect(metricCards).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('maps rating value to emoji correctly', () => {
|
||||
const rating = wrapper.vm.csatRatings[0]; // assuming this is { value: 1, emoji: '😡' }
|
||||
expect(wrapper.vm.ratingToEmoji(rating.value)).toBe(rating.emoji);
|
||||
});
|
||||
|
||||
it('hides report card if rating filter is enabled', () => {
|
||||
expect(wrapper.html()).not.toContain('bar-chart-stub');
|
||||
});
|
||||
|
||||
it('shows report card if rating filter is not enabled', async () => {
|
||||
await wrapper.setProps({ filters: {} });
|
||||
expect(wrapper.html()).toContain('bar-chart-stub');
|
||||
it('renders rating distribution component', () => {
|
||||
const distribution = wrapper.findComponent({
|
||||
name: 'CsatRatingDistribution',
|
||||
});
|
||||
expect(distribution.exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`CsatMetrics.vue > computes response count correctly 1`] = `
|
||||
"<div class="flex-col lg:flex-row flex flex-wrap mx-0 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-8 gap-4">
|
||||
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL" infotext="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP" disabled="false" class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]" value="100"></csat-metric-card-stub>
|
||||
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL" infotext="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP" disabled="true" class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]" value="--"></csat-metric-card-stub>
|
||||
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL" infotext="CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP" disabled="false" class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]" value="90%"></csat-metric-card-stub>
|
||||
<!--v-if-->
|
||||
</div>"
|
||||
`;
|
||||
@@ -82,6 +82,9 @@ export const getters = {
|
||||
),
|
||||
};
|
||||
},
|
||||
getRatingCount(_state) {
|
||||
return _state.metrics.ratingsCount;
|
||||
},
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
@@ -115,6 +118,13 @@ export const actions = {
|
||||
});
|
||||
});
|
||||
},
|
||||
update: async ({ commit }, { id, reviewNotes }) => {
|
||||
const response = await CSATReports.update(id, {
|
||||
csat_review_notes: reviewNotes,
|
||||
});
|
||||
commit(types.UPDATE_CSAT_RESPONSE, response.data);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
@@ -144,6 +154,7 @@ export const mutations = {
|
||||
};
|
||||
_state.metrics.totalSentMessagesCount = totalSentMessagesCount || 0;
|
||||
},
|
||||
[types.UPDATE_CSAT_RESPONSE]: MutationHelpers.update,
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -86,4 +86,19 @@ describe('#getters', () => {
|
||||
})
|
||||
).toEqual('50.00');
|
||||
});
|
||||
|
||||
it('getRatingCount', () => {
|
||||
const state = {
|
||||
metrics: {
|
||||
ratingsCount: { 1: 10, 2: 20, 3: 15, 4: 3, 5: 2 },
|
||||
},
|
||||
};
|
||||
expect(getters.getRatingCount(state)).toEqual({
|
||||
1: 10,
|
||||
2: 20,
|
||||
3: 15,
|
||||
4: 3,
|
||||
5: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,6 +241,7 @@ export default {
|
||||
SET_CSAT_RESPONSE_UI_FLAG: 'SET_CSAT_RESPONSE_UI_FLAG',
|
||||
SET_CSAT_RESPONSE: 'SET_CSAT_RESPONSE',
|
||||
SET_CSAT_RESPONSE_METRICS: 'SET_CSAT_RESPONSE_METRICS',
|
||||
UPDATE_CSAT_RESPONSE: 'UPDATE_CSAT_RESPONSE',
|
||||
|
||||
// Custom Attributes
|
||||
SET_CUSTOM_ATTRIBUTE_UI_FLAG: 'SET_CUSTOM_ATTRIBUTE_UI_FLAG',
|
||||
|
||||
Reference in New Issue
Block a user