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