fix: Ticks of bar charts.

- Update shadcn charts.
- Refactors user store.
- Fix: pagination incorrect total pages.
- Comestic changes and cleanups.
- Fixes toaster not working in OuterApp.vue.
- Allow complete from address in notification settings from address form field.
This commit is contained in:
Abhinav Raut
2024-11-06 02:44:02 +05:30
parent a530f36f65
commit d77756476e
38 changed files with 333 additions and 297 deletions

View File

@@ -33,7 +33,7 @@ func handleGetAllConversations(r *fastglue.Request) error {
Results: conversations,
Total: total,
PerPage: pageSize,
TotalPages: total / pageSize,
TotalPages: (total + pageSize - 1) / pageSize,
Page: page,
})
}
@@ -61,7 +61,7 @@ func handleGetAssignedConversations(r *fastglue.Request) error {
Results: conversations,
Total: total,
PerPage: pageSize,
TotalPages: total / pageSize,
TotalPages: (total + pageSize - 1) / pageSize,
Page: page,
})
}
@@ -89,7 +89,7 @@ func handleGetUnassignedConversations(r *fastglue.Request) error {
Results: conversations,
Total: total,
PerPage: pageSize,
TotalPages: total / pageSize,
TotalPages: (total + pageSize - 1) / pageSize,
Page: page,
})
}

View File

