mirror of
https://github.com/hanzoai/helpdesk.git
synced 2026-10-12 16:56:02 +00:00
308 lines
8.5 KiB
Vue
308 lines
8.5 KiB
Vue
<template>
|
|
<Dialog
|
|
v-model:open="show"
|
|
size="4xl"
|
|
bare
|
|
@vue:unmounted="resetFilter"
|
|
@after-leave="onAfterLeave"
|
|
>
|
|
<template #default>
|
|
<div class="max-h-[575px]" :style="{ height: 'calc(100vh - 8rem)' }">
|
|
<div class="flex items-center justify-between w-full p-4 pb-2">
|
|
<div class="text-2xl font-semibold">{{ __("Saved Replies") }}</div>
|
|
<Button
|
|
variant="solid"
|
|
icon-left="lucide-plus"
|
|
:label="__('New')"
|
|
@click="onNewSavedReplyClick"
|
|
/>
|
|
</div>
|
|
<div class="p-4">
|
|
<div class="flex items-center gap-2">
|
|
<div class="relative w-full">
|
|
<TextInput
|
|
ref="searchInput"
|
|
:model-value="search"
|
|
@update:model-value="search = $event"
|
|
:placeholder="__('Search')"
|
|
type="text"
|
|
class="focus:ring-0 border-outline-gray-2"
|
|
:debounce="300"
|
|
autofocus
|
|
>
|
|
<template #prefix>
|
|
<LucideSearch class="size-4" />
|
|
</template>
|
|
</TextInput>
|
|
<Button
|
|
v-if="search"
|
|
icon="lucide-x"
|
|
variant="ghost"
|
|
@click="search = ''"
|
|
class="absolute end-1 top-1/2 -translate-y-1/2"
|
|
/>
|
|
</div>
|
|
<Dropdown :options="filters" placement="right">
|
|
<Button
|
|
:label="activeFilter"
|
|
icon-left="lucide-filter"
|
|
class="p-4"
|
|
>
|
|
<template #suffix>
|
|
<p
|
|
class="flex h-5 w-5 items-center justify-center rounded-[5px] bg-surface-white pt-px text-xs font-medium text-ink-gray-8 shadow-sm"
|
|
v-if="savedReplyListResource?.data?.length"
|
|
>
|
|
{{ savedReplyListResource?.data?.length }}
|
|
</p>
|
|
</template>
|
|
</Button>
|
|
</Dropdown>
|
|
</div>
|
|
</div>
|
|
<div class="px-4 h-full overflow-y-auto">
|
|
<div
|
|
v-if="savedReplyListResource?.list?.loading"
|
|
class="flex items-center justify-center mt-24"
|
|
>
|
|
<LoadingIndicator class="size-4" />
|
|
</div>
|
|
<div
|
|
v-if="
|
|
!savedReplyListResource?.list?.loading &&
|
|
savedReplyListResource?.data?.length
|
|
"
|
|
class="grid grid-cols-1 md:grid-cols-3 gap-2 pb-36"
|
|
>
|
|
<div
|
|
v-for="template in savedReplyListResource?.data"
|
|
:key="template.name"
|
|
class="flex h-56 cursor-pointer flex-col gap-2 rounded-lg border p-3 hover:bg-surface-gray-2 relative"
|
|
@click="onTemplateSelect(template)"
|
|
>
|
|
<div class="text-base font-semibold truncate border-b pb-2">
|
|
{{ template.title }}
|
|
</div>
|
|
<TextEditor
|
|
v-if="template.message"
|
|
:content="template.message"
|
|
:editable="false"
|
|
editor-class="!prose-sm max-w-none !text-sm text-ink-gray-5 focus:outline-none"
|
|
class="flex-1 overflow-hidden pointer-events-none"
|
|
/>
|
|
<div
|
|
v-if="
|
|
selectedTemplate.name === template.name &&
|
|
selectedTemplate.isLoading
|
|
"
|
|
class="flex items-center justify-center absolute top-0 start-0 w-full h-full bg-surface-gray-7/20 rounded-lg"
|
|
>
|
|
<LoadingIndicator class="size-4" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="
|
|
!savedReplyListResource?.list?.loading &&
|
|
!savedReplyListResource?.data?.length
|
|
"
|
|
class="mt-2"
|
|
>
|
|
<div class="flex h-56 flex-col items-center justify-center">
|
|
<div class="text-p-sm text-ink-gray-4">
|
|
{{ __("No saved replies found") }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</Dialog>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useConfigStore } from "@/stores/config";
|
|
import { capture } from "@/telemetry";
|
|
import { __ } from "@/translation";
|
|
import { SavedReply } from "@/types";
|
|
import { useStorage } from "@vueuse/core";
|
|
import {
|
|
Button,
|
|
createListResource,
|
|
createResource,
|
|
Dialog,
|
|
Dropdown,
|
|
LoadingIndicator,
|
|
TextEditor,
|
|
TextInput,
|
|
} from "frappe-ui";
|
|
import { storeToRefs } from "pinia";
|
|
import { computed, nextTick, ref, watch } from "vue";
|
|
import {
|
|
setActiveSettingsTab,
|
|
showSettingsModal,
|
|
} from "./Settings/settingsModal";
|
|
|
|
const props = defineProps({
|
|
doctype: {
|
|
type: String,
|
|
default: "",
|
|
},
|
|
ticketId: {
|
|
type: String,
|
|
default: "",
|
|
},
|
|
});
|
|
|
|
const show = defineModel();
|
|
const activeFilter = useStorage("saved-replies-filter", "Personal");
|
|
const { disableGlobalScopeForSavedReplies, teamRestrictionApplied } =
|
|
storeToRefs(useConfigStore());
|
|
|
|
const filters = computed(() => {
|
|
const options = [
|
|
{
|
|
label: __("All"),
|
|
value: "All",
|
|
onClick: () => (activeFilter.value = "All"),
|
|
},
|
|
{
|
|
label: __("Personal"),
|
|
value: "Personal",
|
|
onClick: () => (activeFilter.value = "Personal"),
|
|
},
|
|
{
|
|
label: __("My Team"),
|
|
value: "Team",
|
|
onClick: () => (activeFilter.value = "My Team"),
|
|
},
|
|
{
|
|
label: __("Global"),
|
|
value: "Global",
|
|
onClick: () => (activeFilter.value = "Global"),
|
|
},
|
|
];
|
|
if (teamRestrictionApplied.value && disableGlobalScopeForSavedReplies.value) {
|
|
options.pop();
|
|
}
|
|
return options;
|
|
});
|
|
|
|
// Set default filter to Personal if Global is disabled
|
|
if (
|
|
teamRestrictionApplied.value &&
|
|
disableGlobalScopeForSavedReplies.value &&
|
|
activeFilter.value == "Global"
|
|
) {
|
|
activeFilter.value = "Personal";
|
|
}
|
|
|
|
const emit = defineEmits(["apply"]);
|
|
|
|
const search = ref("");
|
|
const searchInput = ref<InstanceType<typeof TextInput>>();
|
|
const selectedTemplate = ref({
|
|
name: "",
|
|
isLoading: false,
|
|
});
|
|
const pendingTemplate = ref<string | null>(null);
|
|
|
|
function onAfterLeave() {
|
|
if (pendingTemplate.value !== null) {
|
|
emit("apply", pendingTemplate.value);
|
|
pendingTemplate.value = null;
|
|
}
|
|
}
|
|
|
|
const scope = computed(() => {
|
|
return filters.value.find((f) => f.value === activeFilter.value)?.value;
|
|
});
|
|
|
|
const savedReplyListResource = createListResource({
|
|
doctype: "HD Saved Reply",
|
|
fields: ["name", "title", "owner", "scope", "message"],
|
|
filters: {
|
|
scope: scope.value == "All" ? undefined : ["=", scope.value],
|
|
},
|
|
cache: ["SavedReplyListForModal"],
|
|
auto: true,
|
|
orderBy: "modified desc",
|
|
start: 0,
|
|
pageLength: 999,
|
|
});
|
|
|
|
const onTemplateSelect = (template: SavedReply) => {
|
|
if (selectedTemplate.value.isLoading) return;
|
|
selectedTemplate.value = {
|
|
name: template.name,
|
|
isLoading: true,
|
|
};
|
|
const renderResponse = createResource({
|
|
url: "helpdesk.api.saved_replies.get_rendered_saved_reply",
|
|
params: {
|
|
saved_reply_id: template.name,
|
|
ticket_id: props.ticketId,
|
|
},
|
|
onSuccess: (data: string) => {
|
|
selectedTemplate.value = {
|
|
name: "",
|
|
isLoading: false,
|
|
};
|
|
// If user cancelled (Escape/outside click) while API was in flight, discard
|
|
if (!show.value) return;
|
|
pendingTemplate.value = data;
|
|
show.value = false;
|
|
capture("saved_reply_applied");
|
|
},
|
|
});
|
|
renderResponse.submit().catch(() => {
|
|
selectedTemplate.value = {
|
|
name: "",
|
|
isLoading: false,
|
|
};
|
|
});
|
|
};
|
|
|
|
const onNewSavedReplyClick = () => {
|
|
show.value = false;
|
|
showSettingsModal.value = true;
|
|
setActiveSettingsTab("Saved Replies");
|
|
};
|
|
|
|
const resetFilter = () => {
|
|
savedReplyListResource.filters = {
|
|
...savedReplyListResource.filters,
|
|
title: undefined,
|
|
};
|
|
};
|
|
|
|
watch(search, (newValue) => {
|
|
savedReplyListResource.filters = {
|
|
...savedReplyListResource.filters,
|
|
title: ["like", `%${newValue}%`],
|
|
};
|
|
savedReplyListResource.list.reload();
|
|
});
|
|
|
|
watch(activeFilter, () => {
|
|
savedReplyListResource.filters = {
|
|
...savedReplyListResource?.filters,
|
|
scope: scope.value == "All" ? undefined : ["=", scope.value],
|
|
};
|
|
savedReplyListResource.list.reload();
|
|
});
|
|
|
|
watch(
|
|
show,
|
|
(newValue) => {
|
|
if (newValue) {
|
|
nextTick(() => {
|
|
const inputEl = searchInput.value?.$el?.querySelector("input");
|
|
inputEl?.focus();
|
|
});
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
</script>
|