feat: new package report, move exisiting report code from conversations pkg to report package

- new sla performance overview cards.
This commit is contained in:
Abhinav Raut
2025-06-06 02:07:19 +05:30
parent 50baa3f38e
commit d532a99771
21 changed files with 838 additions and 339 deletions

View File

@@ -549,30 +549,6 @@ func handleUpdateContactCustomAttributes(r *fastglue.Request) error {
return r.SendEnvelope(true)
}
// handleDashboardCounts retrieves general dashboard counts for all users.
func handleDashboardCounts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
counts, err := app.conversation.GetDashboardCounts(0, 0)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(counts)
}
// handleDashboardCharts retrieves general dashboard chart data.
func handleDashboardCharts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
charts, err := app.conversation.GetDashboardChart(0, 0)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(charts)
}
// enforceConversationAccess fetches the conversation and checks if the user has access to it.
func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmodels.Conversation, error) {
conversation, err := app.conversation.GetConversation(0, uuid)

View File

@@ -159,8 +159,9 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.DELETE("/api/v1/roles/{id}", perm(handleDeleteRole, "roles:manage"))
// Reports.
g.GET("/api/v1/reports/overview/counts", perm(handleDashboardCounts, "reports:manage"))
g.GET("/api/v1/reports/overview/charts", perm(handleDashboardCharts, "reports:manage"))
g.GET("/api/v1/reports/overview/sla", perm(handleOverviewSLA, "reports:manage"))
g.GET("/api/v1/reports/overview/counts", perm(handleOverviewCounts, "reports:manage"))
g.GET("/api/v1/reports/overview/charts", perm(handleOverviewCharts, "reports:manage"))
// Templates.
g.GET("/api/v1/templates", perm(handleGetTemplates, "templates:manage"))

View File

@@ -35,6 +35,7 @@ import (
notifier "github.com/abhinavxd/libredesk/internal/notification"
emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email"
"github.com/abhinavxd/libredesk/internal/oidc"
"github.com/abhinavxd/libredesk/internal/report"
"github.com/abhinavxd/libredesk/internal/role"
"github.com/abhinavxd/libredesk/internal/search"
"github.com/abhinavxd/libredesk/internal/setting"
@@ -823,6 +824,20 @@ func initActivityLog(db *sqlx.DB, i18n *i18n.I18n) *activitylog.Manager {
return m
}
// initReport inits report manager.
func initReport(db *sqlx.DB, i18n *i18n.I18n) *report.Manager {
lo := initLogger("report")
m, err := report.New(report.Opts{
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing report manager: %v", err)
}
return m
}
// initLogger initializes a logf logger.
func initLogger(src string) *logf.Logger {
lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env")

View File

@@ -23,6 +23,7 @@ import (
customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute"
"github.com/abhinavxd/libredesk/internal/macro"
notifier "github.com/abhinavxd/libredesk/internal/notification"
"github.com/abhinavxd/libredesk/internal/report"
"github.com/abhinavxd/libredesk/internal/search"
"github.com/abhinavxd/libredesk/internal/sla"
"github.com/abhinavxd/libredesk/internal/view"
@@ -90,6 +91,7 @@ type App struct {
activityLog *activitylog.Manager
notifier *notifier.Service
customAttribute *customAttribute.Manager
report *report.Manager
// Global state that stores data on an available app update.
update *AppUpdate
@@ -224,6 +226,7 @@ func main() {
customAttribute: initCustomAttribute(db, i18n),
authz: initAuthz(i18n),
view: initView(db),
report: initReport(db, i18n),
csat: initCSAT(db, i18n),
search: initSearch(db, i18n),
role: initRole(db, i18n),

45
cmd/report.go Normal file
View File

@@ -0,0 +1,45 @@
package main
import (
"strconv"
"github.com/zerodha/fastglue"
)
// handleOverviewCounts retrieves general dashboard counts for all users.
func handleOverviewCounts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
counts, err := app.report.GetOverViewCounts()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(counts)
}
// handleOverviewCharts retrieves general dashboard chart data.
func handleOverviewCharts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
)
charts, err := app.report.GetOverviewChart(days)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(charts)
}
// handleOverviewSLA retrieves SLA data for the dashboard.
func handleOverviewSLA(r *fastglue.Request) error {
var (
app = r.Context.(*App)
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
)
sla, err := app.report.GetOverviewSLA(days)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(sla)
}

View File

@@ -281,7 +281,8 @@ const uploadMedia = (data) =>
}
})
const getOverviewCounts = () => http.get('/api/v1/reports/overview/counts')
const getOverviewCharts = () => http.get('/api/v1/reports/overview/charts')
const getOverviewCharts = (params) => http.get('/api/v1/reports/overview/charts', { params })
const getOverviewSLA = (params) => http.get('/api/v1/reports/overview/sla', { params })
const getLanguage = (lang) => http.get(`/api/v1/lang/${lang}`)
const createInbox = (data) =>
http.post('/api/v1/inboxes', data, {
@@ -360,6 +361,7 @@ export default {
getViewConversations,
getOverviewCharts,
getOverviewCounts,
getOverviewSLA,
getConversationParticipants,
getConversationMessage,
getConversationMessages,

View File

@@ -0,0 +1,79 @@
<template>
<div class="flex items-center gap-2">
<Select v-model="selectedDays" @update:model-value="handleFilterChange">
<SelectTrigger class="w-[140px] h-8 text-xs">
<SelectValue
:placeholder="
t('globals.messages.select', {
name: t('globals.terms.day', 2)
})
"
/>
</SelectTrigger>
<SelectContent class="text-xs">
<SelectItem value="0">{{ $t('globals.terms.today') }}</SelectItem>
<SelectItem value="1">{{ $t('filters.last_1_day') }}</SelectItem>
<SelectItem value="2">{{ $t('filters.last_2_days') }}</SelectItem>
<SelectItem value="7">{{ $t('filters.last_7_days') }}</SelectItem>
<SelectItem value="30">{{ $t('filters.last_30_days') }}</SelectItem>
<SelectItem value="90">{{ $t('filters.last_90_days') }}</SelectItem>
<SelectItem value="custom">{{ $t('filters.custom_days') }}</SelectItem>
</SelectContent>
</Select>
<div v-if="selectedDays === 'custom'" class="flex items-center gap-2">
<Input
v-model="customDaysInput"
type="number"
min="1"
max="365"
:placeholder="$t('filters.days_placeholder')"
class="w-20"
@blur="handleCustomDaysChange"
@keyup.enter="handleCustomDaysChange"
/>
<span class="text-xs text-muted-foreground">{{
$t('globals.terms.day', 2).toLowerCase()
}}</span>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
const { t } = useI18n()
const emit = defineEmits(['filterChange'])
const selectedDays = ref('30')
const customDaysInput = ref('')
const handleFilterChange = (value) => {
if (value === 'custom') {
customDaysInput.value = '30'
emit('filterChange', 30)
} else {
emit('filterChange', parseInt(value))
}
}
const handleCustomDaysChange = () => {
const days = parseInt(customDaysInput.value)
if (days && days > 0 && days <= 365) {
emit('filterChange', days)
} else {
customDaysInput.value = '30'
emit('filterChange', 30)
}
}
handleFilterChange(selectedDays.value)
</script>

View File

@@ -0,0 +1 @@
export { default as DateFilter } from './DateFilter.vue'

View File

@@ -1,16 +1,16 @@
<template>
<div class="flex flex-1 flex-col gap-x-5 box p-5 space-y-5">
<div class="flex items-center space-x-2">
<p class="text-2xl flex items-center">{{ title }}</p>
<div class="flex flex-1 flex-col gap-5 box p-5">
<div class="flex items-center">
<p class="text-2xl font-medium">{{ title }}</p>
</div>
<div class="flex justify-between pr-32">
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
<div
v-for="(item, key) in filteredCounts"
:key="key"
class="flex flex-col items-center gap-y-2"
class="flex flex-col items-center gap-2 text-center"
>
<span class="text-muted-foreground">{{ labels[key] }}</span>
<span class="text-2xl font-medium">{{ item }}</span>
<span class="text-sm text-muted-foreground">{{ labels[key] }}</span>
<span class="text-2xl font-semibold">{{ item }}</span>
</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import App from '@/App.vue'
import OuterApp from '@/OuterApp.vue'
import DashboardView from '@/views/reports/DashboardView.vue'
import OverviewView from '@/views/reports/OverviewView.vue'
import InboxLayout from '@/layouts/inbox/InboxLayout.vue'
import SearchView from '@/views/search/SearchView.vue'
import AccountLayout from '@/layouts/account/AccountLayout.vue'
@@ -62,7 +62,7 @@ const routes = [
{
path: 'overview',
name: 'overview',
component: DashboardView,
component: OverviewView,
meta: { title: 'Overview' }
},
]

View File

@@ -15,4 +15,14 @@ export function getRelativeTime (timestamp, now = new Date()) {
console.error('Error parsing time', error, 'timestamp', timestamp)
return ''
}
}
export const formatDuration = (seconds, showSeconds = true) => {
const totalSeconds = Math.floor(seconds)
if (totalSeconds < 60) return `${totalSeconds}s`
if (totalSeconds < 3600) return `${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`
const hours = Math.floor(totalSeconds / 3600)
const mins = Math.floor((totalSeconds % 3600) / 60)
const secs = totalSeconds % 60
return `${hours}h ${mins}m ${showSeconds ? `${secs}s` : ''}`
}

View File

@@ -1,153 +0,0 @@
<template>
<div class="overflow-y-auto">
<div
class="p-4 w-[calc(100%-3rem)]"
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }"
>
<Spinner v-if="isLoading" />
<div class="space-y-4">
<div class="text-sm text-gray-500 text-right">
{{ $t('globals.terms.lastUpdated') }}: {{ new Date(lastUpdate).toLocaleTimeString() }}
</div>
<div class="mt-7 flex w-full space-x-4">
<Card title="Open conversations" :counts="cardCounts" :labels="agentCountCardsLabels" />
<Card
class="w-8/12"
title="Agent status"
:counts="agentStatusCounts"
:labels="agentStatusLabels"
/>
</div>
<div class="rounded box w-full p-5">
<LineChart :data="chartData.processedData" />
</div>
<div class="rounded box w-full p-5">
<BarChart :data="chartData.status_summary" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { handleHTTPError } from '@/utils/http'
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({})
const lastUpdate = ref(new Date())
let updateInterval
const agentCountCardsLabels = {
open: t('globals.terms.open'),
awaiting_response: t('globals.terms.awaitingResponse'),
unassigned: t('globals.terms.unassigned'),
pending: t('globals.terms.pending')
}
const agentStatusLabels = {
agents_online: t('globals.terms.online'),
agents_offline: t('globals.terms.offline'),
agents_away: t('globals.terms.away')
}
const agentStatusCounts = ref({
agents_online: 0,
agents_offline: 0,
agents_away: 0
})
onMounted(() => {
getDashboardData()
startRealtimeUpdates()
})
onUnmounted(() => {
stopRealtimeUpdates()
})
const startRealtimeUpdates = () => {
updateInterval = setInterval(() => {
getDashboardData()
lastUpdate.value = new Date()
}, 60000)
}
const stopRealtimeUpdates = () => {
clearInterval(updateInterval)
}
const getDashboardData = () => {
isLoading.value = true
Promise.allSettled([getCardStats(), getDashboardCharts()]).finally(() => {
isLoading.value = false
})
}
const getCardStats = async () => {
return api
.getOverviewCounts()
.then((resp) => {
cardCounts.value = resp.data.data
agentStatusCounts.value = {
agents_online: cardCounts.value.agents_online,
agents_offline: cardCounts.value.agents_offline,
agents_away: cardCounts.value.agents_away
}
})
.catch((error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
})
}
const getDashboardCharts = async () => {
return api
.getOverviewCharts()
.then((resp) => {
chartData.value.new_conversations = resp.data.data.new_conversations || []
chartData.value.resolved_conversations = resp.data.data.resolved_conversations || []
chartData.value.messages_sent = resp.data.data.messages_sent || []
// Get all dates from all datasets
const allDates = [
...chartData.value.new_conversations.map((item) => item.date),
...chartData.value.resolved_conversations.map((item) => item.date),
...chartData.value.messages_sent.map((item) => item.date)
]
// Create unique sorted dates
const uniqueDates = [...new Set(allDates)].sort((a, b) => new Date(a) - new Date(b))
// Process data for all dates
chartData.value.processedData = uniqueDates.map((date) => ({
date,
[t('report.chart.newConversations')]:
chartData.value.new_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, {
variant: 'destructive',
description: handleHTTPError(error).message
})
})
}
</script>

View File

@@ -0,0 +1,278 @@
<template>
<div class="overflow-y-auto">
<div
class="p-6 w-[calc(100%-3rem)]"
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }"
>
<Spinner v-if="isLoading" />
<div class="space-y-4">
<div class="text-sm text-gray-500 text-left">
{{ $t('globals.terms.lastUpdated') }}: {{ lastUpdateFormatted }}
</div>
<!-- First row -->
<div class="mt-7 space-y-4">
<!-- Cards for Open Conversations and Agent Status -->
<div class="flex w-full space-x-4">
<Card
class="flex-1"
title="Open conversations"
:counts="cardCounts"
:labels="conversationCountLabels"
/>
<Card
class="flex-1"
title="Agent status"
:counts="agentStatusCounts"
:labels="agentStatusLabels"
/>
</div>
<!-- SLA Card with Date Filter -->
<div class="w-full rounded box p-5">
<div class="flex justify-between items-center mb-4">
<p class="text-2xl font-medium">{{ slaCardTitle }}</p>
<DateFilter @filter-change="handleSlaFilterChange" :label="''" />
</div>
<div class="grid grid-cols-2 md:grid-cols-5 gap-6">
<div
v-for="(item, key) in filteredSlaCounts"
:key="key"
class="flex flex-col items-center gap-2 text-center"
>
<span class="text-sm text-muted-foreground">{{ slaLabels[key] }}</span>
<span class="text-2xl font-semibold">{{ item }}</span>
</div>
</div>
</div>
</div>
<!-- Line Chart with Date Filter -->
<div class="rounded box w-full p-5">
<div class="flex justify-between items-center mb-4">
<p class="text-2xl font-medium">{{ $t('report.chart.title') }}</p>
<DateFilter @filter-change="handleChartFilterChange" :label="''" />
</div>
<LineChart :data="processedLineData" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { handleHTTPError } from '@/utils/http'
import { formatDuration } from '@/utils/datetime'
import Card from '@/features/reports/OverviewCard.vue'
import LineChart from '@/features/reports/OverviewLineChart.vue'
import Spinner from '@/components/ui/spinner/Spinner.vue'
import { DateFilter } from '@/components/ui/date-filter'
import { useI18n } from 'vue-i18n'
import api from '@/api'
const emitter = useEmitter()
const { t } = useI18n()
const isLoading = ref(false)
const lastUpdate = ref(new Date())
const cardCounts = ref({})
const chartData = ref({ status_summary: [] })
let updateInterval = null
const agentStatusCounts = ref({
agents_online: 0,
agents_offline: 0,
agents_away: 0,
agents_reassigning: 0
})
const slaCounts = ref({
first_response_met_count: 0,
first_response_breached_count: 0,
next_response_met_count: 0,
next_response_breached_count: 0,
resolution_met_count: 0,
resolution_breached_count: 0,
avg_first_response_time_sec: 0,
avg_next_response_time_sec: 0,
avg_resolution_time_sec: 0
})
// Date filter state
const slaDays = ref(30)
const chartDays = ref(90)
const formattedSlaCounts = computed(() => ({
...slaCounts.value,
avg_first_response_time_sec: formatDuration(slaCounts.value.avg_first_response_time_sec, false),
avg_next_response_time_sec: formatDuration(slaCounts.value.avg_next_response_time_sec, false),
avg_resolution_time_sec: formatDuration(slaCounts.value.avg_resolution_time_sec, false)
}))
// Filter out counts that don't have a label.
const filteredSlaCounts = computed(() => {
return Object.fromEntries(
Object.entries(formattedSlaCounts.value).filter(([key]) => slaLabels.value[key])
)
})
// Dynamic SLA card title based on selected days
const slaCardTitle = computed(() => t('report.sla.cardTitle', { days: slaDays.value }))
const lastUpdateFormatted = computed(() => lastUpdate.value.toLocaleTimeString())
const conversationCountLabels = computed(() => ({
open: t('globals.terms.open'),
awaiting_response: t('globals.terms.awaitingResponse'),
unassigned: t('globals.terms.unassigned'),
pending: t('globals.terms.pending')
}))
const agentStatusLabels = computed(() => ({
agents_online: t('globals.terms.online'),
agents_offline: t('globals.terms.offline'),
agents_away: t('globals.terms.away'),
agents_reassigning: t('globals.messages.reassigning')
}))
const slaLabels = computed(() => ({
first_response_met_count: t('report.sla.firstRespMet'),
first_response_breached_count: t('report.sla.firstRespBreached'),
avg_first_response_time_sec: t('report.sla.avgFirstResp'),
next_response_met_count: t('report.sla.nextRespMet'),
next_response_breached_count: t('report.sla.nextRespBreached'),
avg_next_response_time_sec: t('report.sla.avgNextResp'),
resolution_met_count: t('report.sla.resolutionMet'),
resolution_breached_count: t('report.sla.resolutionBreached'),
avg_resolution_time_sec: t('report.sla.avgResolution')
}))
const processedLineData = computed(() => {
const { new_conversations = [], resolved_conversations = [] } = chartData.value
const dateMap = new Map()
new_conversations.forEach((item) => {
dateMap.set(item.date, {
date: item.date,
[t('report.chart.newConversations')]: item.count,
[t('report.chart.resolvedConversations')]: 0
})
})
resolved_conversations.forEach((item) => {
const existing = dateMap.get(item.date)
if (existing) {
existing[t('report.chart.resolvedConversations')] = item.count
} else {
dateMap.set(item.date, {
date: item.date,
[t('report.chart.newConversations')]: 0,
[t('report.chart.resolvedConversations')]: item.count
})
}
})
return Array.from(dateMap.values()).sort((a, b) => new Date(a.date) - new Date(b.date))
})
const showError = (error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
const fetchCardStats = async () => {
try {
const { data } = await api.getOverviewCounts()
cardCounts.value = data.data
agentStatusCounts.value = {
agents_online: data.data.agents_online || 0,
agents_offline: data.data.agents_offline || 0,
agents_away: data.data.agents_away || 0,
agents_reassigning: data.data.agents_reassigning || 0
}
} catch (error) {
showError(error)
}
}
const fetchSLAStats = async (days = slaDays.value) => {
try {
const { data } = await api.getOverviewSLA({ days })
slaCounts.value = { ...slaCounts.value, ...data.data }
} catch (error) {
showError(error)
}
}
const fetchChartData = async (days = chartDays.value) => {
try {
const { data } = await api.getOverviewCharts({ days })
chartData.value = {
new_conversations: data.data.new_conversations || [],
resolved_conversations: data.data.resolved_conversations || [],
messages_sent: data.data.messages_sent || []
}
} catch (error) {
showError(error)
}
}
// Date filter handlers
const handleSlaFilterChange = async (days) => {
slaDays.value = days
isLoading.value = true
try {
await fetchSLAStats(days)
} finally {
isLoading.value = false
lastUpdate.value = new Date()
}
}
const handleChartFilterChange = async (days) => {
chartDays.value = days
isLoading.value = true
try {
await fetchChartData(days)
} finally {
isLoading.value = false
lastUpdate.value = new Date()
}
}
const loadDashboardData = async () => {
isLoading.value = true
try {
await Promise.allSettled([fetchCardStats(), fetchSLAStats(), fetchChartData()])
} finally {
isLoading.value = false
lastUpdate.value = new Date()
}
}
const startRealtimeUpdates = () => {
if (updateInterval) clearInterval(updateInterval)
updateInterval = setInterval(loadDashboardData, 60000)
}
const stopRealtimeUpdates = () => {
if (updateInterval) {
clearInterval(updateInterval)
updateInterval = null
}
}
onMounted(() => {
loadDashboardData()
startRealtimeUpdates()
})
onUnmounted(() => {
stopRealtimeUpdates()
})
</script>

View File

@@ -7,6 +7,7 @@
"globals.terms.team": "Team | Teams",
"globals.terms.message": "Message | Messages",
"globals.terms.activityMessage": "Activity Message | Activity Messages",
"globals.terms.account": "Account | Accounts",
"globals.terms.conversation": "Conversation | Conversations",
"globals.terms.provider": "Provider | Providers",
"globals.terms.state": "State | States",
@@ -98,8 +99,10 @@
"globals.terms.privateNote": "Private note | Private notes",
"globals.terms.automationRule": "Automation Rule | Automation Rules",
"globals.terms.subject": "Subject | Subjects",
"globals.terms.today": "Today",
"globals.messages.replying": "Replying",
"globals.messages.golangDurationHoursMinutes": "Duration in hours or minutes. Example: 1h, 30m, 1h30m",
"globals.messages.reassigning": "Reassigning",
"globals.messages.badRequest": "Bad request",
"globals.messages.visibleWhen": "Visible when",
"globals.messages.adjustFilters": "Try adjusting filters",
@@ -145,8 +148,9 @@
"globals.messages.denied": "{name} denied",
"globals.messages.noResults": "No {name} found",
"globals.messages.enter": "Enter {name}",
"globals.messages.yes": "Yes",
"globals.messages.yes": "Yes {name}",
"globals.messages.no": "No {name}",
"globals.messages.select": "Select {name}",
"globals.messages.type": "{name} type",
"globals.messages.typeOf": "Type of {name}",
"globals.messages.invalidEmailAddress": "Invalid email address",
@@ -184,6 +188,12 @@
"globals.messages.goDuration": "Invalid duration. Please use a valid duration format (e.g. 30s, 30m, 1h, 48h, etc.)",
"globals.messages.invalidFromAddress": "Invalid from email address format, make sure it's a valid email address in the format `Name <mail@example.com>`",
"globals.messages.canOnlyDeleteOwn": "You can only delete your own {name}",
"filters.last_1_day": "Last 1 day",
"filters.last_2_days": "Last 2 days",
"filters.last_7_days": "Last 7 days",
"filters.last_30_days": "Last 30 days",
"filters.last_90_days": "Last 90 days",
"filters.custom_days": "Custom days",
"user.resetPasswordTokenExpired": "Token is invalid or expired, please try again by requesting a new password reset link",
"user.userCannotDeleteSelf": "You cannot delete yourself",
"media.fileSizeTooLarge": "File size too large, please upload a file less than {size} ",
@@ -541,6 +551,17 @@
"globals.buttons.reset": "Reset",
"report.chart.newConversations": "New conversations",
"report.chart.resolvedConversations": "Resolved conversations",
"report.chart.title": "Conversation Trends",
"report.sla.cardTitle": "SLA Performance (Last {days} days)",
"report.sla.firstRespMet": "First Response Met",
"report.sla.firstRespBreached": "First Response Breached",
"report.sla.avgFirstResp": "Avg First Response Time",
"report.sla.nextRespMet": "Next Response Met",
"report.sla.nextRespBreached": "Next Response Breached",
"report.sla.avgNextResp": "Avg Next Response Time",
"report.sla.resolutionMet": "Resolution Met",
"report.sla.resolutionBreached": "Resolution Breached",
"report.sla.avgResolution": "Avg Resolution Time",
"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",

View File

@@ -217,10 +217,6 @@ type queries struct {
RemoveConversationAssignee *sqlx.Stmt `query:"remove-conversation-assignee"`
GetLatestMessage *sqlx.Stmt `query:"get-latest-message"`
// Dashboard queries.
GetDashboardCharts string `query:"get-dashboard-charts"`
GetDashboardCounts string `query:"get-dashboard-counts"`
// Message queries.
GetMessage *sqlx.Stmt `query:"get-message"`
GetMessages string `query:"get-messages"`
@@ -639,83 +635,6 @@ func (c *Manager) UpdateConversationStatus(uuid string, statusID int, status, sn
return nil
}
// GetDashboardCounts returns dashboard counts
// TODO: Rename to overview [reports/overview].
func (c *Manager) GetDashboardCounts(userID, teamID int) (json.RawMessage, error) {
var counts = json.RawMessage{}
tx, err := c.db.BeginTxx(context.Background(), &sql.TxOptions{
ReadOnly: true,
})
if err != nil {
c.lo.Error("error starting db txn", "error", err)
return nil, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetchingCount", "name", "{globals.terms.dashboard}"), nil)
}
defer tx.Rollback()
var (
cond string
qArgs []interface{}
)
if userID > 0 {
cond = " AND assigned_user_id = $1"
qArgs = append(qArgs, userID)
} else if teamID > 0 {
cond = " AND assigned_team_id = $1"
qArgs = append(qArgs, teamID)
}
// TODO: Add date range filter support.
cond += " AND c.created_at >= NOW() - INTERVAL '30 days'"
query := fmt.Sprintf(c.q.GetDashboardCounts, cond)
if err := tx.Get(&counts, query, qArgs...); err != nil {
c.lo.Error("error fetching dashboard counts", "error", err)
return nil, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetchingCount", "name", "{globals.terms.dashboard}"), nil)
}
if err := tx.Commit(); err != nil {
c.lo.Error("error committing db txn", "error", err)
return nil, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetchingCount", "name", "{globals.terms.dashboard}"), nil)
}
return counts, nil
}
// GetDashboardChart returns dashboard chart data
func (c *Manager) GetDashboardChart(userID, teamID int) (json.RawMessage, error) {
var stats = json.RawMessage{}
tx, err := c.db.BeginTxx(context.Background(), &sql.TxOptions{
ReadOnly: true,
})
if err != nil {
c.lo.Error("error starting db txn", "error", err)
return nil, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetchingChart", "name", "{globals.terms.dashboard}"), nil)
}
defer tx.Rollback()
var (
cond string
qArgs []interface{}
)
// TODO: Add date range filter support.
if userID > 0 {
cond = " AND assigned_user_id = $1"
qArgs = append(qArgs, userID)
} else if teamID > 0 {
cond = " AND assigned_team_id = $1"
qArgs = append(qArgs, teamID)
}
cond += " AND c.created_at >= NOW() - INTERVAL '90 days'"
// Apply the same condition across queries.
query := fmt.Sprintf(c.q.GetDashboardCharts, cond, cond, cond)
if err := tx.Get(&stats, query, qArgs...); err != nil {
c.lo.Error("error fetching dashboard charts", "error", err)
return nil, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetchingChart", "name", "{globals.terms.dashboard}"), nil)
}
return stats, nil
}
// SetConversationTags sets the tags associated with a conversation.
func (c *Manager) SetConversationTags(uuid string, action string, tagNames []string, actor umodels.User) error {
// Get current tags list.

View File

@@ -262,74 +262,6 @@ FROM conversations c
JOIN inboxes inb ON c.inbox_id = inb.id
WHERE assigned_user_id IS NULL AND assigned_team_id IS NOT NULL;
-- name: get-dashboard-counts
SELECT json_build_object(
'open', COUNT(*),
'awaiting_response', COUNT(CASE WHEN c.waiting_since IS NOT NULL THEN 1 END),
'unassigned', COUNT(CASE WHEN c.assigned_user_id IS NULL THEN 1 END),
'pending', COUNT(CASE WHEN c.first_reply_at IS NOT NULL THEN 1 END),
'agents_online', (SELECT COUNT(*) FROM users WHERE availability_status = 'online' AND type = 'agent' AND deleted_at is null),
'agents_away', (SELECT COUNT(*) FROM users WHERE availability_status = 'away_manual' AND type = 'agent' AND deleted_at is null),
'agents_offline', (SELECT COUNT(*) FROM users WHERE availability_status = 'offline' AND type = 'agent' AND deleted_at is null)
)
FROM conversations c
INNER JOIN conversation_statuses s ON c.status_id = s.id
WHERE s.name not in ('Resolved', 'Closed') AND 1=1 %s;
-- name: get-dashboard-charts
WITH new_conversations AS (
SELECT json_agg(row_to_json(agg)) AS data
FROM (
SELECT
TO_CHAR(created_at::date, 'YYYY-MM-DD') AS date,
COUNT(*) AS count
FROM
conversations c
WHERE 1=1 %s
GROUP BY
date
ORDER BY
date
) agg
),
resolved_conversations AS (
SELECT json_agg(row_to_json(agg)) AS data
FROM (
SELECT
TO_CHAR(resolved_at::date, 'YYYY-MM-DD') AS date,
COUNT(*) AS count
FROM
conversations c
WHERE c.resolved_at IS NOT NULL AND 1=1 %s
GROUP BY
date
ORDER BY
date
) agg
),
status_summary AS (
SELECT json_agg(row_to_json(agg)) AS data
FROM (
SELECT
s.name as status,
COUNT(*) FILTER (WHERE p.name = 'Low') AS "Low",
COUNT(*) FILTER (WHERE p.name = 'Medium') AS "Medium",
COUNT(*) FILTER (WHERE p.name = 'High') AS "High"
FROM
conversations c
LEFT join conversation_statuses s on s.id = c.status_id
LEFT join conversation_priorities p on p.id = c.priority_id
WHERE 1=1 AND s.name > '' %s
GROUP BY
s.name
) agg
)
SELECT json_build_object(
'new_conversations', (SELECT data FROM new_conversations),
'resolved_conversations', (SELECT data FROM resolved_conversations),
'status_summary', (SELECT data FROM status_summary)
) AS result;
-- name: update-conversation-first-reply-at
UPDATE conversations
SET first_reply_at = $2

View File

@@ -0,0 +1,13 @@
package models
type OverviewSLA struct {
FirstResponseMetCount int `json:"first_response_met_count" db:"first_response_met_count"`
FirstResponseBreachedCount int `json:"first_response_breached_count" db:"first_response_breached_count"`
AvgFirstResponseTimeSec float64 `json:"avg_first_response_time_sec" db:"avg_first_response_time_sec"`
NextResponseMetCount int `json:"next_response_met_count" db:"next_response_met_count"`
NextResponseBreachedCount int `json:"next_response_breached_count" db:"next_response_breached_count"`
AvgNextResponseTimeSec float64 `json:"avg_next_response_time_sec" db:"avg_next_response_time_sec"`
ResolutionMetCount int `json:"resolution_met_count" db:"resolution_met_count"`
ResolutionBreachedCount int `json:"resolution_breached_count" db:"resolution_breached_count"`
AvgResolutionTimeSec float64 `json:"avg_resolution_time_sec" db:"avg_resolution_time_sec"`
}

222
internal/report/queries.sql Normal file
View File

@@ -0,0 +1,222 @@
-- name: get-overview-counts
SELECT
json_build_object(
'open',
COUNT(*),
'awaiting_response',
COUNT(
CASE
WHEN c.last_message_sender = 'contact' THEN 1
END
),
'unassigned',
COUNT(
CASE
WHEN c.assigned_user_id IS NULL THEN 1
END
),
'pending',
COUNT(
CASE
WHEN c.first_reply_at IS NULL THEN 1
END
),
'agents_online',
(
SELECT
COUNT(*)
FROM
users
WHERE
availability_status = 'online'
AND type = 'agent'
AND deleted_at is null
),
'agents_away',
(
SELECT
COUNT(*)
FROM
users
WHERE
availability_status = 'away_manual'
AND type = 'agent'
AND deleted_at is null
),
'agents_reassigning',
(
SELECT
COUNT(*)
FROM
users
WHERE
availability_status = 'away_and_reassigning'
AND type = 'agent'
AND deleted_at is null
),
'agents_offline',
(
SELECT
COUNT(*)
FROM
users
WHERE
availability_status = 'offline'
AND type = 'agent'
AND deleted_at is null
)
)
FROM
conversations c
INNER JOIN conversation_statuses s ON c.status_id = s.id
WHERE
s.name not in ('Resolved', 'Closed');
-- name: get-overview-sla-counts
WITH first_and_resolution AS (
SELECT
COUNT(*) FILTER (
WHERE
first_response_met_at IS NOT NULL
) AS first_response_met_count,
COUNT(*) FILTER (
WHERE
first_response_breached_at IS NOT NULL
) AS first_response_breached_count,
COUNT(*) FILTER (
WHERE
resolution_met_at IS NOT NULL
) AS resolution_met_count,
COUNT(*) FILTER (
WHERE
resolution_breached_at IS NOT NULL
) AS resolution_breached_count,
COALESCE(
AVG(
EXTRACT(
EPOCH
FROM
(first_response_met_at - created_at)
)
) FILTER (
WHERE
first_response_met_at IS NOT NULL
),
0
) AS avg_first_response_time_sec,
COALESCE(
AVG(
EXTRACT(
EPOCH
FROM
(resolution_met_at - created_at)
)
) FILTER (
WHERE
resolution_met_at IS NOT NULL
),
0
) AS avg_resolution_time_sec
FROM
applied_slas
WHERE
created_at >= NOW() - INTERVAL '%d days'
),
next_response AS (
SELECT
COUNT(*) FILTER (
WHERE
met_at IS NOT NULL
) AS next_response_met_count,
COUNT(*) FILTER (
WHERE
breached_at IS NOT NULL
) AS next_response_breached_count,
COALESCE(
AVG(
EXTRACT(
EPOCH
FROM
(met_at - created_at)
)
) FILTER (
WHERE
met_at IS NOT NULL
),
0
) AS avg_next_response_time_sec
FROM
sla_events
WHERE
created_at >= NOW() - INTERVAL '%d days'
AND type = 'next_response'
)
SELECT
fas.first_response_met_count,
fas.first_response_breached_count,
fas.avg_first_response_time_sec,
nr.next_response_met_count,
nr.next_response_breached_count,
nr.avg_next_response_time_sec,
fas.resolution_met_count,
fas.resolution_breached_count,
fas.avg_resolution_time_sec
FROM
first_and_resolution fas,
next_response nr;
-- name: get-overview-charts
WITH new_conversations AS (
SELECT
json_agg(row_to_json(agg)) AS data
FROM
(
SELECT
TO_CHAR(created_at :: date, 'YYYY-MM-DD') AS date,
COUNT(*) AS count
FROM
conversations c
WHERE
c.created_at >= NOW() - INTERVAL '%d days'
GROUP BY
date
ORDER BY
date
) agg
),
resolved_conversations AS (
SELECT
json_agg(row_to_json(agg)) AS data
FROM
(
SELECT
TO_CHAR(resolved_at :: date, 'YYYY-MM-DD') AS date,
COUNT(*) AS count
FROM
conversations c
WHERE
c.resolved_at IS NOT NULL
AND c.created_at >= NOW() - INTERVAL '%d days'
GROUP BY
date
ORDER BY
date
) agg
)
SELECT
json_build_object(
'new_conversations',
(
SELECT
data
FROM
new_conversations
),
'resolved_conversations',
(
SELECT
data
FROM
resolved_conversations
)
) AS result;

135
internal/report/report.go Normal file
View File

@@ -0,0 +1,135 @@
// Package report handles the management of reports.
package report
import (
"context"
"database/sql"
"embed"
"encoding/json"
"fmt"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/report/models"
"github.com/jmoiron/sqlx"
"github.com/knadh/go-i18n"
"github.com/zerodha/logf"
)
var (
//go:embed queries.sql
efs embed.FS
)
type Manager struct {
q queries
lo *logf.Logger
i18n *i18n.I18n
db *sqlx.DB
}
// Opts contains options for initializing the report Manager.
type Opts struct {
DB *sqlx.DB
Lo *logf.Logger
I18n *i18n.I18n
}
// queries contains prepared SQL queries.
type queries struct {
GetOverviewCharts string `query:"get-overview-charts"`
GetOverviewCounts string `query:"get-overview-counts"`
GetOverviewSLA string `query:"get-overview-sla-counts"`
}
// New creates and returns a new instance of the Manager.
func New(opts Opts) (*Manager, error) {
var q queries
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
return nil, err
}
return &Manager{
q: q,
lo: opts.Lo,
i18n: opts.I18n,
db: opts.DB,
}, nil
}
// GetOverViewCounts returns overview counts
func (m *Manager) GetOverViewCounts() (json.RawMessage, error) {
var counts = json.RawMessage{}
tx, err := m.db.BeginTxx(context.Background(), &sql.TxOptions{
ReadOnly: true,
})
if err != nil {
m.lo.Error("error starting db txn", "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetchingCount", "name", "{globals.terms.overview}"), nil)
}
defer tx.Rollback()
if err := tx.Get(&counts, m.q.GetOverviewCounts); err != nil {
m.lo.Error("error fetching overview counts", "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetchingCount", "name", "{globals.terms.overview}"), nil)
}
if err := tx.Commit(); err != nil {
m.lo.Error("error committing db txn", "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetchingCount", "name", "{globals.terms.overview}"), nil)
}
return counts, nil
}
// GetOverviewSLA returns overview SLA data
func (m *Manager) GetOverviewSLA(days int) (json.RawMessage, error) {
tx, err := m.db.BeginTxx(context.Background(), &sql.TxOptions{
ReadOnly: true,
})
if err != nil {
m.lo.Error("error starting db txn", "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetchingCount", "name", "{globals.terms.overview}"), nil)
}
defer tx.Rollback()
var result models.OverviewSLA
// Format query with days parameter for both CTEs
query := fmt.Sprintf(m.q.GetOverviewSLA, days, days)
if err := tx.Get(&result, query); err != nil {
m.lo.Error("error fetching overview SLA data", "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetchingCount", "name", "{globals.terms.overview}"), nil)
}
if err := tx.Commit(); err != nil {
m.lo.Error("error committing db txn", "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetchingCount", "name", "{globals.terms.overview}"), nil)
}
slaData, err := json.Marshal(result)
if err != nil {
m.lo.Error("error marshaling SLA data", "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetchingCount", "name", "{globals.terms.overview}"), nil)
}
return slaData, nil
}
// GetOverviewChart returns overview chart data
func (m *Manager) GetOverviewChart(days int) (json.RawMessage, error) {
var stats = json.RawMessage{}
tx, err := m.db.BeginTxx(context.Background(), &sql.TxOptions{
ReadOnly: true,
})
if err != nil {
m.lo.Error("error starting db txn", "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetchingChart", "name", "{globals.terms.overview}"), nil)
}
defer tx.Rollback()
query := fmt.Sprintf(m.q.GetOverviewCharts, days, days)
if err := tx.Get(&stats, query); err != nil {
m.lo.Error("error fetching overview charts", "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetchingChart", "name", "{globals.terms.overview}"), nil)
}
return stats, nil
}