@@ -57,7 +57,7 @@ type constants struct {
AppBaseURL string
LogoURL string
SiteName string
Filestore string
UploadProvider string
AllowedUploadFileExtensions []string
MaxFileUploadSizeMB float64
}
@@ -107,7 +107,7 @@ func initConstants() constants {
AppBaseURL: ko.String("app.root_url"),
LogoURL: ko.String("app.logo_url"),
SiteName: ko.String("app.site_name"),
Filestore: ko.MustString("upload.provider"),
UploadProvider: ko.MustString("upload.provider"),
AllowedUploadFileExtensions: ko.Strings("app.allowed_file_upload_extensions"),
MaxFileUploadSizeMB: ko.Float64("app.max_file_upload_size"),
}
@@ -594,7 +594,6 @@ func initStatus(db *sqlx.DB) *status.Manager {
return manager
}
// initPriority inits conversation priority manager.
func initPriority(db *sqlx.DB) *priority.Manager {
manager, err := priority.New(priority.Opts{

View File

@@ -42,7 +42,7 @@ var (
// App is the global app context which is passed and injected in the http handlers.
type App struct {
constant constants
consts constants
fs stuffbin.FileSystem
auth *auth_.Auth
authz *authz.Enforcer
@@ -167,7 +167,7 @@ func main() {
conversation: conversation,
automation: automation,
oidc: oidc,
constant: constants,
consts: constants,
notifier: notifier,
authz: initAuthz(),
status: initStatus(db),

View File

@@ -63,17 +63,17 @@ func handleMediaUpload(r *fastglue.Request) error {
srcExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcFileName)), ".")
// Check file size
if bytesToMegabytes(srcFileSize) > app.constant.MaxFileUploadSizeMB {
app.lo.Error("error: uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", app.constant.MaxFileUploadSizeMB)
if bytesToMegabytes(srcFileSize) > app.consts.MaxFileUploadSizeMB {
app.lo.Error("error: uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", app.consts.MaxFileUploadSizeMB)
return r.SendErrorEnvelope(
http.StatusRequestEntityTooLarge,
fmt.Sprintf("File size is too large. Please upload file lesser than %f MB", app.constant.MaxFileUploadSizeMB),
fmt.Sprintf("File size is too large. Please upload file lesser than %f MB", app.consts.MaxFileUploadSizeMB),
nil,
envelope.GeneralError,
)
}
if !slices.Contains(app.constant.AllowedUploadFileExtensions, "*") && !slices.Contains(app.constant.AllowedUploadFileExtensions, srcExt) {
if !slices.Contains(app.consts.AllowedUploadFileExtensions, "*") && !slices.Contains(app.consts.AllowedUploadFileExtensions, srcExt) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Unsupported file type", nil, envelope.InputError)
}
@@ -151,7 +151,7 @@ func handleServeMedia(r *fastglue.Request) error {
// Check if the user has permission to access the linked model.
allowed, err := app.authz.EnforceMediaAccess(user, media.Model.String)
if err != nil {
if err != nil {
app.lo.Error("error checking media permission", "error", err, "model", media.Model.String, "model_id", media.ModelID)
return sendErrorEnvelope(r, err)
}
@@ -172,7 +172,7 @@ func handleServeMedia(r *fastglue.Request) error {
return r.SendErrorEnvelope(http.StatusUnauthorized, "Permission denied", nil, envelope.PermissionError)
}
switch ko.String("upload.provider") {
switch app.consts.UploadProvider {
case "fs":
fasthttp.ServeFile(r.RequestCtx, filepath.Join(ko.String("upload.fs.upload_path"), uuid))
case "s3":

View File

@@ -49,7 +49,7 @@ func handleGetMessages(r *fastglue.Request) error {
Results: messages,
Page: page,
PerPage: pageSize,
TotalPages: total / pageSize,
TotalPages: (total + pageSize - 1) / pageSize,
})
}

View File

@@ -40,7 +40,7 @@ func handleGetOIDC(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
o.RedirectURI = fmt.Sprintf("%s%s", app.constant.AppBaseURL, fmt.Sprintf(redirectURI, o.ID))
o.RedirectURI = fmt.Sprintf("%s%s", app.consts.AppBaseURL, fmt.Sprintf(redirectURI, o.ID))
return r.SendEnvelope(o)
}

View File

@@ -12,7 +12,7 @@ import (
)
func ErrHandler(ctx *fasthttp.RequestCtx, status int, reason error) {
fmt.Printf("error status %d - error %d", status, reason)
fmt.Printf("error status %d: %s", status, reason)
}
var upgrader = websocket.FastHTTPUpgrader{

View File

@@ -29,13 +29,13 @@
"@tiptap/starter-kit": "^2.4.0",
"@tiptap/suggestion": "^2.4.0",
"@tiptap/vue-3": "^2.4.0",
"@unovis/ts": "^1.4.3",
"@unovis/vue": "^1.4.3",
"@unovis/ts": "^1.4.4",
"@unovis/vue": "^1.4.4",
"@vee-validate/zod": "^4.13.2",
"@vue/reactivity": "^3.4.15",
"@vue/runtime-core": "^3.4.15",
"@vueup/vue-quill": "^1.2.0",
"@vueuse/core": "^10.11.1",
"@vueuse/core": "^11.2.0",
"add": "^2.0.6",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",

View File

@@ -1,21 +1,16 @@
<template>
<Toaster />
<TooltipProvider :delay-duration="200">
<div class="font-inter">
<div class="flex">
<NavBar :is-collapsed="isCollapsed" :links="navLinks" :bottom-links="bottomLinks"
class="shadow shadow-gray-300 h-screen" />
<ResizablePanelGroup direction="horizontal" auto-save-id="app.vue.resizable.panel">
<ResizableHandle id="resize-handle-1" />
<ResizablePanel id="resize-panel-2">
<div class="w-full h-screen">
<RouterView />
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
</TooltipProvider>
<div class="flex">
<NavBar :is-collapsed="isCollapsed" :links="navLinks" :bottom-links="bottomLinks"
class="shadow shadow-gray-300 h-screen" />
<ResizablePanelGroup direction="horizontal" auto-save-id="app.vue.resizable.panel">
<ResizableHandle id="resize-handle-1" />
<ResizablePanel id="resize-panel-2">
<div class="w-full h-screen">
<RouterView />
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</template>
<script setup>
@@ -25,10 +20,8 @@ import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user'
import { initWS } from '@/websocket.js'
import { useEmitter } from '@/composables/useEmitter'
import { Toaster } from '@/components/ui/toast'
import { useToast } from '@/components/ui/toast/use-toast'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
import { TooltipProvider } from '@/components/ui/tooltip'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import NavBar from '@/components/NavBar.vue'
@@ -99,7 +92,7 @@ const initToaster = () => {
const navLinks = computed(() =>
allNavLinks.filter((link) =>
!link.permission || (userStore.userPermissions.includes(link.permission) && link.permission)
!link.permission || (userStore.permissions.includes(link.permission) && link.permission)
)
)
</script>

View File

@@ -1,7 +1,14 @@
<template>
<RouterView />
<Toaster />
<TooltipProvider :delay-duration="250">
<div class="font-inter">
<RouterView />
</div>
</TooltipProvider>
</template>
<script setup>
import { RouterView } from 'vue-router'
import { Toaster } from '@/components/ui/toast'
import { TooltipProvider } from '@/components/ui/tooltip'
</script>

View File

@@ -11,6 +11,8 @@
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
overflow-y: hidden;
}
.page-content {
@@ -24,62 +26,62 @@ body {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border:240 5.9% 90%;
--input:240 5.9% 90%;
--ring:240 5.9% 10%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.75rem;
}
.dark {
--background:240 10% 3.9%;
--foreground:0 0% 98%;
--card:240 10% 3.9%;
--card-foreground:0 0% 98%;
--popover:240 10% 3.9%;
--popover-foreground:0 0% 98%;
--primary:0 0% 98%;
--primary-foreground:240 5.9% 10%;
--secondary:240 3.7% 15.9%;
--secondary-foreground:0 0% 98%;
--muted:240 3.7% 15.9%;
--muted-foreground:240 5% 64.9%;
--accent:240 3.7% 15.9%;
--accent-foreground:0 0% 98%;
--destructive:0 62.8% 30.6%;
--destructive-foreground:0 0% 98%;
--border:240 3.7% 15.9%;
--input:240 3.7% 15.9%;
--ring:240 4.9% 83.9%;
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
@@ -174,23 +176,22 @@ body {
@apply p-0;
}
::-webkit-scrollbar {
width: 8px;
width: 10px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background-color: #888;
background-color: #888;
border-radius: 4px;
border: 2px solid transparent;
}
::-webkit-scrollbar-thumb:hover {
background-color: #555;
background-color: #555;
}
* {
@@ -198,10 +199,16 @@ body {
}
.code-editor {
@apply rounded-md border shadow h-[65vh] min-h-[250px] w-full relative
@apply rounded-md border shadow h-[65vh] min-h-[250px] w-full relative;
}
.ql-container {
.ql-container .ql-editor {
margin-top: 0 !important;
height: 200px !important;
border-radius: var(--radius) !important;
@apply rounded-lg rounded-t-none;
}
.ql-toolbar {
@apply rounded-t-lg;
}

View File

@@ -7,7 +7,7 @@
</div>
<div class="flex space-x-5">
<Avatar class="size-28">
<AvatarImage :src="userStore.userAvatar" alt="Cropped Image" />
<AvatarImage :src="userStore.avatar" alt="Cropped Image" />
<AvatarFallback>{{ userStore.getInitials }}</AvatarFallback>
</Avatar>
@@ -98,7 +98,7 @@ const getResult = async () => {
if (!cropper) return
croppedBlob = await cropper.getBlob()
if (!croppedBlob) return
userStore.userAvatar = URL.createObjectURL(croppedBlob)
userStore.setAvatar(URL.createObjectURL(croppedBlob))
showCropper.value = false
}

View File

@@ -58,7 +58,7 @@ const allNavItems = [
]
const sidebarNavItems = computed(() =>
allNavItems.filter((item) => !item.permission || item.permission && userStore.userPermissions.includes(item.permission))
allNavItems.filter((item) => !item.permission || item.permission && userStore.permissions.includes(item.permission))
)
</script>

View File

@@ -41,7 +41,7 @@
<CircleX size="21" />
</div>
</div>
<div v-if="action.type && conversationActions[action.type].inputType === 'richtext'" class="pl-0">
<div v-if="action.type && conversationActions[action.type].inputType === 'richtext'" class="pl-0 shadow">
<QuillEditor theme="snow" v-model:content="action.value" contentType="html"
@update:content="(value) => handleValueChange(value, index)" class="h-32 mb-12" />
</div>

View File

@@ -115,8 +115,10 @@ import {
} from '@/components/ui/form'
import { Spinner } from '@/components/ui/spinner'
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
import { useRouter } from 'vue-router'
const isLoading = ref(false)
const router = useRouter()
const emitter = useEmitter()
const rule = ref({
id: 0,
@@ -250,8 +252,12 @@ const handleSave = async (values) => {
// 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)
if (props.id > 0) {
await api.updateAutomationRule(props.id, updatedRule)
} else {
await api.createAutomationRule(updatedRule)
router.push('/admin/automations')
}
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Saved',
description: "Rule saved successfully"
@@ -304,20 +310,7 @@ onMounted(async () => {
}
}
firstRuleGroup.value = getFirstGroup()
// Convert multi tag select values separated by commas to an array
firstRuleGroup.value.rules.forEach(rule => {
if (!Array.isArray(rule.value))
if (["contains", "not contains"].includes(rule.operator)) {
rule.value = rule.value ? rule.value.split(',') : []
}
})
secondRuleGroup.value = getSecondGroup()
secondRuleGroup.value?.rules?.forEach(rule => {
if (!Array.isArray(rule.value))
if (["contains", "not contains"].includes(rule.operator)) {
rule.value = rule.value ? rule.value.split(',') : []
}
})
groupOperator.value = getGroupOperator()
})

View File

@@ -1,13 +1,13 @@
<template>
<div class="flex flex-col box px-5 py-6 rounded-lg justify-center">
<div class="flex justify-between space-y-3">
<div class="flex flex-col box px-5 rounded-lg justify-center py-3">
<div class="flex justify-between space-y-1">
<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-if="!rule.disabled" class="text-[9px]">Enabled</Badge>
<Badge v-else variant="secondary">Disabled</Badge>
</div>
</span>
@@ -16,7 +16,7 @@
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button>
<EllipsisVertical size="21"></EllipsisVertical>
<EllipsisVertical size="18"></EllipsisVertical>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>

View File

@@ -120,9 +120,10 @@
<FormItem>
<FormLabel>From Email Address</FormLabel>
<FormControl>
<Input type="text" placeholder="admin@yourcompany.com" v-bind="componentField" />
<Input type="text" placeholder="From email address. e.g. My Support <mysupport@example.com>" v-bind="componentField" />
</FormControl>
<FormMessage />
<FormDescription>From email address. e.g. My Support &lt;mysupport@example.com&gt;</FormDescription>
</FormItem>
</FormField>
@@ -152,7 +153,8 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage
FormMessage,
FormDescription,
} from '@/components/ui/form'
import {
Select,

View File

@@ -45,7 +45,7 @@ export const smtpConfigSchema = z.object({
.enum(['plain', 'login', 'cram', 'none'])
.describe('Authentication protocol')
.default('plain'),
email_address: z.string().describe('Email address').email(),
email_address: z.string().describe('Email address'),
max_msg_retries: z
.number({
invalid_type_error: 'Must be a number.'

View File

@@ -37,6 +37,6 @@ const getAttachmentName = (name) => {
}
const downloadAttachment = () => {
window.open(props.attachment.url, '_blank');
window.open(props.attachment.url, '_blank')
}
</script>

View File

@@ -14,7 +14,6 @@ import Link from '@tiptap/extension-link'
const emit = defineEmits([
'send',
'input',
'editorText',
'updateBold',
'updateItalic',
@@ -25,7 +24,6 @@ const emit = defineEmits([
const props = defineProps({
placeholder: String,
messageType: String,
isBold: Boolean,
isItalic: Boolean,
clearContent: Boolean,
@@ -56,10 +54,6 @@ const editor = ref(
// No outline for the editor.
class: 'outline-none'
},
// Emit new input text.
handleTextInput: (view, from, to, text) => {
emit('input', text)
}
}
})
)
@@ -153,6 +147,7 @@ onUnmounted(() => {
}
}
}
br.ProseMirror-trailingBreak {
display: none;
}

View File

@@ -8,7 +8,8 @@
'cursor-pointer rounded p-1 hover:bg-secondary',
{ 'bg-secondary': index === selectedResponseIndex }
]" @click="selectCannedResponse(response.content)" @mouseenter="selectedResponseIndex = index">
<span class="font-semibold">{{ response.title }}</span> - {{ getTextFromHTML(response.content).slice(0, 150) }}...
<span class="font-semibold">{{ response.title }}</span> - {{ getTextFromHTML(response.content).slice(0, 150)
}}...
</li>
</ul>
</div>
@@ -24,11 +25,12 @@
</Tabs>
</div>
<!-- Editor -->
<!-- Main 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="cannedResponses" />
:contentToSet="contentToSet" :cannedResponses="cannedResponses" />
<!-- Attachments preview -->
<AttachmentsPreview :attachments="attachments" :onDelete="handleOnFileDelete"></AttachmentsPreview>
@@ -71,7 +73,7 @@ const uploadedFiles = ref([])
const messageType = ref('reply')
const selectedResponseIndex = ref(-1)
const responsesList = ref(null)
let editorInstance = null
let editorInstance = ref(null)
onMounted(async () => {
try {
@@ -138,7 +140,7 @@ const hasText = computed(() => {
})
const onEditorReady = (editor) => {
editorInstance = editor
editorInstance.value = editor
}
const handleFileUpload = (event) => {
@@ -169,7 +171,7 @@ const handleInlineImageUpload = (event) => {
inline: true,
})
.then((resp) => {
editorInstance.commands.setImage({
editorInstance.value.commands.setImage({
src: resp.data.data.url,
alt: resp.data.data.filename,
title: resp.data.data.uuid,

View File

@@ -1,17 +1,26 @@
<template>
<BarChart :data="data" index="status" :categories="priorities" :show-grid-line="true"
:margin="{ top: 0, bottom: 0, left: 0, right: 0 }" />
<BarChart :data="data" index="status" :categories="priorities" :show-grid-line="true" :show-x-axis="true"
:show-y-axis="true" type="grouped" :x-formatter="xFormatter" :y-formatter="yFormatter" />
</template>
<script setup>
import { BarChart } from '@/components/ui/chart-bar'
defineProps({
const props = defineProps({
data: {
type: Array,
required: true,
default: () => []
}
})
const xFormatter = (tick) => {
return props.data[tick]?.status ?? ''
}
const yFormatter = (tick) => {
return Number.isInteger(tick) ? tick : ''
}
const priorities = ["Low", "Medium", "High"]
</script>

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col space-y-6" v-if="userStore.getFullName">
<div>
<span class="font-medium text-2xl space-y-1">
<span class="font-medium text-3xl space-y-1">
<p>Hi, {{ userStore.getFullName }}</p>
<p class="text-sm-muted">🌤 {{ format(new Date(), 'EEEE, MMMM d, HH:mm a') }}</p>
</span>

View File

@@ -34,12 +34,14 @@ import ActivityMessageBubble from './ActivityMessageBubble.vue'
import AgentMessageBubble from './AgentMessageBubble.vue'
import MessagesSkeleton from './MessagesSkeleton.vue'
import { useConversationStore } from '@/stores/conversation'
import { useUserStore } from '@/stores/user'
import { Button } from '@/components/ui/button'
import { RefreshCw } from 'lucide-vue-next'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
const conversationStore = useConversationStore()
const userStore = useUserStore()
const threadEl = ref(null)
const emitter = useEmitter()
@@ -55,9 +57,9 @@ const scrollToBottom = () => {
onMounted(() => {
scrollToBottom()
// On new outgoing message to the current conversation, scroll to the bottom.
emitter.on(EMITTER_EVENTS.NEW_OUTGOING_MESSAGE, (data) => {
if (data.conversation_uuid === conversationStore.current.uuid) {
// Scroll to bottom on new message from logged in user
emitter.on(EMITTER_EVENTS.NEW_MESSAGE, (data) => {
if (data.conversation_uuid === conversationStore.current.uuid && data.message.sender_id === userStore.userID) {
scrollToBottom()
}
})

View File

@@ -1,10 +1,19 @@
<script setup>
import { VisAxis, VisGroupedBar, VisStackedBar, VisXYContainer } from '@unovis/vue'
import { Axis, GroupedBar, StackedBar } from '@unovis/ts'
import { computed, ref } from 'vue'
import { useMounted } from '@vueuse/core'
import { ChartCrosshair, ChartLegend, defaultColors } from '@/components/ui/chart'
import { cn } from '@/lib/utils'
import {
ChartCrosshair,
ChartLegend,
defaultColors,
} from '@/components/ui/chart';
import { cn } from '@/lib/utils';
import { Axis, GroupedBar, StackedBar } from '@unovis/ts';
import {
VisAxis,
VisGroupedBar,
VisStackedBar,
VisXYContainer,
} from '@unovis/vue';
import { useMounted } from '@vueuse/core';
import { computed, ref } from 'vue';
const props = defineProps({
data: { type: Array, required: true },
@@ -12,9 +21,9 @@ const props = defineProps({
index: { type: null, required: true },
colors: { type: Array, required: false },
margin: {
type: null,
type: Object,
required: false,
default: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
default: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
},
filterOpacity: { type: Number, required: false, default: 0.2 },
xFormatter: { type: Function, required: false },
@@ -26,43 +35,53 @@ const props = defineProps({
showGridLine: { type: Boolean, required: false, default: true },
customTooltip: { type: null, required: false },
type: { type: String, required: false, default: 'grouped' },
roundedCorners: { type: Number, required: false, default: 0 }
})
const emits = defineEmits(['legendItemClick'])
roundedCorners: { type: Number, required: false, default: 0 },
});
const emits = defineEmits(['legendItemClick']);
const index = computed(() => props.index)
const index = computed(() => props.index);
const colors = computed(() =>
props.colors?.length ? props.colors : defaultColors(props.categories.length)
)
props.colors?.length ? props.colors : defaultColors(props.categories.length),
);
const legendItems = ref(
props.categories.map((category, i) => ({
name: category,
color: colors.value[i],
inactive: false
}))
)
inactive: false,
})),
);
const isMounted = useMounted()
const isMounted = useMounted();
function handleLegendItemClick(d, i) {
emits('legendItemClick', d, i)
emits('legendItemClick', d, i);
}
const VisBarComponent = computed(() => (props.type === 'grouped' ? VisGroupedBar : VisStackedBar))
const VisBarComponent = computed(() =>
props.type === 'grouped' ? VisGroupedBar : VisStackedBar,
);
const selectorsBar = computed(() =>
props.type === 'grouped' ? GroupedBar.selectors.bar : StackedBar.selectors.bar
)
props.type === 'grouped'
? GroupedBar.selectors.bar
: StackedBar.selectors.bar,
);
</script>
<template>
<div :class="cn('w-full h-[400px] flex flex-col items-end', $attrs.class ?? '')">
<div
:class="cn('w-full h-[400px] flex flex-col items-end', $attrs.class ?? '')"
>
<ChartLegend
v-if="showLegend"
v-model:items="legendItems"
@legend-item-click="handleLegendItemClick"
/>
<VisXYContainer :data="data" :style="{ height: isMounted ? '100%' : 'auto' }" :margin="margin">
<VisXYContainer
:data="data"
:style="{ height: isMounted ? '100%' : 'auto' }"
:margin="margin"
>
<ChartCrosshair
v-if="showTooltip"
:colors="colors"
@@ -80,10 +99,10 @@ const selectorsBar = computed(() =>
:attributes="{
[selectorsBar]: {
opacity: (d, i) => {
const pos = i % categories.length
return legendItems[pos]?.inactive ? filterOpacity : 1
}
}
const pos = i % categories.length;
return legendItems[pos]?.inactive ? filterOpacity : 1;
},
},
}"
/>
@@ -104,8 +123,8 @@ const selectorsBar = computed(() =>
:grid-line="showGridLine"
:attributes="{
[Axis.selectors.grid]: {
class: 'text-muted'
}
class: 'text-muted',
},
}"
tick-text-color="hsl(var(--vis-text-color))"
/>

View File

@@ -1 +1 @@
export { default as BarChart } from './BarChart.vue'
export { default as BarChart } from './BarChart.vue';

View File

@@ -1,11 +1,15 @@
<script setup>
import { CurveType } from '@unovis/ts'
import { VisAxis, VisLine, VisXYContainer } from '@unovis/vue'
import { Axis, Line } from '@unovis/ts'
import { computed, ref } from 'vue'
import { useMounted } from '@vueuse/core'
import { ChartCrosshair, ChartLegend, defaultColors } from '@/components/ui/chart'
import { cn } from '@/lib/utils'
import {
ChartCrosshair,
ChartLegend,
defaultColors,
} from '@/components/ui/chart';
import { cn } from '@/lib/utils';
import { CurveType } from '@unovis/ts';
import { Axis, Line } from '@unovis/ts';
import { VisAxis, VisLine, VisXYContainer } from '@unovis/vue';
import { useMounted } from '@vueuse/core';
import { computed, ref } from 'vue';
const props = defineProps({
data: { type: Array, required: true },
@@ -13,9 +17,9 @@ const props = defineProps({
index: { type: null, required: true },
colors: { type: Array, required: false },
margin: {
type: null,
type: Object,
required: false,
default: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
default: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
},
filterOpacity: { type: Number, required: false, default: 0.2 },
xFormatter: { type: Function, required: false },
@@ -26,33 +30,35 @@ const props = defineProps({
showLegend: { type: Boolean, required: false, default: true },
showGridLine: { type: Boolean, required: false, default: true },
customTooltip: { type: null, required: false },
curveType: { type: String, required: false, default: CurveType.MonotoneX }
})
curveType: { type: String, required: false, default: CurveType.MonotoneX },
});
const emits = defineEmits(['legendItemClick'])
const emits = defineEmits(['legendItemClick']);
const index = computed(() => props.index)
const index = computed(() => props.index);
const colors = computed(() =>
props.colors?.length ? props.colors : defaultColors(props.categories.length)
)
props.colors?.length ? props.colors : defaultColors(props.categories.length),
);
const legendItems = ref(
props.categories.map((category, i) => ({
name: category,
color: colors.value[i],
inactive: false
}))
)
inactive: false,
})),
);
const isMounted = useMounted()
const isMounted = useMounted();
function handleLegendItemClick(d, i) {
emits('legendItemClick', d, i)
emits('legendItemClick', d, i);
}
</script>
<template>
<div :class="cn('w-full h-[400px] flex flex-col items-end', $attrs.class ?? '')">
<div
:class="cn('w-full h-[400px] flex flex-col items-end', $attrs.class ?? '')"
>
<ChartLegend
v-if="showLegend"
v-model:items="legendItems"
@@ -80,10 +86,11 @@ function handleLegendItemClick(d, i) {
:color="colors[i]"
:attributes="{
[Line.selectors.line]: {
opacity: legendItems.find((item) => item.name === category)?.inactive
opacity: legendItems.find((item) => item.name === category)
?.inactive
? filterOpacity
: 1
}
: 1,
},
}"
/>
</template>
@@ -105,8 +112,8 @@ function handleLegendItemClick(d, i) {
:grid-line="showGridLine"
:attributes="{
[Axis.selectors.grid]: {
class: 'text-muted'
}
class: 'text-muted',
},
}"
tick-text-color="hsl(var(--vis-text-color))"
/>

View File

@@ -1 +1 @@
export { default as LineChart } from './LineChart.vue'
export { default as LineChart } from './LineChart.vue';

View File

@@ -8,4 +8,4 @@ export const CONVERSTION_WS_ACTIONS = {
SUB_LIST: 'conversations_list_sub',
SET_CURRENT: 'conversation_set_current',
UNSET_CURRENT: 'conversation_unset_current'
}
}

View File

@@ -1,6 +1,5 @@
export const EMITTER_EVENTS = {
REFRESH_LIST: 'refresh-list',
SHOW_TOAST: 'show-toast',
NEW_OUTGOING_MESSAGE: 'new-outgoing-message',
NEW_INCOMING_MESSAGE: 'new-incoming-message',
NEW_MESSAGE: 'new-message',
}

View File

@@ -186,10 +186,11 @@ export const useConversationStore = defineStore('conversation', () => {
try {
const response = await api.getConversationMessage(cuuid, uuid)
if (response?.data?.data) {
const message = response.data.data
if (!messages.data.some((m) => m.uuid === message.uuid)) {
messages.data.push(message)
const newMsg = response.data.data
if (!messages.data.some((m) => m.uuid === newMsg.uuid)) {
messages.data.push(newMsg)
}
return newMsg
}
} catch (error) {
messages.errorMessage = handleHTTPError(error).message
@@ -354,23 +355,16 @@ export const useConversationStore = defineStore('conversation', () => {
}
// Adds a new message to conversation.
function updateConversationMessageList (message) {
async function updateConversationMessageList (message) {
// Fetch entire message only if the convesation is open and the message is not present in the list.
if (conversation?.data?.uuid === message.conversation_uuid) {
if (!messages.data.some((msg) => msg.uuid === message.uuid)) {
fetchParticipants(message.conversation_uuid)
fetchMessage(message.conversation_uuid, message.uuid)
const fetchedMessage = await fetchMessage(message.conversation_uuid, message.uuid)
updateAssigneeLastSeen(message.conversation_uuid)
if (message.type === 'outgoing') {
setTimeout(() => {
emitter.emit(EMITTER_EVENTS.NEW_OUTGOING_MESSAGE, { conversation_uuid: message.conversation_uuid })
}, 50)
}
if (message.type === 'incoming') {
setTimeout(() => {
emitter.emit(EMITTER_EVENTS.NEW_INCOMING_MESSAGE, { conversation_uuid: message.conversation_uuid })
}, 50)
}
setTimeout(() => {
emitter.emit(EMITTER_EVENTS.NEW_MESSAGE, { conversation_uuid: message.conversation_uuid, message: fetchedMessage })
}, 50)
}
}
}

View File

@@ -1,4 +1,4 @@
import { ref, computed } from 'vue'
import { computed, reactive } from 'vue'
import { defineStore } from 'pinia'
import { handleHTTPError } from '@/utils/http'
import { useEmitter } from '@/composables/useEmitter'
@@ -6,81 +6,74 @@ import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import api from '@/api'
export const useUserStore = defineStore('user', () => {
const userAvatar = ref('')
const userFirstName = ref('')
const userLastName = ref('')
const userPermissions = ref([])
let user = reactive({
id: null,
first_name: '',
last_name: '',
avatar_url: '',
permissions: []
})
const emitter = useEmitter()
// Setters
const setAvatar = (avatar) => {
userAvatar.value = avatar
}
const userID = computed(() => user.id)
const firstName = computed(() => user.first_name)
const lastName = computed(() => user.last_name)
const avatar = computed(() => user.avatar_url)
const permissions = computed(() => user.permissions || [])
const setFirstName = (firstName) => {
userFirstName.value = firstName
}
const getFullName = computed(() => {
if (!user.first_name && !user.last_name) return ''
return `${user.first_name || ''} ${user.last_name || ''}`.trim()
})
const setLastName = (lastName) => {
userLastName.value = lastName
}
// Computed properties
const getInitials = computed(() => {
const firstInitial = userFirstName.value.charAt(0).toUpperCase()
const lastInitial = userLastName.value.charAt(0).toUpperCase()
const firstInitial = user.first_name?.charAt(0)?.toUpperCase() || ''
const lastInitial = user.last_name?.charAt(0)?.toUpperCase() || ''
return `${firstInitial}${lastInitial}`
})
const getFullName = computed(() => {
return `${userFirstName.value} ${userLastName.value}`
})
// Fetches current user.
const getCurrentUser = async () => {
try {
const response = await api.getCurrentUser()
const userData = response?.data?.data
if (userData) {
const { avatar_url, first_name, last_name, permissions } = userData
setAvatar(avatar_url)
setFirstName(first_name)
setLastName(last_name)
userPermissions.value = permissions || []
Object.assign(user, userData)
} else {
throw new Error('No user data found')
}
} catch (error) {
if (error.response) {
if (error.response.status !== 401) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not fetch current user',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
if (error.response?.status !== 401) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not fetch current user',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
}
const setAvatar = (avatarURL) => {
if (typeof avatarURL !== 'string') {
console.warn('Avatar URL must be a string')
return
}
user.avatar_url = avatarURL
}
const clearAvatar = () => {
userAvatar.value = ''
user.avatar_url = ''
}
return {
// State
userFirstName,
userLastName,
userAvatar,
userPermissions,
// Computed
userID,
firstName,
lastName,
avatar,
permissions,
getFullName,
getInitials,
// Actions
setAvatar,
setFirstName,
setLastName,
getCurrentUser,
clearAvatar
clearAvatar,
setAvatar,
}
})
})

View File

@@ -1,18 +1,18 @@
<template>
<Spinner v-if="isLoading"></Spinner>
<div class="page-content w-9/12" :class="{ 'soft-fade': isLoading }">
<div>
<DashboardGreet></DashboardGreet>
</div>
<div class="page-content">
<Spinner v-if="isLoading"></Spinner>
<DashboardGreet></DashboardGreet>
<div class="mt-7" v-auto-animate>
<Card :counts="cardCounts" :labels="agentCountCardsLabels" />
</div>
<div class="flex my-7 justify-between items-center space-x-5">
<div class="dashboard-card p-5">
<LineChart :data="chartData.new_conversations"></LineChart>
</div>
<div class="dashboard-card p-5">
<BarChart :data="chartData.status_summary"></BarChart>
<div class="w-11/12" :class="{ 'soft-fade': isLoading }">
<div class="flex my-7 justify-between items-center space-x-5">
<div class="dashboard-card p-5">
<LineChart :data="chartData.new_conversations"></LineChart>
</div>
<div class="dashboard-card p-5">
<BarChart :data="chartData.status_summary"></BarChart>
</div>
</div>
</div>
</div>

View File

@@ -7,7 +7,7 @@
<CardHeader class="space-y-1">
<CardTitle class="text-2xl text-center">Reset Password</CardTitle>
<p class="text-sm text-muted-foreground text-center">
Enter your email address and we'll send you a link to reset your password.
Enter your email to receive a password reset link.
</p>
</CardHeader>
<CardContent class="grid gap-4">

View File

@@ -123,16 +123,8 @@ const loginAction = () => {
email: loginForm.value.email,
password: loginForm.value.password
})
.then((resp) => {
const userData = resp.data.data
if (userData) {
userStore.$patch({
userAvatar: userData.avatar_url,
userFirstName: userData.first_name,
userLastName: userData.last_name
})
.then(() => {
router.push({ name: 'dashboard' })
}
})
.catch((error) => {
errorMessage.value = handleHTTPError(error).message

View File

@@ -10,15 +10,12 @@ export default defineConfig({
proxy: {
'/api': {
target: 'http://127.0.0.1:9000',
changeOrigin: true,
},
'/logout': {
target: 'http://127.0.0.1:9000',
changeOrigin: true,
},
'/uploads': {
target: 'http://127.0.0.1:9000',
changeOrigin: true,
},
'/ws': {
target: 'ws://127.0.0.1:9000',

View File

@@ -1895,10 +1895,10 @@
dependencies:
lodash-es "^4.17.21"
"@unovis/ts@^1.4.3":
version "1.4.3"
resolved "https://registry.yarnpkg.com/@unovis/ts/-/ts-1.4.3.tgz#3e176e72d0db0c1c48ed5217bd35d32a082e37ba"
integrity sha512-QYh1Qot1N9L6ZQg+uuhcsI3iuic9c6VVjlex3ipRqYDvrDDN6N+SG2555+9z+yAV6cbVsD1/EkMfK+o84PPjSw==
"@unovis/ts@^1.4.4":
version "1.4.4"
resolved "https://registry.yarnpkg.com/@unovis/ts/-/ts-1.4.4.tgz#36464fdcb3cf3c9a6e6ffa4f3573500ed1c6e4ec"
integrity sha512-OoAbCdxSq3tvEKDUDkWNIkPWJ1tnjklWR+8W3zhVqUkOvpqTYW8IjAl+07dPS9/qv+mtjZry5zOt1aBCMPCfdA==
dependencies:
"@emotion/css" "^11.7.1"
"@juggle/resize-observer" "^3.3.1"
@@ -1933,10 +1933,10 @@
topojson-client "^3.1.0"
tslib "^2.3.1"
"@unovis/vue@^1.4.3":
version "1.4.3"
resolved "https://registry.yarnpkg.com/@unovis/vue/-/vue-1.4.3.tgz#ddf2a8ef56cd6e7fce371a6ef0f220a72d26ff23"
integrity sha512-L45ncN+e1dynA2cvHyq2ss+hLNBBPC1bl+i9JNveLufl+k7qybbt2IrioKBGQEbVmzXuJ80r2f3cD64i6ca9jg==
"@unovis/vue@^1.4.4":
version "1.4.4"
resolved "https://registry.yarnpkg.com/@unovis/vue/-/vue-1.4.4.tgz#836396fdc3f92ee08c1e68f0862d4ad8557f08bd"
integrity sha512-19xdi/0+18NzUyRh4sK0GCIv5JbqSMSEqXaMio4Vya8I/8JYU0Sgyo4jwr+O60Rl8fSOXScL9NJQHxPHJeTcMg==
"@vee-validate/zod@^4.13.2":
version "4.13.2"
@@ -2132,7 +2132,7 @@
quill "^1.3.7"
quill-delta "^4.2.2"
"@vueuse/core@^10.11.0", "@vueuse/core@^10.11.1":
"@vueuse/core@^10.11.0":
version "10.11.1"
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-10.11.1.tgz#15d2c0b6448d2212235b23a7ba29c27173e0c2c6"
integrity sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==
@@ -2152,6 +2152,16 @@
"@vueuse/shared" "10.9.0"
vue-demi ">=0.14.7"
"@vueuse/core@^11.2.0":
version "11.2.0"
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-11.2.0.tgz#3fc6c0963051bb154dc4c08061889405e3fc745d"
integrity sha512-JIUwRcOqOWzcdu1dGlfW04kaJhW3EXnnjJJfLTtddJanymTL7lF1C0+dVVZ/siLfc73mWn+cGP1PE1PKPruRSA==
dependencies:
"@types/web-bluetooth" "^0.0.20"
"@vueuse/metadata" "11.2.0"
"@vueuse/shared" "11.2.0"
vue-demi ">=0.14.10"
"@vueuse/metadata@10.11.1":
version "10.11.1"
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-10.11.1.tgz#209db7bb5915aa172a87510b6de2ca01cadbd2a7"
@@ -2162,6 +2172,11 @@
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-10.9.0.tgz#769a1a9db65daac15cf98084cbf7819ed3758620"
integrity sha512-iddNbg3yZM0X7qFY2sAotomgdHK7YJ6sKUvQqbvwnf7TmaVPxS4EJydcNsVejNdS8iWCtDk+fYXr7E32nyTnGA==
"@vueuse/metadata@11.2.0":
version "11.2.0"
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-11.2.0.tgz#fd02cbbc7d08cb4592fceea0486559b89ae38643"
integrity sha512-L0ZmtRmNx+ZW95DmrgD6vn484gSpVeRbgpWevFKXwqqQxW9hnSi2Ppuh2BzMjnbv4aJRiIw8tQatXT9uOB23dQ==
"@vueuse/shared@10.11.1", "@vueuse/shared@^10.11.0":
version "10.11.1"
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-10.11.1.tgz#62b84e3118ae6e1f3ff38f4fbe71b0c5d0f10938"
@@ -2176,6 +2191,13 @@
dependencies:
vue-demi ">=0.14.7"
"@vueuse/shared@11.2.0":
version "11.2.0"
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-11.2.0.tgz#7fb2f3cade6b6c00ef97e613f187ee9bdcfb9a3a"
integrity sha512-VxFjie0EanOudYSgMErxXfq6fo8vhr5ICI+BuE3I9FnX7ePllEsVrRQ7O6Q1TLgApeLuPKcHQxAXpP+KnlrJsg==
dependencies:
vue-demi ">=0.14.10"
"@withtypes/mime@^0.1.2":
version "0.1.2"
resolved "https://registry.yarnpkg.com/@withtypes/mime/-/mime-0.1.2.tgz#956883251b0786f2c898ed76a23594ea6d9939e0"
@@ -9421,6 +9443,11 @@ vue-demi@>=0.13.0, vue-demi@>=0.14.5, vue-demi@>=0.14.7:
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.7.tgz#8317536b3ef74c5b09f268f7782e70194567d8f2"
integrity sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==
vue-demi@>=0.14.10:
version "0.14.10"
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04"
integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==
vue-demi@>=0.14.8:
version "0.14.8"
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.8.tgz#00335e9317b45e4a68d3528aaf58e0cec3d5640a"

View File

@@ -12,15 +12,14 @@ type User struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
UUID string `db:"uuid" json:"uuid"`
FirstName string `db:"first_name" json:"first_name"`
LastName string `db:"last_name" json:"last_name"`
Email string `db:"email" json:"email,omitempty"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
Disabled bool `db:"disabled" json:"disabled"`
Password string `db:"password" json:"-"`
NewPassword string `db:"-" json:"new_password"`
SendWelcomeEmail bool `db:"-" json:"send_welcome_email"`
NewPassword string `db:"-" json:"new_password,omitempty"`
SendWelcomeEmail bool `db:"-" json:"send_welcome_email,omitempty"`
Roles pq.StringArray `db:"roles" json:"roles"`
Permissions pq.StringArray `db:"permissions" json:"permissions"`
Teams tmodels.Teams `db:"teams" json:"teams"`