mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-10-23 05:11:57 +00:00
feat: new package report, move exisiting report code from conversations pkg to report package
- new sla performance overview cards.
This commit is contained in:
@@ -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)
|
||||
|
@@ -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"))
|
||||
|
15
cmd/init.go
15
cmd/init.go
@@ -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")
|
||||
|
@@ -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
45
cmd/report.go
Normal 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)
|
||||
}
|
@@ -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,
|
||||
|
79
frontend/src/components/ui/date-filter/DateFilter.vue
Normal file
79
frontend/src/components/ui/date-filter/DateFilter.vue
Normal 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>
|
1
frontend/src/components/ui/date-filter/index.js
Normal file
1
frontend/src/components/ui/date-filter/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as DateFilter } from './DateFilter.vue'
|
@@ -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>
|
@@ -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' }
|
||||
},
|
||||
]
|
||||
|
@@ -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` : ''}`
|
||||
}
|
@@ -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>
|
278
frontend/src/views/reports/OverviewView.vue
Normal file
278
frontend/src/views/reports/OverviewView.vue
Normal 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>
|
23
i18n/en.json
23
i18n/en.json
@@ -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",
|
||||
|
@@ -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.
|
||||
|
@@ -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
|
||||
|
13
internal/report/models/models.go
Normal file
13
internal/report/models/models.go
Normal 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
222
internal/report/queries.sql
Normal 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
135
internal/report/report.go
Normal 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
|
||||
}
|
Reference in New Issue
Block a user