chore: Update captain pending FAQ interface (#12752)
# Pull Request Template ## Description **This PR includes,** - Added new pending FAQs view with approve/edit/delete actions for each response. - Implemented banner notification showing pending FAQ count on main approved responses page. - Created dedicated route for pending FAQs review at /captain/responses/pending. - Added automatic pending count updates when switching assistants or routes. - Modified ResponseCard component to show action buttons instead of dropdown in pending view. Fixes https://linear.app/chatwoot/issue/CW-5833/pending-faqs-in-a-different-ux ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? ### Loom video https://www.loom.com/share/5fe8f79b04cd4681b9360c48710b9373 ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.v
|
|||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||||
import Policy from 'dashboard/components/policy.vue';
|
import Policy from 'dashboard/components/policy.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: {
|
id: {
|
||||||
@@ -59,6 +60,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
showActions: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
|
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
|
||||||
@@ -159,73 +164,116 @@ const handleDocumentableClick = () => {
|
|||||||
<span class="text-n-slate-11 text-sm line-clamp-5">
|
<span class="text-n-slate-11 text-sm line-clamp-5">
|
||||||
{{ answer }}
|
{{ answer }}
|
||||||
</span>
|
</span>
|
||||||
<div v-if="!compact" class="items-center justify-between hidden lg:flex">
|
<div
|
||||||
<div class="inline-flex items-center">
|
v-if="!compact"
|
||||||
<span
|
class="flex items-start justify-between flex-col-reverse md:flex-row gap-3"
|
||||||
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
|
>
|
||||||
>
|
<Policy v-if="showActions" :permissions="['administrator']">
|
||||||
<i class="i-woot-captain" />
|
<div class="flex items-center gap-2 sm:gap-5 w-full">
|
||||||
{{ assistant?.name || '' }}
|
<Button
|
||||||
</span>
|
v-if="status === 'pending'"
|
||||||
<div
|
:label="$t('CAPTAIN.RESPONSES.OPTIONS.APPROVE')"
|
||||||
v-if="documentable"
|
icon="i-lucide-circle-check-big"
|
||||||
class="shrink-0 text-sm text-n-slate-11 inline-flex line-clamp-1 gap-1 ml-3"
|
sm
|
||||||
>
|
link
|
||||||
|
class="hover:!no-underline"
|
||||||
|
@click="
|
||||||
|
handleAssistantAction({ action: 'approve', value: 'approve' })
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:label="$t('CAPTAIN.RESPONSES.OPTIONS.EDIT_RESPONSE')"
|
||||||
|
icon="i-lucide-pencil-line"
|
||||||
|
sm
|
||||||
|
slate
|
||||||
|
link
|
||||||
|
class="hover:!no-underline"
|
||||||
|
@click="
|
||||||
|
handleAssistantAction({
|
||||||
|
action: 'edit',
|
||||||
|
value: 'edit',
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:label="$t('CAPTAIN.RESPONSES.OPTIONS.DELETE_RESPONSE')"
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
sm
|
||||||
|
ruby
|
||||||
|
link
|
||||||
|
class="hover:!no-underline"
|
||||||
|
@click="
|
||||||
|
handleAssistantAction({ action: 'delete', value: 'delete' })
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Policy>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3"
|
||||||
|
:class="{ 'justify-between w-full': !showActions }"
|
||||||
|
>
|
||||||
|
<div class="inline-flex items-center gap-3 min-w-0">
|
||||||
<span
|
<span
|
||||||
v-if="documentable.type === 'Captain::Document'"
|
v-if="status === 'approved'"
|
||||||
class="inline-flex items-center gap-1 truncate over"
|
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<i class="i-ph-files-light text-base" />
|
<Icon icon="i-woot-captain" class="size-3.5" />
|
||||||
<span class="max-w-96 truncate" :title="documentable.name">
|
{{ assistant?.name || '' }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
v-if="documentable"
|
||||||
|
class="text-sm text-n-slate-11 grid grid-cols-[auto_1fr] items-center gap-1 min-w-0"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
v-if="documentable.type === 'Captain::Document'"
|
||||||
|
icon="i-ph-files-light"
|
||||||
|
class="size-3.5"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
v-else-if="documentable.type === 'User'"
|
||||||
|
icon="i-ph-user-circle-plus"
|
||||||
|
class="size-3.5"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
v-else-if="documentable.type === 'Conversation'"
|
||||||
|
icon="i-ph-chat-circle-dots"
|
||||||
|
class="size-3.5"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="documentable.type === 'Captain::Document'"
|
||||||
|
class="truncate"
|
||||||
|
:title="documentable.name"
|
||||||
|
>
|
||||||
{{ documentable.name }}
|
{{ documentable.name }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="documentable.type === 'User'"
|
|
||||||
class="inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<i class="i-ph-user-circle-plus text-base" />
|
|
||||||
<span
|
<span
|
||||||
class="max-w-96 truncate"
|
v-else-if="documentable.type === 'User'"
|
||||||
|
class="truncate"
|
||||||
:title="documentable.available_name"
|
:title="documentable.available_name"
|
||||||
>
|
>
|
||||||
{{ documentable.available_name }}
|
{{ documentable.available_name }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
<span
|
||||||
<span
|
v-else-if="documentable.type === 'Conversation'"
|
||||||
v-else-if="documentable.type === 'Conversation'"
|
class="hover:underline truncate cursor-pointer"
|
||||||
class="inline-flex items-center gap-1 group cursor-pointer"
|
role="button"
|
||||||
role="button"
|
@click="handleDocumentableClick"
|
||||||
@click="handleDocumentableClick"
|
>
|
||||||
>
|
|
||||||
<i class="i-ph-chat-circle-dots text-base" />
|
|
||||||
<span class="group-hover:underline">
|
|
||||||
{{
|
{{
|
||||||
t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, {
|
t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, {
|
||||||
id: documentable.display_id,
|
id: documentable.display_id,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</div>
|
||||||
<span v-else />
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="status !== 'approved'"
|
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1"
|
||||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
|
|
||||||
>
|
>
|
||||||
<i
|
<Icon icon="i-ph-calendar-dot" class="size-3.5" />
|
||||||
class="i-ph-stack text-base"
|
{{ timestamp }}
|
||||||
:title="t('CAPTAIN.RESPONSES.STATUS.TITLE')"
|
|
||||||
/>
|
|
||||||
{{ t(`CAPTAIN.RESPONSES.STATUS.${status.toUpperCase()}`) }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
|
|
||||||
>
|
|
||||||
<i class="i-ph-calendar-dot" />
|
|
||||||
{{ timestamp }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardLayout>
|
</CardLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -861,6 +861,7 @@
|
|||||||
},
|
},
|
||||||
"RESPONSES": {
|
"RESPONSES": {
|
||||||
"HEADER": "FAQs",
|
"HEADER": "FAQs",
|
||||||
|
"PENDING_FAQS": "Pending FAQs",
|
||||||
"ADD_NEW": "Create new FAQ",
|
"ADD_NEW": "Create new FAQ",
|
||||||
"DOCUMENTABLE": {
|
"DOCUMENTABLE": {
|
||||||
"CONVERSATION": "Conversation #{id}"
|
"CONVERSATION": "Conversation #{id}"
|
||||||
@@ -900,6 +901,10 @@
|
|||||||
"APPROVED": "Approved",
|
"APPROVED": "Approved",
|
||||||
"ALL": "All"
|
"ALL": "All"
|
||||||
},
|
},
|
||||||
|
"PENDING_BANNER": {
|
||||||
|
"TITLE": "Captain has found some FAQs your customers were looking for.",
|
||||||
|
"ACTION": "Click here to review"
|
||||||
|
},
|
||||||
"FORM_DESCRIPTION": "Add a question and its corresponding answer to the knowledge base and select the assistant it should be associated with.",
|
"FORM_DESCRIPTION": "Add a question and its corresponding answer to the knowledge base and select the assistant it should be associated with.",
|
||||||
"CREATE": {
|
"CREATE": {
|
||||||
"TITLE": "Add an FAQ",
|
"TITLE": "Add an FAQ",
|
||||||
@@ -931,9 +936,9 @@
|
|||||||
"APPROVE_SUCCESS_MESSAGE": "The FAQ was marked as approved"
|
"APPROVE_SUCCESS_MESSAGE": "The FAQ was marked as approved"
|
||||||
},
|
},
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"APPROVE": "Mark as approved",
|
"APPROVE": "Approve",
|
||||||
"EDIT_RESPONSE": "Edit FAQ",
|
"EDIT_RESPONSE": "Edit",
|
||||||
"DELETE_RESPONSE": "Delete FAQ"
|
"DELETE_RESPONSE": "Delete"
|
||||||
},
|
},
|
||||||
"EMPTY_STATE": {
|
"EMPTY_STATE": {
|
||||||
"TITLE": "No FAQs Found",
|
"TITLE": "No FAQs Found",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
|
|||||||
import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
|
import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
|
||||||
import DocumentsIndex from './documents/Index.vue';
|
import DocumentsIndex from './documents/Index.vue';
|
||||||
import ResponsesIndex from './responses/Index.vue';
|
import ResponsesIndex from './responses/Index.vue';
|
||||||
|
import ResponsesPendingIndex from './responses/Pending.vue';
|
||||||
import CustomToolsIndex from './tools/Index.vue';
|
import CustomToolsIndex from './tools/Index.vue';
|
||||||
|
|
||||||
export const routes = [
|
export const routes = [
|
||||||
@@ -125,6 +126,19 @@ export const routes = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: frontendURL('accounts/:accountId/captain/responses/pending'),
|
||||||
|
component: ResponsesPendingIndex,
|
||||||
|
name: 'captain_responses_pending',
|
||||||
|
meta: {
|
||||||
|
permissions: ['administrator', 'agent'],
|
||||||
|
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||||
|
installationTypes: [
|
||||||
|
INSTALLATION_TYPES.CLOUD,
|
||||||
|
INSTALLATION_TYPES.ENTERPRISE,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: frontendURL('accounts/:accountId/captain/tools'),
|
path: frontendURL('accounts/:accountId/captain/tools'),
|
||||||
component: CustomToolsIndex,
|
component: CustomToolsIndex,
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||||
import { useAlert } from 'dashboard/composables';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { OnClickOutside } from '@vueuse/components';
|
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
import { debounce } from '@chatwoot/utils';
|
import { debounce } from '@chatwoot/utils';
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
|
||||||
|
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||||
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
|
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
|
||||||
@@ -37,7 +35,6 @@ const selectedResponse = ref(null);
|
|||||||
const deleteDialog = ref(null);
|
const deleteDialog = ref(null);
|
||||||
const bulkDeleteDialog = ref(null);
|
const bulkDeleteDialog = ref(null);
|
||||||
|
|
||||||
const selectedStatus = ref('all');
|
|
||||||
const selectedAssistant = ref('all');
|
const selectedAssistant = ref('all');
|
||||||
const dialogType = ref('');
|
const dialogType = ref('');
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
@@ -45,54 +42,17 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
const createDialog = ref(null);
|
const createDialog = ref(null);
|
||||||
|
|
||||||
const isStatusFilterOpen = ref(false);
|
|
||||||
const shouldShowDropdown = computed(() => {
|
const shouldShowDropdown = computed(() => {
|
||||||
if (assistants.value.length === 0) return false;
|
if (assistants.value.length === 0) return false;
|
||||||
|
|
||||||
return !isFetching.value;
|
return !isFetching.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusOptions = computed(() =>
|
const pendingCount = useMapGetter('captainResponses/getPendingCount');
|
||||||
['all', 'pending', 'approved'].map(key => ({
|
|
||||||
label: t(`CAPTAIN.RESPONSES.STATUS.${key.toUpperCase()}`),
|
|
||||||
value: key,
|
|
||||||
action: 'filter',
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredResponses = computed(() => {
|
|
||||||
return selectedStatus.value === 'pending'
|
|
||||||
? responses.value.filter(r => r.status === 'pending')
|
|
||||||
: responses.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedStatusLabel = computed(() => {
|
|
||||||
const status = statusOptions.value.find(
|
|
||||||
option => option.value === selectedStatus.value
|
|
||||||
);
|
|
||||||
return t('CAPTAIN.RESPONSES.FILTER.STATUS', {
|
|
||||||
selected: status ? status.label : '',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
deleteDialog.value.dialogRef.open();
|
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 handleCreate = () => {
|
const handleCreate = () => {
|
||||||
dialogType.value = 'create';
|
dialogType.value = 'create';
|
||||||
@@ -105,9 +65,7 @@ const handleEdit = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAction = ({ action, id }) => {
|
const handleAction = ({ action, id }) => {
|
||||||
selectedResponse.value = filteredResponses.value.find(
|
selectedResponse.value = responses.value.find(response => id === response.id);
|
||||||
response => id === response.id
|
|
||||||
);
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (action === 'delete') {
|
if (action === 'delete') {
|
||||||
handleDelete();
|
handleDelete();
|
||||||
@@ -115,9 +73,6 @@ const handleAction = ({ action, id }) => {
|
|||||||
if (action === 'edit') {
|
if (action === 'edit') {
|
||||||
handleEdit();
|
handleEdit();
|
||||||
}
|
}
|
||||||
if (action === 'approve') {
|
|
||||||
handleAccept();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -136,10 +91,8 @@ const handleCreateClose = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchResponses = (page = 1) => {
|
const fetchResponses = (page = 1) => {
|
||||||
const filterParams = { page };
|
const filterParams = { page, status: 'approved' };
|
||||||
if (selectedStatus.value !== 'all') {
|
|
||||||
filterParams.status = selectedStatus.value;
|
|
||||||
}
|
|
||||||
if (selectedAssistant.value !== 'all') {
|
if (selectedAssistant.value !== 'all') {
|
||||||
filterParams.assistantId = selectedAssistant.value;
|
filterParams.assistantId = selectedAssistant.value;
|
||||||
}
|
}
|
||||||
@@ -155,7 +108,7 @@ const hoveredCard = ref(null);
|
|||||||
|
|
||||||
const bulkSelectionState = computed(() => {
|
const bulkSelectionState = computed(() => {
|
||||||
const selectedCount = bulkSelectedIds.value.size;
|
const selectedCount = bulkSelectedIds.value.size;
|
||||||
const totalCount = filteredResponses.value?.length || 0;
|
const totalCount = responses.value?.length || 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasSelected: selectedCount > 0,
|
hasSelected: selectedCount > 0,
|
||||||
@@ -168,13 +121,13 @@ const bulkCheckbox = computed({
|
|||||||
get: () => bulkSelectionState.value.allSelected,
|
get: () => bulkSelectionState.value.allSelected,
|
||||||
set: value => {
|
set: value => {
|
||||||
bulkSelectedIds.value = value
|
bulkSelectedIds.value = value
|
||||||
? new Set(filteredResponses.value.map(r => r.id))
|
? new Set(responses.value.map(r => r.id))
|
||||||
: new Set();
|
: new Set();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildSelectedCountLabel = computed(() => {
|
const buildSelectedCountLabel = computed(() => {
|
||||||
const count = filteredResponses.value?.length || 0;
|
const count = responses.value?.length || 0;
|
||||||
return bulkSelectionState.value.allSelected
|
return bulkSelectionState.value.allSelected
|
||||||
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
|
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
|
||||||
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
|
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
|
||||||
@@ -191,7 +144,7 @@ const handleCardSelect = id => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchResponseAfterBulkAction = () => {
|
const fetchResponseAfterBulkAction = () => {
|
||||||
const hasNoResponsesLeft = filteredResponses.value?.length === 0;
|
const hasNoResponsesLeft = responses.value?.length === 0;
|
||||||
const currentPage = responseMeta.value?.page;
|
const currentPage = responseMeta.value?.page;
|
||||||
|
|
||||||
if (hasNoResponsesLeft) {
|
if (hasNoResponsesLeft) {
|
||||||
@@ -208,22 +161,6 @@ const fetchResponseAfterBulkAction = () => {
|
|||||||
bulkSelectedIds.value = new Set();
|
bulkSelectedIds.value = new Set();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBulkApprove = async () => {
|
|
||||||
try {
|
|
||||||
await store.dispatch(
|
|
||||||
'captainBulkActions/handleBulkApprove',
|
|
||||||
Array.from(bulkSelectedIds.value)
|
|
||||||
);
|
|
||||||
|
|
||||||
fetchResponseAfterBulkAction();
|
|
||||||
useAlert(t('CAPTAIN.RESPONSES.BULK_APPROVE.SUCCESS_MESSAGE'));
|
|
||||||
} catch (error) {
|
|
||||||
useAlert(
|
|
||||||
error?.message || t('CAPTAIN.RESPONSES.BULK_APPROVE.ERROR_MESSAGE')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPageChange = page => {
|
const onPageChange = page => {
|
||||||
// Store current selection state before fetching new page
|
// Store current selection state before fetching new page
|
||||||
const wasAllPageSelected = bulkSelectionState.value.allSelected;
|
const wasAllPageSelected = bulkSelectionState.value.allSelected;
|
||||||
@@ -238,7 +175,7 @@ const onPageChange = page => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onDeleteSuccess = () => {
|
const onDeleteSuccess = () => {
|
||||||
if (filteredResponses.value?.length === 0 && responseMeta.value?.page > 1) {
|
if (responses.value?.length === 0 && responseMeta.value?.page > 1) {
|
||||||
onPageChange(responseMeta.value.page - 1);
|
onPageChange(responseMeta.value.page - 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -247,12 +184,6 @@ const onBulkDeleteSuccess = () => {
|
|||||||
fetchResponseAfterBulkAction();
|
fetchResponseAfterBulkAction();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStatusFilterChange = ({ value }) => {
|
|
||||||
selectedStatus.value = value;
|
|
||||||
isStatusFilterOpen.value = false;
|
|
||||||
fetchResponses();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAssistantFilterChange = assistant => {
|
const handleAssistantFilterChange = assistant => {
|
||||||
selectedAssistant.value = assistant;
|
selectedAssistant.value = assistant;
|
||||||
fetchResponses();
|
fetchResponses();
|
||||||
@@ -262,9 +193,14 @@ const debouncedSearch = debounce(async () => {
|
|||||||
fetchResponses();
|
fetchResponses();
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
|
const navigateToPendingFAQs = () => {
|
||||||
|
router.push({ name: 'captain_responses_pending' });
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.dispatch('captainAssistants/get');
|
store.dispatch('captainAssistants/get');
|
||||||
fetchResponses();
|
fetchResponses();
|
||||||
|
store.dispatch('captainResponses/fetchPendingCount');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -276,8 +212,8 @@ onMounted(() => {
|
|||||||
:header-title="$t('CAPTAIN.RESPONSES.HEADER')"
|
:header-title="$t('CAPTAIN.RESPONSES.HEADER')"
|
||||||
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||||
:is-fetching="isFetching"
|
:is-fetching="isFetching"
|
||||||
:is-empty="!filteredResponses.length"
|
:is-empty="!responses.length"
|
||||||
:show-pagination-footer="!isFetching && !!filteredResponses.length"
|
:show-pagination-footer="!isFetching && !!responses.length"
|
||||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||||
@update:current-page="onPageChange"
|
@update:current-page="onPageChange"
|
||||||
@click="handleCreate"
|
@click="handleCreate"
|
||||||
@@ -315,25 +251,7 @@ onMounted(() => {
|
|||||||
v-if="!bulkSelectionState.hasSelected"
|
v-if="!bulkSelectionState.hasSelected"
|
||||||
class="flex gap-3 justify-between w-full items-center"
|
class="flex gap-3 justify-between w-full items-center"
|
||||||
>
|
>
|
||||||
<div class="flex gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<OnClickOutside @trigger="isStatusFilterOpen = false">
|
|
||||||
<Button
|
|
||||||
:label="selectedStatusLabel"
|
|
||||||
icon="i-lucide-chevron-down"
|
|
||||||
size="sm"
|
|
||||||
color="slate"
|
|
||||||
trailing-icon
|
|
||||||
class="max-w-48"
|
|
||||||
@click="isStatusFilterOpen = !isStatusFilterOpen"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DropdownMenu
|
|
||||||
v-if="isStatusFilterOpen"
|
|
||||||
:menu-items="statusOptions"
|
|
||||||
class="mt-2"
|
|
||||||
@action="handleStatusFilterChange"
|
|
||||||
/>
|
|
||||||
</OnClickOutside>
|
|
||||||
<AssistantSelector
|
<AssistantSelector
|
||||||
:assistant-id="selectedAssistant"
|
:assistant-id="selectedAssistant"
|
||||||
@update="handleAssistantFilterChange"
|
@update="handleAssistantFilterChange"
|
||||||
@@ -344,6 +262,7 @@ onMounted(() => {
|
|||||||
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
|
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
|
||||||
class="w-64"
|
class="w-64"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
type="search"
|
||||||
autofocus
|
autofocus
|
||||||
@input="debouncedSearch"
|
@input="debouncedSearch"
|
||||||
/>
|
/>
|
||||||
@@ -380,15 +299,6 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="h-4 w-px bg-n-strong" />
|
<div class="h-4 w-px bg-n-strong" />
|
||||||
<div class="flex gap-3 items-center">
|
<div class="flex gap-3 items-center">
|
||||||
<Button
|
|
||||||
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
|
|
||||||
sm
|
|
||||||
ghost
|
|
||||||
icon="i-lucide-check"
|
|
||||||
class="!px-1.5"
|
|
||||||
@click="handleBulkApprove"
|
|
||||||
/>
|
|
||||||
<div class="h-4 w-px bg-n-strong" />
|
|
||||||
<Button
|
<Button
|
||||||
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
|
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
|
||||||
sm
|
sm
|
||||||
@@ -406,10 +316,19 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template #body>
|
<template #body>
|
||||||
<LimitBanner class="mb-5" />
|
<LimitBanner class="mb-5" />
|
||||||
|
<Banner
|
||||||
|
v-if="pendingCount > 0"
|
||||||
|
color="blue"
|
||||||
|
class="mb-4"
|
||||||
|
:action-label="$t('CAPTAIN.RESPONSES.PENDING_BANNER.ACTION')"
|
||||||
|
@action="navigateToPendingFAQs"
|
||||||
|
>
|
||||||
|
{{ $t('CAPTAIN.RESPONSES.PENDING_BANNER.TITLE') }}
|
||||||
|
</Banner>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<ResponseCard
|
<ResponseCard
|
||||||
v-for="response in filteredResponses"
|
v-for="response in responses"
|
||||||
:id="response.id"
|
:id="response.id"
|
||||||
:key="response.id"
|
:key="response.id"
|
||||||
:question="response.question"
|
:question="response.question"
|
||||||
@@ -422,6 +341,7 @@ onMounted(() => {
|
|||||||
:is-selected="bulkSelectedIds.has(response.id)"
|
:is-selected="bulkSelectedIds.has(response.id)"
|
||||||
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
|
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
|
||||||
:show-menu="!bulkSelectedIds.has(response.id)"
|
:show-menu="!bulkSelectedIds.has(response.id)"
|
||||||
|
:show-actions="false"
|
||||||
@action="handleAction"
|
@action="handleAction"
|
||||||
@navigate="handleNavigationAction"
|
@navigate="handleNavigationAction"
|
||||||
@select="handleCardSelect"
|
@select="handleCardSelect"
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||||
|
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
import { debounce } from '@chatwoot/utils';
|
||||||
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||||
|
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||||
|
import Input from 'dashboard/components-next/input/Input.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 AssistantSelector from 'dashboard/components-next/captain/pageComponents/AssistantSelector.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';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const store = useStore();
|
||||||
|
const { isOnChatwootCloud } = useAccount();
|
||||||
|
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||||
|
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||||
|
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 selectedAssistant = ref('all');
|
||||||
|
const dialogType = ref('');
|
||||||
|
const searchQuery = ref('');
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const createDialog = ref(null);
|
||||||
|
|
||||||
|
const shouldShowDropdown = computed(() => {
|
||||||
|
if (assistants.value.length === 0) return false;
|
||||||
|
return !isFetching.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const backUrl = computed(() =>
|
||||||
|
frontendURL(`accounts/${route.params.accountId}/captain/responses`)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 handleCreate = () => {
|
||||||
|
dialogType.value = 'create';
|
||||||
|
nextTick(() => createDialog.value.dialogRef.open());
|
||||||
|
};
|
||||||
|
|
||||||
|
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 fetchResponses = (page = 1) => {
|
||||||
|
const filterParams = { page, status: 'pending' };
|
||||||
|
|
||||||
|
if (selectedAssistant.value !== 'all') {
|
||||||
|
filterParams.assistantId = selectedAssistant.value;
|
||||||
|
}
|
||||||
|
if (searchQuery.value) {
|
||||||
|
filterParams.search = searchQuery.value;
|
||||||
|
}
|
||||||
|
store.dispatch('captainResponses/get', filterParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bulk action
|
||||||
|
const bulkSelectedIds = ref(new Set());
|
||||||
|
const hoveredCard = ref(null);
|
||||||
|
|
||||||
|
const bulkSelectionState = computed(() => {
|
||||||
|
const selectedCount = bulkSelectedIds.value.size;
|
||||||
|
const totalCount = filteredResponses.value?.length || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasSelected: selectedCount > 0,
|
||||||
|
isIndeterminate: selectedCount > 0 && selectedCount < totalCount,
|
||||||
|
allSelected: totalCount > 0 && selectedCount === totalCount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulkCheckbox = computed({
|
||||||
|
get: () => bulkSelectionState.value.allSelected,
|
||||||
|
set: value => {
|
||||||
|
bulkSelectedIds.value = value
|
||||||
|
? new Set(filteredResponses.value.map(r => r.id))
|
||||||
|
: new Set();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildSelectedCountLabel = computed(() => {
|
||||||
|
const count = filteredResponses.value?.length || 0;
|
||||||
|
return bulkSelectionState.value.allSelected
|
||||||
|
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
|
||||||
|
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
try {
|
||||||
|
await store.dispatch(
|
||||||
|
'captainBulkActions/handleBulkApprove',
|
||||||
|
Array.from(bulkSelectedIds.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
fetchResponseAfterBulkAction();
|
||||||
|
useAlert(t('CAPTAIN.RESPONSES.BULK_APPROVE.SUCCESS_MESSAGE'));
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(
|
||||||
|
error?.message || t('CAPTAIN.RESPONSES.BULK_APPROVE.ERROR_MESSAGE')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPageChange = page => {
|
||||||
|
const wasAllPageSelected = bulkSelectionState.value.allSelected;
|
||||||
|
const hadPartialSelection = bulkSelectedIds.value.size > 0;
|
||||||
|
|
||||||
|
fetchResponses(page);
|
||||||
|
|
||||||
|
if (wasAllPageSelected || hadPartialSelection) {
|
||||||
|
bulkSelectedIds.value = new Set();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeleteSuccess = () => {
|
||||||
|
if (filteredResponses.value?.length === 0 && responseMeta.value?.page > 1) {
|
||||||
|
onPageChange(responseMeta.value.page - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBulkDeleteSuccess = () => {
|
||||||
|
fetchResponseAfterBulkAction();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssistantFilterChange = assistant => {
|
||||||
|
selectedAssistant.value = assistant;
|
||||||
|
fetchResponses();
|
||||||
|
};
|
||||||
|
|
||||||
|
const debouncedSearch = debounce(async () => {
|
||||||
|
fetchResponses();
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.dispatch('captainAssistants/get');
|
||||||
|
fetchResponses();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageLayout
|
||||||
|
:total-count="responseMeta.totalCount"
|
||||||
|
:current-page="responseMeta.page"
|
||||||
|
:button-policy="['administrator']"
|
||||||
|
:header-title="$t('CAPTAIN.RESPONSES.PENDING_FAQS')"
|
||||||
|
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||||
|
:is-fetching="isFetching"
|
||||||
|
:is-empty="!filteredResponses.length"
|
||||||
|
:show-pagination-footer="!isFetching && !!filteredResponses.length"
|
||||||
|
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||||
|
:back-url="backUrl"
|
||||||
|
@update:current-page="onPageChange"
|
||||||
|
@click="handleCreate"
|
||||||
|
>
|
||||||
|
<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 #emptyState>
|
||||||
|
<ResponsePageEmptyState @click="handleCreate" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #paywall>
|
||||||
|
<CaptainPaywall />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #controls>
|
||||||
|
<div
|
||||||
|
v-if="shouldShowDropdown"
|
||||||
|
class="mb-4 -mt-3 flex justify-between items-center py-1"
|
||||||
|
:class="{
|
||||||
|
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 w-fit':
|
||||||
|
bulkSelectionState.hasSelected,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="!bulkSelectionState.hasSelected"
|
||||||
|
class="flex gap-3 justify-between w-full items-center"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<AssistantSelector
|
||||||
|
:assistant-id="selectedAssistant"
|
||||||
|
@update="handleAssistantFilterChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
v-model="searchQuery"
|
||||||
|
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
|
||||||
|
class="w-64"
|
||||||
|
size="sm"
|
||||||
|
type="search"
|
||||||
|
autofocus
|
||||||
|
@input="debouncedSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<transition
|
||||||
|
name="slide-fade"
|
||||||
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
|
enter-from-class="opacity-0 transform ltr:-translate-x-4 rtl:translate-x-4"
|
||||||
|
enter-to-class="opacity-100 transform translate-x-0"
|
||||||
|
leave-active-class="hidden opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="bulkSelectionState.hasSelected"
|
||||||
|
class="flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<Checkbox
|
||||||
|
v-model="bulkCheckbox"
|
||||||
|
:indeterminate="bulkSelectionState.isIndeterminate"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-n-slate-12 font-medium tabular-nums">
|
||||||
|
{{ buildSelectedCountLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-n-slate-10 tabular-nums">
|
||||||
|
{{
|
||||||
|
$t('CAPTAIN.RESPONSES.SELECTED', {
|
||||||
|
count: bulkSelectedIds.size,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-4 w-px bg-n-strong" />
|
||||||
|
<div class="flex gap-3 items-center">
|
||||||
|
<Button
|
||||||
|
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
|
||||||
|
sm
|
||||||
|
ghost
|
||||||
|
icon="i-lucide-check"
|
||||||
|
class="!px-1.5"
|
||||||
|
@click="handleBulkApprove"
|
||||||
|
/>
|
||||||
|
<div class="h-4 w-px bg-n-strong" />
|
||||||
|
<Button
|
||||||
|
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
|
||||||
|
sm
|
||||||
|
ruby
|
||||||
|
ghost
|
||||||
|
class="!px-1.5"
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
@click="bulkDeleteDialog.dialogRef.open()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<DeleteDialog
|
||||||
|
v-if="selectedResponse"
|
||||||
|
ref="deleteDialog"
|
||||||
|
:entity="selectedResponse"
|
||||||
|
type="Responses"
|
||||||
|
@delete-success="onDeleteSuccess"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BulkDeleteDialog
|
||||||
|
v-if="bulkSelectedIds"
|
||||||
|
ref="bulkDeleteDialog"
|
||||||
|
:bulk-ids="bulkSelectedIds"
|
||||||
|
type="Responses"
|
||||||
|
@delete-success="onBulkDeleteSuccess"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CreateResponseDialog
|
||||||
|
v-if="dialogType"
|
||||||
|
ref="createDialog"
|
||||||
|
:type="dialogType"
|
||||||
|
:selected-response="selectedResponse"
|
||||||
|
@close="handleCreateClose"
|
||||||
|
/>
|
||||||
|
</PageLayout>
|
||||||
|
</template>
|
||||||
@@ -1,9 +1,22 @@
|
|||||||
import CaptainResponseAPI from 'dashboard/api/captain/response';
|
import CaptainResponseAPI from 'dashboard/api/captain/response';
|
||||||
import { createStore } from './storeFactory';
|
import { createStore } from './storeFactory';
|
||||||
|
|
||||||
|
const SET_PENDING_COUNT = 'SET_PENDING_COUNT';
|
||||||
|
|
||||||
export default createStore({
|
export default createStore({
|
||||||
name: 'CaptainResponse',
|
name: 'CaptainResponse',
|
||||||
API: CaptainResponseAPI,
|
API: CaptainResponseAPI,
|
||||||
|
getters: {
|
||||||
|
getPendingCount: state => state.meta.pendingCount || 0,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
[SET_PENDING_COUNT](state, count) {
|
||||||
|
state.meta = {
|
||||||
|
...state.meta,
|
||||||
|
pendingCount: Number(count),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
actions: mutations => ({
|
actions: mutations => ({
|
||||||
removeBulkResponses: ({ commit, state }, ids) => {
|
removeBulkResponses: ({ commit, state }, ids) => {
|
||||||
const updatedRecords = state.records.filter(
|
const updatedRecords = state.records.filter(
|
||||||
@@ -28,5 +41,18 @@ export default createStore({
|
|||||||
|
|
||||||
commit(mutations.SET, updatedRecords);
|
commit(mutations.SET, updatedRecords);
|
||||||
},
|
},
|
||||||
|
fetchPendingCount: async ({ commit }, assistantId) => {
|
||||||
|
try {
|
||||||
|
const response = await CaptainResponseAPI.get({
|
||||||
|
status: 'pending',
|
||||||
|
page: 1,
|
||||||
|
assistantId,
|
||||||
|
});
|
||||||
|
const count = response.data?.meta?.total_count || 0;
|
||||||
|
commit(SET_PENDING_COUNT, count);
|
||||||
|
} catch (error) {
|
||||||
|
commit(SET_PENDING_COUNT, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const createMutations = mutationTypes => ({
|
|||||||
},
|
},
|
||||||
[mutationTypes.SET_META](state, meta) {
|
[mutationTypes.SET_META](state, meta) {
|
||||||
state.meta = {
|
state.meta = {
|
||||||
|
...state.meta,
|
||||||
totalCount: Number(meta.total_count),
|
totalCount: Number(meta.total_count),
|
||||||
page: Number(meta.page),
|
page: Number(meta.page),
|
||||||
};
|
};
|
||||||
@@ -69,7 +70,7 @@ export const createCrudActions = (API, mutationTypes) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const createStore = options => {
|
export const createStore = options => {
|
||||||
const { name, API, actions, getters } = options;
|
const { name, API, actions, getters, mutations } = options;
|
||||||
const mutationTypes = generateMutationTypes(name);
|
const mutationTypes = generateMutationTypes(name);
|
||||||
|
|
||||||
const customActions = actions ? actions(mutationTypes) : {};
|
const customActions = actions ? actions(mutationTypes) : {};
|
||||||
@@ -81,7 +82,10 @@ export const createStore = options => {
|
|||||||
...createGetters(),
|
...createGetters(),
|
||||||
...(getters || {}),
|
...(getters || {}),
|
||||||
},
|
},
|
||||||
mutations: createMutations(mutationTypes),
|
mutations: {
|
||||||
|
...createMutations(mutationTypes),
|
||||||
|
...(mutations || {}),
|
||||||
|
},
|
||||||
actions: {
|
actions: {
|
||||||
...createCrudActions(API, mutationTypes),
|
...createCrudActions(API, mutationTypes),
|
||||||
...customActions,
|
...customActions,
|
||||||
|
|||||||
Reference in New Issue
Block a user