mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-02 13:03:35 +00:00
feat: get statuses / priorities from their respective tables
- adds schema.sql - format entire codebase
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
72
config.sample.toml
Normal 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:*}"
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -20,4 +20,4 @@ const router = useRouter()
|
||||
const newRule = () => {
|
||||
router.push({ path: `/admin/automations/new` })
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -15,5 +15,5 @@ defineProps({
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
<template>
|
||||
Priority
|
||||
</template>
|
||||
<template>Priority</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
<script setup></script>
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
<template>
|
||||
Status
|
||||
</template>
|
||||
<template>Status</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
<script setup></script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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({})
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -111,7 +111,7 @@ const columns = [
|
||||
inbox,
|
||||
onEditInbox: (id) => handleEditInbox(id),
|
||||
onDeleteInbox: (id) => handleDeleteInbox(id),
|
||||
onToggleInbox: (id) => handleToggleInbox(id),
|
||||
onToggleInbox: (id) => handleToggleInbox(id)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
])
|
||||
});
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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.'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user