feat: get statuses / priorities from their respective tables

- adds schema.sql
- format entire codebase
This commit is contained in:
Abhinav Raut
2024-08-25 18:29:04 +05:30
parent d20469dab8
commit d647d88502
340 changed files with 7251 additions and 6583 deletions

View File

@@ -223,4 +223,27 @@ func handleDashboardCharts(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(stats)
}
}
func handleGetAllStatuses(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
out, err := app.conversation.GetAllStatuses()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(out)
}
func handleGetAllPriorities(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
out, err := app.conversation.GetAllPriorities()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(out)
}

View File

@@ -55,6 +55,12 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/message/{uuid}/retry", perm(handleRetryMessage))
g.GET("/api/message/{uuid}", perm(handleGetMessage))
// Conversation statuses.
g.GET("/api/conversation/statuses", perm(handleGetAllStatuses))
// Conversation priorities.
g.GET("/api/conversation/priorities", perm(handleGetAllPriorities))
// Media.
g.POST("/api/media", perm(handleMediaUpload))

72
config.sample.toml Normal file
View File

@@ -0,0 +1,72 @@
[app]
log_level = "debug"
env = "dev"
[app.server]
name = "artemis"
address = "0.0.0.0:9009"
socket = ""
read_timeout = "5s"
write_timeout = "5s"
max_body_size = 10000000
keepalive_timeout = "10s"
[s3]
access_key = 'test'
secret_key = 'test'
region = 'ap-south-1'
bucket = 'test'
bucket_type = "private"
expiry = "15m"
[db]
host = "127.0.0.1"
port = 5432
user = "postgres"
password = "postgres"
database = "artemis"
ssl_mode = "disable"
max_open = 10
max_idle = 10
max_lifetime = "10s"
[redis]
address = "127.0.0.1:6379"
password = ""
db = 0
[message]
dispatch_concurrency = 10
dispatch_read_interval = "50ms"
reader_concurrency = 1
incoming_queue_size = 10000
outgoing_queue_size = 10000
[notification]
[notification.provider]
[notification.provider.email]
username = "test@gmail.com"
host = "smtp.gmail.com"
name = "Gmail"
port = 587
password = "test"
max_conns = 2
idle_timeout = "15s"
wait_timeout = "5s"
auth_protocol = "plain"
email_address = "test@gmail.com"
max_msg_retries = 2
[autoassigner]
assign_interval = "15s"
[frontend.static_server]
enabled = true
root_path = "dist/static/"
index_path = "dist/index.html"
# `{filepath:*}` is a catch-all wildcard for httprouter(the router mux)
uri = "/static/{filepath:*}"

View File

@@ -4,9 +4,17 @@
<div class="bg-background text-foreground">
<div v-if="$route.path !== '/'">
<ResizablePanelGroup direction="horizontal" auto-save-id="app.vue.resizable.panel">
<ResizablePanel id="resize-panel-1" collapsible :default-size="10" :collapsed-size="1" :min-size="7"
:max-size="20" :class="cn(isCollapsed && 'min-w-[50px] transition-all duration-200 ease-in-out')"
@expand="toggleNav(false)" @collapse="toggleNav(true)">
<ResizablePanel
id="resize-panel-1"
collapsible
:default-size="10"
:collapsed-size="1"
:min-size="7"
:max-size="20"
:class="cn(isCollapsed && 'min-w-[50px] transition-all duration-200 ease-in-out')"
@expand="toggleNav(false)"
@collapse="toggleNav(true)"
>
<NavBar :is-collapsed="isCollapsed" :links="navLinks" :bottom-links="bottomLinks" />
</ResizablePanel>
<ResizableHandle id="resize-handle-1" />
@@ -25,80 +33,74 @@
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { RouterView, useRouter } from 'vue-router';
import { cn } from '@/lib/utils';
import { useI18n } from 'vue-i18n';
import { useUserStore } from '@/stores/user';
import { initWS } from '@/websocket.js';
import { ref, onMounted, computed } from 'vue'
import { RouterView, useRouter } from 'vue-router'
import { cn } from '@/lib/utils'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user'
import { initWS } from '@/websocket.js'
import { Toaster } from '@/components/ui/toast';
import NavBar from '@/components/NavBar.vue';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
import { TooltipProvider } from '@/components/ui/tooltip';
import { Toaster } from '@/components/ui/toast'
import NavBar from '@/components/NavBar.vue'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
import { TooltipProvider } from '@/components/ui/tooltip'
const { t } = useI18n();
const isCollapsed = ref(false);
const { t } = useI18n()
const isCollapsed = ref(false)
const allNavLinks = ref([
{
title: t('navbar.dashboard'),
to: '/dashboard',
label: '',
icon: 'lucide:layout-dashboard',
icon: 'lucide:layout-dashboard'
},
{
title: t('navbar.conversations'),
to: '/conversations',
label: '',
icon: 'lucide:message-circle-more',
icon: 'lucide:message-circle-more'
},
{
title: t('navbar.account'),
to: '/account/profile',
label: '',
icon: 'lucide:circle-user-round',
icon: 'lucide:circle-user-round'
},
{
title: t('navbar.admin'),
to: '/admin/general',
label: '',
icon: 'lucide:settings',
permission: 'admin:get',
},
]);
permission: 'admin:get'
}
])
const bottomLinks = ref(
[
{
to: '/logout',
icon: 'lucide:log-out',
title: 'Logout'
}
]
)
const userStore = useUserStore();
const router = useRouter();
const bottomLinks = ref([
{
to: '/logout',
icon: 'lucide:log-out',
title: 'Logout'
}
])
const userStore = useUserStore()
const router = useRouter()
function toggleNav (v) {
isCollapsed.value = v;
function toggleNav(v) {
isCollapsed.value = v
}
onMounted(() => {
userStore.getCurrentUser().catch((err) => {
if (err.response && err.response.status === 401) {
router.push('/login');
router.push('/login')
}
});
initWS();
});
})
initWS()
})
const navLinks = computed(() =>
allNavLinks.value.filter((link) =>
link.permission ? userStore.hasPermission(link.permission) : true
)
);
)
</script>

View File

