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:
Pranav
2026-01-15 19:53:57 -08:00
committed by GitHub
parent da42a4e4a1
commit a8b302d4cd
40 changed files with 1376 additions and 305 deletions

View File

@@ -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"

View File

@@ -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>

View File

@@ -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"
}
}
},

View File

@@ -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>

View File

@@ -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>

View File

@@ -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];
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
});
});

View File

@@ -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>"
`;

View File

@@ -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 {

View File

@@ -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,
});
});
});

View File

@@ -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',