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