feat(captain): add FAQ suggestion review interface

This commit is contained in:
aakashb95
2026-07-14 23:00:38 +05:30
parent 44ad817fb3
commit d3e1ad56ac
8 changed files with 673 additions and 304 deletions
@@ -0,0 +1,35 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainFaqSuggestions extends ApiClient {
constructor() {
super('captain/faq_suggestions', { accountScoped: true });
}
get({ page = 1, search, assistantId, status = 'open' } = {}) {
return axios.get(this.url, {
params: {
page,
search,
assistant_id: assistantId,
status,
},
});
}
update(id, data) {
return axios.patch(`${this.url}/${id}`, { faq_suggestion: data });
}
approve(id, data) {
return axios.post(`${this.url}/${id}/approve`, {
faq_suggestion: data,
});
}
dismiss(id) {
return axios.post(`${this.url}/${id}/dismiss`);
}
}
export default new CaptainFaqSuggestions();
@@ -0,0 +1,119 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({
suggestion: {
type: Object,
required: true,
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['approve', 'dismiss', 'review']);
const { t } = useI18n();
const sourceLabel = computed(() =>
t('CAPTAIN.FAQ_SUGGESTIONS.SOURCE_COUNT', {
count: props.suggestion.source_count,
})
);
const updatedAt = computed(() =>
dynamicTime(props.suggestion.updated_at || props.suggestion.created_at)
);
const language = computed(() =>
props.suggestion.language?.replace('_', '-').toUpperCase()
);
</script>
<template>
<article
class="group flex flex-col gap-4 rounded-xl border border-n-weak bg-n-solid-2 p-5 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-n-strong hover:shadow-md"
>
<div
class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"
>
<div class="min-w-0">
<div class="mb-3 flex flex-wrap items-center gap-2">
<span
class="inline-flex items-center gap-1.5 rounded-full bg-n-brand/10 px-2.5 py-1 text-xs font-medium text-n-blue-11"
>
<Icon icon="i-lucide-messages-square" class="size-3.5" />
{{ sourceLabel }}
</span>
<span
v-if="language"
class="rounded-full bg-n-alpha-2 px-2.5 py-1 text-xs font-medium text-n-slate-11"
>
{{ language }}
</span>
</div>
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ suggestion.question }}
</h3>
</div>
<Button
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.REVIEW')"
icon="i-lucide-list-collapse"
size="sm"
variant="faded"
color="slate"
class="shrink-0"
@click="emit('review', suggestion)"
/>
</div>
<p class="line-clamp-3 text-sm leading-6 text-n-slate-11">
{{ suggestion.answer }}
</p>
<div
class="flex flex-col gap-3 border-t border-n-weak pt-4 sm:flex-row sm:items-center sm:justify-between"
>
<div
class="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2 text-xs text-n-slate-10"
>
<span class="inline-flex min-w-0 items-center gap-1.5">
<Icon icon="i-woot-captain" class="size-3.5 shrink-0" />
<span class="truncate">{{ suggestion.assistant?.name }}</span>
</span>
<span class="inline-flex items-center gap-1.5">
<Icon icon="i-lucide-clock-3" class="size-3.5" />
{{ updatedAt }}
</span>
</div>
<Policy :permissions="['administrator']">
<div class="flex items-center gap-2">
<Button
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.DISMISS')"
icon="i-lucide-x"
size="sm"
variant="ghost"
color="slate"
:disabled="isLoading"
@click="emit('dismiss', suggestion)"
/>
<Button
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.APPROVE')"
icon="i-lucide-circle-check-big"
size="sm"
:is-loading="isLoading"
:disabled="isLoading"
@click="emit('approve', suggestion)"
/>
</div>
</Policy>
</div>
</article>
</template>
@@ -0,0 +1,272 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { dynamicTime } from 'shared/helpers/timeHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Policy from 'dashboard/components/policy.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
const props = defineProps({
suggestion: {
type: Object,
required: true,
},
});
const emit = defineEmits(['close', 'resolved']);
const { t } = useI18n();
const router = useRouter();
const store = useStore();
const dialogRef = ref(null);
const details = ref(null);
const uiFlags = useMapGetter('captainFaqSuggestions/getUIFlags');
const isFetching = computed(() => uiFlags.value.fetchingItem);
const isSaving = computed(() => uiFlags.value.updatingItem);
const isDismissing = computed(() => uiFlags.value.deletingItem);
const state = reactive({
question: props.suggestion.question,
answer: props.suggestion.answer,
});
const observations = computed(() => details.value?.observations || []);
const isInvalid = computed(
() => !state.question.trim() || !state.answer.trim()
);
const loadDetails = async () => {
try {
details.value = await store.dispatch(
'captainFaqSuggestions/show',
props.suggestion.id
);
} catch (error) {
useAlert(
error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.LOAD_DETAILS')
);
}
};
const close = () => dialogRef.value.close();
const handleSave = async () => {
try {
const updatedSuggestion = await store.dispatch(
'captainFaqSuggestions/update',
{
id: props.suggestion.id,
question: state.question.trim(),
answer: state.answer.trim(),
}
);
details.value = { ...details.value, ...updatedSuggestion };
useAlert(t('CAPTAIN.FAQ_SUGGESTIONS.SUCCESS.SAVED'));
} catch (error) {
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.SAVE'));
}
};
const handleApprove = async () => {
try {
await store.dispatch('captainFaqSuggestions/approve', {
id: props.suggestion.id,
question: state.question.trim(),
answer: state.answer.trim(),
});
useAlert(t('CAPTAIN.FAQ_SUGGESTIONS.SUCCESS.APPROVED'));
emit('resolved', { id: props.suggestion.id, action: 'approved' });
close();
} catch (error) {
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.APPROVE'));
}
};
const handleDismiss = async () => {
try {
await store.dispatch('captainFaqSuggestions/dismiss', props.suggestion.id);
useAlert(t('CAPTAIN.FAQ_SUGGESTIONS.SUCCESS.DISMISSED'));
emit('resolved', { id: props.suggestion.id, action: 'dismissed' });
close();
} catch (error) {
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.DISMISS'));
}
};
const openConversation = conversationId => {
close();
router.push({
name: 'inbox_conversation',
params: { conversation_id: conversationId },
});
};
onMounted(loadDetails);
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
width="3xl"
position="top"
overflow-y-auto
:title="$t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.TITLE')"
:show-cancel-button="false"
:show-confirm-button="false"
@close="emit('close')"
>
<template #description>
<p class="mb-0 text-sm text-n-slate-11">
{{
$t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.DESCRIPTION', {
count: suggestion.source_count,
})
}}
</p>
</template>
<div
class="grid min-h-0 grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_18rem]"
>
<div class="flex min-w-0 flex-col gap-4">
<Input
v-model="state.question"
:label="$t('CAPTAIN.RESPONSES.FORM.QUESTION.LABEL')"
:placeholder="$t('CAPTAIN.RESPONSES.FORM.QUESTION.PLACEHOLDER')"
/>
<TextArea
v-model="state.answer"
:label="$t('CAPTAIN.RESPONSES.FORM.ANSWER.LABEL')"
:placeholder="$t('CAPTAIN.RESPONSES.FORM.ANSWER.PLACEHOLDER')"
auto-height
resize
min-height="10rem"
max-height="20rem"
/>
<div class="flex flex-wrap items-center gap-2 text-xs text-n-slate-10">
<span
class="inline-flex items-center gap-1.5 rounded-full bg-n-alpha-2 px-2.5 py-1"
>
<Icon icon="i-lucide-languages" class="size-3.5" />
{{ suggestion.language?.replace('_', '-').toUpperCase() }}
</span>
<span
class="inline-flex items-center gap-1.5 rounded-full bg-n-alpha-2 px-2.5 py-1"
>
<Icon icon="i-woot-captain" class="size-3.5" />
{{ suggestion.assistant?.name }}
</span>
</div>
</div>
<section class="min-h-0 rounded-xl border border-n-weak bg-n-alpha-2 p-3">
<div class="mb-3 flex items-center justify-between gap-2 px-1">
<h4 class="text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.SOURCES') }}
</h4>
<span class="text-xs tabular-nums text-n-slate-10">
{{
$t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.SOURCE_PROGRESS', {
visible: observations.length,
total: suggestion.source_count,
})
}}
</span>
</div>
<div v-if="isFetching" class="flex h-40 items-center justify-center">
<Spinner />
</div>
<div
v-else-if="!observations.length"
class="flex h-40 items-center justify-center px-4 text-center text-sm text-n-slate-10"
>
{{ $t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.NO_SOURCES') }}
</div>
<div v-else class="flex max-h-[24rem] flex-col gap-2 overflow-y-auto">
<button
v-for="observation in observations"
:key="observation.id"
type="button"
class="flex w-full flex-col gap-1.5 rounded-lg border border-n-weak bg-n-solid-2 p-3 text-start transition-colors hover:border-n-brand"
@click="openConversation(observation.conversation.id)"
>
<span class="flex items-center justify-between gap-2">
<span class="text-xs font-medium text-n-blue-11">
{{
$t('CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION', {
id: observation.conversation.display_id,
})
}}
</span>
<Icon
icon="i-lucide-arrow-up-right"
class="size-3.5 text-n-slate-10"
/>
</span>
<span class="line-clamp-2 text-xs leading-5 text-n-slate-11">
{{ observation.generated_question }}
</span>
<span class="text-xs text-n-slate-10">
{{ dynamicTime(observation.created_at) }}
</span>
</button>
</div>
</section>
</div>
<template #footer>
<div
class="flex w-full flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<Policy :permissions="['administrator']">
<Button
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.DISMISS')"
icon="i-lucide-x"
variant="ghost"
color="ruby"
:is-loading="isDismissing"
:disabled="isSaving || isDismissing"
@click="handleDismiss"
/>
</Policy>
<div class="flex items-center justify-end gap-2">
<Button
:label="$t('DIALOG.BUTTONS.CANCEL')"
variant="faded"
color="slate"
:disabled="isSaving || isDismissing"
@click="close"
/>
<Policy :permissions="['administrator']">
<Button
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.SAVE')"
variant="faded"
color="slate"
:is-loading="isSaving"
:disabled="isInvalid || isSaving || isDismissing"
@click="handleSave"
/>
<Button
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.APPROVE_FAQ')"
icon="i-lucide-circle-check-big"
:is-loading="isSaving"
:disabled="isInvalid || isSaving || isDismissing"
@click="handleApprove"
/>
</Policy>
</div>
</div>
</template>
</Dialog>
</template>
@@ -1120,8 +1120,8 @@
"ALL": "All"
},
"PENDING_BANNER": {
"TITLE": "Captain has found some FAQs your customers were looking for.",
"ACTION": "Click here to review"
"TITLE": "Captain grouped recurring customer questions into FAQ suggestions.",
"ACTION": "Review suggestions"
},
"FORM_DESCRIPTION": "Add a question and its corresponding answer to the knowledge base and select the assistant it should be associated with.",
"CREATE": {
@@ -1163,6 +1163,42 @@
}
}
},
"FAQ_SUGGESTIONS": {
"HEADER": "FAQ suggestions",
"SEARCH_PLACEHOLDER": "Search suggestions...",
"QUEUE_COUNT": "{count} suggestion to review | {count} suggestions to review",
"QUEUE_HINT": "Ranked by customer demand",
"SOURCE_COUNT": "{count} conversation | {count} conversations",
"REVIEW": "Review sources",
"APPROVE": "Approve",
"APPROVE_FAQ": "Approve FAQ",
"DISMISS": "Dismiss",
"SAVE": "Save changes",
"DETAILS": {
"TITLE": "Review FAQ suggestion",
"DESCRIPTION": "Captain grouped {count} conversation into this suggestion. Review the evidence and refine the FAQ before approval. | Captain grouped {count} conversations into this suggestion. Review the evidence and refine the FAQ before approval.",
"SOURCES": "Source conversations",
"SOURCE_PROGRESS": "{visible} of {total}",
"NO_SOURCES": "No accessible source conversations were found."
},
"EMPTY_STATE": {
"TITLE": "The review queue is clear",
"SUBTITLE": "New reusable questions will appear here after Captain groups similar resolved conversations.",
"CLEAR_SEARCH": "Clear search"
},
"SUCCESS": {
"APPROVED": "FAQ approved and added to Captain's knowledge.",
"DISMISSED": "Suggestion dismissed.",
"SAVED": "Suggestion updated."
},
"ERRORS": {
"LOAD": "There was an error loading FAQ suggestions. Please try again.",
"LOAD_DETAILS": "There was an error loading the source conversations.",
"APPROVE": "There was an error approving the FAQ. Please try again.",
"DISMISS": "There was an error dismissing the suggestion. Please try again.",
"SAVE": "There was an error saving the suggestion. Please try again."
}
},
"INBOXES": {
"HEADER": "Connected Inboxes",
"ADD_NEW": "Connect a new inbox",
@@ -41,7 +41,7 @@ const createDialog = ref(null);
const selectedAssistantId = computed(() => Number(route.params.assistantId));
const pendingCount = useMapGetter('captainResponses/getPendingCount');
const suggestionCount = useMapGetter('captainFaqSuggestions/getOpenCount');
const handleDelete = () => {
deleteDialog.value.dialogRef.open();
@@ -196,7 +196,7 @@ const navigateToPendingFAQs = () => {
onMounted(() => {
initializeFromURL();
store.dispatch(
'captainResponses/fetchPendingCount',
'captainFaqSuggestions/fetchOpenCount',
selectedAssistantId.value
);
});
@@ -271,7 +271,7 @@ onMounted(() => {
<template #body>
<LimitBanner class="mb-5" />
<Banner
v-if="pendingCount > 0"
v-if="suggestionCount > 0"
color="blue"
class="mb-4 -mt-3"
:action-label="$t('CAPTAIN.RESPONSES.PENDING_BANNER.ACTION')"
@@ -1,45 +1,40 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { computed, nextTick, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useRoute, useRouter } from 'vue-router';
import { debounce } from '@chatwoot/utils';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
import CreateResponseDialog from 'dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue';
import ResponsePageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/ResponsePageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import FaqSuggestionCard from 'dashboard/components-next/captain/assistant/FaqSuggestionCard.vue';
import FaqSuggestionReviewDialog from 'dashboard/components-next/captain/pageComponents/response/FaqSuggestionReviewDialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
const router = useRouter();
const route = useRoute();
const router = useRouter();
const store = useStore();
const { isOnChatwootCloud } = useAccount();
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responseMeta = useMapGetter('captainResponses/getMeta');
const responses = useMapGetter('captainResponses/getRecords');
const isFetching = computed(() => uiFlags.value.fetchingList);
const selectedResponse = ref(null);
const deleteDialog = ref(null);
const bulkDeleteDialog = ref(null);
const selectedAssistantId = computed(() => route.params.assistantId);
const dialogType = ref('');
const searchQuery = ref('');
const { t } = useI18n();
const createDialog = ref(null);
const suggestions = useMapGetter('captainFaqSuggestions/getRecords');
const suggestionMeta = useMapGetter('captainFaqSuggestions/getMeta');
const uiFlags = useMapGetter('captainFaqSuggestions/getUIFlags');
const searchQuery = ref('');
const selectedSuggestion = ref(null);
const reviewDialog = ref(null);
const activeSuggestionId = ref(null);
const selectedAssistantId = computed(() => Number(route.params.assistantId));
const isFetching = computed(() => uiFlags.value.fetchingList);
const isMutating = computed(
() => uiFlags.value.updatingItem || uiFlags.value.deletingItem
);
const hasActiveFilters = computed(() => Boolean(searchQuery.value));
const backUrl = computed(() => ({
name: 'captain_assistants_responses_index',
@@ -49,273 +44,150 @@ const backUrl = computed(() => ({
},
}));
// Filter out approved responses in pending view
const filteredResponses = computed(() =>
responses.value.filter(response => response.status !== 'approved')
);
const handleDelete = () => {
deleteDialog.value.dialogRef.open();
};
const handleAccept = async () => {
try {
await store.dispatch('captainResponses/update', {
id: selectedResponse.value.id,
status: 'approved',
});
useAlert(t(`CAPTAIN.RESPONSES.EDIT.APPROVE_SUCCESS_MESSAGE`));
} catch (error) {
const errorMessage =
error?.message || t(`CAPTAIN.RESPONSES.EDIT.ERROR_MESSAGE`);
useAlert(errorMessage);
} finally {
selectedResponse.value = null;
}
};
const handleEdit = () => {
dialogType.value = 'edit';
nextTick(() => createDialog.value.dialogRef.open());
};
const handleAction = ({ action, id }) => {
selectedResponse.value = filteredResponses.value.find(
response => id === response.id
);
nextTick(() => {
if (action === 'delete') {
handleDelete();
}
if (action === 'edit') {
handleEdit();
}
if (action === 'approve') {
handleAccept();
}
});
};
const handleNavigationAction = ({ id, type }) => {
if (type === 'Conversation') {
router.push({
name: 'inbox_conversation',
params: { conversation_id: id },
});
}
};
const handleCreateClose = () => {
dialogType.value = '';
selectedResponse.value = null;
};
const updateURLWithFilters = (page, search) => {
const query = {
page: page || 1,
};
if (search) {
query.search = search;
}
const updateURL = (page, search) => {
const query = { page: page || 1 };
if (search) query.search = search;
router.replace({ query });
};
const fetchResponses = (page = 1) => {
const filterParams = { page, status: 'pending' };
if (selectedAssistantId.value) {
filterParams.assistantId = selectedAssistantId.value;
}
if (searchQuery.value) {
filterParams.search = searchQuery.value;
}
// Update URL with current filters
updateURLWithFilters(page, searchQuery.value);
store.dispatch('captainResponses/get', filterParams);
};
// Bulk action
const bulkSelectedIds = ref(new Set());
const hoveredCard = ref(null);
const buildSelectedCountLabel = computed(() => {
const count = filteredResponses.value?.length || 0;
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
return isAllSelected
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
});
const selectedCountLabel = computed(() => {
return t('CAPTAIN.RESPONSES.SELECTED', {
count: bulkSelectedIds.value.size,
});
});
const handleCardHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
const handleCardSelect = id => {
const selected = new Set(bulkSelectedIds.value);
selected[selected.has(id) ? 'delete' : 'add'](id);
bulkSelectedIds.value = selected;
};
const fetchResponseAfterBulkAction = () => {
const hasNoResponsesLeft = filteredResponses.value?.length === 0;
const currentPage = responseMeta.value?.page;
if (hasNoResponsesLeft) {
const pageToFetch = currentPage > 1 ? currentPage - 1 : currentPage;
fetchResponses(pageToFetch);
} else {
fetchResponses(currentPage);
}
bulkSelectedIds.value = new Set();
};
const handleBulkApprove = async () => {
const fetchSuggestions = async (page = 1) => {
updateURL(page, searchQuery.value);
try {
await store.dispatch(
'captainBulkActions/handleBulkApprove',
Array.from(bulkSelectedIds.value)
);
fetchResponseAfterBulkAction();
useAlert(t('CAPTAIN.RESPONSES.BULK_APPROVE.SUCCESS_MESSAGE'));
await store.dispatch('captainFaqSuggestions/get', {
page,
search: searchQuery.value,
assistantId: selectedAssistantId.value,
});
} catch (error) {
useAlert(
error?.message || t('CAPTAIN.RESPONSES.BULK_APPROVE.ERROR_MESSAGE')
);
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.LOAD'));
}
};
const onPageChange = page => {
const hadSelection = bulkSelectedIds.value.size > 0;
const refreshCurrentPage = () => {
const currentPage = suggestionMeta.value.page || 1;
const page =
suggestions.value.length || currentPage === 1
? currentPage
: currentPage - 1;
fetchSuggestions(page);
};
fetchResponses(page);
if (hadSelection) {
bulkSelectedIds.value = new Set();
const handleApprove = async suggestion => {
activeSuggestionId.value = suggestion.id;
try {
await store.dispatch('captainFaqSuggestions/approve', {
id: suggestion.id,
question: suggestion.question,
answer: suggestion.answer,
});
useAlert(t('CAPTAIN.FAQ_SUGGESTIONS.SUCCESS.APPROVED'));
refreshCurrentPage();
} catch (error) {
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.APPROVE'));
} finally {
activeSuggestionId.value = null;
}
};
const onDeleteSuccess = () => {
if (filteredResponses.value?.length === 0 && responseMeta.value?.page > 1) {
onPageChange(responseMeta.value.page - 1);
const handleDismiss = async suggestion => {
activeSuggestionId.value = suggestion.id;
try {
await store.dispatch('captainFaqSuggestions/dismiss', suggestion.id);
useAlert(t('CAPTAIN.FAQ_SUGGESTIONS.SUCCESS.DISMISSED'));
refreshCurrentPage();
} catch (error) {
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.DISMISS'));
} finally {
activeSuggestionId.value = null;
}
};
const onBulkDeleteSuccess = () => {
fetchResponseAfterBulkAction();
const handleReview = suggestion => {
selectedSuggestion.value = suggestion;
nextTick(() => reviewDialog.value.dialogRef.open());
};
const debouncedSearch = debounce(async () => {
fetchResponses(1);
}, 500);
const handleReviewClose = () => {
selectedSuggestion.value = null;
};
const hasActiveFilters = computed(() => {
return Boolean(searchQuery.value);
});
const handleResolved = () => {
refreshCurrentPage();
};
const debouncedSearch = debounce(() => fetchSuggestions(1), 500);
const clearFilters = () => {
searchQuery.value = '';
fetchResponses(1);
fetchSuggestions(1);
};
const initializeFromURL = () => {
if (route.query.search) {
searchQuery.value = route.query.search;
}
const pageFromURL = parseInt(route.query.page, 10) || 1;
fetchResponses(pageFromURL);
searchQuery.value = route.query.search || '';
fetchSuggestions(parseInt(route.query.page, 10) || 1);
};
onMounted(() => {
initializeFromURL();
});
onMounted(initializeFromURL);
</script>
<template>
<PageLayout
:total-count="responseMeta.totalCount"
:current-page="responseMeta.page"
:header-title="$t('CAPTAIN.RESPONSES.PENDING_FAQS')"
:total-count="suggestionMeta.totalCount"
:current-page="suggestionMeta.page"
:header-title="$t('CAPTAIN.FAQ_SUGGESTIONS.HEADER')"
:is-fetching="isFetching"
:is-empty="!filteredResponses.length"
:show-pagination-footer="!isFetching && !!filteredResponses.length"
:is-empty="!suggestions.length"
:show-pagination-footer="!isFetching && !!suggestions.length"
:show-know-more="false"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
:back-url="backUrl"
@update:current-page="onPageChange"
@update:current-page="fetchSuggestions"
>
<template #knowMore>
<FeatureSpotlightPopover
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
:hide-actions="!isOnChatwootCloud"
fallback-thumbnail="/assets/images/dashboard/captain/faqs-popover-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-popover-dark.svg"
learn-more-url="https://chwt.app/captain-faq"
/>
</template>
<template #search>
<div
v-if="bulkSelectedIds.size === 0"
class="flex gap-3 justify-between w-full items-center"
>
<Input
v-model="searchQuery"
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
class="w-64"
size="sm"
type="search"
autofocus
@input="debouncedSearch"
/>
</div>
<Input
v-model="searchQuery"
:placeholder="$t('CAPTAIN.FAQ_SUGGESTIONS.SEARCH_PLACEHOLDER')"
class="w-64"
size="sm"
type="search"
autofocus
@input="debouncedSearch"
/>
</template>
<template #subHeader>
<BulkSelectBar
v-model="bulkSelectedIds"
:all-items="filteredResponses"
:select-all-label="buildSelectedCountLabel"
:selected-count-label="selectedCountLabel"
:delete-label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
class="w-fit"
:class="{
'mb-2': bulkSelectedIds.size > 0,
}"
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
<div
v-if="suggestions.length"
class="mb-2 flex items-center gap-2 text-sm text-n-slate-11"
>
<template #secondaryActions>
<Button
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
sm
ghost
icon="i-lucide-check"
class="!px-1.5"
@click="handleBulkApprove"
/>
</template>
</BulkSelectBar>
<span class="font-medium text-n-slate-12">
{{
$t('CAPTAIN.FAQ_SUGGESTIONS.QUEUE_COUNT', {
count: suggestionMeta.totalCount,
})
}}
</span>
<span class="border-s border-n-weak ps-2">
{{ $t('CAPTAIN.FAQ_SUGGESTIONS.QUEUE_HINT') }}
</span>
</div>
</template>
<template #emptyState>
<ResponsePageEmptyState
variant="pending"
:has-active-filters="hasActiveFilters"
@clear-filters="clearFilters"
/>
<EmptyStateLayout
:title="$t('CAPTAIN.FAQ_SUGGESTIONS.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.FAQ_SUGGESTIONS.EMPTY_STATE.SUBTITLE')"
>
<template #actions>
<Button
v-if="hasActiveFilters"
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.EMPTY_STATE.CLEAR_SEARCH')"
variant="link"
size="sm"
@click="clearFilters"
/>
</template>
</EmptyStateLayout>
</template>
<template #paywall>
@@ -323,54 +195,25 @@ onMounted(() => {
</template>
<template #body>
<LimitBanner class="mb-5" />
<div class="flex flex-col gap-4">
<ResponseCard
v-for="response in filteredResponses"
:id="response.id"
:key="response.id"
:question="response.question"
:answer="response.answer"
:assistant="response.assistant"
:documentable="response.documentable"
:status="response.status"
:created-at="response.created_at"
:updated-at="response.updated_at"
:is-selected="bulkSelectedIds.has(response.id)"
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
:show-menu="false"
:show-actions="!bulkSelectedIds.has(response.id)"
@action="handleAction"
@navigate="handleNavigationAction"
@select="handleCardSelect"
@hover="isHovered => handleCardHover(isHovered, response.id)"
<div class="flex flex-col gap-4 pb-6">
<FaqSuggestionCard
v-for="suggestion in suggestions"
:key="suggestion.id"
:suggestion="suggestion"
:is-loading="isMutating && activeSuggestionId === suggestion.id"
@approve="handleApprove"
@dismiss="handleDismiss"
@review="handleReview"
/>
</div>
</template>
<DeleteDialog
v-if="selectedResponse"
ref="deleteDialog"
:entity="selectedResponse"
type="Responses"
@delete-success="onDeleteSuccess"
/>
<BulkDeleteDialog
v-if="bulkSelectedIds"
ref="bulkDeleteDialog"
:bulk-ids="bulkSelectedIds"
type="AssistantResponse"
@delete-success="onBulkDeleteSuccess"
/>
<CreateResponseDialog
v-if="dialogType"
ref="createDialog"
:type="dialogType"
:selected-response="selectedResponse"
@close="handleCreateClose"
<FaqSuggestionReviewDialog
v-if="selectedSuggestion"
ref="reviewDialog"
:suggestion="selectedSuggestion"
@close="handleReviewClose"
@resolved="handleResolved"
/>
</PageLayout>
</template>
@@ -0,0 +1,62 @@
import CaptainFaqSuggestionsAPI from 'dashboard/api/captain/faqSuggestions';
import { createStore } from '../storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
const SET_OPEN_COUNT = 'SET_OPEN_COUNT';
export default createStore({
name: 'CaptainFaqSuggestion',
API: CaptainFaqSuggestionsAPI,
getters: {
getRecords: state => state.records,
getOpenCount: state => state.meta.openCount || 0,
},
mutations: {
[SET_OPEN_COUNT](state, count) {
state.meta = {
...state.meta,
openCount: Number(count),
};
},
},
actions: mutations => ({
approve: async ({ commit }, { id, question, answer }) => {
commit(mutations.SET_UI_FLAG, { updatingItem: true });
try {
const response = await CaptainFaqSuggestionsAPI.approve(id, {
question,
answer,
});
commit(mutations.DELETE, id);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutations.SET_UI_FLAG, { updatingItem: false });
}
},
dismiss: async ({ commit }, id) => {
commit(mutations.SET_UI_FLAG, { deletingItem: true });
try {
await CaptainFaqSuggestionsAPI.dismiss(id);
commit(mutations.DELETE, id);
return id;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutations.SET_UI_FLAG, { deletingItem: false });
}
},
fetchOpenCount: async ({ commit }, assistantId) => {
try {
const response = await CaptainFaqSuggestionsAPI.get({
assistantId,
page: 1,
});
commit(SET_OPEN_COUNT, response.data?.meta?.total_count || 0);
} catch (error) {
commit(SET_OPEN_COUNT, 0);
}
},
}),
});
+2
View File
@@ -53,6 +53,7 @@ import webhooks from './modules/webhooks';
import captainAssistants from './captain/assistant';
import captainDocuments from './captain/document';
import captainResponses from './captain/response';
import captainFaqSuggestions from './captain/faqSuggestions';
import captainInboxes from './captain/inboxes';
import captainBulkActions from './captain/bulkActions';
import copilotThreads from './captain/copilotThreads';
@@ -118,6 +119,7 @@ export default createStore({
captainAssistants,
captainDocuments,
captainResponses,
captainFaqSuggestions,
captainInboxes,
captainBulkActions,
copilotThreads,