feat(captain): add FAQ suggestion review interface
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user