@@ -1,175 +1,178 @@
import axios from 'axios';
import qs from 'qs';
import axios from 'axios'
import qs from 'qs'
const http = axios.create({
timeout: 10000,
responseType: 'json',
});
responseType: 'json'
})
// Request interceptor.
http.interceptors.request.use(request => {
http.interceptors.request.use((request) => {
// Set content type for POST/PUT requests if the content type is not set.
if (
(request.method === 'post' || request.method === 'put') &&
!request.headers['Content-Type']
) {
request.headers['Content-Type'] = 'application/x-www-form-urlencoded';
request.data = qs.stringify(request.data);
if ((request.method === 'post' || request.method === 'put') && !request.headers['Content-Type']) {
request.headers['Content-Type'] = 'application/x-www-form-urlencoded'
request.data = qs.stringify(request.data)
}
return request;
});
return request
})
const getAllStatuses = () => http.get('/api/conversation/statuses')
const getAllPriorities = () => http.get('/api/conversation/priorities')
const createTag = (data) => http.post('/api/tags', data)
const updateTag = (id, data) => http.put(`/api/tags/${id}`, data)
const deleteTag = (id) => http.delete(`/api/tags/${id}`)
const getTemplate = (id) => http.get(`/api/templates/${id}`)
const getTemplates = () => http.get('/api/templates')
const createTemplate = (data) => http.post('/api/templates', data, {
headers: {
'Content-Type': 'application/json',
},
})
const updateTemplate = (id, data) => http.put(`/api/templates/${id}`, data, {
headers: {
'Content-Type': 'application/json',
},
})
const createOIDC = (data) => http.post("/api/oidc", data, {
headers: {
'Content-Type': 'application/json',
},
})
const getAllOIDC = () => http.get("/api/oidc")
const createTemplate = (data) =>
http.post('/api/templates', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateTemplate = (id, data) =>
http.put(`/api/templates/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const createOIDC = (data) =>
http.post('/api/oidc', data, {
headers: {
'Content-Type': 'application/json'
}
})
const getAllOIDC = () => http.get('/api/oidc')
const getOIDC = (id) => http.get(`/api/oidc/${id}`)
const updateOIDC = (id, data) => http.put(`/api/oidc/${id}`, data, {
headers: {
'Content-Type': 'application/json',
},
})
const updateOIDC = (id, data) =>
http.put(`/api/oidc/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteOIDC = (id) => http.delete(`/api/oidc/${id}`)
const updateSettings = (key, data) => http.put(`/api/settings/${key}`, data, {
headers: {
'Content-Type': 'application/json',
},
});
const updateSettings = (key, data) =>
http.put(`/api/settings/${key}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const getSettings = (key) => http.get(`/api/settings/${key}`)
const login = data => http.post(`/api/login`, data);
const getAutomationRules = type =>
const login = (data) => http.post(`/api/login`, data)
const getAutomationRules = (type) =>
http.get(`/api/automation/rules`, {
params: { type: type },
});
const toggleAutomationRule = id =>
http.put(`/api/automation/rules/${id}/toggle`);
const getAutomationRule = id => http.get(`/api/automation/rules/${id}`);
params: { type: type }
})
const toggleAutomationRule = (id) => http.put(`/api/automation/rules/${id}/toggle`)
const getAutomationRule = (id) => http.get(`/api/automation/rules/${id}`)
const updateAutomationRule = (id, data) =>
http.put(`/api/automation/rules/${id}`, data, {
headers: {
'Content-Type': 'application/json',
},
});
const createAutomationRule = data =>
'Content-Type': 'application/json'
}
})
const createAutomationRule = (data) =>
http.post(`/api/automation/rules`, data, {
headers: {
'Content-Type': 'application/json',
},
});
const getRoles = () => http.get('/api/roles');
const getRole = id => http.get(`/api/roles/${id}`);
const createRole = data =>
'Content-Type': 'application/json'
}
})
const getRoles = () => http.get('/api/roles')
const getRole = (id) => http.get(`/api/roles/${id}`)
const createRole = (data) =>
http.post('/api/roles', data, {
headers: {
'Content-Type': 'application/json',
},
});
const updateRole = (id, data) => http.put(`/api/roles/${id}`, data, {
headers: {
'Content-Type': 'application/json',
},
});
const deleteRole = id => http.delete(`/api/roles/${id}`);
const deleteAutomationRule = id => http.delete(`/api/automation/rules/${id}`);
const getUser = id => http.get(`/api/users/${id}`);
const getTeam = id => http.get(`/api/teams/${id}`);
const getTeams = () => http.get('/api/teams');
const getUsers = () => http.get('/api/users');
const updateCurrentUser = (data) => http.put('/api/users/me', data, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
'Content-Type': 'application/json'
}
})
const updateRole = (id, data) =>
http.put(`/api/roles/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteRole = (id) => http.delete(`/api/roles/${id}`)
const deleteAutomationRule = (id) => http.delete(`/api/automation/rules/${id}`)
const getUser = (id) => http.get(`/api/users/${id}`)
const getTeam = (id) => http.get(`/api/teams/${id}`)
const getTeams = () => http.get('/api/teams')
const getUsers = () => http.get('/api/users')
const updateCurrentUser = (data) =>
http.put('/api/users/me', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
const deleteUserAvatar = () => http.delete('/api/users/me/avatar')
const getCurrentUser = () => http.get('/api/users/me');
const getTags = () => http.get('/api/tags');
const upsertTags = (uuid, data) =>
http.post(`/api/conversations/${uuid}/tags`, data);
const getCurrentUser = () => http.get('/api/users/me')
const getTags = () => http.get('/api/tags')
const upsertTags = (uuid, data) => http.post(`/api/conversations/${uuid}/tags`, data)
const updateAssignee = (uuid, assignee_type, data) =>
http.put(`/api/conversations/${uuid}/assignee/${assignee_type}`, data);
const updateStatus = (uuid, data) =>
http.put(`/api/conversations/${uuid}/status`, data);
const updatePriority = (uuid, data) =>
http.put(`/api/conversations/${uuid}/priority`, data);
const updateAssigneeLastSeen = uuid =>
http.put(`/api/conversations/${uuid}/last-seen`);
const getMessage = uuid => http.get(`/api/message/${uuid}`);
const retryMessage = uuid => http.get(`/api/message/${uuid}/retry`);
const getMessages = (uuid, page) => http.get(`/api/conversations/${uuid}/messages`, {
params: { page: page },
});
http.put(`/api/conversations/${uuid}/assignee/${assignee_type}`, data)
const updateStatus = (uuid, data) => http.put(`/api/conversations/${uuid}/status`, data)
const updatePriority = (uuid, data) => http.put(`/api/conversations/${uuid}/priority`, data)
const updateAssigneeLastSeen = (uuid) => http.put(`/api/conversations/${uuid}/last-seen`)
const getMessage = (uuid) => http.get(`/api/message/${uuid}`)
const retryMessage = (uuid) => http.get(`/api/message/${uuid}/retry`)
const getMessages = (uuid, page) =>
http.get(`/api/conversations/${uuid}/messages`, {
params: { page: page }
})
const sendMessage = (uuid, data) =>
http.post(`/api/conversations/${uuid}/messages`, data, {
headers: {
'Content-Type': 'application/json',
},
});
const getConversation = uuid => http.get(`/api/conversations/${uuid}`);
const getConversationParticipants = uuid =>
http.get(`/api/conversations/${uuid}/participants`);
const getCannedResponses = () => http.get('/api/canned-responses');
'Content-Type': 'application/json'
}
})
const getConversation = (uuid) => http.get(`/api/conversations/${uuid}`)
const getConversationParticipants = (uuid) => http.get(`/api/conversations/${uuid}/participants`)
const getCannedResponses = () => http.get('/api/canned-responses')
const getAssignedConversations = (page, filter) =>
http.get(`/api/conversations/assigned?page=${page}&filter=${filter}`);
http.get(`/api/conversations/assigned?page=${page}&filter=${filter}`)
const getTeamConversations = (page, filter) =>
http.get(`/api/conversations/team?page=${page}&filter=${filter}`);
http.get(`/api/conversations/team?page=${page}&filter=${filter}`)
const getAllConversations = (page, filter) =>
http.get(`/api/conversations/all?page=${page}&filter=${filter}`);
const uploadMedia = data =>
http.get(`/api/conversations/all?page=${page}&filter=${filter}`)
const uploadMedia = (data) =>
http.post('/api/media', data, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
const getGlobalDashboardCounts = () => http.get('/api/dashboard/global/counts');
const getGlobalDashboardCharts = () => http.get('/api/dashboard/global/charts');
const getUserDashboardCounts = () => http.get(`/api/dashboard/me/counts`);
const getUserDashboardCharts = () => http.get(`/api/dashboard/me/charts`);
const getLanguage = lang => http.get(`/api/lang/${lang}`);
const createUser = data => http.post('/api/users', data, {
headers: {
'Content-Type': 'application/json',
},
});
const updateUser = (id, data) => http.put(`/api/users/${id}`, data, {
headers: {
'Content-Type': 'application/json',
},
});
const updateTeam = (id, data) => http.put(`/api/teams/${id}`, data);
const createTeam = data => http.post('/api/teams', data);
const createInbox = data =>
'Content-Type': 'multipart/form-data'
}
})
const getGlobalDashboardCounts = () => http.get('/api/dashboard/global/counts')
const getGlobalDashboardCharts = () => http.get('/api/dashboard/global/charts')
const getUserDashboardCounts = () => http.get(`/api/dashboard/me/counts`)
const getUserDashboardCharts = () => http.get(`/api/dashboard/me/charts`)
const getLanguage = (lang) => http.get(`/api/lang/${lang}`)
const createUser = (data) =>
http.post('/api/users', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateUser = (id, data) =>
http.put(`/api/users/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateTeam = (id, data) => http.put(`/api/teams/${id}`, data)
const createTeam = (data) => http.post('/api/teams', data)
const createInbox = (data) =>
http.post('/api/inboxes', data, {
headers: {
'Content-Type': 'application/json',
},
});
const getInboxes = () => http.get('/api/inboxes');
const getInbox = id => http.get(`/api/inboxes/${id}`);
const toggleInbox = id => http.put(`/api/inboxes/${id}/toggle`);
'Content-Type': 'application/json'
}
})
const getInboxes = () => http.get('/api/inboxes')
const getInbox = (id) => http.get(`/api/inboxes/${id}`)
const toggleInbox = (id) => http.put(`/api/inboxes/${id}/toggle`)
const updateInbox = (id, data) =>
http.put(`/api/inboxes/${id}`, data, {
headers: {
'Content-Type': 'application/json',
},
});
const deleteInbox = id => http.delete(`/api/inboxes/${id}`);
'Content-Type': 'application/json'
}
})
const deleteInbox = (id) => http.delete(`/api/inboxes/${id}`)
export default {
login,
@@ -237,4 +240,6 @@ export default {
createTag,
updateTag,
deleteTag,
};
getAllStatuses,
getAllPriorities
}

View File

@@ -26,21 +26,29 @@ const getButtonVariant = (to) => {
</script>
<template>
<div :data-collapsed="isCollapsed" class="group flex flex-col gap-4 py-2 data-[collapsed=true]:py-2 h-full">
<nav class="grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2">
<div
:data-collapsed="isCollapsed"
class="group flex flex-col gap-4 py-2 data-[collapsed=true]:py-2 h-full"
>
<nav
class="grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2"
>
<template v-for="(link, index) of links">
<!-- Collapsed -->
<router-link :to="link.to" v-if="isCollapsed" :key="`1-${index}`">
<TooltipProvider :delay-duration="10">
<Tooltip>
<TooltipTrigger as-child>
<span :class="cn(
buttonVariants({ variant: getButtonVariant(link.to), size: 'icon' }),
'h-9 w-9',
link.variant === getButtonVariant(link.to) &&
'dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white'
)
">
<span
:class="
cn(
buttonVariants({ variant: getButtonVariant(link.to), size: 'icon' }),
'h-9 w-9',
link.variant === getButtonVariant(link.to) &&
'dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white'
)
"
>
<Icon :icon="link.icon" class="size-5" />
<span class="sr-only">{{ link.title }}</span>
</span>
@@ -56,20 +64,30 @@ const getButtonVariant = (to) => {
</router-link>
<!-- Expanded -->
<router-link v-else :to="link.to" :key="`2-${index}`" :class="cn(
buttonVariants({ variant: getButtonVariant(link.to), size: 'sm' }),
link.variant === getButtonVariant(link.to) &&
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
'justify-start'
)
">
<router-link
v-else
:to="link.to"
:key="`2-${index}`"
:class="
cn(
buttonVariants({ variant: getButtonVariant(link.to), size: 'sm' }),
link.variant === getButtonVariant(link.to) &&
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
'justify-start'
)
"
>
<Icon :icon="link.icon" class="mr-2 size-5" />
{{ link.title }}
<span v-if="link.label" :class="cn(
'ml-',
link.variant === getButtonVariant(link.to) && 'text-background dark:text-white'
)
">
<span
v-if="link.label"
:class="
cn(
'ml-',
link.variant === getButtonVariant(link.to) && 'text-background dark:text-white'
)
"
>
{{ link.label }}
</span>
</router-link>
@@ -82,12 +100,20 @@ const getButtonVariant = (to) => {
<TooltipProvider :delay-duration="10">
<Tooltip>
<TooltipTrigger as-child>
<router-link :to="bottomLink.to" :class="cn(
buttonVariants({ variant: getButtonVariant(bottomLink.to), size: isCollapsed ? 'icon' : 'sm' }),
bottomLink.variant === getButtonVariant(bottomLink.to) &&
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
'justify-start'
)">
<router-link
:to="bottomLink.to"
:class="
cn(
buttonVariants({
variant: getButtonVariant(bottomLink.to),
size: isCollapsed ? 'icon' : 'sm'
}),
bottomLink.variant === getButtonVariant(bottomLink.to) &&
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
'justify-start'
)
"
>
<Icon :icon="bottomLink.icon" class="mr-2 size-5" v-if="!isCollapsed" />
<span v-if="!isCollapsed">{{ bottomLink.title }}</span>
<Icon :icon="bottomLink.icon" class="size-5 mx-auto" v-else />

View File

@@ -1,5 +1,5 @@
<script setup>
import PageHeader from '@/components/common/PageHeader.vue';
import PageHeader from '@/components/common/PageHeader.vue'
import SidebarNav from '@/components/common/SidebarNav.vue'
const sidebarNavItems = [
@@ -7,7 +7,7 @@ const sidebarNavItems = [
title: 'Profile',
href: '/account/profile',
description: 'Update your profile'
},
}
]
</script>

View File

@@ -1,118 +1,132 @@
<template>
<div>
<div class="flex flex-col space-y-5">
<div class="space-y-1">
<span class="sub-title">Public avatar</span>
<p class="text-muted-foreground text-xs">Change your avatar here.</p>
</div>
<div class="flex space-x-5">
<Avatar class="size-28">
<AvatarImage :src="userStore.userAvatar" alt="Cropped Image" />
<AvatarFallback>{{ userStore.getInitials }}</AvatarFallback>
</Avatar>
<div>
<div class="flex flex-col space-y-5">
<div class="space-y-1">
<span class="sub-title">Public avatar</span>
<p class="text-muted-foreground text-xs">Change your avatar here.</p>
</div>
<div class="flex space-x-5">
<Avatar class="size-28">
<AvatarImage :src="userStore.userAvatar" alt="Cropped Image" />
<AvatarFallback>{{ userStore.getInitials }}</AvatarFallback>
</Avatar>
<div class="flex flex-col space-y-5 justify-center">
<input ref="uploadInput" type="file" hidden accept="image/jpg, image/jpeg, image/png, image/gif"
@change="selectFile" />
<Button class="w-28" @click="selectAvatar" size="sm">
Choose a file...
</Button>
<div class="flex flex-col space-y-5 justify-center">
<input
ref="uploadInput"
type="file"
hidden
accept="image/jpg, image/jpeg, image/png, image/gif"
@change="selectFile"
/>
<Button class="w-28" @click="selectAvatar" size="sm"> Choose a file... </Button>
<Button class="w-28" @click="removeAvatar" variant="destructive" size="sm">
Remove avatar
</Button>
</div>
</div>
<Button class="w-28" @click="saveUser" size="sm">Save Changes</Button>
<Dialog :open="showCropper">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle class="text-xl">Crop avatar</DialogTitle>
</DialogHeader>
<VuePictureCropper
:boxStyle="{ width: '100%', height: '400px', backgroundColor: '#f8f8f8', margin: 'auto' }"
:img="newUserAvatar" :options="{ viewMode: 1, dragMode: 'crop', aspectRatio: 1 }" />
<DialogFooter class="sm:justify-end">
<Button variant="secondary" @click="closeDialog">
Close
</Button>
<Button @click="getResult">Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Button class="w-28" @click="removeAvatar" variant="destructive" size="sm">
Remove avatar
</Button>
</div>
</div>
<Button class="w-28" @click="saveUser" size="sm">Save Changes</Button>
<Dialog :open="showCropper">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle class="text-xl">Crop avatar</DialogTitle>
</DialogHeader>
<VuePictureCropper
:boxStyle="{
width: '100%',
height: '400px',
backgroundColor: '#f8f8f8',
margin: 'auto'
}"
:img="newUserAvatar"
:options="{ viewMode: 1, dragMode: 'crop', aspectRatio: 1 }"
/>
<DialogFooter class="sm:justify-end">
<Button variant="secondary" @click="closeDialog"> Close </Button>
<Button @click="getResult">Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
</template>
<script setup>
import { useUserStore } from '@/stores/user';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { ref } from 'vue';
import VuePictureCropper, { cropper } from 'vue-picture-cropper';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useUserStore } from '@/stores/user'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { ref } from 'vue'
import VuePictureCropper, { cropper } from 'vue-picture-cropper'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import api from '@/api'
const userStore = useUserStore();
const uploadInput = ref(null);
const newUserAvatar = ref('');
const showCropper = ref(false);
let croppedBlob = null;
let avatarFile = null;
const userStore = useUserStore()
const uploadInput = ref(null)
const newUserAvatar = ref('')
const showCropper = ref(false)
let croppedBlob = null
let avatarFile = null
const selectAvatar = () => {
uploadInput.value.click();
};
uploadInput.value.click()
}
const selectFile = (event) => {
newUserAvatar.value = '';
const { files } = event.target;
if (!files || !files.length) return;
newUserAvatar.value = ''
const { files } = event.target
if (!files || !files.length) return
avatarFile = files[0];
const reader = new FileReader();
reader.readAsDataURL(avatarFile);
reader.onload = () => {
newUserAvatar.value = String(reader.result);
showCropper.value = true;
uploadInput.value.value = '';
};
};
avatarFile = files[0]
const reader = new FileReader()
reader.readAsDataURL(avatarFile)
reader.onload = () => {
newUserAvatar.value = String(reader.result)
showCropper.value = true
uploadInput.value.value = ''
}
}
const closeDialog = () => {
showCropper.value = false;
};
showCropper.value = false
}
const getResult = async () => {
if (!cropper) return;
croppedBlob = await cropper.getBlob();
if (!croppedBlob) return;
userStore.userAvatar = URL.createObjectURL(croppedBlob);
showCropper.value = false;
};
if (!cropper) return
croppedBlob = await cropper.getBlob()
if (!croppedBlob) return
userStore.userAvatar = URL.createObjectURL(croppedBlob)
showCropper.value = false
}
const saveUser = async () => {
const formData = new FormData();
formData.append('files', croppedBlob, 'avatar.png');
try {
await api.updateCurrentUser(formData);
// Handle success
} catch (error) {
// Handle error
}
};
const formData = new FormData()
formData.append('files', croppedBlob, 'avatar.png')
try {
await api.updateCurrentUser(formData)
// Handle success
} catch (error) {
// Handle error
}
}
const removeAvatar = async () => {
croppedBlob = null;
try {
await api.deleteUserAvatar();
// Handle success
} catch (error) {
// Handle error
}
};
croppedBlob = null
try {
await api.deleteUserAvatar()
// Handle success
} catch (error) {
// Handle error
}
}
</script>

View File

@@ -1,10 +1,10 @@
<script setup>
import { computed } from 'vue'
import PageHeader from '@/components/common/PageHeader.vue';
import SidebarNav from '@/components/common/SidebarNav.vue';
import { useUserStore } from '@/stores/user';
import PageHeader from '@/components/common/PageHeader.vue'
import SidebarNav from '@/components/common/SidebarNav.vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore();
const userStore = useUserStore()
const allNavItems = [
{
@@ -17,7 +17,7 @@ const allNavItems = [
title: 'Conversations',
href: '/admin/conversations',
description: 'Manage conversation tags, statuses and priorities.',
permission: null,
permission: null
},
{
title: 'Inboxes',
@@ -54,12 +54,12 @@ const allNavItems = [
href: '/admin/oidc',
description: 'Manage OpenID Connect configurations',
permission: 'login:manage'
},
];
}
]
const sidebarNavItems = computed(() =>
allNavItems.filter(item => userStore.hasPermission(item.permission))
);
const sidebarNavItems = computed(() =>
allNavItems.filter((item) => userStore.hasPermission(item.permission))
)
</script>
<template>

View File

@@ -1,61 +1,71 @@
<template>
<div class="box border p-5 space-y-5 rounded">
<div class="space-y-5">
<div v-for="(action, index) in actions" :key="index" class="space-y-5">
<div v-if="index > 0">
<hr class="border-t-2 border-dotted border-gray-300">
</div>
<div class="flex space-x-5 justify-between">
<Select v-model="action.type"
@update:modelValue="(value) => handleFieldChange(value, index)">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select action" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Conversation</SelectLabel>
<SelectItem v-for="(actionItem, key) in conversationActions" :key="key" :value="key">
{{ actionItem.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div class="cursor-pointer" @click="removeAction(index)">
<CircleX size="21" />
</div>
</div>
<div>
<Input type="text" placeholder="Set value" :modelValue="action.value"
@update:modelValue="(value) => handleValueChange(value, index)" />
</div>
</div>
<div class="box border p-5 space-y-5 rounded">
<div class="space-y-5">
<div v-for="(action, index) in actions" :key="index" class="space-y-5">
<div v-if="index > 0">
<hr class="border-t-2 border-dotted border-gray-300" />
</div>
<div class="flex space-x-5 justify-between">
<Select
v-model="action.type"
@update:modelValue="(value) => handleFieldChange(value, index)"
>
<SelectTrigger class="w-56">
<SelectValue placeholder="Select action" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Conversation</SelectLabel>
<SelectItem
v-for="(actionItem, key) in conversationActions"
:key="key"
:value="key"
>
{{ actionItem.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div class="cursor-pointer" @click="removeAction(index)">
<CircleX size="21" />
</div>
</div>
<div>
<Button variant="outline" @click="addAction" size="sm">Add action</Button>
<Input
type="text"
placeholder="Set value"
:modelValue="action.value"
@update:modelValue="(value) => handleValueChange(value, index)"
/>
</div>
</div>
</div>
<div>
<Button variant="outline" @click="addAction" size="sm">Add action</Button>
</div>
</div>
</template>
<script setup>
import { toRefs } from 'vue'
import { Button } from '@/components/ui/button'
import { CircleX } from 'lucide-vue-next';
import { CircleX } from 'lucide-vue-next'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
const props = defineProps({
actions: {
type: Object,
required: true,
},
actions: {
type: Object,
required: true
}
})
const { actions } = toRefs(props)
@@ -63,39 +73,39 @@ const { actions } = toRefs(props)
const emit = defineEmits(['update-actions', 'add-action', 'remove-action'])
const handleFieldChange = (value, index) => {
actions.value[index].type = value
emitUpdate(index)
actions.value[index].type = value
emitUpdate(index)
}
const handleValueChange = (value, index) => {
actions.value[index].value = value
emitUpdate(index)
actions.value[index].value = value
emitUpdate(index)
}
const removeAction = (index) => {
emit('remove-action', index)
emit('remove-action', index)
}
const addAction = (index) => {
emit('add-action', index)
emit('add-action', index)
}
const emitUpdate = (index) => {
emit('update-actions', actions, index)
emit('update-actions', actions, index)
}
const conversationActions = {
"assign_team": {
"label": "Assign to team"
},
"assign_user": {
"label": "Assign to user"
},
"set_status": {
"label": "Set status"
},
"set_priority": {
"label": "Set priority"
},
assign_team: {
label: 'Assign to team'
},
assign_user: {
label: 'Assign to user'
},
set_status: {
label: 'Set status'
},
set_priority: {
label: 'Set priority'
}
}
</script>
</script>

View File

@@ -20,4 +20,4 @@ const router = useRouter()
const newRule = () => {
router.push({ path: `/admin/automations/new` })
}
</script>
</script>

View File

@@ -1,35 +1,24 @@
<template>
<Tabs default-value="conversation_creation">
<TabsList class="grid w-full grid-cols-3 mb-5">
<TabsTrigger value="conversation_creation">
Conversation creation
</TabsTrigger>
<TabsTrigger value="conversation_updates">
Conversation updates
</TabsTrigger>
<TabsTrigger value="time_triggers">
Time triggers
</TabsTrigger>
</TabsList>
<TabsContent value="conversation_creation">
<TabConversationCreation />
</TabsContent>
<TabsContent value="conversation_updates">
<TabConversationUpdate />
</TabsContent>
<TabsContent value="time_triggers">
<TabTimeTrigger />
</TabsContent>
</Tabs>
<Tabs default-value="conversation_creation">
<TabsList class="grid w-full grid-cols-3 mb-5">
<TabsTrigger value="conversation_creation"> Conversation creation </TabsTrigger>
<TabsTrigger value="conversation_updates"> Conversation updates </TabsTrigger>
<TabsTrigger value="time_triggers"> Time triggers </TabsTrigger>
</TabsList>
<TabsContent value="conversation_creation">
<TabConversationCreation />
</TabsContent>
<TabsContent value="conversation_updates">
<TabConversationUpdate />
</TabsContent>
<TabsContent value="time_triggers">
<TabTimeTrigger />
</TabsContent>
</Tabs>
</template>
<script setup>
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import TabConversationCreation from './TabConversationCreation.vue'
import TabConversationUpdate from './TabConversationUpdate.vue'

View File

@@ -1,232 +1,237 @@
<template>
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<span class="title">{{ formTitle }}</span>
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<span class="title">{{ formTitle }}</span>
<div class="space-y-5">
<div class="space-y-5">
<div class="space-y-5">
<div class="grid w-full max-w-sm items-center gap-1.5">
<Label for="rule_name">Name</Label>
<Input id="rule_name" type="text" placeholder="Name for this rule" v-model="rule.name" />
</div>
<div class="grid w-full max-w-sm items-center gap-1.5">
<Label for="rule_name">Description</Label>
<Input id="rule_name" type="text" placeholder="Description for this rule" v-model="rule.description" />
</div>
<div class="grid w-full max-w-sm items-center gap-1.5">
<Label for="rule_type">Type</Label>
<Select id="rule_type" :modelValue="rule.type" @update:modelValue="handeTypeUpdate">
<SelectTrigger>
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="new_conversation">
New conversation
</SelectItem>
<SelectItem value="conversation_update">
Conversation update
</SelectItem>
<SelectItem value="time_trigger">
Time trigger
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<p class="font-semibold">Match these rules</p>
<RuleBox :ruleGroup="firstRuleGroup" @update-group="handleUpdateGroup" @add-condition="handleAddCondition"
@remove-condition="handleRemoveCondition" :groupIndex=0 />
<div class="flex justify-center">
<div class="flex items-center space-x-2">
<Button :class="[
groupOperator === 'AND' ? 'bg-black' : 'bg-gray-100 text-black'
]" @click="toggleGroupOperator('AND')">
AND
</Button>
<Button :class="[
groupOperator === 'OR' ? 'bg-black' : 'bg-gray-100 text-black'
]" @click="toggleGroupOperator('OR')">
OR
</Button>
</div>
</div>
<RuleBox :ruleGroup="secondRuleGroup" @update-group="handleUpdateGroup" @add-condition="handleAddCondition"
@remove-condition="handleRemoveCondition" :groupIndex=1 />
<p class="font-semibold">Perform these actions</p>
<ActionBox :actions="getActions()" :update-actions="handleUpdateActions" @add-action="handleAddAction"
@remove-action="handleRemoveAction" />
<Button @click="handleSave">Save</Button>
<div class="grid w-full max-w-sm items-center gap-1.5">
<Label for="rule_name">Name</Label>
<Input id="rule_name" type="text" placeholder="Name for this rule" v-model="rule.name" />
</div>
<div class="grid w-full max-w-sm items-center gap-1.5">
<Label for="rule_name">Description</Label>
<Input
id="rule_name"
type="text"
placeholder="Description for this rule"
v-model="rule.description"
/>
</div>
<div class="grid w-full max-w-sm items-center gap-1.5">
<Label for="rule_type">Type</Label>
<Select id="rule_type" :modelValue="rule.type" @update:modelValue="handeTypeUpdate">
<SelectTrigger>
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="new_conversation"> New conversation </SelectItem>
<SelectItem value="conversation_update"> Conversation update </SelectItem>
<SelectItem value="time_trigger"> Time trigger </SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<p class="font-semibold">Match these rules</p>
<RuleBox
:ruleGroup="firstRuleGroup"
@update-group="handleUpdateGroup"
@add-condition="handleAddCondition"
@remove-condition="handleRemoveCondition"
:groupIndex="0"
/>
<div class="flex justify-center">
<div class="flex items-center space-x-2">
<Button
:class="[groupOperator === 'AND' ? 'bg-black' : 'bg-gray-100 text-black']"
@click="toggleGroupOperator('AND')"
>
AND
</Button>
<Button
:class="[groupOperator === 'OR' ? 'bg-black' : 'bg-gray-100 text-black']"
@click="toggleGroupOperator('OR')"
>
OR
</Button>
</div>
</div>
<RuleBox
:ruleGroup="secondRuleGroup"
@update-group="handleUpdateGroup"
@add-condition="handleAddCondition"
@remove-condition="handleRemoveCondition"
:groupIndex="1"
/>
<p class="font-semibold">Perform these actions</p>
<ActionBox
:actions="getActions()"
:update-actions="handleUpdateActions"
@add-action="handleAddAction"
@remove-action="handleRemoveAction"
/>
<Button @click="handleSave">Save</Button>
</div>
</template>
<script setup>
import { onMounted, ref, computed } from 'vue';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import RuleBox from './RuleBox.vue';
import ActionBox from './ActionBox.vue';
import api from '@/api';
import { onMounted, ref, computed } from 'vue'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import RuleBox from './RuleBox.vue'
import ActionBox from './ActionBox.vue'
import api from '@/api'
import { useRouter } from 'vue-router'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import {
CustomBreadcrumb,
} from '@/components/ui/breadcrumb'
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
const rule = ref({
"id": 0,
"name": "",
"description": "",
"type": "new_conversation",
"rules": [
id: 0,
name: '',
description: '',
type: 'new_conversation',
rules: [
{
type: 'new_conversation',
groups: [
{
"type": "new_conversation",
"groups": [
{
"rules": [
],
"logical_op": "OR"
},
{
"rules": [
],
"logical_op": "OR"
}
],
"actions": [],
"group_operator": "OR"
rules: [],
logical_op: 'OR'
},
{
rules: [],
logical_op: 'OR'
}
]
],
actions: [],
group_operator: 'OR'
}
]
})
const props = defineProps({
id: {
type: [String, Number],
required: false,
},
id: {
type: [String, Number],
required: false
}
})
const breadcrumbPageLabel = () => {
if (props.id > 0)
return "Edit rule"
return "New rule"
if (props.id > 0) return 'Edit rule'
return 'New rule'
}
const formTitle = computed(() => {
if (props.id > 0)
return "Edit existing rule"
return "Create new rule"
if (props.id > 0) return 'Edit existing rule'
return 'Create new rule'
})
const breadcrumbLinks = [
{ path: '/admin/automations', label: 'Automations' },
{ path: '#', label: breadcrumbPageLabel() }
{ path: '/admin/automations', label: 'Automations' },
{ path: '#', label: breadcrumbPageLabel() }
]
const router = useRouter()
const firstRuleGroup = ref([])
const secondRuleGroup = ref([])
const groupOperator = ref("")
const groupOperator = ref('')
const getFirstGroup = () => {
if (rule.value.rules?.[0]?.groups?.[0]) {
return rule.value.rules[0].groups[0]
}
return []
if (rule.value.rules?.[0]?.groups?.[0]) {
return rule.value.rules[0].groups[0]
}
return []
}
const getSecondGroup = () => {
if (rule.value.rules?.[0]?.groups?.[1]) {
return rule.value.rules[0].groups[1]
}
return []
if (rule.value.rules?.[0]?.groups?.[1]) {
return rule.value.rules[0].groups[1]
}
return []
}
const getActions = () => {
if (rule.value.rules?.[0]?.actions) {
return rule.value.rules[0].actions
}
return []
if (rule.value.rules?.[0]?.actions) {
return rule.value.rules[0].actions
}
return []
}
const toggleGroupOperator = (value) => {
if (rule.value.rules?.[0]) {
rule.value.rules[0].group_operator = value;
groupOperator.value = value;
}
if (rule.value.rules?.[0]) {
rule.value.rules[0].group_operator = value
groupOperator.value = value
}
}
const getGroupOperator = () => {
if (rule.value.rules?.[0]) {
return rule.value.rules[0].group_operator;
}
return "";
if (rule.value.rules?.[0]) {
return rule.value.rules[0].group_operator
}
return ''
}
const handleUpdateGroup = (value, groupIndex) => {
rule.value.rules[0].groups[groupIndex] = value.value
rule.value.rules[0].groups[groupIndex] = value.value
}
const handleAddCondition = (groupIndex) => {
rule.value.rules[0].groups[groupIndex].rules.push({})
rule.value.rules[0].groups[groupIndex].rules.push({})
}
const handleRemoveCondition = (groupIndex, ruleIndex) => {
rule.value.rules[0].groups[groupIndex].rules.splice(ruleIndex, 1)
rule.value.rules[0].groups[groupIndex].rules.splice(ruleIndex, 1)
}
const handleUpdateActions = (value, index) => {
rule.value.rules[0].actions[index] = value
rule.value.rules[0].actions[index] = value
}
const handleAddAction = () => {
rule.value.rules[0].actions.push({})
rule.value.rules[0].actions.push({})
}
const handleRemoveAction = (index) => {
rule.value.rules[0].actions.splice(index, 1)
rule.value.rules[0].actions.splice(index, 1)
}
const handeTypeUpdate = (value) => {
rule.value.type = value
rule.value.type = value
}
const handleSave = async () => {
const updatedRule = { ...rule.value }
// Delete fields not required.
delete updatedRule.created_at
delete updatedRule.updated_at
if (props.id > 0)
await api.updateAutomationRule(props.id, updatedRule)
else
await api.createAutomationRule(updatedRule)
router.push({ path: `/admin/automations` })
const updatedRule = { ...rule.value }
// Delete fields not required.
delete updatedRule.created_at
delete updatedRule.updated_at
if (props.id > 0) await api.updateAutomationRule(props.id, updatedRule)
else await api.createAutomationRule(updatedRule)
router.push({ path: `/admin/automations` })
}
onMounted(async () => {
if (props.id > 0) {
try {
let resp = await api.getAutomationRule(props.id)
rule.value = resp.data.data;
} catch (error) {
console.log(error)
router.push({ path: `/admin/automations` })
}
if (props.id > 0) {
try {
let resp = await api.getAutomationRule(props.id)
rule.value = resp.data.data
} catch (error) {
console.log(error)
router.push({ path: `/admin/automations` })
}
firstRuleGroup.value = getFirstGroup()
secondRuleGroup.value = getSecondGroup()
groupOperator.value = getGroupOperator()
}
firstRuleGroup.value = getFirstGroup()
secondRuleGroup.value = getSecondGroup()
groupOperator.value = getGroupOperator()
})
</script>

View File

@@ -1,77 +1,89 @@
<template>
<div>
<div class="mb-5">
<RadioGroup class="flex" :modelValue="ruleGroup.logical_op" @update:modelValue="handleGroupOperator">
<div class="flex items-center space-x-2">
<RadioGroupItem value="OR" />
<Label for="r1">Match <b>ANY</b> of below.</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroupItem value="AND" />
<Label for="r1">Match <b>ALL</b> of below.</Label>
</div>
</RadioGroup>
<div>
<div class="mb-5">
<RadioGroup
class="flex"
:modelValue="ruleGroup.logical_op"
@update:modelValue="handleGroupOperator"
>
<div class="flex items-center space-x-2">
<RadioGroupItem value="OR" />
<Label for="r1">Match <b>ANY</b> of below.</Label>
</div>
<div class="box border p-5 space-y-5 rounded-lg">
<div class="space-y-5">
<div v-for="(rule, index) in ruleGroup.rules" :key="rule" class="space-y-5">
<div v-if="index > 0">
<hr class="border-t-2 border-dotted border-gray-300">
</div>
<div class="flex justify-between">
<div class="flex space-x-5">
<Select v-model="rule.field"
@update:modelValue="(value) => handleFieldChange(value, index)">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select field" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Conversation</SelectLabel>
<SelectItem v-for="(field, key) in conversationFields" :key="key" :value="key">
{{ field.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Select v-model="rule.operator"
@update:modelValue="(value) => handleOperatorChange(value, index)">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select operator" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="(field, key) in operators" :key="key" :value="key">
{{ field.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div class="cursor-pointer" @click="removeCondition(index)">
<CircleX size="21" />
</div>
</div>
<div>
<Input type="text" placeholder="Set value" :modelValue="rule.value"
@update:modelValue="(value) => handleValueChange(value, index)" />
</div>
<div class="flex items-center space-x-2">
<Checkbox id="terms" :defaultChecked="rule.case_sensitive_match"
@update:checked="(value) => handleCaseSensitiveCheck(value, index)" />
<label for="terms">
Case sensitive match
</label>
</div>
</div>
</div>
<div>
<Button variant="outline" size="sm" @click="addCondition">Add condition</Button>
</div>
<div class="flex items-center space-x-2">
<RadioGroupItem value="AND" />
<Label for="r1">Match <b>ALL</b> of below.</Label>
</div>
</RadioGroup>
</div>
<div class="box border p-5 space-y-5 rounded-lg">
<div class="space-y-5">
<div v-for="(rule, index) in ruleGroup.rules" :key="rule" class="space-y-5">
<div v-if="index > 0">
<hr class="border-t-2 border-dotted border-gray-300" />
</div>
<div class="flex justify-between">
<div class="flex space-x-5">
<Select
v-model="rule.field"
@update:modelValue="(value) => handleFieldChange(value, index)"
>
<SelectTrigger class="w-56">
<SelectValue placeholder="Select field" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Conversation</SelectLabel>
<SelectItem v-for="(field, key) in conversationFields" :key="key" :value="key">
{{ field.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Select
v-model="rule.operator"
@update:modelValue="(value) => handleOperatorChange(value, index)"
>
<SelectTrigger class="w-56">
<SelectValue placeholder="Select operator" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="(field, key) in operators" :key="key" :value="key">
{{ field.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div class="cursor-pointer" @click="removeCondition(index)">
<CircleX size="21" />
</div>
</div>
<div>
<Input
type="text"
placeholder="Set value"
:modelValue="rule.value"
@update:modelValue="(value) => handleValueChange(value, index)"
/>
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="terms"
:defaultChecked="rule.case_sensitive_match"
@update:checked="(value) => handleCaseSensitiveCheck(value, index)"
/>
<label for="terms"> Case sensitive match </label>
</div>
</div>
</div>
<div>
<Button variant="outline" size="sm" @click="addCondition">Add condition</Button>
</div>
</div>
</div>
</template>
<script setup>
@@ -80,27 +92,27 @@ import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { CircleX } from 'lucide-vue-next';
import { CircleX } from 'lucide-vue-next'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
const props = defineProps({
ruleGroup: {
type: Object,
required: true,
},
groupIndex: {
Type: Number,
required: true,
}
ruleGroup: {
type: Object,
required: true
},
groupIndex: {
Type: Number,
required: true
}
})
const { ruleGroup } = toRefs(props)
@@ -108,81 +120,81 @@ const { ruleGroup } = toRefs(props)
const emit = defineEmits(['update-group', 'add-condition', 'remove-condition'])
const handleGroupOperator = (value) => {
ruleGroup.value.logical_op = value
emitUpdate()
ruleGroup.value.logical_op = value
emitUpdate()
}
const handleFieldChange = (value, ruleIndex) => {
ruleGroup.value.rules[ruleIndex].field = value
emitUpdate()
ruleGroup.value.rules[ruleIndex].field = value
emitUpdate()
}
const handleOperatorChange = (value, ruleIndex) => {
ruleGroup.value.rules[ruleIndex].operator = value
emitUpdate()
ruleGroup.value.rules[ruleIndex].operator = value
emitUpdate()
}
const handleValueChange = (value, ruleIndex) => {
ruleGroup.value.rules[ruleIndex].value = value
emitUpdate()
ruleGroup.value.rules[ruleIndex].value = value
emitUpdate()
}
const handleCaseSensitiveCheck = (value, ruleIndex) => {
ruleGroup.value.rules[ruleIndex].case_sensitive_match = value
emitUpdate()
ruleGroup.value.rules[ruleIndex].case_sensitive_match = value
emitUpdate()
}
const removeCondition = (index) => {
emit('remove-condition', props.groupIndex, index)
emit('remove-condition', props.groupIndex, index)
}
const addCondition = () => {
emit('add-condition', props.groupIndex)
emit('add-condition', props.groupIndex)
}
const emitUpdate = () => {
emit('update-group', ruleGroup, props.groupIndex)
emit('update-group', ruleGroup, props.groupIndex)
}
const conversationFields = {
"content": {
"label": "Content"
},
"subject": {
"label": "Subject"
},
"status": {
"label": "Status"
},
"priority": {
"label": "Priority"
},
"assigned_team": {
"label": "Assigned team"
},
"assigned_user": {
"label": "Assigned user"
}
content: {
label: 'Content'
},
subject: {
label: 'Subject'
},
status: {
label: 'Status'
},
priority: {
label: 'Priority'
},
assigned_team: {
label: 'Assigned team'
},
assigned_user: {
label: 'Assigned user'
}
}
const operators = {
"contains": {
"label": "Contains"
},
"not_contains": {
"label": "Not contains"
},
"equals": {
"label": "Equals"
},
"not_equals": {
"label": "Not equals"
},
"set": {
"label": "Set"
},
"not_set": {
"label": "Not set"
}
contains: {
label: 'Contains'
},
not_contains: {
label: 'Not contains'
},
equals: {
label: 'Equals'
},
not_equals: {
label: 'Not equals'
},
set: {
label: 'Set'
},
not_set: {
label: 'Not set'
}
}
</script>
</script>

View File

@@ -1,55 +1,55 @@
<template>
<div class="flex flex-col box px-5 py-6 rounded-lg justify-center">
<div class="flex justify-between space-y-3">
<div>
<span class="sub-title space-x-3 flex justify-center items-center">
<div class="text-base">
{{ rule.name }}
</div>
<div class="mb-1">
<Badge v-if="!rule.disabled">Enabled</Badge>
<Badge v-else variant="secondary">Disabled</Badge>
</div>
</span>
</div>
<div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button>
<EllipsisVertical size=21></EllipsisVertical>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="navigateToEditRule(rule.id)">
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem @click="$emit('delete-rule', rule.id)">
<span>Delete</span>
</DropdownMenuItem>
<DropdownMenuItem @click="$emit('toggle-rule', rule.id)" v-if="!rule.disabled">
<span>Disable</span>
</DropdownMenuItem>
<DropdownMenuItem @click="$emit('toggle-rule', rule.id)" v-else>
<span>Enable</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<p class="text-sm-muted">
{{ rule.description }}
</p>
<div class="flex flex-col box px-5 py-6 rounded-lg justify-center">
<div class="flex justify-between space-y-3">
<div>
<span class="sub-title space-x-3 flex justify-center items-center">
<div class="text-base">
{{ rule.name }}
</div>
<div class="mb-1">
<Badge v-if="!rule.disabled">Enabled</Badge>
<Badge v-else variant="secondary">Disabled</Badge>
</div>
</span>
</div>
<div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button>
<EllipsisVertical size="21"></EllipsisVertical>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="navigateToEditRule(rule.id)">
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem @click="$emit('delete-rule', rule.id)">
<span>Delete</span>
</DropdownMenuItem>
<DropdownMenuItem @click="$emit('toggle-rule', rule.id)" v-if="!rule.disabled">
<span>Disable</span>
</DropdownMenuItem>
<DropdownMenuItem @click="$emit('toggle-rule', rule.id)" v-else>
<span>Enable</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<p class="text-sm-muted">
{{ rule.description }}
</p>
</div>
</template>
<script setup>
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { EllipsisVertical } from 'lucide-vue-next';
import { EllipsisVertical } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { Badge } from '@/components/ui/badge'
@@ -57,13 +57,13 @@ const router = useRouter()
defineEmits(['delete-rule', 'toggle-rule'])
defineProps({
rule: {
type: Object,
required: true,
},
rule: {
type: Object,
required: true
}
})
const navigateToEditRule = (id) => {
router.push({ path: `/admin/automations/${id}/edit` })
router.push({ path: `/admin/automations/${id}/edit` })
}
</script>
</script>

View File

@@ -1,38 +1,44 @@
<template>
<div class="space-y-5">
<div>
<p class="text-sm-muted">Rules that run when a new conversation is created</p>
</div>
<div v-if="showRuleList" class="space-y-5">
<RuleList v-for="rule in rules" :key="rule.name" :rule="rule" @delete-rule="deleteRule" @toggle-rule="toggleRule"/>
</div>
<div class="space-y-5">
<div>
<p class="text-sm-muted">Rules that run when a new conversation is created</p>
</div>
<div v-if="showRuleList" class="space-y-5">
<RuleList
v-for="rule in rules"
:key="rule.name"
:rule="rule"
@delete-rule="deleteRule"
@toggle-rule="toggleRule"
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import RuleList from './RuleList.vue'
import api from '@/api';
import api from '@/api'
const showRuleList = ref(true)
const rules = ref([])
onMounted(() => {
fetchRules()
fetchRules()
})
const fetchRules = async () => {
let resp = await api.getAutomationRules("new_conversation")
rules.value = resp.data.data
let resp = await api.getAutomationRules('new_conversation')
rules.value = resp.data.data
}
const deleteRule = async (id) => {
await api.deleteAutomationRule(id)
fetchRules()
await api.deleteAutomationRule(id)
fetchRules()
}
const toggleRule = async (id) => {
await api.toggleAutomationRule(id)
fetchRules()
await api.toggleAutomationRule(id)
fetchRules()
}
</script>
</script>

View File

@@ -1,38 +1,44 @@
<template>
<div class="space-y-5">
<div>
<p class="text-muted-foreground text-sm">Rules that run when a conversation is updated</p>
</div>
<div v-if="showRuleList" class="space-y-5">
<RuleList v-for="rule in rules" :key="rule.name" :rule="rule" @delete-rule="deleteRule" @toggle-rule="toggleRule"/>
</div>
<div class="space-y-5">
<div>
<p class="text-muted-foreground text-sm">Rules that run when a conversation is updated</p>
</div>
<div v-if="showRuleList" class="space-y-5">
<RuleList
v-for="rule in rules"
:key="rule.name"
:rule="rule"
@delete-rule="deleteRule"
@toggle-rule="toggleRule"
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import RuleList from './RuleList.vue'
import api from '@/api';
import api from '@/api'
const showRuleList = ref(true)
const rules = ref([])
onMounted(() => {
fetchRules()
fetchRules()
})
const fetchRules = async () => {
let resp = await api.getAutomationRules("conversation_update")
rules.value = resp.data.data
let resp = await api.getAutomationRules('conversation_update')
rules.value = resp.data.data
}
const deleteRule = async (id) => {
await api.deleteAutomationRule(id)
fetchRules()
await api.deleteAutomationRule(id)
fetchRules()
}
const toggleRule = async (id) => {
await api.toggleAutomationRule(id)
fetchRules()
await api.toggleAutomationRule(id)
fetchRules()
}
</script>
</script>

View File

@@ -1,39 +1,44 @@
<template>
<div class="space-y-5">
<div>
<p class="text-muted-foreground text-sm">Rules that run every hour</p>
</div>
<div v-if="showRuleList" class="space-y-5">
<RuleList v-for="rule in rules" :key="rule.name" :rule="rule" @delete-rule="deleteRule"
@toggle-rule="toggleRule" />
</div>
<div class="space-y-5">
<div>
<p class="text-muted-foreground text-sm">Rules that run every hour</p>
</div>
<div v-if="showRuleList" class="space-y-5">
<RuleList
v-for="rule in rules"
:key="rule.name"
:rule="rule"
@delete-rule="deleteRule"
@toggle-rule="toggleRule"
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import RuleList from './RuleList.vue'
import api from '@/api';
import api from '@/api'
const showRuleList = ref(true)
const rules = ref([])
onMounted(() => {
fetchRules()
fetchRules()
})
const fetchRules = async () => {
let resp = await api.getAutomationRules("time_trigger")
rules.value = resp.data.data
let resp = await api.getAutomationRules('time_trigger')
rules.value = resp.data.data
}
const deleteRule = async (id) => {
await api.deleteAutomationRule(id)
fetchRules()
await api.deleteAutomationRule(id)
fetchRules()
}
const toggleRule = async (id) => {
await api.toggleAutomationRule(id)
fetchRules()
await api.toggleAutomationRule(id)
fetchRules()
}
</script>
</script>

View File

@@ -1,6 +1,8 @@
<template>
<div class="box flex-1 rounded-lg px-8 py-4 transition-shadow duration-170 cursor-pointer hover:bg-muted"
@click="handleClick">
<div
class="box flex-1 rounded-lg px-8 py-4 transition-shadow duration-170 cursor-pointer hover:bg-muted"
@click="handleClick"
>
<div class="flex items-center mb-4">
<component :is="icon" size="16" class="mr-2" />
<p class="text-lg">{{ title }}</p>

View File

@@ -15,5 +15,5 @@ defineProps({
type: String,
required: true
}
});
</script>
})
</script>

View File

@@ -1,55 +1,61 @@
<template>
<div>
<div class="mb-5">
<PageHeader title="Conversation" description="Manage conversation settings" />
</div>
<div class="flex space-x-5">
<AdminMenuCard v-for="card in cards" :key="card.title" :onClick="card.onClick" :title="card.title"
:subTitle="card.subTitle" :icon="card.icon">
</AdminMenuCard>
</div>
<div>
<div class="mb-5">
<PageHeader title="Conversation" description="Manage conversation settings" />
</div>
<router-view></router-view>
<div class="flex space-x-5">
<AdminMenuCard
v-for="card in cards"
:key="card.title"
:onClick="card.onClick"
:title="card.title"
:subTitle="card.subTitle"
:icon="card.icon"
>
</AdminMenuCard>
</div>
</div>
<router-view></router-view>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { Tag, TrendingUp, Activity } from 'lucide-vue-next'
import AdminMenuCard from '@/components/admin/common/MenuCard.vue'
import PageHeader from '../common/PageHeader.vue';
import PageHeader from '../common/PageHeader.vue'
const router = useRouter()
const navigateToTags = () => {
router.push('/admin/conversations/tags')
router.push('/admin/conversations/tags')
}
const navigateToStatus = () => {
router.push('/admin/conversations/status')
router.push('/admin/conversations/status')
}
const navigateToPriority = () => {
router.push('/admin/conversations/priority')
router.push('/admin/conversations/priority')
}
const cards = [
{
title: 'Tags',
subTitle: 'Manage conversation tags.',
onClick: navigateToTags,
icon: Tag
},
{
title: 'Priority',
subTitle: 'Manage conversation priorities.',
onClick: navigateToPriority,
icon: Activity
},
{
title: 'Status',
subTitle: 'Manage conversation statuses.',
onClick: navigateToStatus,
icon: TrendingUp
},
{
title: 'Tags',
subTitle: 'Manage conversation tags.',
onClick: navigateToTags,
icon: Tag
},
{
title: 'Priority',
subTitle: 'Manage conversation priorities.',
onClick: navigateToPriority,
icon: Activity
},
{
title: 'Status',
subTitle: 'Manage conversation statuses.',
onClick: navigateToStatus,
icon: TrendingUp
}
]
</script>
</script>

View File

@@ -1,6 +1,3 @@
<template>
Priority
</template>
<template>Priority</template>
<script setup>
</script>
<script setup></script>

View File

@@ -1,6 +1,3 @@
<template>
Status
</template>
<template>Status</template>
<script setup>
</script>
<script setup></script>

View File

@@ -10,9 +10,7 @@
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add a Tag</DialogTitle>
<DialogDescription>
Set tag name. Click save when you're done.
</DialogDescription>
<DialogDescription> Set tag name. Click save when you're done. </DialogDescription>
</DialogHeader>
<form @submit.prevent="onSubmit">
<FormField v-slot="{ field }" name="name">
@@ -51,7 +49,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
@@ -61,7 +59,7 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogTrigger
} from '@/components/ui/dialog'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
@@ -76,13 +74,12 @@ const dialogOpen = ref(false)
onMounted(() => {
getTags()
emit.on('refresh-list', (data) => {
if (data?.name === "tags")
getTags()
if (data?.name === 'tags') getTags()
})
})
const form = useForm({
validationSchema: toTypedSchema(formSchema),
validationSchema: toTypedSchema(formSchema)
})
const getTags = async () => {
@@ -99,5 +96,4 @@ const onSubmit = form.handleSubmit(async (values) => {
console.error('Failed to create tag:', error)
}
})
</script>

View File

@@ -3,36 +3,36 @@ import dropdown from './dataTableDropdown.vue'
import { format } from 'date-fns'
export const columns = [
{
accessorKey: 'name',
header: function () {
return h('div', { class: 'text-center' }, 'Name')
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
}
{
accessorKey: 'name',
header: function () {
return h('div', { class: 'text-center' }, 'Name')
},
{
accessorKey: 'created_at',
header: function () {
return h('div', { class: 'text-center' }, 'Created at')
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp'))
}
},
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => {
const tag = row.original
return h(
'div',
{ class: 'relative' },
h(dropdown, {
tag
})
)
}
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
}
},
{
accessorKey: 'created_at',
header: function () {
return h('div', { class: 'text-center' }, 'Created at')
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp'))
}
},
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => {
const tag = row.original
return h(
'div',
{ class: 'relative' },
h(dropdown, {
tag
})
)
}
}
]

View File

@@ -1,81 +1,74 @@
<template>
<Dialog v-model:open="dialogOpen">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="w-8 h-8 p-0">
<span class="sr-only">Open menu</span>
<MoreHorizontal class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DialogTrigger as-child>
<DropdownMenuItem>
Edit
</DropdownMenuItem>
</DialogTrigger>
<DropdownMenuItem @click="deleteTag">
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit tag</DialogTitle>
<DialogDescription>
Change the tag name. Click save when you're done.
</DialogDescription>
</DialogHeader>
<form @submit.prevent="onSubmit">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input type="text" placeholder="billing, tech" v-bind="componentField" />
</FormControl>
<FormDescription>Renaming the tag will rename it across all conversations.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<DialogFooter>
<Button type="submit" size="sm">
Save changes
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog v-model:open="dialogOpen">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="w-8 h-8 p-0">
<span class="sr-only">Open menu</span>
<MoreHorizontal class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DialogTrigger as-child>
<DropdownMenuItem> Edit </DropdownMenuItem>
</DialogTrigger>
<DropdownMenuItem @click="deleteTag"> Delete </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit tag</DialogTitle>
<DialogDescription> Change the tag name. Click save when you're done. </DialogDescription>
</DialogHeader>
<form @submit.prevent="onSubmit">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input type="text" placeholder="billing, tech" v-bind="componentField" />
</FormControl>
<FormDescription
>Renaming the tag will rename it across all conversations.</FormDescription
>
<FormMessage />
</FormItem>
</FormField>
<DialogFooter>
<Button type="submit" size="sm"> Save changes </Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
<script setup>
import { watch, ref } from 'vue'
import { MoreHorizontal } from 'lucide-vue-next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { formSchema } from './formSchema.js'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { useEmitter } from '@/composables/useEmitter'
@@ -85,44 +78,44 @@ const dialogOpen = ref(false)
const emit = useEmitter()
const props = defineProps({
tag: {
type: Object,
required: true,
default: () => ({
id: '',
name: ''
})
}
tag: {
type: Object,
required: true,
default: () => ({
id: '',
name: ''
})
}
})
const form = useForm({
validationSchema: toTypedSchema(formSchema),
validationSchema: toTypedSchema(formSchema)
})
const onSubmit = form.handleSubmit(async (values) => {
await api.updateTag(props.tag.id, values)
dialogOpen.value = false
emitRefreshTagsList()
await api.updateTag(props.tag.id, values)
dialogOpen.value = false
emitRefreshTagsList()
})
const deleteTag = async () => {
await api.deleteTag(props.tag.id)
dialogOpen.value = false
emitRefreshTagsList()
await api.deleteTag(props.tag.id)
dialogOpen.value = false
emitRefreshTagsList()
}
const emitRefreshTagsList = () => {
emit.emit('refresh-list', {
name: 'tags'
})
emit.emit('refresh-list', {
name: 'tags'
})
}
// Watch for changes in initialValues and update the form.
watch(
() => props.tag,
(newValues) => {
form.setValues(newValues)
},
{ immediate: true, deep: true }
() => props.tag,
(newValues) => {
form.setValues(newValues)
},
{ immediate: true, deep: true }
)
</script>
</script>

View File

@@ -1,12 +1,11 @@
import * as z from 'zod';
import * as z from 'zod'
export const formSchema = z.object({
name: z
.string({
required_error: 'Tag name is required.'
})
.min(1, {
message: 'First name must be at least 1 character.'
}),
});
name: z
.string({
required_error: 'Tag name is required.'
})
.min(1, {
message: 'First name must be at least 1 character.'
})
})

View File

@@ -9,28 +9,27 @@
import { ref, onMounted } from 'vue'
import GeneralSettingForm from './GeneralSettingForm.vue'
import PageHeader from '../common/PageHeader.vue'
import api from '@/api';
import api from '@/api'
const initialValues = ref({})
onMounted(async () => {
const response = await api.getSettings('general');
const data = response.data.data;
const response = await api.getSettings('general')
const data = response.data.data
initialValues.value = Object.keys(data).reduce((acc, key) => {
// Remove 'app.' prefix
const newKey = key.replace(/^app\./, '');
acc[newKey] = data[key];
return acc;
}, {});
});
const newKey = key.replace(/^app\./, '')
acc[newKey] = data[key]
return acc
}, {})
})
const submitForm = (values) => {
// Prepend keys with `app.`
const updatedValues = Object.fromEntries(
Object.entries(values).map(([key, value]) => [`app.${key}`, value])
);
api.updateSettings('general', updatedValues);
)
api.updateSettings('general', updatedValues)
}
</script>
</script>

View File

@@ -1,90 +1,92 @@
<template>
<form @submit="onSubmit" class="w-2/3 space-y-6">
<FormField v-slot="{ field }" name="site_name">
<FormItem v-auto-animate>
<FormLabel>Site Name</FormLabel>
<FormControl>
<Input type="text" placeholder="Site Name" v-bind="field" />
</FormControl>
<FormDescription>Enter the site name</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<form @submit="onSubmit" class="w-2/3 space-y-6">
<FormField v-slot="{ field }" name="site_name">
<FormItem v-auto-animate>
<FormLabel>Site Name</FormLabel>
<FormControl>
<Input type="text" placeholder="Site Name" v-bind="field" />
</FormControl>
<FormDescription>Enter the site name</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ field }" name="lang">
<FormItem>
<FormLabel>Language</FormLabel>
<FormControl>
<Select v-bind="field" :modelValue="field.value">
<SelectTrigger>
<SelectValue placeholder="Select a language" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="en">
English
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormDescription>Select language for the app.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ field }" name="lang">
<FormItem>
<FormLabel>Language</FormLabel>
<FormControl>
<Select v-bind="field" :modelValue="field.value">
<SelectTrigger>
<SelectValue placeholder="Select a language" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="en"> English </SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormDescription>Select language for the app.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ field }" name="root_url">
<FormItem v-auto-animate>
<FormLabel>Root URL</FormLabel>
<FormControl>
<Input type="text" placeholder="Root URL" v-bind="field" />
</FormControl>
<FormDescription>Root URL of the app.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ field }" name="root_url">
<FormItem v-auto-animate>
<FormLabel>Root URL</FormLabel>
<FormControl>
<Input type="text" placeholder="Root URL" v-bind="field" />
</FormControl>
<FormDescription>Root URL of the app.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ field }" name="favicon_url" :value="props.initialValues.favicon_url">
<FormItem v-auto-animate>
<FormLabel>Favicon URL</FormLabel>
<FormControl>
<Input type="text" placeholder="Favicon URL" v-bind="field" />
</FormControl>
<FormDescription>Favicon URL for the app.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ field }" name="favicon_url" :value="props.initialValues.favicon_url">
<FormItem v-auto-animate>
<FormLabel>Favicon URL</FormLabel>
<FormControl>
<Input type="text" placeholder="Favicon URL" v-bind="field" />
</FormControl>
<FormDescription>Favicon URL for the app.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ field }" name="max_file_upload_size" :value="props.initialValues.max_file_upload_size">
<FormItem v-auto-animate>
<FormLabel>Max allowed file upload size</FormLabel>
<FormControl>
<Input type="number" placeholder="10" v-bind="field" />
</FormControl>
<FormDescription>In megabytes.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-slot="{ field }"
name="max_file_upload_size"
:value="props.initialValues.max_file_upload_size"
>
<FormItem v-auto-animate>
<FormLabel>Max allowed file upload size</FormLabel>
<FormControl>
<Input type="number" placeholder="10" v-bind="field" />
</FormControl>
<FormDescription>In megabytes.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField name="allowed_file_upload_extensions" v-slot="{ componentField }">
<FormItem>
<FormLabel>Allowed file extensions</FormLabel>
<FormControl>
<TagsInput v-model="componentField.modelValue">
<TagsInputItem v-for="item in componentField.modelValue" :key="item" :value="item">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput placeholder="jpg" />
</TagsInput>
</FormControl>
<FormDescription>Use `*` to allow any file.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField name="allowed_file_upload_extensions" v-slot="{ componentField }">
<FormItem>
<FormLabel>Allowed file extensions</FormLabel>
<FormControl>
<TagsInput v-model="componentField.modelValue">
<TagsInputItem v-for="item in componentField.modelValue" :key="item" :value="item">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput placeholder="jpg" />
</TagsInput>
</FormControl>
<FormDescription>Use `*` to allow any file.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" size="sm"> {{ submitLabel }} </Button>
</form>
<Button type="submit" size="sm"> {{ submitLabel }} </Button>
</form>
</template>
<script setup>
@@ -95,55 +97,60 @@ import { toTypedSchema } from '@vee-validate/zod'
import { formSchema } from './formSchema.js'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { TagsInput, TagsInputInput, TagsInputItem, TagsInputItemDelete, TagsInputItemText } from '@/components/ui/tags-input'
import {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
} from '@/components/ui/tags-input'
import { Input } from '@/components/ui/input'
const props = defineProps({
initialValues: {
type: Object,
required: false
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
required: false,
default: () => 'Submit'
},
initialValues: {
type: Object,
required: false
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
required: false,
default: () => 'Submit'
}
})
const form = useForm({
validationSchema: toTypedSchema(formSchema),
validationSchema: toTypedSchema(formSchema)
})
const onSubmit = form.handleSubmit((values) => {
props.submitForm(values)
props.submitForm(values)
})
// Watch for changes in initialValues and update the form.
watch(
() => props.initialValues,
(newValues) => {
form.setValues(newValues);
},
{ deep: true }
);
</script>
() => props.initialValues,
(newValues) => {
form.setValues(newValues)
},
{ deep: true }
)
</script>

View File

@@ -1,42 +1,37 @@
import * as z from 'zod'
export const formSchema = z.object({
site_name: z
.string({
required_error: 'Site name is required.'
})
.min(1, {
message: 'Site name must be at least 1 characters.'
}),
lang: z
.string().optional(),
root_url: z
.string({
required_error: 'Root URL is required.'
})
.url({
message: 'Root URL must be a valid URL.'
}),
favicon_url: z
.string({
required_error: 'Favicon URL is required.'
})
.url({
message: 'Favicon URL must be a valid URL.'
}),
max_file_upload_size: z
.number({
required_error: 'Max upload file size is required.'
})
.min(1, {
message: 'Max upload file size must be at least 1 MB.'
})
.max(1024, {
message: 'Max upload file size cannot exceed 128 MB.'
}),
allowed_file_upload_extensions: z
.array(z.string())
.nullable()
.default([])
.optional(),
site_name: z
.string({
required_error: 'Site name is required.'
})
.min(1, {
message: 'Site name must be at least 1 characters.'
}),
lang: z.string().optional(),
root_url: z
.string({
required_error: 'Root URL is required.'
})
.url({
message: 'Root URL must be a valid URL.'
}),
favicon_url: z
.string({
required_error: 'Favicon URL is required.'
})
.url({
message: 'Favicon URL must be a valid URL.'
}),
max_file_upload_size: z
.number({
required_error: 'Max upload file size is required.'
})
.min(1, {
message: 'Max upload file size must be at least 1 MB.'
})
.max(1024, {
message: 'Max upload file size cannot exceed 128 MB.'
}),
allowed_file_upload_extensions: z.array(z.string()).nullable().default([]).optional()
})

View File

@@ -14,7 +14,7 @@ import { CustomBreadcrumb } from '@/components/ui/breadcrumb/index.js'
const breadcrumbLinks = [
{ path: '/admin/inboxes', label: 'Inboxes' },
{ path: '#', label: 'Edit Inbox' },
{ path: '#', label: 'Edit Inbox' }
]
const router = useRouter()
const inbox = ref({})

View File

@@ -1,58 +1,64 @@
<template>
<AutoForm class="w-11/12 space-y-6" :schema="emailChannelFormSchema" :form="form" :field-config="{
name: {
description: 'Name for your inbox.'
},
from: {
label: 'Email address',
description: 'From email address. e.g. My Support <mysupport@example.com>'
},
imap: {
label: 'IMAP',
password: {
inputProps: {
type: 'password',
placeholder: ''
<AutoForm
class="w-11/12 space-y-6"
:schema="emailChannelFormSchema"
:form="form"
:field-config="{
name: {
description: 'Name for your inbox.'
},
from: {
label: 'Email address',
description: 'From email address. e.g. My Support <mysupport@example.com>'
},
imap: {
label: 'IMAP',
password: {
inputProps: {
type: 'password',
placeholder: ''
}
},
read_interval: {
label: 'Emails scan interval'
}
},
read_interval: {
label: 'Emails scan interval'
}
},
smtp: {
label: 'SMTP',
max_conns: {
label: 'Max connections',
description: 'Maximum number of concurrent connections to the server.'
},
max_msg_retries: {
label: 'Retries',
description: 'Number of times to retry when a message fails.'
},
idle_timeout: {
label: 'Idle timeout',
description: `IdleTimeout is the maximum time to wait for new activity on a connection
smtp: {
label: 'SMTP',
max_conns: {
label: 'Max connections',
description: 'Maximum number of concurrent connections to the server.'
},
max_msg_retries: {
label: 'Retries',
description: 'Number of times to retry when a message fails.'
},
idle_timeout: {
label: 'Idle timeout',
description: `IdleTimeout is the maximum time to wait for new activity on a connection
before closing it and removing it from the pool.`
},
wait_timeout: {
label: 'Wait timeout',
description: `PoolWaitTimeout is the maximum time to wait to obtain a connection from
},
wait_timeout: {
label: 'Wait timeout',
description: `PoolWaitTimeout is the maximum time to wait to obtain a connection from
a pool before timing out. This may happen when all open connections are
busy sending e-mails and they're not returning to the pool fast enough.
This is also the timeout used when creating new SMTP connections.
`
},
auth_protocol: {
label: 'Auth protocol'
},
password: {
inputProps: {
type: 'password',
placeholder: ''
},
auth_protocol: {
label: 'Auth protocol'
},
password: {
inputProps: {
type: 'password',
placeholder: ''
}
}
}
}
}" @submit="submitForm">
}"
@submit="submitForm"
>
<Button type="submit" size="sm"> {{ props.submitLabel }} </Button>
</AutoForm>
</template>
@@ -90,8 +96,7 @@ const form = useForm({
watch(
() => props.initialValues,
(newValues) => {
if (newValues)
form.setValues(newValues)
if (newValues) form.setValues(newValues)
},
{ deep: true, immediate: true }
)

View File

@@ -41,7 +41,9 @@ function toggleInbox(id) {
<DropdownMenuContent>
<DropdownMenuItem @click="editInbox(props.inbox.id)"> Edit </DropdownMenuItem>
<DropdownMenuItem @click="deleteInbox(props.inbox.id)"> Delete </DropdownMenuItem>
<DropdownMenuItem @click="toggleInbox(props.inbox.id)" v-if="props.inbox.disabled"> Enable </DropdownMenuItem>
<DropdownMenuItem @click="toggleInbox(props.inbox.id)" v-if="props.inbox.disabled">
Enable
</DropdownMenuItem>
<DropdownMenuItem @click="toggleInbox(props.inbox.id)" v-else> Disable </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -111,7 +111,7 @@ const columns = [
inbox,
onEditInbox: (id) => handleEditInbox(id),
onDeleteInbox: (id) => handleDeleteInbox(id),
onToggleInbox: (id) => handleToggleInbox(id),
onToggleInbox: (id) => handleToggleInbox(id)
})
)
}

View File

@@ -5,15 +5,27 @@
<div class="space-y-10">
<div class="mt-10">
<Stepper class="flex w-full items-start gap-2" v-model="currentStep">
<StepperItem v-for="step in steps" :key="step.step" v-slot="{ state }"
class="relative flex w-full flex-col items-center justify-center" :step="step.step">
<StepperSeparator v-if="step.step !== steps[steps.length - 1].step"
class="absolute left-[calc(50%+20px)] right-[calc(-50%+10px)] top-5 block h-0.5 shrink-0 rounded-full bg-muted group-data-[state=completed]:bg-primary" />
<StepperItem
v-for="step in steps"
:key="step.step"
v-slot="{ state }"
class="relative flex w-full flex-col items-center justify-center"
:step="step.step"
>
<StepperSeparator
v-if="step.step !== steps[steps.length - 1].step"
class="absolute left-[calc(50%+20px)] right-[calc(-50%+10px)] top-5 block h-0.5 shrink-0 rounded-full bg-muted group-data-[state=completed]:bg-primary"
/>
<div>
<Button :variant="state === 'completed' || state === 'active' ? 'default' : 'outline'" size="icon"
<Button
:variant="state === 'completed' || state === 'active' ? 'default' : 'outline'"
size="icon"
class="z-10 rounded-full shrink-0"
:class="[state === 'active' && 'ring-2 ring-ring ring-offset-2 ring-offset-background']">
:class="[
state === 'active' && 'ring-2 ring-ring ring-offset-2 ring-offset-background'
]"
>
<Check v-if="state === 'completed'" class="size-5" />
<span v-if="state === 'active'">{{ currentStep }}</span>
<span v-if="state === 'inactive'">{{ step.step }}</span>
@@ -21,12 +33,16 @@
</div>
<div class="mt-5 flex flex-col items-center text-center">
<StepperTitle :class="[state === 'active' && 'text-primary']"
class="text-sm font-semibold transition lg:text-base">
<StepperTitle
:class="[state === 'active' && 'text-primary']"
class="text-sm font-semibold transition lg:text-base"
>
{{ step.title }}
</StepperTitle>
<StepperDescription :class="[state === 'active' && 'text-primary']"
class="sr-only text-xs text-muted-foreground transition md:not-sr-only lg:text-sm">
<StepperDescription
:class="[state === 'active' && 'text-primary']"
class="sr-only text-xs text-muted-foreground transition md:not-sr-only lg:text-sm"
>
{{ step.description }}
</StepperDescription>
</div>
@@ -36,8 +52,14 @@
<div>
<div v-if="currentStep === 1" class="space-y-6">
<MenuCard v-for="channel in channels" :key="channel.title" :onClick="channel.onClick" :title="channel.title"
:subTitle="channel.subTitle" :icon="channel.icon">
<MenuCard
v-for="channel in channels"
:key="channel.title"
:onClick="channel.onClick"
:title="channel.title"
:subTitle="channel.subTitle"
:icon="channel.icon"
>
</MenuCard>
</div>
@@ -62,9 +84,15 @@ import { useToast } from '@/components/ui/toast/use-toast'
import { handleHTTPError } from '@/utils/http'
import { useRouter } from 'vue-router'
import { CustomBreadcrumb } from '@/components/ui/breadcrumb/index.js'
import { Check, Mail } from 'lucide-vue-next';
import { Check, Mail } from 'lucide-vue-next'
import MenuCard from '@/components/admin/common/MenuCard.vue'
import { Stepper, StepperDescription, StepperItem, StepperSeparator, StepperTitle } from '@/components/ui/stepper'
import {
Stepper,
StepperDescription,
StepperItem,
StepperSeparator,
StepperTitle
} from '@/components/ui/stepper'
import EmailInboxForm from '@/components/admin/inbox/EmailInboxForm.vue'
import api from '@/api'
@@ -75,13 +103,13 @@ const steps = [
{
step: 1,
title: 'Channel',
description: 'Choose a channel',
description: 'Choose a channel'
},
{
step: 2,
title: 'Configure',
description: 'Configure channel',
},
description: 'Configure channel'
}
]
const selectChannel = (channel) => {
@@ -99,12 +127,12 @@ const channels = [
subTitle: 'Create email inbox',
onClick: selectEmailChannel,
icon: Mail
},
}
]
const breadcrumbLinks = [
{ path: '/admin/inboxes', label: 'Inboxes' },
{ path: '#', label: 'New Inbox' },
{ path: '#', label: 'New Inbox' }
]
const router = useRouter()
@@ -133,7 +161,7 @@ const submitForm = (values) => {
createInbox(payload)
}
async function createInbox (payload) {
async function createInbox(payload) {
try {
await api.createInbox(payload)
router.push('/admin/inboxes')

View File

@@ -26,7 +26,8 @@ export const emailChannelFormSchema = z.object({
.string()
.describe('Email scan interval')
.refine(isGoDuration, {
message: 'Invalid duration format. Should be a number followed by s (seconds), m (minutes), or h (hours).',
message:
'Invalid duration format. Should be a number followed by s (seconds), m (minutes), or h (hours).'
})
.default('30s')
})
@@ -85,7 +86,8 @@ export const emailChannelFormSchema = z.object({
'Time to wait for new activity on a connection before closing it and removing it from the pool (s for seconds, m for minutes, h for hours).'
)
.refine(isGoDuration, {
message: 'Invalid duration format. Should be a number followed by s (seconds), m (minutes), or h (hours).',
message:
'Invalid duration format. Should be a number followed by s (seconds), m (minutes), or h (hours).'
})
.default('5s'),
wait_timeout: z
@@ -94,7 +96,8 @@ export const emailChannelFormSchema = z.object({
'Time to wait for new activity on a connection before closing it and removing it from the pool (s for seconds, m for minutes, h for hours).'
)
.refine(isGoDuration, {
message: 'Invalid duration format. Should be a number followed by s (seconds), m (minutes), or h (hours).',
message:
'Invalid duration format. Should be a number followed by s (seconds), m (minutes), or h (hours).'
})
.default('5s'),
auth_protocol: z.enum(['login', 'cram', 'plain', 'none']).default('none').optional()
@@ -104,7 +107,7 @@ export const emailChannelFormSchema = z.object({
.describe('SMTP servers')
.default([
{
host: "smtp.yourmailserver.com",
host: 'smtp.yourmailserver.com',
port: 25,
username: '',
password: '',
@@ -115,4 +118,4 @@ export const emailChannelFormSchema = z.object({
auth_protocol: 'plain'
}
])
});
})

View File

@@ -1,8 +1,8 @@
<template>
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<OIDCForm :initial-values="oidc" :submitForm="submitForm" :isNewForm=isNewForm />
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<OIDCForm :initial-values="oidc" :submitForm="submitForm" :isNewForm="isNewForm" />
</template>
<script setup>
@@ -13,48 +13,47 @@ import { useRouter } from 'vue-router'
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
const oidc = ref({
"provider": "Google"
provider: 'Google'
})
const router = useRouter()
const props = defineProps({
id: {
type: String,
required: false
}
id: {
type: String,
required: false
}
})
const submitForm = async (values) => {
if (props.id) {
await api.updateOIDC(props.id, values)
} else {
await api.createOIDC(values)
router.push("/admin/oidc")
}
if (props.id) {
await api.updateOIDC(props.id, values)
} else {
await api.createOIDC(values)
router.push('/admin/oidc')
}
}
const breadCrumLabel = () => {
return props.id ? "Edit" : 'New';
return props.id ? 'Edit' : 'New'
}
const isNewForm = computed(() => {
return props.id ? false : true;
return props.id ? false : true
})
const breadcrumbLinks = [
{ path: '/admin/oidc', label: 'OIDC' },
{ path: '#', label: breadCrumLabel() }
{ path: '/admin/oidc', label: 'OIDC' },
{ path: '#', label: breadCrumLabel() }
]
onMounted(async () => {
if (props.id) {
try {
const resp = await api.getOIDC(props.id)
oidc.value = resp.data.data
} catch (error) {
console.log(error)
}
if (props.id) {
try {
const resp = await api.getOIDC(props.id)
oidc.value = resp.data.data
} catch (error) {
console.log(error)
}
}
})
</script>
</script>

View File

@@ -1,103 +1,97 @@
<template>
<form @submit="onSubmit" class="w-2/3 space-y-6">
<form @submit="onSubmit" class="w-2/3 space-y-6">
<FormField v-slot="{ componentField }" name="provider">
<FormItem>
<FormLabel>Provider</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="Google"> Google </SelectItem>
<SelectItem value="Github"> Github </SelectItem>
<SelectItem value="Custom"> Custom </SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="provider">
<FormItem>
<FormLabel>Provider</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="Google">
Google
</SelectItem>
<SelectItem value="Github">
Github
</SelectItem>
<SelectItem value="Custom">
Custom
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="name">
<FormItem v-auto-animate>
<FormLabel>Name</FormLabel>
<FormControl>
<Input type="text" placeholder="Google" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="name">
<FormItem v-auto-animate>
<FormLabel>Name</FormLabel>
<FormControl>
<Input type="text" placeholder="Google" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="provider_url">
<FormItem v-auto-animate>
<FormLabel>Provider URL</FormLabel>
<FormControl>
<Input type="text" placeholder="Provider URL" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="provider_url">
<FormItem v-auto-animate>
<FormLabel>Provider URL</FormLabel>
<FormControl>
<Input type="text" placeholder="Provider URL" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="client_id">
<FormItem v-auto-animate>
<FormLabel>Client ID</FormLabel>
<FormControl>
<Input type="text" placeholder="Client ID" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="client_id">
<FormItem v-auto-animate>
<FormLabel>Client ID</FormLabel>
<FormControl>
<Input type="text" placeholder="Client ID" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="client_secret">
<FormItem v-auto-animate>
<FormLabel>Client Secret</FormLabel>
<FormControl>
<Input type="password" placeholder="Client Secret" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="client_secret">
<FormItem v-auto-animate>
<FormLabel>Client Secret</FormLabel>
<FormControl>
<Input type="password" placeholder="Client Secret" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="redirect_uri" v-if="!isNewForm">
<FormItem v-auto-animate>
<FormLabel>Redirect URI</FormLabel>
<FormControl>
<Input type="text" placeholder="Redirect URI" v-bind="componentField" readonly />
<span
class="absolute end-0 inset-y-0 flex items-center justify-center px-2 cursor-pointer"
>
<Copy size="16" />
</span>
</FormControl>
<FormDescription>Set this URI for callback.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="redirect_uri" v-if="!isNewForm">
<FormItem v-auto-animate>
<FormLabel>Redirect URI</FormLabel>
<FormControl>
<Input type="text" placeholder="Redirect URI" v-bind="componentField" readonly />
<span class="absolute end-0 inset-y-0 flex items-center justify-center px-2 cursor-pointer">
<Copy size="16" />
</span>
</FormControl>
<FormDescription>Set this URI for callback.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField name="disabled" v-slot="{ value, handleChange }" v-if="!isNewForm">
<FormItem>
<FormControl>
<div class="flex items-center space-x-2">
<Checkbox :checked="value" @update:checked="handleChange" />
<Label>Disable</Label>
</div>
</FormControl>
<FormDescription></FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField name="disabled" v-slot="{ value, handleChange }" v-if="!isNewForm">
<FormItem>
<FormControl>
<div class="flex items-center space-x-2">
<Checkbox :checked="value" @update:checked="handleChange" />
<Label>Disable</Label>
</div>
</FormControl>
<FormDescription></FormDescription>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" size="sm"> {{ submitLabel }} </Button>
</form>
<Button type="submit" size="sm"> {{ submitLabel }} </Button>
</form>
</template>
<script setup>
@@ -110,57 +104,57 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import { Copy } from 'lucide-vue-next';
import { Copy } from 'lucide-vue-next'
const props = defineProps({
initialValues: {
type: Object,
required: false
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
required: false,
default: () => 'Save'
},
isNewForm: {
type: Boolean
}
initialValues: {
type: Object,
required: false
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
required: false,
default: () => 'Save'
},
isNewForm: {
type: Boolean
}
})
const form = useForm({
validationSchema: toTypedSchema(oidcLoginFormSchema),
validationSchema: toTypedSchema(oidcLoginFormSchema)
})
const onSubmit = form.handleSubmit((values) => {
props.submitForm(values)
props.submitForm(values)
})
// Watch for changes in initialValues and update the form.
watch(
() => props.initialValues,
(newValues) => {
form.setValues(newValues)
},
{ deep: true, immediate: true }
() => props.initialValues,
(newValues) => {
form.setValues(newValues)
},
{ deep: true, immediate: true }
)
</script>
</script>

View File

@@ -10,7 +10,6 @@
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import DataTable from '@/components/admin/DataTable.vue'
@@ -28,8 +27,7 @@ const emit = useEmitter()
onMounted(() => {
fetchAll()
emit.on('refresh-list', (data) => {
if (data?.name === "inbox")
fetchAll
if (data?.name === 'inbox') fetchAll
})
})
@@ -39,6 +37,6 @@ const fetchAll = async () => {
}
const navigateToAddOIDC = () => {
router.push("/admin/oidc/new")
router.push('/admin/oidc/new')
}
</script>
</script>

View File

@@ -3,59 +3,59 @@ import dropdown from './dataTableDropdown.vue'
import { format } from 'date-fns'
export const columns = [
{
accessorKey: 'name',
header: function () {
return h('div', { class: 'text-center' }, 'Name')
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
}
{
accessorKey: 'name',
header: function () {
return h('div', { class: 'text-center' }, 'Name')
},
{
accessorKey: 'provider',
header: function () {
return h('div', { class: 'text-center' }, 'Provider')
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('provider'))
}
},
{
accessorKey: 'disabled',
header: () => h('div', { class: 'text-center' }, 'Enabled'),
cell: ({ row }) => {
const disabled = row.getValue('disabled')
return h('div', { class: 'text-center' }, [
h('input', {
type: 'checkbox',
checked: !disabled,
disabled: true
})
])
}
},
{
accessorKey: 'updated_at',
header: function () {
return h('div', { class: 'text-center' }, 'Modified at')
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
}
},
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => {
const role = row.original
return h(
'div',
{ class: 'relative' },
h(dropdown, {
role
})
)
}
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
}
},
{
accessorKey: 'provider',
header: function () {
return h('div', { class: 'text-center' }, 'Provider')
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('provider'))
}
},
{
accessorKey: 'disabled',
header: () => h('div', { class: 'text-center' }, 'Enabled'),
cell: ({ row }) => {
const disabled = row.getValue('disabled')
return h('div', { class: 'text-center' }, [
h('input', {
type: 'checkbox',
checked: !disabled,
disabled: true
})
])
}
},
{
accessorKey: 'updated_at',
header: function () {
return h('div', { class: 'text-center' }, 'Modified at')
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
}
},
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => {
const role = row.original
return h(
'div',
{ class: 'relative' },
h(dropdown, {
role
})
)
}
}
]

View File

@@ -1,10 +1,10 @@
<script setup>
import { MoreHorizontal } from 'lucide-vue-next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { useRouter } from 'vue-router'
@@ -15,38 +15,38 @@ const router = useRouter()
const emit = useEmitter()
const props = defineProps({
role: {
type: Object,
required: true,
default: () => ({
id: ''
})
}
role: {
type: Object,
required: true,
default: () => ({
id: ''
})
}
})
function edit (id) {
router.push({ path: `/admin/oidc/${id}/edit` })
function edit(id) {
router.push({ path: `/admin/oidc/${id}/edit` })
}
async function deleteOIDC (id) {
await api.deleteOIDC(id)
emit.emit('refresh-list', {
name: 'inbox'
})
async function deleteOIDC(id) {
await api.deleteOIDC(id)
emit.emit('refresh-list', {
name: 'inbox'
})
}
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="w-8 h-8 p-0">
<span class="sr-only">Open menu</span>
<MoreHorizontal class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="edit(props.role.id)"> Edit </DropdownMenuItem>
<DropdownMenuItem @click="deleteOIDC(props.role.id)"> Delete </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="w-8 h-8 p-0">
<span class="sr-only">Open menu</span>
<MoreHorizontal class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="edit(props.role.id)"> Edit </DropdownMenuItem>
<DropdownMenuItem @click="deleteOIDC(props.role.id)"> Delete </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>

View File

@@ -1,28 +1,23 @@
import * as z from 'zod'
export const oidcLoginFormSchema = z.object({
disabled: z
.boolean().optional(),
name: z
.string({
required_error: 'Name is required.'
}),
provider: z
.string().optional(),
provider_url: z
.string({
required_error: 'Provider URL is required.'
})
.url({
message: 'Provider URL must be a valid URL.'
}),
client_id: z
.string({
required_error: 'Client ID is required.'
}),
client_secret: z
.string({
required_error: 'Client Secret is required.'
}),
redirect_uri: z.string().readonly().optional(),
disabled: z.boolean().optional(),
name: z.string({
required_error: 'Name is required.'
}),
provider: z.string().optional(),
provider_url: z
.string({
required_error: 'Provider URL is required.'
})
.url({
message: 'Provider URL must be a valid URL.'
}),
client_id: z.string({
required_error: 'Client ID is required.'
}),
client_secret: z.string({
required_error: 'Client Secret is required.'
}),
redirect_uri: z.string().readonly().optional()
})

View File

@@ -4,8 +4,14 @@
<PageHeader title="Teams" description="Manage teams, users and roles" />
</div>
<div class="flex space-x-5">
<AdminMenuCard v-for="card in cards" :key="card.title" :onClick="card.onClick" :title="card.title"
:subTitle="card.subTitle" :icon="card.icon">
<AdminMenuCard
v-for="card in cards"
:key="card.title"
:onClick="card.onClick"
:title="card.title"
:subTitle="card.subTitle"
:icon="card.icon"
>
</AdminMenuCard>
</div>
</div>
@@ -16,7 +22,7 @@
import { useRouter } from 'vue-router'
import { Users, UserRoundCog, User } from 'lucide-vue-next'
import AdminMenuCard from '@/components/admin/common/MenuCard.vue'
import PageHeader from '../common/PageHeader.vue';
import PageHeader from '../common/PageHeader.vue'
const router = useRouter()
@@ -50,6 +56,6 @@ const cards = [
subTitle: 'Create and manage roles.',
onClick: navigateToRoles,
icon: UserRoundCog
},
}
]
</script>

View File

@@ -1,8 +1,8 @@
<template>
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<RoleForm :initial-values="role" :submitForm="submitForm" />
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<RoleForm :initial-values="role" :submitForm="submitForm" />
</template>
<script setup>
@@ -15,27 +15,27 @@ import api from '@/api'
const role = ref({})
const { toast } = useToast()
const props = defineProps({
id: {
type: String,
required: true
}
id: {
type: String,
required: true
}
})
onMounted(async () => {
const resp = await api.getRole(props.id)
role.value = resp.data.data
const resp = await api.getRole(props.id)
role.value = resp.data.data
})
const breadcrumbLinks = [
{ path: '/admin/teams', label: 'Teams' },
{ path: '/admin/teams/roles', label: 'Roles' },
{ path: '#', label: 'Edit role' }
{ path: '/admin/teams', label: 'Teams' },
{ path: '/admin/teams/roles', label: 'Roles' },
{ path: '#', label: 'Edit role' }
]
const submitForm = async (values) => {
await api.updateRole(props.id, values)
toast({
description: 'Role saved successfully',
});
await api.updateRole(props.id, values)
toast({
description: 'Role saved successfully'
})
}
</script>
</script>

View File

@@ -1,26 +1,26 @@
<template>
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<RoleForm :initial-values="{}" :submitForm="submitForm"/>
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<RoleForm :initial-values="{}" :submitForm="submitForm" />
</template>
<script setup>
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
import RoleForm from './RoleForm.vue';
import api from '@/api';
import RoleForm from './RoleForm.vue'
import api from '@/api'
import { useRouter } from 'vue-router'
const router = useRouter()
const breadcrumbLinks = [
{ path: '/admin/teams', label: 'Teams' },
{ path: '/admin/teams/roles', label: 'Roles' },
{ path: '#', label: 'Add role' }
{ path: '/admin/teams', label: 'Teams' },
{ path: '/admin/teams/roles', label: 'Roles' },
{ path: '#', label: 'Add role' }
]
const submitForm = async (values) => {
await api.createRole(values)
router.push('/admin/teams/roles')
await api.createRole(values)
router.push('/admin/teams/roles')
}
</script>
</script>

View File

@@ -1,45 +1,59 @@
<template>
<form @submit.prevent="onSubmit" class="w-2/3 space-y-6">
<FormField v-slot="{ componentField }" name="name">
<FormItem v-auto-animate>
<FormLabel>Name</FormLabel>
<FormControl>
<Input type="text" placeholder="Agent" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input type="text" placeholder="This role is for all support agents" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<form @submit.prevent="onSubmit" class="w-2/3 space-y-6">
<FormField v-slot="{ componentField }" name="name">
<FormItem v-auto-animate>
<FormLabel>Name</FormLabel>
<FormControl>
<Input type="text" placeholder="Agent" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
type="text"
placeholder="This role is for all support agents"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<p class="text-base">Set permissions for this role</p>
<p class="text-base">Set permissions for this role</p>
<div v-for="entity in permissions" :key="entity.name" class="border box p-4 rounded-lg shadow-sm">
<p class="text-lg mb-5">{{ entity.name }}</p>
<div class="space-y-4">
<FormField v-for="permission in entity.permissions" :key="permission.name" type="checkbox"
:name="permission.name">
<FormItem class="flex flex-col gap-y-5 space-y-0 rounded-lg">
<div class="flex space-x-3">
<FormControl>
<Checkbox :checked="selectedPermissions.includes(permission.name)"
@update:checked="(newValue) => handleChange(newValue, permission.name)" />
<FormLabel>{{ permission.label }}</FormLabel>
</FormControl>
</div>
</FormItem>
</FormField>
<div
v-for="entity in permissions"
:key="entity.name"
class="border box p-4 rounded-lg shadow-sm"
>
<p class="text-lg mb-5">{{ entity.name }}</p>
<div class="space-y-4">
<FormField
v-for="permission in entity.permissions"
:key="permission.name"
type="checkbox"
:name="permission.name"
>
<FormItem class="flex flex-col gap-y-5 space-y-0 rounded-lg">
<div class="flex space-x-3">
<FormControl>
<Checkbox
:checked="selectedPermissions.includes(permission.name)"
@update:checked="(newValue) => handleChange(newValue, permission.name)"
/>
<FormLabel>{{ permission.label }}</FormLabel>
</FormControl>
</div>
</div>
<Button type="submit" size="sm">{{ submitLabel }}</Button>
</form>
</FormItem>
</FormField>
</div>
</div>
<Button type="submit" size="sm">{{ submitLabel }}</Button>
</form>
</template>
<script setup>
@@ -54,92 +68,92 @@ import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/comp
import { Input } from '@/components/ui/input'
const props = defineProps({
initialValues: {
type: Object,
required: false
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
required: false,
default: () => 'Submit'
},
initialValues: {
type: Object,
required: false
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
required: false,
default: () => 'Submit'
}
})
const permissions = ref([
{
name: 'Conversation',
permissions: [
{ name: 'conversation:reply', label: 'Reply to conversations' },
{ name: 'conversation:edit_all_properties', label: 'Edit all conversation properties' },
{ name: 'conversation:edit_status', label: 'Edit conversation status' },
{ name: 'conversation:edit_priority', label: 'Edit conversation priority' },
{ name: 'conversation:edit_team', label: 'Edit conversation team' },
{ name: 'conversation:edit_user', label: 'Edit conversation user' },
{ name: 'conversation:view_all', label: 'View all conversations' },
{ name: 'conversation:view_team', label: 'View team conversations' },
{ name: 'conversation:view_assigned', label: 'View assigned conversations' }
]
},
{
name: 'Admin',
permissions: [
{ name: 'admin:access', label: 'Access the admin panel' },
{ name: 'settings:manage_general', label: 'Manage general settings' },
{ name: 'settings:manage_file', label: 'Manage file upload settings' },
{ name: 'login:manage', label: 'Manage login settings' },
{ name: 'inboxes:manage', label: 'Manage inboxes' },
{ name: 'users:manage', label: 'Manage users' },
{ name: 'teams:manage', label: 'Manage teams' },
{ name: 'roles:manage', label: 'Manage roles' },
{ name: 'automations:manage', label: 'Manage automations' },
{ name: 'templates:manage', label: 'Manage templates' }
]
},
{
name: 'Dashboard',
permissions: [
{ name: 'dashboard:view_global', label: 'Access global dashboard' },
{ name: 'dashboard:view_team_self', label: 'Access dashboard of teams the user is part of' }
]
}
]);
{
name: 'Conversation',
permissions: [
{ name: 'conversation:reply', label: 'Reply to conversations' },
{ name: 'conversation:edit_all_properties', label: 'Edit all conversation properties' },
{ name: 'conversation:edit_status', label: 'Edit conversation status' },
{ name: 'conversation:edit_priority', label: 'Edit conversation priority' },
{ name: 'conversation:edit_team', label: 'Edit conversation team' },
{ name: 'conversation:edit_user', label: 'Edit conversation user' },
{ name: 'conversation:view_all', label: 'View all conversations' },
{ name: 'conversation:view_team', label: 'View team conversations' },
{ name: 'conversation:view_assigned', label: 'View assigned conversations' }
]
},
{
name: 'Admin',
permissions: [
{ name: 'admin:access', label: 'Access the admin panel' },
{ name: 'settings:manage_general', label: 'Manage general settings' },
{ name: 'settings:manage_file', label: 'Manage file upload settings' },
{ name: 'login:manage', label: 'Manage login settings' },
{ name: 'inboxes:manage', label: 'Manage inboxes' },
{ name: 'users:manage', label: 'Manage users' },
{ name: 'teams:manage', label: 'Manage teams' },
{ name: 'roles:manage', label: 'Manage roles' },
{ name: 'automations:manage', label: 'Manage automations' },
{ name: 'templates:manage', label: 'Manage templates' }
]
},
{
name: 'Dashboard',
permissions: [
{ name: 'dashboard:view_global', label: 'Access global dashboard' },
{ name: 'dashboard:view_team_self', label: 'Access dashboard of teams the user is part of' }
]
}
])
const selectedPermissions = ref([])
const form = useForm({
validationSchema: toTypedSchema(formSchema),
initialValues: props.initialValues
validationSchema: toTypedSchema(formSchema),
initialValues: props.initialValues
})
const onSubmit = form.handleSubmit((values) => {
values.permissions = selectedPermissions.value
props.submitForm(values)
values.permissions = selectedPermissions.value
props.submitForm(values)
})
const handleChange = (value, perm) => {
if (value) {
selectedPermissions.value.push(perm)
} else {
const index = selectedPermissions.value.indexOf(perm)
if (index > -1) {
selectedPermissions.value.splice(index, 1)
}
if (value) {
selectedPermissions.value.push(perm)
} else {
const index = selectedPermissions.value.indexOf(perm)
if (index > -1) {
selectedPermissions.value.splice(index, 1)
}
}
}
// Watch for changes in initialValues and update the form.
watch(
() => props.initialValues,
(newValues) => {
if (newValues) {
form.setValues(newValues)
selectedPermissions.value = newValues.permissions || []
}
},
{ deep: true }
() => props.initialValues,
(newValues) => {
if (newValues) {
form.setValues(newValues)
selectedPermissions.value = newValues.permissions || []
}
},
{ deep: true }
)
</script>

View File

@@ -1,14 +1,14 @@
<template>
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<div class="flex justify-end mb-5">
<Button @click="navigateToAddRole" size="sm"> New role </Button>
</div>
<div class="w-full">
<DataTable :columns="columns" :data="roles" />
</div>
<router-view></router-view>
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<div class="flex justify-end mb-5">
<Button @click="navigateToAddRole" size="sm"> New role </Button>
</div>
<div class="w-full">
<DataTable :columns="columns" :data="roles" />
</div>
<router-view></router-view>
</template>
<script setup>
@@ -26,28 +26,28 @@ const { toast } = useToast()
const router = useRouter()
const roles = ref([])
const breadcrumbLinks = [
{ path: '/admin/teams', label: 'Teams' },
{ path: '#', label: 'Roles' }
{ path: '/admin/teams', label: 'Teams' },
{ path: '#', label: 'Roles' }
]
const getRoles = async () => {
try {
const resp = await api.getRoles()
roles.value = resp.data.data
} catch (error) {
toast({
title: 'Could not fetch roles.',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
try {
const resp = await api.getRoles()
roles.value = resp.data.data
} catch (error) {
toast({
title: 'Could not fetch roles.',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
onMounted(async () => {
getRoles()
getRoles()
})
const navigateToAddRole = () => {
router.push('/admin/teams/roles/new')
router.push('/admin/teams/roles/new')
}
</script>
</script>

View File

@@ -1,20 +1,20 @@
import * as z from 'zod'
export const formSchema = z.object({
name: z
.string({
required_error: 'Name is required.'
})
.min(2, {
message: 'First name must be at least 2 characters.'
}),
name: z
.string({
required_error: 'Name is required.'
})
.min(2, {
message: 'First name must be at least 2 characters.'
}),
description: z
.string({
required_error: 'Description is required.'
})
.min(2, {
message: 'First name must be at least 2 characters.'
}),
permissions: z.array(z.string()).optional()
})
description: z
.string({
required_error: 'Description is required.'
})
.min(2, {
message: 'First name must be at least 2 characters.'
}),
permissions: z.array(z.string()).optional()
})

View File

@@ -1,8 +1,8 @@
<template>
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<TeamForm :initial-values="{}" :submitForm="submitForm" />
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<TeamForm :initial-values="{}" :submitForm="submitForm" />
</template>
<script setup>
@@ -14,24 +14,24 @@ import api from '@/api'
const { toast } = useToast()
const breadcrumbLinks = [
{ path: '/admin/teams', label: 'Teams' },
{ path: '/admin/teams/teams', label: 'Teams' },
{ path: '/admin/teams/teams/new', label: 'New team' },
{ path: '/admin/teams', label: 'Teams' },
{ path: '/admin/teams/teams', label: 'Teams' },
{ path: '/admin/teams/teams/new', label: 'New team' }
]
const submitForm = (values) => {
createTeam(values)
createTeam(values)
}
const createTeam = async (values) => {
try {
await api.createTeam(values)
} catch (error) {
toast({
title: 'Could not create team.',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
try {
await api.createTeam(values)
} catch (error) {
toast({
title: 'Could not create team.',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
</script>
</script>

View File

@@ -60,11 +60,11 @@ const props = defineProps({
type: String,
required: false,
default: () => 'Submit'
},
}
})
const form = useForm({
validationSchema: toTypedSchema(teamFormSchema),
validationSchema: toTypedSchema(teamFormSchema)
})
const onSubmit = form.handleSubmit((values) => {

View File

@@ -18,7 +18,11 @@ export const columns = [
return h('div', { class: 'text-center' }, 'Modified at')
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, format(row.getValue('updated_at'), 'PPpp'))
return h(
'div',
{ class: 'text-center font-medium' },
format(row.getValue('updated_at'), 'PPpp')
)
}
},
{

View File

@@ -28,7 +28,7 @@ import { useRouter } from 'vue-router'
const breadcrumbLinks = [
{ path: '/admin/teams', label: 'Teams' },
{ path: '/admin/teams/', label: 'Teams' },
{ path: '/admin/teams/', label: 'Teams' }
]
const router = useRouter()
@@ -48,7 +48,6 @@ const getData = async () => {
}
}
const navigateToAddTeam = () => {
router.push('/admin/teams/teams/new')
}

View File

@@ -33,7 +33,11 @@
<FormItem>
<FormLabel>Select teams</FormLabel>
<FormControl>
<SelectTag v-model="componentField.modelValue" :items="teamNames" placeHolder="Select teams"></SelectTag>
<SelectTag
v-model="componentField.modelValue"
:items="teamNames"
placeHolder="Select teams"
></SelectTag>
</FormControl>
<FormMessage />
</FormItem>
@@ -43,7 +47,11 @@
<FormItem>
<FormLabel>Select roles</FormLabel>
<FormControl>
<SelectTag v-model="componentField.modelValue" :items="roleNames" placeHolder="Select roles"></SelectTag>
<SelectTag
v-model="componentField.modelValue"
:items="roleNames"
placeHolder="Select roles"
></SelectTag>
</FormControl>
<FormMessage />
</FormItem>
@@ -75,9 +83,7 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import {
SelectTag
} from '@/components/ui/select'
import { SelectTag } from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import api from '@/api'
@@ -105,24 +111,19 @@ const props = defineProps({
}
})
onMounted(async () => {
try {
const [teamsResp, rolesResp] = await Promise.all([
api.getTeams(),
api.getRoles()
]);
teams.value = teamsResp.data.data;
roles.value = rolesResp.data.data;
const [teamsResp, rolesResp] = await Promise.all([api.getTeams(), api.getRoles()])
teams.value = teamsResp.data.data
roles.value = rolesResp.data.data
} catch (err) {
console.log(err);
console.log(err)
}
});
})
const teamNames = computed(() => teams.value.map(team => team.name));
const roleNames = computed(() => roles.value.map(role => role.name));
const teamNames = computed(() => teams.value.map((team) => team.name))
const roleNames = computed(() => roles.value.map((role) => role.name))
const form = useForm({
validationSchema: toTypedSchema(userFormSchema),
@@ -141,4 +142,4 @@ watch(
},
{ immediate: true, deep: true }
)
</script>
</script>

View File

@@ -36,7 +36,11 @@ export const columns = [
return h('div', { class: 'text-center' }, 'Modified at')
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, format(row.getValue('updated_at'), 'PPpp'))
return h(
'div',
{ class: 'text-center font-medium' },
format(row.getValue('updated_at'), 'PPpp')
)
}
},
{

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import * as z from 'zod'
export const userFormSchema = z.object({
first_name: z
@@ -23,5 +23,5 @@ export const userFormSchema = z.object({
roles: z.array(z.string()).optional(),
send_welcome_email: z.boolean().optional(),
});
send_welcome_email: z.boolean().optional()
})

View File

@@ -1,8 +1,8 @@
<template>
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<TemplateForm :initial-values="template" :submitForm="submitForm" />
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<TemplateForm :initial-values="template" :submitForm="submitForm" />
</template>
<script setup>
@@ -16,38 +16,38 @@ const template = ref({})
const router = useRouter()
const props = defineProps({
id: {
type: String,
required: true
}
id: {
type: String,
required: true
}
})
const submitForm = async (values) => {
if (props.id) {
await api.updateTemplate(props.id, values)
} else {
await api.createTemplate(values)
router.push("/admin/templates")
}
if (props.id) {
await api.updateTemplate(props.id, values)
} else {
await api.createTemplate(values)
router.push('/admin/templates')
}
}
const breadCrumLabel = () => {
return props.id ? "Edit" : 'New';
return props.id ? 'Edit' : 'New'
}
const breadcrumbLinks = [
{ path: '/admin/templates', label: 'Templates' },
{ path: '#', label: breadCrumLabel() }
{ path: '/admin/templates', label: 'Templates' },
{ path: '#', label: breadCrumLabel() }
]
onMounted(async () => {
if (props.id) {
try {
const resp = await api.getTemplate(props.id)
template.value = resp.data.data
} catch (error) {
console.log(error)
}
if (props.id) {
try {
const resp = await api.getTemplate(props.id)
template.value = resp.data.data
} catch (error) {
console.log(error)
}
}
})
</script>
</script>

View File

@@ -1,41 +1,41 @@
<template>
<form @submit="onSubmit" class="w-2/3 space-y-6">
<FormField v-slot="{ componentField }" name="name">
<FormItem v-auto-animate>
<FormLabel>Name</FormLabel>
<FormControl>
<Input type="text" placeholder="Template name" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<form @submit="onSubmit" class="w-2/3 space-y-6">
<FormField v-slot="{ componentField }" name="name">
<FormItem v-auto-animate>
<FormLabel>Name</FormLabel>
<FormControl>
<Input type="text" placeholder="Template name" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="body">
<FormItem v-auto-animate>
<FormLabel>HTML body</FormLabel>
<FormControl>
<Textarea placeholder="HTML here.." v-bind="componentField" class="h-52" />
</FormControl>
<FormDescription>{{ templateBodyDescription() }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="body">
<FormItem v-auto-animate>
<FormLabel>HTML body</FormLabel>
<FormControl>
<Textarea placeholder="HTML here.." v-bind="componentField" class="h-52" />
</FormControl>
<FormDescription>{{ templateBodyDescription() }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField name="is_default" v-slot="{ value, handleChange }">
<FormItem>
<FormControl>
<div class="flex items-center space-x-2">
<Checkbox :checked="value" @update:checked="handleChange"/>
<Label>Is default</Label>
</div>
</FormControl>
<FormDescription>There can be only one default template.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField name="is_default" v-slot="{ value, handleChange }">
<FormItem>
<FormControl>
<div class="flex items-center space-x-2">
<Checkbox :checked="value" @update:checked="handleChange" />
<Label>Is default</Label>
</div>
</FormControl>
<FormDescription>There can be only one default template.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" size="sm"> {{ submitLabel }} </Button>
</form>
<Button type="submit" size="sm"> {{ submitLabel }} </Button>
</form>
</template>
<script setup>
@@ -46,12 +46,12 @@ import { toTypedSchema } from '@vee-validate/zod'
import { formSchema } from './formSchema.js'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
@@ -59,37 +59,37 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
const props = defineProps({
initialValues: {
type: Object,
required: false
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
required: false,
default: () => 'Save'
},
initialValues: {
type: Object,
required: false
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
required: false,
default: () => 'Save'
}
})
const templateBodyDescription = () => 'Make sure the template has {{ .Content }}'
const form = useForm({
validationSchema: toTypedSchema(formSchema),
validationSchema: toTypedSchema(formSchema)
})
const onSubmit = form.handleSubmit((values) => {
props.submitForm(values)
props.submitForm(values)
})
// Watch for changes in initialValues and update the form.
watch(
() => props.initialValues,
(newValues) => {
form.setValues(newValues)
},
{ deep: true }
() => props.initialValues,
(newValues) => {
form.setValues(newValues)
},
{ deep: true }
)
</script>
</script>

View File

@@ -1,18 +1,17 @@
<template>
<div>
<div class="flex justify-between mb-5">
<PageHeader title="Templates" description="Manage email templates" />
<div class="flex justify-end mb-4">
<Button @click="navigateToAddTemplate" size="sm"> New template </Button>
</div>
</div>
<div class="w-full">
<DataTable :columns="columns" :data="templates" />
</div>
<div>
<div class="flex justify-between mb-5">
<PageHeader title="Templates" description="Manage email templates" />
<div class="flex justify-end mb-4">
<Button @click="navigateToAddTemplate" size="sm"> New template </Button>
</div>
</div>
<div class="w-full">
<DataTable :columns="columns" :data="templates" />
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import DataTable from '@/components/admin/DataTable.vue'
@@ -26,11 +25,11 @@ const templates = ref([])
const router = useRouter()
onMounted(async () => {
const resp = await api.getTemplates()
templates.value = resp.data.data
const resp = await api.getTemplates()
templates.value = resp.data.data
})
const navigateToAddTemplate = () => {
router.push('/admin/templates/new')
router.push('/admin/templates/new')
}
</script>
</script>

View File

@@ -1,10 +1,10 @@
<script setup>
import { MoreHorizontal } from 'lucide-vue-next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { useRouter } from 'vue-router'
@@ -12,30 +12,30 @@ import { useRouter } from 'vue-router'
const router = useRouter()
const props = defineProps({
template: {
type: Object,
required: true,
default: () => ({
id: ''
})
}
template: {
type: Object,
required: true,
default: () => ({
id: ''
})
}
})
function editTemplate (id) {
router.push({ path: `/admin/templates/${id}/edit` })
function editTemplate(id) {
router.push({ path: `/admin/templates/${id}/edit` })
}
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="w-8 h-8 p-0">
<span class="sr-only">Open menu</span>
<MoreHorizontal class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="editTemplate(props.template.id)"> Edit </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="w-8 h-8 p-0">
<span class="sr-only">Open menu</span>
<MoreHorizontal class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="editTemplate(props.template.id)"> Edit </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>

View File

@@ -1,13 +1,11 @@
import * as z from 'zod'
export const formSchema = z.object({
name: z
.string({
required_error: 'Template name is required.'
}),
body: z
.string({
required_error: 'Template body is required.'
}),
is_default: z.boolean().optional()
name: z.string({
required_error: 'Template name is required.'
}),
body: z.string({
required_error: 'Template body is required.'
}),
is_default: z.boolean().optional()
})

View File

@@ -1,106 +1,104 @@
<template>
<form @submit="onLocalFsSubmit" class="w-2/3 space-y-6">
<FormField v-slot="{ componentField }" name="provider">
<FormItem >
<FormLabel>Provider</FormLabel>
<FormControl>
<Select v-bind="componentField" v-model="componentField.modelValue"
@update:modelValue="handleProviderUpdate">
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="s3">
S3
</SelectItem>
<SelectItem value="localfs">
Local filesystem
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<form @submit="onLocalFsSubmit" class="w-2/3 space-y-6">
<FormField v-slot="{ componentField }" name="provider">
<FormItem>
<FormLabel>Provider</FormLabel>
<FormControl>
<Select
v-bind="componentField"
v-model="componentField.modelValue"
@update:modelValue="handleProviderUpdate"
>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="s3"> S3 </SelectItem>
<SelectItem value="localfs"> Local filesystem </SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="upload_path">
<FormItem v-auto-animate>
<FormLabel>Upload path</FormLabel>
<FormControl>
<Input type="text" placeholder="/home/ubuntu/uploads" v-bind="componentField" />
</FormControl>
<FormDescription> Path to the directory where files will be uploaded. </FormDescription>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" size="sm"> {{ submitLabel }} </Button>
</form>
<FormField v-slot="{ componentField }" name="upload_path">
<FormItem v-auto-animate>
<FormLabel>Upload path</FormLabel>
<FormControl>
<Input type="text" placeholder="/home/ubuntu/uploads" v-bind="componentField" />
</FormControl>
<FormDescription> Path to the directory where files will be uploaded. </FormDescription>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" size="sm"> {{ submitLabel }} </Button>
</form>
</template>
<script setup>
import { watch } from 'vue'
import { watch } from 'vue'
import { Button } from '@/components/ui/button'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { localFsFormSchema } from './formSchema.js'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
const props = defineProps({
initialValues: {
type: Object,
required: false
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
required: false,
default: () => 'Submit'
},
initialValues: {
type: Object,
required: false
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
required: false,
default: () => 'Submit'
}
})
const emit = defineEmits(['provider-update'])
const localFsForm = useForm({
validationSchema: toTypedSchema(localFsFormSchema),
validationSchema: toTypedSchema(localFsFormSchema)
})
const onLocalFsSubmit = localFsForm.handleSubmit((values) => {
props.submitForm(values)
props.submitForm(values)
})
const handleProviderUpdate = (value) => {
emit('provider-update', value)
emit('provider-update', value)
}
// Watch for changes in initialValues and update the form.
watch(
() => props.initialValues,
(newValues) => {
localFsForm.setValues(newValues)
},
{ deep: true, immediate: true }
() => props.initialValues,
(newValues) => {
localFsForm.setValues(newValues)
},
{ deep: true, immediate: true }
)
</script>

View File

@@ -1,127 +1,128 @@
<template>
<form @submit="onS3FormSubmit" class="w-2/3 space-y-6">
<FormField v-slot="{ componentField }" name="provider">
<FormItem>
<FormLabel>Provider</FormLabel>
<FormControl>
<Select v-bind="componentField" v-model="componentField.modelValue"
@update:modelValue="handleProviderUpdate">
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="s3">
S3
</SelectItem>
<SelectItem value="localfs">
Local filesystem
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<form @submit="onS3FormSubmit" class="w-2/3 space-y-6">
<FormField v-slot="{ componentField }" name="provider">
<FormItem>
<FormLabel>Provider</FormLabel>
<FormControl>
<Select
v-bind="componentField"
v-model="componentField.modelValue"
@update:modelValue="handleProviderUpdate"
>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="s3"> S3 </SelectItem>
<SelectItem value="localfs"> Local filesystem </SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="region">
<FormItem v-auto-animate>
<FormLabel>Region</FormLabel>
<FormControl>
<Input type="text" placeholder="ap-south-1" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="region">
<FormItem v-auto-animate>
<FormLabel>Region</FormLabel>
<FormControl>
<Input type="text" placeholder="ap-south-1" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="access_key">
<FormItem v-auto-animate>
<FormLabel>AWS access key</FormLabel>
<FormControl>
<Input type="text" placeholder="AWS access key" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="access_key">
<FormItem v-auto-animate>
<FormLabel>AWS access key</FormLabel>
<FormControl>
<Input type="text" placeholder="AWS access key" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="access_secret">
<FormItem v-auto-animate>
<FormLabel>AWS access secret</FormLabel>
<FormControl>
<Input type="password" placeholder="AWS access secret" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="access_secret">
<FormItem v-auto-animate>
<FormLabel>AWS access secret</FormLabel>
<FormControl>
<Input type="password" placeholder="AWS access secret" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="bucket_type">
<FormItem>
<FormLabel>Bucket type</FormLabel>
<FormControl>
<Select v-bind="componentField" v-model="componentField.modelValue">
<SelectTrigger>
<SelectValue placeholder="Select bucket type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="public">
Public
</SelectItem>
<SelectItem value="private">
Private
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="bucket_type">
<FormItem>
<FormLabel>Bucket type</FormLabel>
<FormControl>
<Select v-bind="componentField" v-model="componentField.modelValue">
<SelectTrigger>
<SelectValue placeholder="Select bucket type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="public"> Public </SelectItem>
<SelectItem value="private"> Private </SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="bucket">
<FormItem v-auto-animate>
<FormLabel>Bucket</FormLabel>
<FormControl>
<Input type="text" placeholder="Bucket" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="bucket">
<FormItem v-auto-animate>
<FormLabel>Bucket</FormLabel>
<FormControl>
<Input type="text" placeholder="Bucket" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="bucket_path">
<FormItem v-auto-animate>
<FormLabel>Bucket path</FormLabel>
<FormControl>
<Input type="text" placeholder="Bucket path" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="bucket_path">
<FormItem v-auto-animate>
<FormLabel>Bucket path</FormLabel>
<FormControl>
<Input type="text" placeholder="Bucket path" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="upload_expiry">
<FormItem v-auto-animate>
<FormLabel>Upload expiry</FormLabel>
<FormControl>
<Input type="text" placeholder="24h" v-bind="componentField" />
</FormControl>
<FormDescription>Only applicable for private buckets.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="upload_expiry">
<FormItem v-auto-animate>
<FormLabel>Upload expiry</FormLabel>
<FormControl>
<Input type="text" placeholder="24h" v-bind="componentField" />
</FormControl>
<FormDescription>Only applicable for private buckets.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="url">
<FormItem v-auto-animate>
<FormLabel>S3 backend URL</FormLabel>
<FormControl>
<Input type="url" placeholder="https://ap-south-1.s3.amazonaws.com" v-bind="componentField"/>
</FormControl>
<FormDescription>Only change if using a custom S3 compatible backend like Minio.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" size="sm"> {{ submitLabel }} </Button>
</form>
<FormField v-slot="{ componentField }" name="url">
<FormItem v-auto-animate>
<FormLabel>S3 backend URL</FormLabel>
<FormControl>
<Input
type="url"
placeholder="https://ap-south-1.s3.amazonaws.com"
v-bind="componentField"
/>
</FormControl>
<FormDescription
>Only change if using a custom S3 compatible backend like Minio.</FormDescription
>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" size="sm"> {{ submitLabel }} </Button>
</form>
</template>
<script setup>
@@ -132,60 +133,59 @@ import { toTypedSchema } from '@vee-validate/zod'
import { s3FormSchema } from './formSchema.js'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
const props = defineProps({
initialValues: {
type: Object,
required: false
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
required: false,
default: () => 'Submit'
},
initialValues: {
type: Object,
required: false
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
required: false,
default: () => 'Submit'
}
})
const emit = defineEmits(['provider-update'])
const s3Form = useForm({
validationSchema: toTypedSchema(s3FormSchema),
validationSchema: toTypedSchema(s3FormSchema)
})
const onS3FormSubmit = s3Form.handleSubmit((values) => {
props.submitForm(values)
props.submitForm(values)
})
const handleProviderUpdate = (value) => {
emit('provider-update', value)
emit('provider-update', value)
}
// Watch for changes in initialValues and update the form.
watch(
() => props.initialValues,
(newValues) => {
s3Form.setValues(newValues)
},
{ deep: true, immediate: true }
() => props.initialValues,
(newValues) => {
s3Form.setValues(newValues)
},
{ deep: true, immediate: true }
)
</script>

View File

@@ -3,8 +3,13 @@
<PageHeader title="Uploads" description="Manage file upload settings" />
</div>
<div>
<component :is="formProvider === 's3' ? S3Form : LocalFsForm" :submitForm="submitForm"
:initialValues="initialValues" submitLabel="Save" @provider-update="handleProviderUpdate" />
<component
:is="formProvider === 's3' ? S3Form : LocalFsForm"
:submitForm="submitForm"
:initialValues="initialValues"
submitLabel="Save"
@provider-update="handleProviderUpdate"
/>
</div>
</template>
@@ -19,34 +24,34 @@ const initialValues = ref({})
onMounted(async () => {
const resp = await api.getSettings('upload')
const config = resp.data.data;
const modifiedConfig = {};
const config = resp.data.data
const modifiedConfig = {}
for (const key in config) {
if (key.startsWith('upload.localfs.')) {
const newKey = key.replace('upload.localfs.', '');
modifiedConfig[newKey] = config[key];
const newKey = key.replace('upload.localfs.', '')
modifiedConfig[newKey] = config[key]
} else if (key.startsWith('upload.s3')) {
const newKey = key.replace('upload.s3.', '');
modifiedConfig[newKey] = config[key];
const newKey = key.replace('upload.s3.', '')
modifiedConfig[newKey] = config[key]
} else if (key.startsWith('upload.')) {
const newKey = key.replace('upload.', '');
modifiedConfig[newKey] = config[key];
const newKey = key.replace('upload.', '')
modifiedConfig[newKey] = config[key]
}
}
initialValues.value = modifiedConfig
})
const submitForm = async (values) => {
const prefixedValues = {};
const prefixedValues = {}
for (const key in values) {
if (values.provider === 'localfs') {
prefixedValues[`upload.localfs.${key}`] = values[key];
prefixedValues[`upload.localfs.${key}`] = values[key]
} else if (values.provider === 's3') {
prefixedValues[`upload.s3.${key}`] = values[key];
prefixedValues[`upload.s3.${key}`] = values[key]
}
}
prefixedValues['upload.provider'] = values.provider;
await api.updateSettings('upload', prefixedValues);
prefixedValues['upload.provider'] = values.provider
await api.updateSettings('upload', prefixedValues)
}
const formProvider = computed(() => {

View File

@@ -1,55 +1,44 @@
import * as z from 'zod'
export const s3FormSchema = z.object({
provider: z
.string({
required_error: 'Provider is required.'
}),
region: z
.string({
required_error: 'Region is required.'
}),
access_key: z
.string({
required_error: 'AWS access key is required.'
}),
access_secret: z
.string({
required_error: 'AWS access secret is required.'
}),
bucket_type: z
.string({
required_error: 'Bucket type is required.'
}),
bucket: z
.string({
required_error: 'Bucket is required.'
}),
bucket_path: z
.string({
required_error: 'Bucket path is required.'
}),
upload_expiry: z
.string({
required_error: 'Upload expiry is required.'
}),
url: z
.string({
required_error: 'S3 backend URL is required.'
})
.url({
message: 'S3 backend URL must be a valid URL.'
}),
provider: z.string({
required_error: 'Provider is required.'
}),
region: z.string({
required_error: 'Region is required.'
}),
access_key: z.string({
required_error: 'AWS access key is required.'
}),
access_secret: z.string({
required_error: 'AWS access secret is required.'
}),
bucket_type: z.string({
required_error: 'Bucket type is required.'
}),
bucket: z.string({
required_error: 'Bucket is required.'
}),
bucket_path: z.string({
required_error: 'Bucket path is required.'
}),
upload_expiry: z.string({
required_error: 'Upload expiry is required.'
}),
url: z
.string({
required_error: 'S3 backend URL is required.'
})
.url({
message: 'S3 backend URL must be a valid URL.'
})
})
export const localFsFormSchema = z.object({
provider: z
.string({
required_error: 'Provider is required.'
}),
upload_path: z
.string({
required_error: 'Upload path is required.'
}),
})
provider: z.string({
required_error: 'Provider is required.'
}),
upload_path: z.string({
required_error: 'Upload path is required.'
})
})

View File

@@ -1,47 +1,51 @@
<template>
<div class="flex m-2 items-end text-sm overflow-hidden text-ellipsis whitespace-nowrap cursor-pointer">
<div v-for="(attachment) in attachments" :key="attachment.uuid"
class="flex items-center p-1 bg-[#F5F5F4] gap-1 rounded-md max-w-[15rem]">
<!-- Filename tooltip -->
<Tooltip>
<TooltipTrigger as-child>
<div class="overflow-hidden text-ellipsis whitespace-nowrap">
{{ attachment.name }}
</div>
</TooltipTrigger>
<TooltipContent>
{{ attachment.name }}
</TooltipContent>
</Tooltip>
<div>
{{ formatBytes(attachment.size) }}
</div>
<div @click="onDelete(attachment.uuid)">
<X size="13" />
</div>
</div>
<div
class="flex m-2 items-end text-sm overflow-hidden text-ellipsis whitespace-nowrap cursor-pointer"
>
<div
v-for="attachment in attachments"
:key="attachment.uuid"
class="flex items-center p-1 bg-[#F5F5F4] gap-1 rounded-md max-w-[15rem]"
>
<!-- Filename tooltip -->
<Tooltip>
<TooltipTrigger as-child>
<div class="overflow-hidden text-ellipsis whitespace-nowrap">
{{ getAttachmentName(attachment.filename) }}
</div>
</TooltipTrigger>
<TooltipContent>
{{ attachment.filename }}
</TooltipContent>
</Tooltip>
<div>
{{ formatBytes(attachment.size) }}
</div>
<div @click="onDelete(attachment.uuid)">
<X size="13" />
</div>
</div>
</div>
</template>
<script setup>
import { formatBytes } from '@/utils/file.js';
import { formatBytes } from '@/utils/file.js'
import { X } from 'lucide-vue-next';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { X } from 'lucide-vue-next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
defineProps({
attachments: {
type: Array,
required: true,
},
onDelete: {
type: Function,
required: true,
}
attachments: {
type: Array,
required: true
},
onDelete: {
type: Function,
required: true
}
})
const getAttachmentName = (name) => {
return name.substring(0, 20)
}
</script>

View File

@@ -1,36 +1,31 @@
<template>
<div class="flex items-center group text-left">
<div class="relative w-36 h-28 flex items-center justify-center">
<div>
<span class="size-20">📄</span>
</div>
<div class="
p-1
absolute
inset-0
text-gray-50
opacity-10
group-hover:opacity-100
overlay
text-wrap
">
<p class="font-bold text-xs opacity-0 group-hover:opacity-100">{{ getAttachmentName(attachment.name) }}</p>
<p class="text-xs opacity-0 group-hover:opacity-100">{{ formatBytes(attachment.size) }}</p>
</div>
</div>
<div class="flex items-center group text-left">
<div class="relative w-36 h-28 flex items-center justify-center">
<div>
<span class="size-20">📄</span>
</div>
<div
class="p-1 absolute inset-0 text-gray-50 opacity-10 group-hover:opacity-100 overlay text-wrap"
>
<p class="font-bold text-xs opacity-0 group-hover:opacity-100">
{{ getAttachmentName(attachment.name) }}
</p>
<p class="text-xs opacity-0 group-hover:opacity-100">{{ formatBytes(attachment.size) }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { formatBytes } from '@/utils/file.js';
import { formatBytes } from '@/utils/file.js'
defineProps({
attachment: {
type: Object,
required: true,
},
attachment: {
type: Object,
required: true
}
})
const getAttachmentName = (name) => {
return name.substring(0, 50)
return name.substring(0, 50)
}
</script>
</script>

View File

@@ -1,34 +1,31 @@
<template>
<div class="flex flex-wrap items-center group text-left">
<div class="relative">
<img :src=attachment.url class="w-36 h-28 flex items-center object-cover" />
<div class="
p-1
absolute
inset-0
text-gray-50
opacity-0
group-hover:opacity-100
overlay
text-wrap
">
<p class="font-bold text-xs">{{ trimAttachmentName(attachment.name) }}</p>
<p class="text-xs">{{ formatBytes(attachment.size) }}</p>
</div>
</div>
<div class="flex flex-wrap items-center group text-left">
<div class="relative">
<img
:src="getThumbFilepath(attachment.url)"
class="w-36 h-28 flex items-center object-cover"
/>
<div
class="p-1 absolute inset-0 text-gray-50 opacity-0 group-hover:opacity-100 overlay text-wrap"
>
<p class="font-bold text-xs">{{ trimAttachmentName(attachment.name) }}</p>
<p class="text-xs">{{ formatBytes(attachment.size) }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { formatBytes } from '@/utils/file.js'
import { formatBytes, getThumbFilepath } from '@/utils/file.js'
defineProps({
attachment: {
type: Object,
required: true,
},
attachment: {
type: Object,
required: true
}
})
const trimAttachmentName = (name) => {
return name.substring(0, 40)
return name.substring(0, 40)
}
</script>
</script>

View File

@@ -1,26 +1,30 @@
<template>
<div class="flex flex-row flex-wrap gap-2 break-all">
<div v-for="(attachment) in attachments" :key="attachment.uuid" class="flex items-center cursor-pointer">
<div>
<ImageAttachmentPreview v-if="isImage(attachment)" :attachment="attachment" />
<FileAttachmentPreview v-else :attachment="attachment" />
</div>
</div>
<div class="flex flex-row flex-wrap gap-2 break-all">
<div
v-for="attachment in attachments"
:key="attachment.uuid"
class="flex items-center cursor-pointer"
>
<div>
<ImageAttachmentPreview v-if="isImage(attachment)" :attachment="attachment" />
<FileAttachmentPreview v-else :attachment="attachment" />
</div>
</div>
</div>
</template>
<script setup>
import ImageAttachmentPreview from "@/components/attachment/ImageAttachmentPreview.vue"
import FileAttachmentPreview from "@/components/attachment/FileAttachmentPreview.vue"
import ImageAttachmentPreview from '@/components/attachment/ImageAttachmentPreview.vue'
import FileAttachmentPreview from '@/components/attachment/FileAttachmentPreview.vue'
defineProps({
attachments: {
type: Array,
required: true,
},
attachments: {
type: Array,
required: true
}
})
const isImage = (attachment) => {
return attachment.content_type.includes('image');
return attachment.content_type.includes('image')
}
</script>

View File

@@ -1,25 +1,25 @@
<template>
<div>
<span class="title">
{{ title }}
</span>
<p class="text-sm-muted">
{{ subTitle }}
</p>
</div>
<Separator class="my-3" />
<div>
<span class="title">
{{ title }}
</span>
<p class="text-sm-muted">
{{ subTitle }}
</p>
</div>
<Separator class="my-3" />
</template>
<script setup>
import { Separator } from '@/components/ui/separator'
defineProps({
title: {
type: String,
required: true
},
subTitle: {
type: String,
required: true
},
});
title: {
type: String,
required: true
},
subTitle: {
type: String,
required: true
}
})
</script>

View File

@@ -1,19 +1,27 @@
<template>
<nav class="flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1">
<router-link v-for="item in navItems" :key="item.title" :to="item.href">
<template v-slot="{ navigate, isActive }">
<Button as="a" :href="item.href" variant="ghost" :class="cn(
'w-full text-left justify-start h-16 pl-3',
isActive || isChildActive(item.href) ? 'bg-muted hover:bg-muted' : ''
)" @click="navigate">
<div class="flex flex-col items-start space-y-1">
<span class="text-sm">{{ item.title }}</span>
<p class="text-xs-muted break-words whitespace-normal">{{ item.description }}</p>
</div>
</Button>
</template>
</router-link>
</nav>
<nav class="flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1">
<router-link v-for="item in navItems" :key="item.title" :to="item.href">
<template v-slot="{ navigate, isActive }">
<Button
as="a"
:href="item.href"
variant="ghost"
:class="
cn(
'w-full text-left justify-start h-16 pl-3',
isActive || isChildActive(item.href) ? 'bg-muted hover:bg-muted' : ''
)
"
@click="navigate"
>
<div class="flex flex-col items-start space-y-1">
<span class="text-sm">{{ item.title }}</span>
<p class="text-xs-muted break-words whitespace-normal">{{ item.description }}</p>
</div>
</Button>
</template>
</router-link>
</nav>
</template>
<script setup>
@@ -25,14 +33,14 @@ import { useRoute } from 'vue-router'
const route = useRoute()
defineProps({
navItems: {
type: Array,
required: true,
default: () => []
}
navItems: {
type: Array,
required: true,
default: () => []
}
})
const isChildActive = (href) => {
return route.path.startsWith(href)
return route.path.startsWith(href)
}
</script>

View File

@@ -23,17 +23,12 @@
<Icon icon="lucide:ellipsis-vertical" class="mt-2 size-6"></Icon>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="handleUpdateStatus('Open')">
<span>Open</span>
</DropdownMenuItem>
<DropdownMenuItem @click="handleUpdateStatus('Processing')">
<span>Processing</span>
</DropdownMenuItem>
<DropdownMenuItem @click="handleUpdateStatus('Spam')">
<span>Mark as spam</span>
</DropdownMenuItem>
<DropdownMenuItem @click="handleUpdateStatus('Resolved')">
<span>Resolve</span>
<DropdownMenuItem
v-for="status in statuses"
:key="status.name"
@click="handleUpdateStatus(status.name)"
>
{{ status.name }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -49,9 +44,8 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref, onMounted } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import { Error } from '@/components/ui/error'
import { Badge } from '@/components/ui/badge'
import {
@@ -63,9 +57,20 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import MessageList from '@/components/message/MessageList.vue'
import ReplyBox from './ReplyBox.vue'
import api from '@/api'
import { Icon } from '@iconify/vue'
const conversationStore = useConversationStore()
const statuses = ref([])
onMounted(() => {
getAllStatuses()
})
const getAllStatuses = async () => {
const resp = await api.getAllStatuses()
statuses.value = resp.data.data
}
const getBadgeVariant = computed(() => {
return conversationStore.conversation.data?.status == 'Spam' ? 'destructive' : 'primary'

View File

@@ -2,27 +2,43 @@
<div class="p-3">
<div>
<Avatar class="size-20">
<AvatarImage :src=conversationStore.conversation.data.avatar_url
v-if="conversationStore.conversation.data.avatar_url" />
<AvatarImage
:src="conversationStore.conversation.data.avatar_url"
v-if="conversationStore.conversation.data.avatar_url"
/>
<AvatarFallback>
{{ conversationStore.conversation.data.first_name.toUpperCase().substring(0, 2) }}
</AvatarFallback>
</Avatar>
<h4 class="mt-3">
{{ conversationStore.conversation.data.first_name + ' ' +
conversationStore.conversation.data.last_name }}
{{
conversationStore.conversation.data.first_name +
' ' +
conversationStore.conversation.data.last_name
}}
</h4>
<p class="text-sm text-muted-foreground flex gap-2 mt-1" v-if="conversationStore.conversation.data.email">
<p
class="text-sm text-muted-foreground flex gap-2 mt-1"
v-if="conversationStore.conversation.data.email"
>
<Mail class="size-3 mt-1"></Mail>
{{ conversationStore.conversation.data.email }}
</p>
<p class="text-sm text-muted-foreground flex gap-2 mt-1" v-if="conversationStore.conversation.data.phone_number">
<p
class="text-sm text-muted-foreground flex gap-2 mt-1"
v-if="conversationStore.conversation.data.phone_number"
>
<Phone class="size-3 mt-1"></Phone>
{{ conversationStore.conversation.data.phone_number }}
</p>
</div>
<Accordion type="single" collapsible class="border-t mt-4" :default-value="actionAccordion.title">
<Accordion
type="single"
collapsible
class="border-t mt-4"
:default-value="actionAccordion.title"
>
<AccordionItem :value="actionAccordion.title">
<AccordionTrigger>
<h4 class="scroll-m-20 text-base font-medium tracking-tight">
@@ -30,20 +46,34 @@
</h4>
</AccordionTrigger>
<AccordionContent>
<!-- Agent assign -->
<div class="mb-3">
<Popover v-model:open="agentSelectDropdownOpen">
<PopoverTrigger as-child>
<Button variant="outline" role="combobox" :aria-expanded="agentSelectDropdownOpen"
class="w-full justify-between">
{{ conversationStore.conversation.data.assigned_user_id
? (() => {
console.log(' ->', conversationStore.conversation.data.assigned_user_id, 'agents ', agents)
const agent = agents.find(agent => agent.id === conversationStore.conversation.data.assigned_user_id);
return agent ? `${agent.first_name} ${agent.last_name}` : "Select agent...";
})()
: "Select agent..."
<Button
variant="outline"
role="combobox"
:aria-expanded="agentSelectDropdownOpen"
class="w-full justify-between"
>
{{
conversationStore.conversation.data.assigned_user_id
? (() => {
console.log(
' ->',
conversationStore.conversation.data.assigned_user_id,
'agents ',
agents
)
const agent = agents.find(
(agent) =>
agent.id === conversationStore.conversation.data.assigned_user_id
)
return agent
? `${agent.first_name} ${agent.last_name}`
: 'Select agent...'
})()
: 'Select agent...'
}}
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -54,20 +84,32 @@
<CommandEmpty>No agent found.</CommandEmpty>
<CommandList>
<CommandGroup>
<CommandItem v-for="agent in agents" :key="agent.id"
:value="agent.id + ':' + agent.first_name + ' ' + agent.last_name" @select="(ev) => {
if (typeof ev.detail.value === 'string') {
const id = ev.detail.value.split(':')[0]
console.log('setting id ', id)
conversationStore.conversation.data.assigned_user_id = Number(id)
<CommandItem
v-for="agent in agents"
:key="agent.id"
:value="agent.id + ':' + agent.first_name + ' ' + agent.last_name"
@select="
(ev) => {
if (typeof ev.detail.value === 'string') {
const id = ev.detail.value.split(':')[0]
console.log('setting id ', id)
conversationStore.conversation.data.assigned_user_id = Number(id)
}
agentSelectDropdownOpen = false
}
agentSelectDropdownOpen = false
}">
"
>
{{ agent.first_name + ' ' + agent.last_name }}
<CheckIcon :class="cn(
'ml-auto h-4 w-4',
conversationStore.conversation.data.assigned_user_id === agent.id ? 'opacity-100' : 'opacity-0',
)" />
<CheckIcon
:class="
cn(
'ml-auto h-4 w-4',
conversationStore.conversation.data.assigned_user_id === agent.id
? 'opacity-100'
: 'opacity-0'
)
"
/>
</CommandItem>
</CommandGroup>
</CommandList>
@@ -81,12 +123,19 @@
<div class="mb-3">
<Popover v-model:open="teamSelectDropdownOpen">
<PopoverTrigger as-child>
<Button variant="outline" role="combobox" :aria-expanded="teamSelectDropdownOpen"
class="w-full justify-between">
{{ conversationStore.conversation.data.assigned_team_id
? teams.find((team) => team.id ===
conversationStore.conversation.data.assigned_team_id)?.name
: "Select team..." }}
<Button
variant="outline"
role="combobox"
:aria-expanded="teamSelectDropdownOpen"
class="w-full justify-between"
>
{{
conversationStore.conversation.data.assigned_team_id
? teams.find(
(team) => team.id === conversationStore.conversation.data.assigned_team_id
)?.name
: 'Select team...'
}}
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@@ -96,18 +145,31 @@
<CommandEmpty>No team found.</CommandEmpty>
<CommandList>
<CommandGroup>
<CommandItem v-for="team in teams" :key="team.id" :value="team.id + ':' + team.name" @select="(ev) => {
if (ev.detail.value) {
const id = ev.detail.value.split(':')[0]
conversationStore.conversation.data.assigned_team_id = Number(id)
}
teamSelectDropdownOpen = false
}">
<CommandItem
v-for="team in teams"
:key="team.id"
:value="team.id + ':' + team.name"
@select="
(ev) => {
if (ev.detail.value) {
const id = ev.detail.value.split(':')[0]
conversationStore.conversation.data.assigned_team_id = Number(id)
}
teamSelectDropdownOpen = false
}
"
>
{{ team.name }}
<CheckIcon :class="cn(
'ml-auto h-4 w-4',
conversationStore.conversation.data.assigned_team_id === team.id ? 'opacity-100' : 'opacity-0',
)" />
<CheckIcon
:class="
cn(
'ml-auto h-4 w-4',
conversationStore.conversation.data.assigned_team_id === team.id
? 'opacity-100'
: 'opacity-0'
)
"
/>
</CommandItem>
</CommandGroup>
</CommandList>
@@ -118,16 +180,22 @@
<!-- Team assign end -->
<!-- Priority -->
<div class="mb-3">
<Popover v-model:open="prioritySelectDropdownOpen">
<PopoverTrigger as-child>
<Button variant="outline" role="combobox" :aria-expanded="prioritySelectDropdownOpen"
class="w-full justify-between">
{{ conversationStore.conversation.data.priority
? priorities.find((priority) => priority ===
conversationStore.conversation.data.priority)
: "Select priority..." }}
<Button
variant="outline"
role="combobox"
:aria-expanded="prioritySelectDropdownOpen"
class="w-full justify-between"
>
{{
conversationStore.conversation.data.priority
? priorities.find(
(priority) => priority === conversationStore.conversation.data.priority
)
: 'Select priority...'
}}
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@@ -137,18 +205,31 @@
<CommandEmpty>No priority found.</CommandEmpty>
<CommandList>
<CommandGroup>
<CommandItem v-for="priority in priorities" :key="priority" :value="priority" @select="(ev) => {
if (ev.detail.value) {
const p = ev.detail.value
conversationStore.conversation.data.priority = p;
}
prioritySelectDropdownOpen = false
}">
<CommandItem
v-for="priority in priorities"
:key="priority"
:value="priority"
@select="
(ev) => {
if (ev.detail.value) {
const p = ev.detail.value
conversationStore.conversation.data.priority = p
}
prioritySelectDropdownOpen = false
}
"
>
{{ priority }}
<CheckIcon :class="cn(
'ml-auto h-4 w-4',
conversationStore.conversation.data.priority === priority ? 'opacity-100' : 'opacity-0',
)" />
<CheckIcon
:class="
cn(
'ml-auto h-4 w-4',
conversationStore.conversation.data.priority === priority
? 'opacity-100'
: 'opacity-0'
)
"
/>
</CommandItem>
</CommandGroup>
</CommandList>
@@ -159,7 +240,11 @@
<!-- Priority end -->
<!-- Tags -->
<TagsInput class="px-0 gap-0 w-full" :model-value="tagsSelected" @update:modelValue="handleUpsertTags">
<TagsInput
class="px-0 gap-0 w-full"
:model-value="tagsSelected"
@update:modelValue="handleUpsertTags"
>
<div class="flex gap-2 flex-wrap items-center px-3">
<TagsInputItem v-for="item in tagsSelected" :key="item" :value="item">
<TagsInputItemText />
@@ -167,31 +252,47 @@
</TagsInputItem>
</div>
<ComboboxRoot v-model="tagsSelected" v-model:open="tagDropdownOpen" v-model:searchTerm="tagSearchTerm"
class="w-full">
<ComboboxRoot
v-model="tagsSelected"
v-model:open="tagDropdownOpen"
v-model:searchTerm="tagSearchTerm"
class="w-full"
>
<ComboboxAnchor as-child>
<ComboboxInput placeholder="Add tags..." as-child>
<TagsInputInput class="w-full px-3" :class="tagsSelected.length > 0 ? 'mt-2' : ''"
@keydown.enter.prevent />
<TagsInputInput
class="w-full px-3"
:class="tagsSelected.length > 0 ? 'mt-2' : ''"
@keydown.enter.prevent
/>
</ComboboxInput>
</ComboboxAnchor>
<ComboboxPortal>
<CommandList position="popper"
class="w-[--radix-popper-anchor-width] rounded-md mt-2 border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2">
<CommandList
position="popper"
class="w-[--radix-popper-anchor-width] rounded-md mt-2 border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
>
<CommandEmpty />
<CommandGroup>
<CommandItem v-for="ftag in tagsFiltered" :key="ftag.value" :value="ftag.label" @select.prevent="(ev) => {
if (typeof ev.detail.value === 'string') {
tagSearchTerm = ''
tagsSelected.push(ev.detail.value)
tagDropdownOpen = false
}
<CommandItem
v-for="ftag in tagsFiltered"
:key="ftag.value"
:value="ftag.label"
@select.prevent="
(ev) => {
if (typeof ev.detail.value === 'string') {
tagSearchTerm = ''
tagsSelected.push(ev.detail.value)
tagDropdownOpen = false
}
if (tagsFiltered.length === 0) {
tagDropdownOpen = false
}
}">
if (tagsFiltered.length === 0) {
tagDropdownOpen = false
}
}
"
>
{{ ftag.label }}
</CommandItem>
</CommandGroup>
@@ -200,12 +301,10 @@
</ComboboxRoot>
</TagsInput>
<!-- Tags end -->
</AccordionContent>
</AccordionItem>
</Accordion>
<Accordion type="single" collapsible :default-value="infoAccordion.title">
<AccordionItem :value="infoAccordion.title">
<AccordionTrigger>
@@ -214,49 +313,35 @@
</h4>
</AccordionTrigger>
<AccordionContent>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">Initiated at</p>
<p>
{{ format(conversationStore.conversation.data.created_at, "PPpp") }}
{{ format(conversationStore.conversation.data.created_at, 'PPpp') }}
</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">
First reply at
</p>
<p class="font-medium">First reply at</p>
<p v-if="conversationStore.conversation.data.first_reply_at">
{{ format(conversationStore.conversation.data.first_reply_at, "PPpp") }}
</p>
<p v-else>
-
{{ format(conversationStore.conversation.data.first_reply_at, 'PPpp') }}
</p>
<p v-else>-</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">
Resolved at
</p>
<p class="font-medium">Resolved at</p>
<p v-if="conversationStore.conversation.data.resolved_at">
{{ format(conversationStore.conversation.data.resolved_at, "PPpp") }}
</p>
<p v-else>
-
{{ format(conversationStore.conversation.data.resolved_at, 'PPpp') }}
</p>
<p v-else>-</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">
Closed at
</p>
<p class="font-medium">Closed at</p>
<p v-if="conversationStore.conversation.data.closed_at">
{{ format(conversationStore.conversation.data.closed_at, "PPpp") }}
</p>
<p v-else>
-
{{ format(conversationStore.conversation.data.closed_at, 'PPpp') }}
</p>
<p v-else>-</p>
</div>
</AccordionContent>
</AccordionItem>
@@ -268,94 +353,124 @@
import { ref, onMounted, computed } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import { format } from 'date-fns'
import api from '@/api';
import api from '@/api'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger
} from '@/components/ui/accordion'
import { CaretSortIcon, CheckIcon } from '@radix-icons/vue'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { ComboboxAnchor, ComboboxInput, ComboboxPortal, ComboboxRoot } from 'radix-vue'
import { CommandEmpty, CommandGroup, CommandInput, Command, CommandItem, CommandList } from '@/components/ui/command'
import { TagsInput, TagsInputInput, TagsInputItem, TagsInputItemDelete, TagsInputItemText } from '@/components/ui/tags-input'
import { Mail, Phone } from "lucide-vue-next"
import {
CommandEmpty,
CommandGroup,
CommandInput,
Command,
CommandItem,
CommandList
} from '@/components/ui/command'
import {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
} from '@/components/ui/tags-input'
import { Mail, Phone } from 'lucide-vue-next'
import { useToast } from '@/components/ui/toast/use-toast'
import { handleHTTPError } from '@/utils/http'
const priorities = ["Low", "Medium", "High"]
const priorities = ref([])
const { toast } = useToast()
const conversationStore = useConversationStore();
const conversationStore = useConversationStore()
const agents = ref([])
const teams = ref([])
const agentSelectDropdownOpen = ref(false)
const teamSelectDropdownOpen = ref(false)
const prioritySelectDropdownOpen = ref(false)
const tagsSelected = computed(() => conversationStore.conversation.data.tags);
const tagsSelected = computed(() => conversationStore.conversation.data.tags)
const tags = ref([])
const tagIDMap = {}
const tagDropdownOpen = ref(false)
const tagSearchTerm = ref('')
const tagsFiltered = computed(() => tags.value.filter(i => !tagsSelected.value.includes(i.label)))
const tagsFiltered = computed(() => tags.value.filter((i) => !tagsSelected.value.includes(i.label)))
const actionAccordion = {
"title": "Actions"
title: 'Actions'
}
const infoAccordion = {
"title": "Information"
title: 'Information'
}
onMounted(() => {
api.getUsers().then((resp) => {
agents.value = resp.data.data;
}).catch(error => {
toast({
title: 'Could not fetch users',
variant: 'destructive',
description: handleHTTPError(error).message,
api
.getUsers()
.then((resp) => {
agents.value = resp.data.data
})
})
api.getTeams().then((resp) => {
teams.value = resp.data.data;
}).catch(error => {
toast({
title: 'Could not fetch teams',
variant: 'destructive',
description: handleHTTPError(error).message,
})
})
api.getTags().then(async (resp) => {
let dt = resp.data.data
dt.forEach(item => {
tags.value.push({
label: item.name,
value: item.id,
.catch((error) => {
toast({
title: 'Could not fetch users',
variant: 'destructive',
description: handleHTTPError(error).message
})
tagIDMap[item.name] = item.id
})
}).catch(error => {
toast({
title: 'Could not fetch tags',
variant: 'destructive',
description: handleHTTPError(error).message,
api
.getTeams()
.then((resp) => {
teams.value = resp.data.data
})
})
.catch((error) => {
toast({
title: 'Could not fetch teams',
variant: 'destructive',
description: handleHTTPError(error).message
})
})
api
.getTags()
.then(async (resp) => {
let dt = resp.data.data
dt.forEach((item) => {
tags.value.push({
label: item.name,
value: item.id
})
tagIDMap[item.name] = item.id
})
})
.catch((error) => {
toast({
title: 'Could not fetch tags',
variant: 'destructive',
description: handleHTTPError(error).message
})
})
getPrioritites()
})
const getPrioritites = async () => {
const resp = await api.getAllPriorities()
priorities.value = resp.data.data.map((priority) => priority.name)
}
const handleAssignedUserChange = (v) => {
conversationStore.updateAssignee("user", {
"assignee_id": v.split(":")[0]
conversationStore.updateAssignee('user', {
assignee_id: v.split(':')[0]
})
}
const handleAssignedTeamChange = (v) => {
conversationStore.updateAssignee("team", {
"assignee_id": v.split(":")[0]
conversationStore.updateAssignee('team', {
assignee_id: v.split(':')[0]
})
}
@@ -370,8 +485,7 @@ const handleUpsertTags = () => {
}
})
conversationStore.upsertTags({
"tag_ids": JSON.stringify(tagIDs)
tag_ids: JSON.stringify(tagIDs)
})
}
</script>
</script>

View File

@@ -1,126 +1,138 @@
<template>
<div class="max-h-[600px] overflow-y-auto">
<EditorContent :editor="editor" />
</div>
<div class="max-h-[600px] overflow-y-auto">
<EditorContent :editor="editor" />
</div>
</template>
<script setup>
import { ref, watch, watchEffect, onUnmounted } from "vue"
import { ref, watch, watchEffect, onUnmounted } from 'vue'
import { useEditor, EditorContent } from '@tiptap/vue-3'
import Placeholder from "@tiptap/extension-placeholder"
import Placeholder from '@tiptap/extension-placeholder'
import Image from '@tiptap/extension-image'
import StarterKit from '@tiptap/starter-kit'
const emit = defineEmits(['send', 'input', 'editorText', 'updateBold', 'updateItalic', 'contentCleared', 'contentSet', 'editorReady'])
const emit = defineEmits([
'send',
'input',
'editorText',
'updateBold',
'updateItalic',
'contentCleared',
'contentSet',
'editorReady'
])
const props = defineProps({
placeholder: String,
messageType: String,
isBold: Boolean,
isItalic: Boolean,
clearContent: Boolean,
contentToSet: String
placeholder: String,
messageType: String,
isBold: Boolean,
isItalic: Boolean,
clearContent: Boolean,
contentToSet: String
})
const editor = ref(useEditor({
const editor = ref(
useEditor({
content: '',
extensions: [
StarterKit,
Image,
Placeholder.configure({
placeholder: () => {
return props.placeholder
},
})
StarterKit,
Image,
Placeholder.configure({
placeholder: () => {
return props.placeholder
}
})
],
autofocus: true,
editorProps: {
attributes: {
// No outline for the editor.
class: "outline-none",
},
// Emit new input text.
handleTextInput: (view, from, to, text) => {
emit('input', text)
}
},
}))
attributes: {
// No outline for the editor.
class: 'outline-none'
},
// Emit new input text.
handleTextInput: (view, from, to, text) => {
emit('input', text)
}
}
})
)
watchEffect(() => {
if (editor.value) {
// Emit the editor instance when it's ready
if (editor.value) {
// Emit the editor instance when it's ready
if (editor.value) {
emit('editorReady', editor.value)
}
emit("editorText", {
text: editor.value.getText(),
html: editor.value.getHTML(),
});
// Emit bold and italic state changes
emit('updateBold', editor.value.isActive('bold'));
emit('updateItalic', editor.value.isActive('italic'));
emit('editorReady', editor.value)
}
emit('editorText', {
text: editor.value.getText(),
html: editor.value.getHTML()
})
// Emit bold and italic state changes
emit('updateBold', editor.value.isActive('bold'))
emit('updateItalic', editor.value.isActive('italic'))
}
})
// Watcher for bold and italic changes
watchEffect(() => {
if (props.isBold !== editor.value?.isActive('bold')) {
if (props.isBold) {
editor.value?.chain().focus().setBold().run();
} else {
editor.value?.chain().focus().unsetBold().run();
}
if (props.isBold !== editor.value?.isActive('bold')) {
if (props.isBold) {
editor.value?.chain().focus().setBold().run()
} else {
editor.value?.chain().focus().unsetBold().run()
}
if (props.isItalic !== editor.value?.isActive('italic')) {
if (props.isItalic) {
editor.value?.chain().focus().setItalic().run();
} else {
editor.value?.chain().focus().unsetItalic().run();
}
}
if (props.isItalic !== editor.value?.isActive('italic')) {
if (props.isItalic) {
editor.value?.chain().focus().setItalic().run()
} else {
editor.value?.chain().focus().unsetItalic().run()
}
});
}
})
// Watcher for clearContent prop
watchEffect(() => {
if (props.clearContent) {
editor.value?.commands.clearContent()
emit('contentCleared')
}
});
if (props.clearContent) {
editor.value?.commands.clearContent()
emit('contentCleared')
}
})
watch(() => props.contentToSet, (newContent) => {
watch(
() => props.contentToSet,
(newContent) => {
if (newContent) {
editor.value.commands.setContent(newContent);
editor.value.commands.focus();
emit('contentSet');
editor.value.commands.setContent(newContent)
editor.value.commands.focus()
emit('contentSet')
}
});
}
)
onUnmounted(() => {
editor.value.destroy()
editor.value.destroy()
})
</script>
<style lang="scss">
// Moving placeholder to the top.
.tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: #adb5bd;
pointer-events: none;
height: 0;
content: attr(data-placeholder);
float: left;
color: #adb5bd;
pointer-events: none;
height: 0;
}
// Editor height
.ProseMirror {
min-height: 150px !important;
max-height: 100% !important;
overflow-y: scroll !important;
padding: 10px 10px;
min-height: 150px !important;
max-height: 100% !important;
overflow-y: scroll !important;
padding: 10px 10px;
}
</style>

View File

@@ -1,220 +1,250 @@
<template>
<div>
<div v-if="filteredCannedResponses.length > 0" class="w-full drop-shadow-sm overflow-hidden p-2 border-t">
<ul ref="responsesList" class="space-y-2 max-h-96 overflow-y-auto">
<li v-for="(response, index) in filteredCannedResponses" :key="response.id"
:class="['cursor-pointer rounded p-1 hover:bg-secondary', { 'bg-secondary': index === selectedResponseIndex }]"
@click="selectResponse(response.content)" @mouseenter="selectedResponseIndex = index">
<span class="font-semibold">{{ response.title }}</span> - {{ response.content }}
</li>
</ul>
</div>
<div class="border-t ">
<!-- Message type toggle -->
<div class="flex justify-between px-2 border-b py-2">
<Tabs v-model:model-value="messageType">
<TabsList>
<TabsTrigger value="reply">
Reply
</TabsTrigger>
<TabsTrigger value="private_note">
Private note
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<!-- Editor -->
<Editor @keydown="handleKeydown" @editorText="handleEditorText" :placeholder="editorPlaceholder"
:isBold="isBold" :clearContent="clearContent" :isItalic="isItalic" @updateBold="updateBold"
@updateItalic="updateItalic" @contentCleared="handleContentCleared" @contentSet="clearContentToSet"
@editorReady="onEditorReady" :messageType="messageType" :contentToSet="contentToSet"
:cannedResponses="cannedResponsesStore.responses" />
<!-- Attachments preview -->
<AttachmentsPreview :attachments="uploadedFiles" :onDelete="handleOnFileDelete"></AttachmentsPreview>
<!-- Bottom menu bar -->
<ReplyBoxBottomMenuBar :handleFileUpload="handleFileUpload"
:handleInlineImageUpload="handleInlineImageUpload" :isBold="isBold" :isItalic="isItalic"
@toggleBold="toggleBold" @toggleItalic="toggleItalic" :hasText="hasText" :handleSend="handleSend">
</ReplyBoxBottomMenuBar>
</div>
<div>
<div
v-if="filteredCannedResponses.length > 0"
class="w-full overflow-hidden p-2 border-t backdrop-blur"
>
<ul ref="responsesList" class="space-y-2 max-h-96 overflow-y-auto">
<li
v-for="(response, index) in filteredCannedResponses"
:key="response.id"
:class="[
'cursor-pointer rounded p-1 hover:bg-secondary',
{ 'bg-secondary': index === selectedResponseIndex }
]"
@click="selectResponse(response.content)"
@mouseenter="selectedResponseIndex = index"
>
<span class="font-semibold">{{ response.title }}</span> - {{ response.content }}
</li>
</ul>
</div>
<div class="border-t">
<!-- Message type toggle -->
<div class="flex justify-between px-2 border-b py-2">
<Tabs v-model:model-value="messageType">
<TabsList>
<TabsTrigger value="reply"> Reply </TabsTrigger>
<TabsTrigger value="private_note"> Private note </TabsTrigger>
</TabsList>
</Tabs>
</div>
<!-- Editor -->
<Editor
@keydown="handleKeydown"
@editorText="handleEditorText"
:placeholder="editorPlaceholder"
:isBold="isBold"
:clearContent="clearContent"
:isItalic="isItalic"
@updateBold="updateBold"
@updateItalic="updateItalic"
@contentCleared="handleContentCleared"
@contentSet="clearContentToSet"
@editorReady="onEditorReady"
:messageType="messageType"
:contentToSet="contentToSet"
:cannedResponses="cannedResponsesStore.responses"
/>
<!-- Attachments preview -->
<AttachmentsPreview
:attachments="uploadedFiles"
:onDelete="handleOnFileDelete"
></AttachmentsPreview>
<!-- Bottom menu bar -->
<ReplyBoxBottomMenuBar
:handleFileUpload="handleFileUpload"
:handleInlineImageUpload="handleInlineImageUpload"
:isBold="isBold"
:isItalic="isItalic"
@toggleBold="toggleBold"
@toggleItalic="toggleItalic"
:hasText="hasText"
:handleSend="handleSend"
>
</ReplyBoxBottomMenuBar>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from "vue";
import api from '@/api';
import { ref, onMounted, computed } from 'vue'
import api from '@/api'
import Editor from './Editor.vue'
import { useConversationStore } from '@/stores/conversation'
import { useCannedResponses } from '@/stores/canned_responses'
import {
Tabs,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs'
import AttachmentsPreview from "@/components/attachment/AttachmentsPreview.vue"
import ReplyBoxBottomMenuBar from "@/components/conversation/ReplyBoxBottomMenuBar.vue"
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import AttachmentsPreview from '@/components/attachment/AttachmentsPreview.vue'
import ReplyBoxBottomMenuBar from '@/components/conversation/ReplyBoxBottomMenuBar.vue'
const clearContent = ref(false)
const isBold = ref(false)
const isItalic = ref(false)
const editorText = ref("")
const editorHTML = ref("")
const contentToSet = ref("")
const editorText = ref('')
const editorHTML = ref('')
const contentToSet = ref('')
const conversationStore = useConversationStore()
const cannedResponsesStore = useCannedResponses()
const filteredCannedResponses = ref([])
const uploadedFiles = ref([])
const messageType = ref("reply")
const messageType = ref('reply')
const selectedResponseIndex = ref(-1)
const responsesList = ref(null)
let editorInstance = null
onMounted(() => {
cannedResponsesStore.fetchAll()
cannedResponsesStore.fetchAll()
})
const updateBold = (newState) => {
isBold.value = newState;
isBold.value = newState
}
const updateItalic = (newState) => {
isItalic.value = newState;
isItalic.value = newState
}
const toggleBold = () => {
isBold.value = !isBold.value;
isBold.value = !isBold.value
}
const toggleItalic = () => {
isItalic.value = !isItalic.value;
isItalic.value = !isItalic.value
}
const editorPlaceholder = computed(() => {
return "Shift + Enter to add a new line; Press '/' to select a Canned Response."
return "Shift + Enter to add a new line; Press '/' to select a Canned Response."
})
const filterCannedResponses = (input) => {
// Extract the text after the last `/`
const lastSlashIndex = input.lastIndexOf('/');
if (lastSlashIndex !== -1) {
const searchText = input.substring(lastSlashIndex + 1).trim();
// Extract the text after the last `/`
const lastSlashIndex = input.lastIndexOf('/')
if (lastSlashIndex !== -1) {
const searchText = input.substring(lastSlashIndex + 1).trim()
// Filter canned responses based on the search text
filteredCannedResponses.value = cannedResponsesStore.responses.filter(response =>
response.title.toLowerCase().includes(searchText.toLowerCase())
);
// Filter canned responses based on the search text
filteredCannedResponses.value = cannedResponsesStore.responses.filter((response) =>
response.title.toLowerCase().includes(searchText.toLowerCase())
)
// Reset the selected response index
selectedResponseIndex.value = filteredCannedResponses.value.length > 0 ? 0 : -1;
} else {
filteredCannedResponses.value = [];
selectedResponseIndex.value = -1;
}
// Reset the selected response index
selectedResponseIndex.value = filteredCannedResponses.value.length > 0 ? 0 : -1
} else {
filteredCannedResponses.value = []
selectedResponseIndex.value = -1
}
}
const handleEditorText = (text) => {
editorText.value = text.text
editorHTML.value = text.html
filterCannedResponses(text.text)
editorText.value = text.text
editorHTML.value = text.html
filterCannedResponses(text.text)
}
const hasText = computed(() => {
return editorText.value.length > 0 ? true : false
});
return editorText.value.length > 0 ? true : false
})
const onEditorReady = (editor) => {
editorInstance = editor
};
editorInstance = editor
}
const handleFileUpload = event => {
for (const file of event.target.files) {
api.uploadMedia({
files: file,
}).then((resp) => {
uploadedFiles.value.push(resp.data.data)
}).catch((err) => {
console.error(err)
})
}
};
const handleFileUpload = (event) => {
for (const file of event.target.files) {
api
.uploadMedia({
files: file
})
.then((resp) => {
uploadedFiles.value.push(resp.data.data)
})
.catch((err) => {
console.error(err)
})
}
}
const handleInlineImageUpload = event => {
for (const file of event.target.files) {
api.uploadMedia({
files: file,
}).then((resp) => {
editorInstance.commands.setImage({
src: resp.data.data.url,
alt: resp.data.data.filename,
title: resp.data.data.filename,
})
}).catch((err) => {
console.error(err)
const handleInlineImageUpload = (event) => {
for (const file of event.target.files) {
api
.uploadMedia({
files: file
})
.then((resp) => {
editorInstance.commands.setImage({
src: resp.data.data.url,
alt: resp.data.data.filename,
title: resp.data.data.filename
})
}
};
})
.catch((err) => {
console.error(err)
})
}
}
const handleContentCleared = () => {
clearContent.value = false
clearContent.value = false
}
const handleSend = async () => {
const attachmentIDs = uploadedFiles.value.map((file) => file.id)
await api.sendMessage(conversationStore.conversation.data.uuid, {
private: messageType.value === "private_note",
message: editorHTML.value,
attachments: attachmentIDs,
})
api.updateAssigneeLastSeen(conversationStore.conversation.data.uuid)
clearContent.value = true
uploadedFiles.value = []
const attachmentIDs = uploadedFiles.value.map((file) => file.id)
await api.sendMessage(conversationStore.conversation.data.uuid, {
private: messageType.value === 'private_note',
message: editorHTML.value,
attachments: attachmentIDs
})
api.updateAssigneeLastSeen(conversationStore.conversation.data.uuid)
clearContent.value = true
uploadedFiles.value = []
}
const handleOnFileDelete = uuid => {
uploadedFiles.value = uploadedFiles.value.filter(item => item.uuid !== uuid);
};
const handleOnFileDelete = (uuid) => {
uploadedFiles.value = uploadedFiles.value.filter((item) => item.uuid !== uuid)
}
const handleKeydown = (event) => {
if (filteredCannedResponses.value.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault();
selectedResponseIndex.value = (selectedResponseIndex.value + 1) % filteredCannedResponses.value.length;
scrollToSelectedItem();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
selectedResponseIndex.value = (selectedResponseIndex.value - 1 + filteredCannedResponses.value.length) % filteredCannedResponses.value.length;
scrollToSelectedItem();
} else if (event.key === 'Enter') {
event.preventDefault();
selectResponse(filteredCannedResponses.value[selectedResponseIndex.value].content);
}
if (filteredCannedResponses.value.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
selectedResponseIndex.value =
(selectedResponseIndex.value + 1) % filteredCannedResponses.value.length
scrollToSelectedItem()
} else if (event.key === 'ArrowUp') {
event.preventDefault()
selectedResponseIndex.value =
(selectedResponseIndex.value - 1 + filteredCannedResponses.value.length) %
filteredCannedResponses.value.length
scrollToSelectedItem()
} else if (event.key === 'Enter') {
event.preventDefault()
selectResponse(filteredCannedResponses.value[selectedResponseIndex.value].content)
}
}
}
const scrollToSelectedItem = () => {
const list = responsesList.value;
const selectedItem = list.children[selectedResponseIndex.value];
if (selectedItem) {
selectedItem.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
}
const list = responsesList.value
const selectedItem = list.children[selectedResponseIndex.value]
if (selectedItem) {
selectedItem.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
})
}
}
const selectResponse = (content) => {
contentToSet.value = content
filteredCannedResponses.value = [];
selectedResponseIndex.value = -1;
contentToSet.value = content
filteredCannedResponses.value = []
selectedResponseIndex.value = -1
}
const clearContentToSet = () => {
contentToSet.value = null;
contentToSet.value = null
}
</script>

View File

@@ -1,60 +1,77 @@
<template>
<div class="flex justify-between items-center border-y h-14 px-2">
<div class="flex justify-items-start gap-2">
<input type="file" class="hidden" ref="attachmentInput" multiple @change="handleFileUpload">
<input type="file" class="hidden" ref="inlineImageInput" accept="image/*"
@change="handleInlineImageUpload">
<Toggle class="px-2 py-2 border-0" variant="outline" @click="toggleBold" :pressed="isBold">
<Bold class="h-4 w-4" />
</Toggle>
<Toggle class="px-2 py-2 border-0" variant="outline" @click="toggleItalic" :pressed="isItalic">
<Italic class="h-4 w-4" />
</Toggle>
<Toggle class="px-2 py-2 border-0" variant="outline" @click="triggerFileUpload" :pressed="false">
<Paperclip class="h-4 w-4" />
</Toggle>
<Toggle class="px-2 py-2 border-0" variant="outline" @click="triggerInlineImage" :pressed="false">
<Image class="h-4 w-4" />
</Toggle>
</div>
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!hasText">
Send
</Button>
<div class="flex justify-between items-center border-y h-14 px-2">
<div class="flex justify-items-start gap-2">
<input type="file" class="hidden" ref="attachmentInput" multiple @change="handleFileUpload" />
<input
type="file"
class="hidden"
ref="inlineImageInput"
accept="image/*"
@change="handleInlineImageUpload"
/>
<Toggle class="px-2 py-2 border-0" variant="outline" @click="toggleBold" :pressed="isBold">
<Bold class="h-4 w-4" />
</Toggle>
<Toggle
class="px-2 py-2 border-0"
variant="outline"
@click="toggleItalic"
:pressed="isItalic"
>
<Italic class="h-4 w-4" />
</Toggle>
<Toggle
class="px-2 py-2 border-0"
variant="outline"
@click="triggerFileUpload"
:pressed="false"
>
<Paperclip class="h-4 w-4" />
</Toggle>
<Toggle
class="px-2 py-2 border-0"
variant="outline"
@click="triggerInlineImage"
:pressed="false"
>
<Image class="h-4 w-4" />
</Toggle>
</div>
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!hasText"> Send </Button>
</div>
</template>
<script setup>
import { ref } from "vue"
import { Button } from '@/components/ui/button';
import { Toggle } from '@/components/ui/toggle';
import { Paperclip, Bold, Italic, Image } from "lucide-vue-next";
import { ref } from 'vue'
import { Button } from '@/components/ui/button'
import { Toggle } from '@/components/ui/toggle'
import { Paperclip, Bold, Italic, Image } from 'lucide-vue-next'
const attachmentInput = ref(null)
const inlineImageInput = ref(null)
const emit = defineEmits(['toggleBold', 'toggleItalic']);
const emit = defineEmits(['toggleBold', 'toggleItalic'])
defineProps({
isBold: Boolean,
isItalic: Boolean,
hasText: Boolean,
handleSend: Function,
handleFileUpload: Function,
handleInlineImageUpload: Function,
});
isBold: Boolean,
isItalic: Boolean,
hasText: Boolean,
handleSend: Function,
handleFileUpload: Function,
handleInlineImageUpload: Function
})
const toggleBold = () => {
emit('toggleBold');
};
emit('toggleBold')
}
const toggleItalic = () => {
emit('toggleItalic');
};
emit('toggleItalic')
}
const triggerFileUpload = () => {
attachmentInput.value.click()
};
attachmentInput.value.click()
}
const triggerInlineImage = () => {
inlineImageInput.value.click();
};
inlineImageInput.value.click()
}
</script>

View File

@@ -1,15 +1,15 @@
<template>
<div class="flex flex-col items-center justify-center h-64">
<MessageCircleWarning :stroke-width="1.4" :size="90" />
<h1 class="text-lg font-semibold text-gray-800">
{{ $t('conversations.emptyState') }}
</h1>
<p class="text-gray-600">
{{ $t('globals.messages.adjustFilters') }}
</p>
</div>
<div class="flex flex-col items-center justify-center h-64">
<MessageCircleWarning :stroke-width="1.4" :size="90" />
<h1 class="text-lg font-semibold text-gray-800">
{{ $t('conversations.emptyState') }}
</h1>
<p class="text-gray-600">
{{ $t('globals.messages.adjustFilters') }}
</p>
</div>
</template>
<script setup>
import { MessageCircleWarning } from 'lucide-vue-next'
</script>
</script>

View File

@@ -1,122 +1,93 @@
<template>
<div class="h-screen">
<div class="flex justify-between px-2 py-2 border-b">
<Tabs v-model:model-value="conversationType">
<TabsList class="w-full flex justify-evenly">
<TabsTrigger value="assigned" class="w-full">
Assigned
</TabsTrigger>
<TabsTrigger value="unassigned" class="w-full">
Unassigned
</TabsTrigger>
<TabsTrigger value="all" class="w-full">
All
</TabsTrigger>
</TabsList>
</Tabs>
<div class="h-screen">
<div class="flex justify-between px-2 py-2 border-b">
<Tabs v-model:model-value="conversationType">
<TabsList class="w-full flex justify-evenly">
<TabsTrigger value="assigned" class="w-full"> Assigned </TabsTrigger>
<TabsTrigger value="unassigned" class="w-full"> Unassigned </TabsTrigger>
<TabsTrigger value="all" class="w-full"> All </TabsTrigger>
</TabsList>
</Tabs>
<Popover>
<PopoverTrigger as-child>
<div class="flex items-center mr-2">
<ListFilter size="20" class="mx-auto cursor-pointer"></ListFilter>
</div>
</PopoverTrigger>
<PopoverContent class="w-52">
<div>
<Select @update:modelValue="handleFilterChange" v-model="predefinedFilter">
<SelectTrigger>
<SelectValue placeholder="Select a filter" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<!-- <SelectLabel>Status</SelectLabel> -->
<SelectItem value="status_all">
All
</SelectItem>
<SelectItem value="status_open">
Open
</SelectItem>
<SelectItem value="status_processing">
Processing
</SelectItem>
<SelectItem value="status_spam">
Spam
</SelectItem>
<SelectItem value="status_resolved">
Resolved
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</PopoverContent>
</Popover>
</div>
<EmptyList v-if="emptyConversations"></EmptyList>
<div class="h-screen overflow-y-scroll pb-[180px] flex flex-col">
<ConversationListItem />
<div v-if="conversationsLoading">
<div class="flex items-center gap-5 p-6 border-b" v-for="index in 8" :key="index">
<Skeleton class="h-12 w-12 rounded-full" />
<div class="space-y-2">
<Skeleton class="h-4 w-[250px]" />
<Skeleton class="h-4 w-[200px]" />
</div>
</div>
</div>
<div class="flex justify-center items-center mt-5 relative">
<div v-if="conversationStore.conversations.hasMore && !hasErrored && hasConversations">
<Button variant="link" @click="loadNextPage">
<Spinner v-if="conversationStore.conversations.loading" />
<p v-else>Load more...</p>
</Button>
</div>
<div v-else-if="everythingLoaded">
All conversations loaded!
</div>
</div>
</div>
<Popover>
<PopoverTrigger as-child>
<div class="flex items-center mr-2">
<ListFilter size="20" class="mx-auto cursor-pointer"></ListFilter>
</div>
</PopoverTrigger>
<PopoverContent class="w-52">
<div>
<Select @update:modelValue="handleFilterChange" v-model="predefinedFilter">
<SelectTrigger>
<SelectValue placeholder="Select a filter" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<!-- <SelectLabel>Status</SelectLabel> -->
<SelectItem value="status_all"> All </SelectItem>
<SelectItem value="status_open"> Open </SelectItem>
<SelectItem value="status_processing"> Processing </SelectItem>
<SelectItem value="status_spam"> Spam </SelectItem>
<SelectItem value="status_resolved"> Resolved </SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</PopoverContent>
</Popover>
</div>
<EmptyList v-if="emptyConversations"></EmptyList>
<div class="h-screen overflow-y-scroll pb-[180px] flex flex-col">
<ConversationListItem />
<div v-if="conversationsLoading">
<div class="flex items-center gap-5 p-6 border-b" v-for="index in 8" :key="index">
<Skeleton class="h-12 w-12 rounded-full" />
<div class="space-y-2">
<Skeleton class="h-4 w-[250px]" />
<Skeleton class="h-4 w-[200px]" />
</div>
</div>
</div>
<div class="flex justify-center items-center mt-5 relative">
<div v-if="conversationStore.conversations.hasMore && !hasErrored && hasConversations">
<Button variant="link" @click="loadNextPage">
<Spinner v-if="conversationStore.conversations.loading" />
<p v-else>Load more...</p>
</Button>
</div>
<div v-else-if="everythingLoaded">All conversations loaded!</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref, watch, computed, onUnmounted } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import { subscribeConversations } from "@/websocket.js"
import { subscribeConversations } from '@/websocket.js'
import { CONVERSATION_LIST_TYPE, CONVERSATION_PRE_DEFINED_FILTERS } from '@/constants/conversation'
import { Error } from '@/components/ui/error'
import { ListFilter } from 'lucide-vue-next';
import { ListFilter } from 'lucide-vue-next'
import { Skeleton } from '@/components/ui/skeleton'
import {
Tabs,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import Spinner from '@/components/ui/spinner/Spinner.vue'
import EmptyList from '@/components/conversationlist/ConversationEmptyList.vue'
import ConversationListItem from '@/components/conversationlist/ConversationListItem.vue'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
const conversationStore = useConversationStore()
const predefinedFilter = ref(CONVERSATION_PRE_DEFINED_FILTERS.ALL)
@@ -124,53 +95,58 @@ const conversationType = ref(CONVERSATION_LIST_TYPE.ASSIGNED)
let listRefreshInterval = null
onMounted(() => {
conversationStore.fetchConversations(conversationType.value, predefinedFilter.value)
subscribeConversations(conversationType.value, predefinedFilter.value)
// Refesh list every 1 minute to sync any missed changes.
listRefreshInterval = setInterval(() => {
conversationStore.fetchConversations(conversationType.value, predefinedFilter.value)
subscribeConversations(conversationType.value, predefinedFilter.value)
// Refesh list every 1 minute to sync any missed changes.
listRefreshInterval = setInterval(() => {
conversationStore.fetchConversations(conversationType.value, predefinedFilter.value)
}, 60000)
}, 60000)
})
onUnmounted(() => {
clearInterval(listRefreshInterval)
clearInterval(listRefreshInterval)
})
watch(conversationType, (newType) => {
conversationStore.fetchConversations(newType, predefinedFilter.value)
subscribeConversations(newType, predefinedFilter.value)
});
conversationStore.fetchConversations(newType, predefinedFilter.value)
subscribeConversations(newType, predefinedFilter.value)
})
const handleFilterChange = (filter) => {
predefinedFilter.value = filter
conversationStore.fetchConversations(conversationType.value, filter)
subscribeConversations(conversationType.value, predefinedFilter.value)
predefinedFilter.value = filter
conversationStore.fetchConversations(conversationType.value, filter)
subscribeConversations(conversationType.value, predefinedFilter.value)
}
const loadNextPage = () => {
conversationStore.fetchNextConversations(conversationType.value, predefinedFilter.value)
};
conversationStore.fetchNextConversations(conversationType.value, predefinedFilter.value)
}
const hasConversations = computed(() => {
return conversationStore.sortedConversations.length !== 0 && !conversationStore.conversations.errorMessage && !conversationStore.conversations.loading
return (
conversationStore.sortedConversations.length !== 0 &&
!conversationStore.conversations.errorMessage &&
!conversationStore.conversations.loading
)
})
const emptyConversations = computed(() => {
return conversationStore.sortedConversations.length === 0 && !conversationStore.conversations.errorMessage && !conversationStore.conversations.loading
return (
conversationStore.sortedConversations.length === 0 &&
!conversationStore.conversations.errorMessage &&
!conversationStore.conversations.loading
)
})
const hasErrored = computed(() => {
return conversationStore.conversations.errorMessage ? true : false
return conversationStore.conversations.errorMessage ? true : false
})
const everythingLoaded = computed(() => {
return !conversationStore.conversations.errorMessage && !emptyConversations.value
return !conversationStore.conversations.errorMessage && !emptyConversations.value
})
const conversationsLoading = computed(() => {
return conversationStore.conversations.loading
return conversationStore.conversations.loading
})
</script>

View File

@@ -1,48 +1,53 @@
<template>
<div class="flex items-center cursor-pointer flex-row hover:bg-slate-50"
:class="{ 'bg-slate-100': conversation.uuid === conversationStore.conversation.data?.uuid }"
v-for="conversation in conversationStore.sortedConversations" :key="conversation.uuid"
@click="router.push('/conversations/' + conversation.uuid)">
<div class="pl-3">
<Avatar class="size-[45px]">
<AvatarImage :src=conversation.avatar_url v-if="conversation.avatar_url" />
<AvatarFallback>
{{ conversation.first_name.substring(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
</div>
<div class="ml-3 w-full border-b pb-2">
<div class="flex justify-between pt-2 pr-3">
<div>
<p class="text-xs text-gray-600 flex gap-x-1">
<Mail size="12" />
{{ conversation.inbox_name }}
</p>
<p class="text-base font-normal">
{{ conversationStore.getContactFullName(conversation.uuid) }}
</p>
</div>
<div>
<span class="text-sm text-muted-foreground" v-if="conversation.last_message_at">
{{ formatTime(conversation.last_message_at) }}
</span>
</div>
</div>
<div class="pt-2 pr-3">
<div class="flex justify-between">
<p class="text-gray-800 max-w-xs text-sm dark:text-white text-ellipsis flex gap-1">
<CheckCheck :size="14" /> {{ conversation.last_message }}
</p>
<div class="flex items-center justify-center bg-green-500 rounded-full w-[20px] h-[20px]"
v-if="conversation.unread_message_count > 0">
<span class="text-white text-xs font-extrabold">
{{ conversation.unread_message_count }}
</span>
</div>
</div>
</div>
</div>
<div
class="flex items-center cursor-pointer flex-row hover:bg-slate-50"
:class="{ 'bg-slate-100': conversation.uuid === conversationStore.conversation.data?.uuid }"
v-for="conversation in conversationStore.sortedConversations"
:key="conversation.uuid"
@click="router.push('/conversations/' + conversation.uuid)"
>
<div class="pl-3">
<Avatar class="size-[45px]">
<AvatarImage :src="conversation.avatar_url" v-if="conversation.avatar_url" />
<AvatarFallback>
{{ conversation.first_name.substring(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
</div>
<div class="ml-3 w-full border-b pb-2">
<div class="flex justify-between pt-2 pr-3">
<div>
<p class="text-xs text-gray-600 flex gap-x-1">
<Mail size="12" />
{{ conversation.inbox_name }}
</p>
<p class="text-base font-normal">
{{ conversationStore.getContactFullName(conversation.uuid) }}
</p>
</div>
<div>
<span class="text-sm text-muted-foreground" v-if="conversation.last_message_at">
{{ formatTime(conversation.last_message_at) }}
</span>
</div>
</div>
<div class="pt-2 pr-3">
<div class="flex justify-between">
<p class="text-gray-800 max-w-xs text-sm dark:text-white text-ellipsis flex gap-1">
<CheckCheck :size="14" /> {{ conversation.last_message }}
</p>
<div
class="flex items-center justify-center bg-green-500 rounded-full w-[20px] h-[20px]"
v-if="conversation.unread_message_count > 0"
>
<span class="text-white text-xs font-extrabold">
{{ conversation.unread_message_count }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
@@ -55,4 +60,4 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
const router = useRouter()
const conversationStore = useConversationStore()
</script>
</script>

View File

@@ -1,16 +1,21 @@
<template>
<BarChart :data="data" index="status" :categories="['Low', 'Medium', 'High']" :show-grid-line="true"
:margin="{ top: 0, bottom: 0, left: 0, right: 0 }" />
<BarChart
:data="data"
index="status"
:categories="['Low', 'Medium', 'High']"
:show-grid-line="true"
:margin="{ top: 0, bottom: 0, left: 0, right: 0 }"
/>
</template>
<script setup>
import { BarChart } from '@/components/ui/chart-bar'
defineProps({
data: {
type: Array,
required: true,
default: () => []
}
});
</script>
data: {
type: Array,
required: true,
default: () => []
}
})
</script>

View File

@@ -1,35 +1,29 @@
<template>
<div class="flex gap-x-5">
<Card class="w-1/6 dashboard-card" v-for="(value, key) in counts" :key="key">
<CardHeader>
<CardTitle class="text-2xl">
{{ value }}
</CardTitle>
<CardDescription>
{{ labels[key] }}
</CardDescription>
</CardHeader>
</Card>
</div>
<div class="flex gap-x-5">
<Card class="w-1/6 dashboard-card" v-for="(value, key) in counts" :key="key">
<CardHeader>
<CardTitle class="text-2xl">
{{ value }}
</CardTitle>
<CardDescription>
{{ labels[key] }}
</CardDescription>
</CardHeader>
</Card>
</div>
</template>
<script setup>
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
defineProps({
counts: {
type: Object,
required: true,
},
labels: {
type: Object,
required: true,
}
counts: {
type: Object,
required: true
},
labels: {
type: Object,
required: true
}
})
</script>
</script>

View File

@@ -1,25 +1,32 @@
<template>
<LineChart :data="renamedData" index="date" :categories="['New conversations']" :y-formatter="(tick) => {
<LineChart
:data="renamedData"
index="date"
:categories="['New conversations']"
:y-formatter="
(tick) => {
return tick
}" />
}
"
/>
</template>
<script setup>
import { computed } from 'vue';
import { computed } from 'vue'
import { LineChart } from '@/components/ui/chart-line'
const props = defineProps({
data: {
type: Array,
required: true,
default: () => []
}
});
data: {
type: Array,
required: true,
default: () => []
}
})
const renamedData = computed(() =>
props.data.map(item => ({
...item,
'New conversations': item.new_conversations
}))
);
const renamedData = computed(() =>
props.data.map((item) => ({
...item,
'New conversations': item.new_conversations
}))
)
</script>

View File

@@ -1,30 +1,26 @@
<template>
<div class="text-center">
<div class="text-muted-foreground text-sm">
{{ message.content }}
<Tooltip>
<TooltipTrigger>
<span class="text-xs ml-1">{{ format(message.updated_at, 'h:mm a') }}</span>
</TooltipTrigger>
<TooltipContent>
<p>
{{ format(message.updated_at, "MMMM dd, yyyy 'at' HH:mm") }}
</p>
</TooltipContent>
</Tooltip>
</div>
<div class="text-center">
<div class="text-muted-foreground text-sm">
{{ message.content }}
<Tooltip>
<TooltipTrigger>
<span class="text-xs ml-1">{{ format(message.updated_at, 'h:mm a') }}</span>
</TooltipTrigger>
<TooltipContent>
<p>
{{ format(message.updated_at, "MMMM dd, yyyy 'at' HH:mm") }}
</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</template>
<script setup>
import { format } from 'date-fns'
import {
Tooltip,
TooltipContent,
TooltipTrigger
} from '@/components/ui/tooltip'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
defineProps({
message: Object,
message: Object
})
</script>
</script>

View File

@@ -1,123 +1,112 @@
<template>
<div class="flex flex-col items-end text-left">
<div class="pr-[47px] mb-1">
<p class="text-muted-foreground text-sm">
{{ getFullName }}
</p>
</div>
{{ mess }}
<div class="flex flex-row gap-2 justify-end">
<div class="
flex
flex-col
message-bubble
justify-end
items-end
relative
!rounded-tr-none
" :class="{
'!bg-[#FEF1E1]': message.private,
'bg-white': !message.private,
'opacity-50 animate-pulse': message.status === 'pending',
'bg-red': message.status === 'failed'
}">
<div v-html=message.content :class="{ 'mb-3': message.attachments.length > 0 }"></div>
<MessageAttachmentPreview :attachments="message.attachments" />
<Spinner v-if="message.status === 'pending'" />
<!-- Icons -->
<div class="flex items-center space-x-2 mt-2">
<Lock :size="10" v-if="isPrivateMessage" />
<CheckCheck :size="14" v-if="showCheckCheck" />
<RotateCcw size="10" @click="retryMessage(message)" class="cursor-pointer"
v-if="showRetry"></RotateCcw>
</div>
</div>
<Avatar class="cursor-pointer">
<AvatarImage :src=getAvatar />
<AvatarFallback>
{{ avatarFallback }}
</AvatarFallback>
</Avatar>
</div>
<div class="pr-[47px]">
<Tooltip>
<TooltipTrigger>
<span class="text-muted-foreground text-xs mt-1">
{{ format(message.updated_at, "h:mm a") }}
</span>
</TooltipTrigger>
<TooltipContent>
{{ format(message.updated_at, "MMMM dd, yyyy 'at' HH:mm") }}
</TooltipContent>
</Tooltip>
</div>
<div class="flex flex-col items-end text-left">
<div class="pr-[47px] mb-1">
<p class="text-muted-foreground text-sm">
{{ getFullName }}
</p>
</div>
{{ mess }}
<div class="flex flex-row gap-2 justify-end">
<div
class="flex flex-col message-bubble justify-end items-end relative !rounded-tr-none"
:class="{
'!bg-[#FEF1E1]': message.private,
'bg-white': !message.private,
'opacity-50 animate-pulse': message.status === 'pending',
'bg-red': message.status === 'failed'
}"
>
<div v-html="message.content" :class="{ 'mb-3': message.attachments.length > 0 }"></div>
<MessageAttachmentPreview :attachments="message.attachments" />
<Spinner v-if="message.status === 'pending'" />
<!-- Icons -->
<div class="flex items-center space-x-2 mt-2">
<Lock :size="10" v-if="isPrivateMessage" />
<CheckCheck :size="14" v-if="showCheckCheck" />
<RotateCcw
size="10"
@click="retryMessage(message)"
class="cursor-pointer"
v-if="showRetry"
></RotateCcw>
</div>
</div>
<Avatar class="cursor-pointer">
<AvatarImage :src="getAvatar" />
<AvatarFallback>
{{ avatarFallback }}
</AvatarFallback>
</Avatar>
</div>
<div class="pr-[47px]">
<Tooltip>
<TooltipTrigger>
<span class="text-muted-foreground text-xs mt-1">
{{ format(message.updated_at, 'h:mm a') }}
</span>
</TooltipTrigger>
<TooltipContent>
{{ format(message.updated_at, "MMMM dd, yyyy 'at' HH:mm") }}
</TooltipContent>
</Tooltip>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { format } from 'date-fns'
import { useConversationStore } from '@/stores/conversation'
import { Lock } from 'lucide-vue-next';
import { Lock } from 'lucide-vue-next'
import api from '@/api'
import {
Tooltip,
TooltipContent,
TooltipTrigger
} from '@/components/ui/tooltip'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Spinner } from '@/components/ui/spinner'
import { RotateCcw, CheckCheck } from 'lucide-vue-next';
import { RotateCcw, CheckCheck } from 'lucide-vue-next'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import MessageAttachmentPreview from "@/components/attachment/MessageAttachmentPreview.vue"
import MessageAttachmentPreview from '@/components/attachment/MessageAttachmentPreview.vue'
const props = defineProps({
message: Object,
message: Object
})
const convStore = useConversationStore();
const convStore = useConversationStore()
const participant = computed(() => {
return convStore.conversation?.participants?.[props.message.sender_uuid] ?? {};
});
return convStore.conversation?.participants?.[props.message.sender_uuid] ?? {}
})
const getFullName = computed(() => {
const firstName = participant.value?.first_name ?? 'Unknown'
const lastName = participant.value?.last_name ?? 'User'
return `${firstName} ${lastName}`;
});
const firstName = participant.value?.first_name ?? 'Unknown'
const lastName = participant.value?.last_name ?? 'User'
return `${firstName} ${lastName}`
})
const getAvatar = computed(() => {
return participant.value?.avatar_url
});
return participant.value?.avatar_url
})
const isPrivateMessage = computed(() => {
return props.message.private
return props.message.private
})
const showCheckCheck = computed(() => {
return props.message.status == 'sent' && !isPrivateMessage.value
return props.message.status == 'sent' && !isPrivateMessage.value
})
const showRetry = computed(() => {
return props.message.status == 'failed'
return props.message.status == 'failed'
})
const avatarFallback = computed(() => {
const firstName = participant.value?.first_name ?? 'A'
return firstName.toUpperCase().substring(0, 2);
});
const firstName = participant.value?.first_name ?? 'A'
return firstName.toUpperCase().substring(0, 2)
})
const retryMessage = (msg) => {
api.retryMessage(msg.uuid)
api.retryMessage(msg.uuid)
}
</script>
</script>

View File

@@ -1,68 +1,67 @@
<template>
<div class="flex flex-col items-start">
<div class="pl-[47px] mb-1">
<p class="text-muted-foreground text-sm">
{{ getFullName }}
</p>
</div>
<div class="flex flex-row gap-2">
<Avatar class="cursor-pointer">
<AvatarImage :src=getAvatar />
<AvatarFallback>
{{ avatarFallback }}
</AvatarFallback>
</Avatar>
<div class="flex flex-col justify-end message-bubble !rounded-tl-none">
<Letter :html=message.content class="mb-1" :class="{ 'mb-3': message.attachments.length > 0 }" />
<MessageAttachmentPreview :attachments="message.attachments" />
</div>
</div>
<div class="pl-[47px]">
<Tooltip>
<TooltipTrigger>
<span class="text-muted-foreground text-xs mt-1">
{{ format(message.updated_at, "h:mm a") }}
</span>
</TooltipTrigger>
<TooltipContent>
<p>
{{ format(message.updated_at, "MMMM dd, yyyy 'at' HH:mm") }}
</p>
</TooltipContent>
</Tooltip>
</div>
<div class="flex flex-col items-start">
<div class="pl-[47px] mb-1">
<p class="text-muted-foreground text-sm">
{{ getFullName }}
</p>
</div>
<div class="flex flex-row gap-2">
<Avatar class="cursor-pointer">
<AvatarImage :src="getAvatar" />
<AvatarFallback>
{{ avatarFallback }}
</AvatarFallback>
</Avatar>
<div class="flex flex-col justify-end message-bubble !rounded-tl-none">
<Letter
:html="message.content"
class="mb-1"
:class="{ 'mb-3': message.attachments.length > 0 }"
/>
<MessageAttachmentPreview :attachments="message.attachments" />
</div>
</div>
<div class="pl-[47px]">
<Tooltip>
<TooltipTrigger>
<span class="text-muted-foreground text-xs mt-1">
{{ format(message.updated_at, 'h:mm a') }}
</span>
</TooltipTrigger>
<TooltipContent>
<p>
{{ format(message.updated_at, "MMMM dd, yyyy 'at' HH:mm") }}
</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</template>
<script setup>
import { computed } from "vue"
import { computed } from 'vue'
import { format } from 'date-fns'
import { useConversationStore } from '@/stores/conversation'
import {
Tooltip,
TooltipContent,
TooltipTrigger
} from '@/components/ui/tooltip'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Letter } from 'vue-letter'
import MessageAttachmentPreview from "@/components/attachment/MessageAttachmentPreview.vue"
import MessageAttachmentPreview from '@/components/attachment/MessageAttachmentPreview.vue'
defineProps({
message: Object,
message: Object
})
const convStore = useConversationStore()
const getAvatar = computed(() => {
return convStore.conversation.data.avatar_url ? convStore.conversation.avatar_url : ''
return convStore.conversation.data.avatar_url ? convStore.conversation.avatar_url : ''
})
const getFullName = computed(() => {
return convStore.conversation.data.first_name + ' ' + convStore.conversation.data.last_name
return convStore.conversation.data.first_name + ' ' + convStore.conversation.data.last_name
})
const avatarFallback = computed(() => {
return convStore.conversation.data.first_name.toUpperCase().substring(0, 2)
return convStore.conversation.data.first_name.toUpperCase().substring(0, 2)
})
</script>
</script>

View File

@@ -1,68 +1,75 @@
<template>
<div ref="threadEl" class="overflow-y-scroll">
<div class="text-center mt-3" v-if="conversationStore.messages.hasMore && !conversationStore.messages.loading">
<Button variant="ghost" @click="conversationStore.fetchNextMessages">
<RefreshCw size="17" class="mr-2" />
Load more
</Button>
</div>
<div v-for="message in conversationStore.sortedMessages" :key="message.uuid"
:class="message.type === 'activity' ? 'm-4' : 'm-6'">
<div v-if="!message.private">
<ContactMessageBubble :message="message" v-if="message.type === 'incoming'" />
<AgentMessageBubble :message="message" v-if="message.type === 'outgoing'" />
</div>
<div v-else-if="isPrivateNote(message)">
<AgentMessageBubble :message="message" v-if="message.type === 'outgoing'" />
</div>
<div v-else-if="message.type === 'activity'">
<ActivityMessageBubble :message="message" />
</div>
</div>
<div ref="threadEl" class="overflow-y-scroll">
<div
class="text-center mt-3"
v-if="conversationStore.messages.hasMore && !conversationStore.messages.loading"
>
<Button variant="ghost" @click="conversationStore.fetchNextMessages">
<RefreshCw size="17" class="mr-2" />
Load more
</Button>
</div>
<div
v-for="message in conversationStore.sortedMessages"
:key="message.uuid"
:class="message.type === 'activity' ? 'm-4' : 'm-6'"
>
<div v-if="!message.private">
<ContactMessageBubble :message="message" v-if="message.type === 'incoming'" />
<AgentMessageBubble :message="message" v-if="message.type === 'outgoing'" />
</div>
<div v-else-if="isPrivateNote(message)">
<AgentMessageBubble :message="message" v-if="message.type === 'outgoing'" />
</div>
<div v-else-if="message.type === 'activity'">
<ActivityMessageBubble :message="message" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue';
import ContactMessageBubble from "./ContactMessageBubble.vue";
import ActivityMessageBubble from "./ActivityMessageBubble.vue";
import AgentMessageBubble from "./AgentMessageBubble.vue";
import { ref, onMounted, watch } from 'vue'
import ContactMessageBubble from './ContactMessageBubble.vue'
import ActivityMessageBubble from './ActivityMessageBubble.vue'
import AgentMessageBubble from './AgentMessageBubble.vue'
import { useConversationStore } from '@/stores/conversation'
import { Button } from '@/components/ui/button';
import { RefreshCw } from 'lucide-vue-next';
import { useEmitter } from '@/composables/useEmitter';
import { Button } from '@/components/ui/button'
import { RefreshCw } from 'lucide-vue-next'
import { useEmitter } from '@/composables/useEmitter'
const conversationStore = useConversationStore()
const threadEl = ref(null)
const emitter = useEmitter()
const scrollToBottom = () => {
setTimeout(() => {
const thread = threadEl.value
if (thread) {
thread.scrollTop = thread.scrollHeight
}
}, 0)
};
setTimeout(() => {
const thread = threadEl.value
if (thread) {
thread.scrollTop = thread.scrollHeight
}
}, 0)
}
onMounted(() => {
scrollToBottom()
// On new outgoing message to the current conversation, scroll to the bottom.
emitter.on('new-outgoing-message', (data) => {
if (data.conversation_uuid === conversationStore.conversation.data.uuid) {
scrollToBottom()
}
});
});
scrollToBottom()
// On new outgoing message to the current conversation, scroll to the bottom.
emitter.on('new-outgoing-message', (data) => {
if (data.conversation_uuid === conversationStore.conversation.data.uuid) {
scrollToBottom()
}
})
})
// On conversation change scroll to the bottom
watch(() => conversationStore.conversation.data, () => {
watch(
() => conversationStore.conversation.data,
() => {
scrollToBottom()
});
}
)
const isPrivateNote = (message) => {
return message.type === "outgoing" && message.private
};
return message.type === 'outgoing' && message.private
}
</script>

View File

@@ -1,5 +1,5 @@
<script setup>
import { AccordionRoot, useForwardPropsEmits } from "radix-vue";
import { AccordionRoot, useForwardPropsEmits } from 'radix-vue'
const props = defineProps({
collapsible: { type: Boolean, required: false },
@@ -10,11 +10,11 @@ const props = defineProps({
as: { type: null, required: false },
type: { type: null, required: false },
modelValue: { type: null, required: false },
defaultValue: { type: null, required: false },
});
const emits = defineEmits(["update:modelValue"]);
defaultValue: { type: null, required: false }
})
const emits = defineEmits(['update:modelValue'])
const forwarded = useForwardPropsEmits(props, emits);
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>

View File

@@ -1,19 +1,19 @@
<script setup>
import { computed } from "vue";
import { AccordionContent } from "radix-vue";
import { cn } from "@/lib/utils";
import { computed } from 'vue'
import { AccordionContent } from 'radix-vue'
import { cn } from '@/lib/utils'
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
class: { type: null, required: false }
})
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
const { class: _, ...delegated } = props
return delegated;
});
return delegated
})
</script>
<template>

View File

@@ -1,23 +1,23 @@
<script setup>
import { computed } from "vue";
import { AccordionItem, useForwardProps } from "radix-vue";
import { cn } from "@/lib/utils";
import { computed } from 'vue'
import { AccordionItem, useForwardProps } from 'radix-vue'
import { cn } from '@/lib/utils'
const props = defineProps({
disabled: { type: Boolean, required: false },
value: { type: String, required: true },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
class: { type: null, required: false }
})
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
const { class: _, ...delegated } = props
return delegated;
});
return delegated
})
const forwardedProps = useForwardProps(delegatedProps);
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>

View File

@@ -1,20 +1,20 @@
<script setup>
import { computed } from "vue";
import { AccordionHeader, AccordionTrigger } from "radix-vue";
import { ChevronDownIcon } from "@radix-icons/vue";
import { cn } from "@/lib/utils";
import { computed } from 'vue'
import { AccordionHeader, AccordionTrigger } from 'radix-vue'
import { ChevronDownIcon } from '@radix-icons/vue'
import { cn } from '@/lib/utils'
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
class: { type: null, required: false }
})
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
const { class: _, ...delegated } = props
return delegated;
});
return delegated
})
</script>
<template>

View File

@@ -1,4 +1,4 @@
export { default as Accordion } from "./Accordion.vue";
export { default as AccordionContent } from "./AccordionContent.vue";
export { default as AccordionItem } from "./AccordionItem.vue";
export { default as AccordionTrigger } from "./AccordionTrigger.vue";
export { default as Accordion } from './Accordion.vue'
export { default as AccordionContent } from './AccordionContent.vue'
export { default as AccordionItem } from './AccordionItem.vue'
export { default as AccordionTrigger } from './AccordionTrigger.vue'

View File

@@ -1,13 +1,13 @@
<script setup>
import { AlertDialogRoot, useForwardPropsEmits } from "radix-vue";
import { AlertDialogRoot, useForwardPropsEmits } from 'radix-vue'
const props = defineProps({
open: { type: Boolean, required: false },
defaultOpen: { type: Boolean, required: false },
});
const emits = defineEmits(["update:open"]);
defaultOpen: { type: Boolean, required: false }
})
const emits = defineEmits(['update:open'])
const forwarded = useForwardPropsEmits(props, emits);
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>

View File

@@ -1,27 +1,24 @@
<script setup>
import { computed } from "vue";
import { AlertDialogAction } from "radix-vue";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
import { computed } from 'vue'
import { AlertDialogAction } from 'radix-vue'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
class: { type: null, required: false }
})
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
const { class: _, ...delegated } = props
return delegated;
});
return delegated
})
</script>
<template>
<AlertDialogAction
v-bind="delegatedProps"
:class="cn(buttonVariants(), props.class)"
>
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>

View File

@@ -1,28 +1,26 @@
<script setup>
import { computed } from "vue";
import { AlertDialogCancel } from "radix-vue";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
import { computed } from 'vue'
import { AlertDialogCancel } from 'radix-vue'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
class: { type: null, required: false }
})
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
const { class: _, ...delegated } = props
return delegated;
});
return delegated
})
</script>
<template>
<AlertDialogCancel
v-bind="delegatedProps"
:class="
cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class)
"
:class="cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class)"
>
<slot />
</AlertDialogCancel>

View File

@@ -1,12 +1,12 @@
<script setup>
import { computed } from "vue";
import { computed } from 'vue'
import {
AlertDialogContent,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from "radix-vue";
import { cn } from "@/lib/utils";
useForwardPropsEmits
} from 'radix-vue'
import { cn } from '@/lib/utils'
const props = defineProps({
forceMount: { type: Boolean, required: false },
@@ -14,24 +14,24 @@ const props = defineProps({
disableOutsidePointerEvents: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
class: { type: null, required: false }
})
const emits = defineEmits([
"escapeKeyDown",
"pointerDownOutside",
"focusOutside",
"interactOutside",
"openAutoFocus",
"closeAutoFocus",
]);
'escapeKeyDown',
'pointerDownOutside',
'focusOutside',
'interactOutside',
'openAutoFocus',
'closeAutoFocus'
])
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
const { class: _, ...delegated } = props
return delegated;
});
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
@@ -44,7 +44,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
:class="
cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
props.class,
props.class
)
"
>

View File

@@ -1,19 +1,19 @@
<script setup>
import { computed } from "vue";
import { AlertDialogDescription } from "radix-vue";
import { cn } from "@/lib/utils";
import { computed } from 'vue'
import { AlertDialogDescription } from 'radix-vue'
import { cn } from '@/lib/utils'
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
class: { type: null, required: false }
})
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
const { class: _, ...delegated } = props
return delegated;
});
return delegated
})
</script>
<template>

View File

@@ -1,20 +1,13 @@
<script setup>
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils'
const props = defineProps({
class: { type: null, required: false },
});
class: { type: null, required: false }
})
</script>
<template>
<div
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<div :class="cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2', props.class)">
<slot />
</div>
</template>

View File

@@ -1,15 +1,13 @@
<script setup>
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils'
const props = defineProps({
class: { type: null, required: false },
});
class: { type: null, required: false }
})
</script>
<template>
<div
:class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
>
<div :class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)">
<slot />
</div>
</template>

View File

@@ -1,26 +1,23 @@
<script setup>
import { computed } from "vue";
import { AlertDialogTitle } from "radix-vue";
import { cn } from "@/lib/utils";
import { computed } from 'vue'
import { AlertDialogTitle } from 'radix-vue'
import { cn } from '@/lib/utils'
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
class: { type: null, required: false }
})
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
const { class: _, ...delegated } = props
return delegated;
});
return delegated
})
</script>
<template>
<AlertDialogTitle
v-bind="delegatedProps"
:class="cn('text-lg font-semibold', props.class)"
>
<AlertDialogTitle v-bind="delegatedProps" :class="cn('text-lg font-semibold', props.class)">
<slot />
</AlertDialogTitle>
</template>

View File

@@ -1,10 +1,10 @@
<script setup>
import { AlertDialogTrigger } from "radix-vue";
import { AlertDialogTrigger } from 'radix-vue'
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
});
as: { type: null, required: false }
})
</script>
<template>

View File

@@ -1,9 +1,9 @@
export { default as AlertDialog } from "./AlertDialog.vue";
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue";
export { default as AlertDialogContent } from "./AlertDialogContent.vue";
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue";
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue";
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue";
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue";
export { default as AlertDialogAction } from "./AlertDialogAction.vue";
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue";
export { default as AlertDialog } from './AlertDialog.vue'
export { default as AlertDialogTrigger } from './AlertDialogTrigger.vue'
export { default as AlertDialogContent } from './AlertDialogContent.vue'
export { default as AlertDialogHeader } from './AlertDialogHeader.vue'
export { default as AlertDialogTitle } from './AlertDialogTitle.vue'
export { default as AlertDialogDescription } from './AlertDialogDescription.vue'
export { default as AlertDialogFooter } from './AlertDialogFooter.vue'
export { default as AlertDialogAction } from './AlertDialogAction.vue'
export { default as AlertDialogCancel } from './AlertDialogCancel.vue'

Some files were not shown because too many files have changed in this diff Show More