feat: translate reports, sla badge, view builder and search

This commit is contained in:
Abhinav Raut
2025-03-31 21:56:51 +05:30
parent 4ec564ee2e
commit 5ce8ed72ba
16 changed files with 126 additions and 81 deletions
@@ -4,7 +4,7 @@
<p class="text-2xl flex items-center">{{ title }}</p>
<div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded">
<span class="blinking-dot"></span>
<p class="uppercase text-xs">Live</p>
<p class="uppercase text-xs">{{ $t('report.live') }}</p>
</div>
</div>
<div class="flex justify-between pr-32">
@@ -1,16 +1,23 @@
<template>
<LineChart :data="data" index="date" :categories="['New conversations', 'Resolved conversations']"
:x-formatter="xFormatter" :y-formatter="yFormatter" />
<LineChart
:data="data"
index="date"
:categories="[t('report.chart.newConversations'), t('report.chart.resolvedConversations')]"
:x-formatter="xFormatter"
:y-formatter="yFormatter"
/>
</template>
<script setup>
import { LineChart } from '@/components/ui/chart-line'
import { useI18n } from 'vue-i18n'
const props = defineProps({
data: {
type: Array,
default: () => []
}
})
const { t } = useI18n()
const xFormatter = (tick) => {
return props.data[tick]?.date ?? ''
@@ -19,5 +26,4 @@ const xFormatter = (tick) => {
const yFormatter = (tick) => {
return Number.isInteger(tick) ? tick : ''
}
</script>
@@ -6,7 +6,7 @@
<Separator orientation="vertical" />
<Input
v-model="model"
placeholder="Search"
:placeholder="t('search.search')"
class="w-full border-none shadow-none focus:ring-0 focus:ring-offset-0"
/>
</div>
@@ -19,7 +19,9 @@ import { Separator } from '@/components/ui/separator'
import { Input } from '@/components/ui/input'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { Search } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
const model = defineModel(() => '')
const { t } = useI18n()
</script>
<style scoped>
+6 -16
View File
@@ -1,13 +1,6 @@
<template>
<div class="max-w-5xl mx-auto p-6 bg-background min-h-screen">
<div v-if="isEmptyResults" class="text-center py-16 rounded-lg">
<SearchXIcon class="h-20 w-20 text-muted-foreground mx-auto mb-6" />
<h2 class="text-2xl font-bold text-foreground mb-3">No results found</h2>
<p class="text-muted-foreground text-lg max-w-md mx-auto">
We couldn't find any matches. Try adjusting your search query.
</p>
</div>
<div v-else class="space-y-8">
<div class="space-y-8">
<div
v-for="(items, type) in results"
:key="type"
@@ -18,7 +11,9 @@
</h2>
<div v-if="items.length === 0" class="p-6 text-muted-foreground">
No {{ type }} found
{{ $t('search.noResults', {
name: type
}) }}
</div>
<div class="divide-y divide-border">
@@ -82,21 +77,16 @@
</template>
<script setup>
import { computed } from 'vue'
import { SearchXIcon, ChevronRightIcon, ClockIcon } from 'lucide-vue-next'
import { ChevronRightIcon, ClockIcon } from 'lucide-vue-next'
import { format, parseISO } from 'date-fns'
const props = defineProps({
defineProps({
results: {
type: Object,
required: true
}
})
const isEmptyResults = computed(() => {
return Object.values(props.results).every((arr) => arr.length === 0)
})
const formatDate = (dateString) => {
const date = parseISO(dateString)
return format(date, 'MMM d, yyyy HH:mm')
+4 -4
View File
@@ -3,9 +3,9 @@
<!-- Overdue-->
<span v-if="sla?.status === 'overdue'" key="overdue" class="sla-badge box sla-overdue">
<AlertCircle size="12" class="text-red-800" />
<span class="sla-text text-red-800"
>{{ label }} Overdue
<span v-if="showExtra">by {{ sla.value }}</span>
<span class="sla-text text-red-800">
<span v-if="!showExtra">{{ label }} {{ $t('sla.overdue') }}</span>
<span v-else>{{ label }} {{ $t('sla.overdueBy') }} {{ sla.value }} </span>
</span>
</span>
@@ -16,7 +16,7 @@
class="sla-badge box sla-hit"
>
<CheckCircle size="12" />
<span class="sla-text">{{ label }} SLA met</span>
<span class="sla-text">{{ label }} {{ $t('sla.met') }}</span>
</span>
<!-- Remaining -->
+15 -9
View File
@@ -9,7 +9,7 @@
<!-- Field -->
<Select v-model="modelFilter.field">
<SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
<SelectValue placeholder="Field" />
<SelectValue :placeholder="t('form.field.selectField')" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -23,7 +23,7 @@
<!-- Operator -->
<Select v-model="modelFilter.operator" v-if="modelFilter.field">
<SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
<SelectValue placeholder="Operator" />
<SelectValue :placeholder="t('form.field.selectOperator')" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -41,7 +41,7 @@
v-if="getFieldOptions(modelFilter).length > 0"
v-model="modelFilter.value"
:items="getFieldOptions(modelFilter)"
placeholder="Select"
:placeholder="t('form.field.select')"
>
<template #item="{ item }">
<div v-if="modelFilter.field === 'assigned_user_id'">
@@ -65,7 +65,7 @@
</template>
<template #selected="{ selected }">
<div v-if="!selected">Select value</div>
<div v-if="!selected">{{ $t('form.field.selectValue') }}</div>
<div v-if="modelFilter.field === 'assigned_user_id'">
<div class="flex items-center gap-2">
<div v-if="selected" class="flex items-center gap-1">
@@ -96,7 +96,7 @@
v-else
v-model="modelFilter.value"
class="bg-transparent hover:bg-slate-100"
placeholder="Value"
:placeholder="t('form.field.value')"
type="text"
/>
</template>
@@ -114,11 +114,16 @@
<div class="flex items-center justify-between pt-3">
<Button variant="ghost" size="sm" @click="addFilter" class="text-slate-600">
<Plus class="w-3 h-3 mr-1" /> Add filter
<Plus class="w-3 h-3 mr-1" />
{{
$t('globals.messages.add', {
name: $t('globals.entities.filter')
})
}}
</Button>
<div class="flex gap-2" v-if="showButtons">
<Button variant="ghost" @click="clearFilters">Reset</Button>
<Button @click="applyFilters">Apply</Button>
<Button variant="ghost" @click="clearFilters">{{ $t('globals.buttons.reset') }}</Button>
<Button @click="applyFilters">{{ $t('globals.buttons.apply') }}</Button>
</div>
</div>
</div>
@@ -138,6 +143,7 @@ import { Plus, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { useI18n } from 'vue-i18n'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
const props = defineProps({
@@ -150,7 +156,7 @@ const props = defineProps({
default: true
}
})
const { t } = useI18n()
const emit = defineEmits(['apply', 'clear'])
const modelValue = defineModel('modelValue', { required: false, default: () => [] })
+19 -19
View File
@@ -2,26 +2,29 @@
<Dialog :open="openDialog" @update:open="openDialog = false">
<DialogContent class="min-w-[40%] min-h-[30%]">
<DialogHeader class="space-y-1">
<DialogTitle>{{ view?.id ? 'Edit' : 'Create' }} view</DialogTitle>
<DialogTitle
>{{ view?.id ? $t('globals.buttons.edit') : $t('globals.buttons.create') }}
view
</DialogTitle>
<DialogDescription>
Create and save custom filter views for quick access to your conversations.
{{ $t('view.form.description') }}
</DialogDescription>
</DialogHeader>
<form @submit.prevent="onSubmit">
<div class="grid gap-4 py-4">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel>{{ $t('form.field.name') }}</FormLabel>
<FormControl>
<Input
id="name"
class="col-span-3"
placeholder="Name"
placeholder=""
v-bind="componentField"
@keydown.enter.prevent="onSubmit"
/>
</FormControl>
<FormDescription>Enter an unique name for your view.</FormDescription>
<FormDescription>{{ $t('view.form.name.description') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
@@ -35,14 +38,14 @@
v-bind="componentField"
/>
</FormControl>
<FormDescription> Set one or more filters to customize view.</FormDescription>
<FormDescription> {{ $t('view.form.filters.description') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
<DialogFooter>
<Button type="submit" :disabled="isSubmitting" :isLoading="isSubmitting">
{{ isSubmitting ? 'Saving...' : 'Save changes' }}
{{ isSubmitting ? t('globals.buttons.saving') : t('globals.buttons.save') }}
</Button>
</DialogFooter>
</form>
@@ -78,14 +81,15 @@ import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import { OPERATOR } from '@/constants/filterConfig.js'
import { useI18n } from 'vue-i18n'
import { z } from 'zod'
import api from '@/api'
const emitter = useEmitter()
const { t } = useI18n()
const openDialog = defineModel('openDialog', { required: false, default: false })
const view = defineModel('view', { required: false, default: {} })
const isSubmitting = ref(false)
const { conversationsListFilters } = useConversationFilters()
const filterFields = computed(() =>
@@ -103,22 +107,19 @@ const formSchema = toTypedSchema(
id: z.number().optional(),
name: z
.string()
.min(2, { message: 'Name must be at least 2 characters.' })
.max(30, { message: 'Name cannot exceed 30 characters.' }),
.min(2, { message: t('view.form.name.length') })
.max(30, { message: t('view.form.name.length') }),
filters: z
.array(
z.object({
model: z.string({ required_error: 'Filter required' }),
field: z.string({ required_error: 'Filter required' }),
operator: z.string({ required_error: 'Filter required' }),
model: z.string({ required_error: t('view.form.filter.required') }),
field: z.string({ required_error: t('view.form.filter.required') }),
operator: z.string({ required_error: t('view.form.filter.required') }),
value: z.union([z.string(), z.number(), z.boolean()]).optional()
})
)
.default([])
.refine(
(filters) => filters.length > 0,
{ message: 'Please add at least one filter.' }
)
.refine((filters) => filters.length > 0, { message: t('view.form.filter.selectAtLeastOne') })
.refine(
(filters) =>
filters.every(
@@ -129,7 +130,7 @@ const formSchema = toTypedSchema(
([OPERATOR.SET, OPERATOR.NOT_SET].includes(f.operator) || f.value)
),
{
message: "Please make sure you've filled the filter fields correctly."
message: t('view.form.filter.partiallyFilled')
}
)
})
@@ -161,7 +162,6 @@ const onSubmit = async () => {
form.resetForm()
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
+1 -1
View File
@@ -5,7 +5,7 @@
class="flex flex-col items-center justify-center flex-grow"
v-if="$route.name === 'admin'"
>
<div>Select a section from the sidebar</div>
<div>{{ $t('admin.empty') }}</div>
</div>
<router-view class="flex-grow" />
</div>
-1
View File
@@ -27,7 +27,6 @@ export const useMacroStore = defineStore('macroStore', () => {
macroList.value = response?.data?.data || []
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
-1
View File
@@ -21,7 +21,6 @@ export const useTagStore = defineStore('tags', () => {
tags.value = response?.data?.data || []
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
-1
View File
@@ -20,7 +20,6 @@ export const useTeamStore = defineStore('team', () => {
teams.value = response?.data?.data || []
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
-1
View File
@@ -65,7 +65,6 @@ export const useUserStore = defineStore('user', () => {
} catch (error) {
if (error.response?.status !== 401) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not fetch current user',
variant: 'destructive',
description: handleHTTPError(error).message
})
-1
View File
@@ -20,7 +20,6 @@ export const useUsersStore = defineStore('users', () => {
users.value = response?.data?.data || []
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
+15 -15
View File
@@ -7,7 +7,7 @@
<Spinner v-if="isLoading" />
<div class="space-y-4">
<div class="text-sm text-gray-500 text-right">
Last updated: {{ new Date(lastUpdate).toLocaleTimeString() }}
{{ $t('report.lastUpdated') }}: {{ new Date(lastUpdate).toLocaleTimeString() }}
</div>
<div class="mt-7 flex w-full space-x-4">
<Card title="Open conversations" :counts="cardCounts" :labels="agentCountCardsLabels" />
@@ -19,10 +19,10 @@
/>
</div>
<div class="rounded-lg box w-full p-5 bg-white">
<LineChart :data="chartData.processedData"></LineChart>
<LineChart :data="chartData.processedData" />
</div>
<div class="rounded-lg box w-full p-5 bg-white">
<BarChart :data="chartData.status_summary"></BarChart>
<BarChart :data="chartData.status_summary" />
</div>
</div>
</div>
@@ -38,9 +38,11 @@ import Card from '@/features/reports/DashboardCard.vue'
import LineChart from '@/features/reports/DashboardLineChart.vue'
import BarChart from '@/features/reports/DashboardBarChart.vue'
import Spinner from '@/components/ui/spinner/Spinner.vue'
import { useI18n } from 'vue-i18n'
import api from '@/api'
const emitter = useEmitter()
const { t } = useI18n()
const isLoading = ref(false)
const cardCounts = ref({})
const chartData = ref({})
@@ -48,16 +50,16 @@ const lastUpdate = ref(new Date())
let updateInterval
const agentCountCardsLabels = {
open: 'Total',
awaiting_response: 'Awaiting Response',
unassigned: 'Unassigned',
pending: 'Pending'
open: t('report.open'),
awaiting_response: t('report.awaiting_response'),
unassigned: t('report.unassigned'),
pending: t('report.pending')
}
const agentStatusLabels = {
agents_online: 'Online',
agents_offline: 'Offline',
agents_away: 'Away'
agents_online: t('user.online'),
agents_offline: t('user.offline'),
agents_away: t('user.away')
}
const agentStatusCounts = ref({
@@ -106,7 +108,6 @@ const getCardStats = async () => {
})
.catch((error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
@@ -134,17 +135,16 @@ const getDashboardCharts = async () => {
// Process data for all dates
chartData.value.processedData = uniqueDates.map((date) => ({
date,
'New conversations':
[t('report.chart.newConversations')]:
chartData.value.new_conversations.find((item) => item.date === date)?.count || 0,
'Resolved conversations':
chartData.value.resolved_conversations.find((item) => item.date === date)?.count || 0,
[t('report.chart.resolvedConversations')]:
chartData.value.resolved_conversations.find((item) => item.date === date)?.count || 0
}))
chartData.value.status_summary = resp.data.data.status_summary || []
})
.catch((error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
+17 -5
View File
@@ -7,7 +7,7 @@
</div>
<div v-else-if="error" class="mt-8 text-center space-y-4">
<p class="text-lg text-destructive">{{ error }}</p>
<Button @click="handleSearch"> Try again </Button>
<Button @click="handleSearch"> {{ $t('search.tryAgain') }} </Button>
</div>
<div v-else>
@@ -15,7 +15,11 @@
v-if="searchPerformed && totalResults === 0"
class="mt-8 text-center text-muted-foreground"
>
No results found for "{{ searchQuery }}". Try a different search term.
{{
$t('search.noResultsForQuery', {
query: searchQuery
})
}}
</p>
<SearchResults v-else-if="searchPerformed" :results="results" class="h-full" />
@@ -23,12 +27,20 @@
v-else-if="searchQuery.length > 0 && searchQuery.length < MIN_SEARCH_LENGTH"
class="mt-8 text-center text-muted-foreground"
>
Please enter at least {{ MIN_SEARCH_LENGTH }} characters to search.
{{
$t('search.minQueryLength', {
length: MIN_SEARCH_LENGTH
})
}}
</p>
<div v-else class="mt-16 text-center">
<h2 class="text-2xl font-semibold text-primary mb-4">Search conversations</h2>
<h2 class="text-2xl font-semibold text-primary mb-4">
{{ $t('search.searchConversations') }}
</h2>
<p class="text-lg text-muted-foreground">
Search by reference number, contact email address or messages in conversations.
{{
$t('search.searchBy')
}}
</p>
</div>
</div>
+36 -2
View File
@@ -51,6 +51,7 @@
"globals.entities.sso": "SSO | SSOs",
"globals.entities.hour": "Hour | Hours",
"globals.entities.day": "Day | Days",
"globals.entities.filter": "Filter | Filters",
"globals.messages.adjustFilters": "Try adjusting your filters",
"globals.messages.errorUploadingFile": "Error uploading file",
"globals.messages.errorUpdating": "Error updating {name}",
@@ -126,6 +127,9 @@
"auth.csrfTokenMismatch": "CSRF token mismatch",
"authz.permissionDenied": "Permission denied",
"user.userAlreadyLoggedIn": "User already logged in",
"user.online": "Online",
"user.offline": "Offline",
"user.away": "Away",
"user.invalidEmailPassword": "Invalid email or password.",
"user.accountDisabled": "Your account is disabled, please contact administrator",
"user.cannotDeleteSystemUser": "Cannot delete system user",
@@ -154,7 +158,6 @@
"macro.partiallyApplied": "Macro partially applied",
"macro.applied": "Macro applied",
"sla.firstResponseTimeAfterResolution": "First response time cannot be after resolution time",
"search.minQueryLength": "Minimum query should be at least {length} characters",
"conversation.resolveWithoutAssignee": "Cannot resolve the conversation without an assigned user, Please assign a user before attempting to resolve",
"conversation.notMemberOfTeam": "You're not a member of this team, Please refresh the page and try again",
"conversation.viewPermissionDenied": "You do not have access to view this view",
@@ -282,6 +285,7 @@
"form.fields.setValue": "Set value",
"form.fields.selectEvents": "Select events",
"form.field.selectOperator": "Select operator",
"form.field.value": "Value",
"form.error.min": "Must be at least {min} characters",
"form.error.max": "Must be at most {max} characters",
"form.error.minmax": "Must be between {min} and {max} characters",
@@ -290,6 +294,7 @@
"form.error.description.required": "Description is required",
"form.error.time.invalid": "Invalid time format (HH:mm)",
"form.error.validUrl": "Invalid URL",
"admin.empty": "Select a section from the sidebar",
"admin.general.updated": "Settings updated successfully",
"admin.general.site_name": "Site Name",
"admin.general.site_name.description": "Name for your support desk.",
@@ -530,11 +535,40 @@
"globals.buttons.create": "Create",
"globals.buttons.enable": "Enable",
"globals.buttons.disable": "Disable",
"globals.buttons.saving": "Saving...",
"globals.buttons.back": "Back",
"globals.buttons.edit": "Edit",
"globals.buttons.new": "New",
"globals.buttons.apply": "Apply",
"globals.buttons.reset": "Reset",
"globals.messages.atleastOneRecipient": "At least one recipient is required",
"globals.messages.startTypingToSearch": "Start typing to search...",
"globals.messages.goHourMinuteDuration": "Invalid duration format. Should be a number followed by h (hours), m (minutes).",
"globals.messages.goDuration": "Invalid duration. Please use a valid duration format (e.g. 30s, 30m, 1h30m, 48h, etc.)"
"globals.messages.goDuration": "Invalid duration. Please use a valid duration format (e.g. 30s, 30m, 1h30m, 48h, etc.)",
"report.live": "Live",
"report.lastUpdated": "Last updated",
"report.total": "Total",
"report.open": "Open",
"report.awaiting_response": "Awaiting Response",
"report.unassigned": "Unassigned",
"report.pending": "Pending",
"report.chart.newConversations": "New conversations",
"report.chart.resolvedConversations": "Resolved conversations",
"search.search": "Search",
"search.noResults": "No {name} found.",
"search.tryAgain": "Try again",
"search.noResultsForQuery": "No results found for query `{query}`. Try a different search term.",
"search.minQueryLength": " Please enter at least {length} characters to search.",
"search.searchConversations": "Search conversations",
"search.searchBy": "Search by reference number, contact email address or messages in conversations.",
"sla.overdue": "Overdue",
"sla.overdueBy": "Overdue by",
"sla.met": "SLA met",
"view.form.description": "Create and save custom filter views for quick access to your conversations.",
"view.form.name.description": "Enter an unique name for your view.",
"view.form.filters.description": "Set one or more filters to customize view.",
"view.form.name.length": "View name should be between 2 and 30 characters.",
"view.form.filter.required": "Filter required.",
"view.form.filter.selectAtLeastOne": "Select at least one filter.",
"view.form.filter.partiallyFilled": "Please make sure you've filled the filter fields correctly."
}