snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { Box, Select, Margins, Option } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { Page, PageHeader, PageScrollableContentWithShadow } from '@rocket.chat/ui-client';
import { useQueryClient } from '@tanstack/react-query';
import type { Key } from 'react';
import { useState, useMemo, useEffect, Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { getDateRange } from '../../../lib/utils/getDateRange';
import AutoCompleteDepartment from '../components/AutoCompleteDepartment';
import Label from '../components/Label';
import AgentStatusChart from './charts/AgentStatusChart';
import ChatDurationChart from './charts/ChatDurationChart';
import ChatsChart from './charts/ChatsChart';
import ChatsPerAgentChart from './charts/ChatsPerAgentChart';
import ChatsPerDepartmentChart from './charts/ChatsPerDepartmentChart';
import ResponseTimesChart from './charts/ResponseTimesChart';
import AgentsOverview from './overviews/AgentsOverview';
import ChatsOverview from './overviews/ChatsOverview';
import ConversationOverview from './overviews/ConversationOverview';
import ProductivityOverview from './overviews/ProductivityOverview';
import { omnichannelQueryKeys } from '../../../lib/queryKeys';
const dateRange = getDateRange();
const RealTimeMonitoringPage = () => {
const { t } = useTranslation();
const [reloadFrequency, setReloadFrequency] = useState(5);
const [departmentId, setDepartment] = useState('');
const queryClient = useQueryClient();
const reloadCharts = useStableCallback(() => {
queryClient.invalidateQueries({ queryKey: omnichannelQueryKeys.analytics.all(departmentId) });
});
useEffect(() => {
const interval = setInterval(reloadCharts, reloadFrequency * 1000);
return () => {
clearInterval(interval);
};
}, [reloadCharts, reloadFrequency]);
const reloadOptions = useMemo(
() => [
[5, <Fragment key='5 seconds'>5 {t('seconds')}</Fragment>] as unknown as SelectOption,
[10, <Fragment key='10 seconds'>10 {t('seconds')}</Fragment>] as unknown as SelectOption,
[30, <Fragment key='30 seconds'>30 {t('seconds')}</Fragment>] as unknown as SelectOption,
[60, <Fragment key='1 minute'>1 {t('minute')}</Fragment>] as unknown as SelectOption,
],
[t],
);
return (
<Page>
<PageHeader title={t('Real_Time_Monitoring')} />
<PageScrollableContentWithShadow>
<Margins block='x4'>
<Box flexDirection='row' display='flex' justifyContent='space-between' alignSelf='center' width='full'>
<Box maxWidth='50%' display='flex' marginInline={4} flexGrow={1} flexDirection='column'>
<Label marginBlock={4}>{t('Departments')}</Label>
<AutoCompleteDepartment
value={departmentId}
onChange={setDepartment}
placeholder={t('All')}
label={t('All')}
onlyMyDepartments
withTitle={false}
renderItem={({ label, ...props }) => <Option {...props} label={<span style={{ whiteSpace: 'normal' }}>{label}</span>} />}
/>
</Box>
<Box maxWidth='50%' display='flex' marginInline={4} flexGrow={1} flexDirection='column'>
<Label marginBlock={4}>{t('Update_every')}</Label>
<Select
options={reloadOptions}
onChange={useStableCallback((val: Key) => setReloadFrequency(val as number))}
value={reloadFrequency}
/>
</Box>
</Box>
<Box display='flex' flexDirection='row' width='full' alignItems='stretch' flexShrink={1}>
<ConversationOverview flexGrow={1} flexShrink={1} width='50%' departmentId={departmentId} dateRange={dateRange} />
</Box>
<Box display='flex' flexDirection='row' width='full' alignItems='stretch' flexShrink={1}>
<ChatsChart flexGrow={1} flexShrink={1} width='50%' marginInlineEnd={2} departmentId={departmentId} dateRange={dateRange} />
<ChatsPerAgentChart
flexGrow={1}
flexShrink={1}
width='50%'
marginInlineStart={2}
departmentId={departmentId}
dateRange={dateRange}
/>
</Box>
<Box display='flex' flexDirection='row' width='full' alignItems='stretch' flexShrink={1}>
<ChatsOverview flexGrow={1} flexShrink={1} width='50%' departmentId={departmentId} dateRange={dateRange} />
</Box>
<Box display='flex' flexDirection='row' width='full' alignItems='stretch' flexShrink={1}>
<AgentStatusChart flexGrow={1} flexShrink={1} width='50%' marginInlineEnd={2} departmentId={departmentId} />
<ChatsPerDepartmentChart
flexGrow={1}
flexShrink={1}
width='50%'
marginInlineStart={2}
departmentId={departmentId}
dateRange={dateRange}
/>
</Box>
<Box display='flex' flexDirection='row' width='full' alignItems='stretch' flexShrink={1}>
<AgentsOverview flexGrow={1} flexShrink={1} departmentId={departmentId} dateRange={dateRange} />
</Box>
<Box display='flex' width='full' flexShrink={1}>
<ChatDurationChart flexGrow={1} flexShrink={1} width='100%' departmentId={departmentId} dateRange={dateRange} />
</Box>
<Box display='flex' flexDirection='row' width='full' alignItems='stretch' flexShrink={1}>
<ProductivityOverview flexGrow={1} flexShrink={1} departmentId={departmentId} dateRange={dateRange} />
</Box>
<Box display='flex' width='full' flexShrink={1}>
<ResponseTimesChart flexGrow={1} flexShrink={1} width='100%' departmentId={departmentId} dateRange={dateRange} />
</Box>
</Margins>
</PageScrollableContentWithShadow>
</Page>
);
};
export default RealTimeMonitoringPage;

View File

@@ -0,0 +1,82 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import type { Box } from '@rocket.chat/fuselage';
import { useEndpoint, type TranslationKey } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type * as chartjs from 'chart.js';
import type { TFunction } from 'i18next';
import type { ComponentPropsWithoutRef, MutableRefObject } from 'react';
import { useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import Chart from './Chart';
import { useChartContext } from './useChartContext';
import { useUpdateChartData } from './useUpdateChartData';
import { omnichannelQueryKeys } from '../../../../lib/queryKeys';
import { drawDoughnutChart } from '../../chartHandler';
const labels = ['Available', 'Away', 'Busy', 'Offline'];
const initialData = {
available: 0,
away: 0,
busy: 0,
offline: 0,
};
const init = (canvas: HTMLCanvasElement, context: chartjs.Chart<'doughnut'> | undefined, t: TFunction) =>
drawDoughnutChart(
canvas,
t('Agents'),
context,
labels.map((l) => t(l as TranslationKey)),
Object.values(initialData),
);
type AgentStatusChartsProps = {
departmentId: ILivechatDepartment['_id'];
} & ComponentPropsWithoutRef<typeof Box>;
const AgentStatusChart = ({ departmentId, ...props }: AgentStatusChartsProps) => {
const { t } = useTranslation();
const canvas: MutableRefObject<HTMLCanvasElement | null> = useRef(null);
const getAgentStatus = useEndpoint('GET', '/v1/livechat/analytics/dashboards/charts/agents-status');
const { isSuccess, data: { offline = 0, available = 0, away = 0, busy = 0 } = initialData } = useQuery({
queryKey: omnichannelQueryKeys.analytics.agentsStatus(departmentId),
queryFn: () => getAgentStatus({ departmentId }),
gcTime: 0,
});
const context = useChartContext({
canvas,
init,
t,
});
const updateChartData = useUpdateChartData({
context,
canvas,
init,
t,
});
useEffect(() => {
if (!context) {
return;
}
if (!isSuccess) {
return;
}
updateChartData(t('Offline'), [offline]);
updateChartData(t('Available'), [available]);
updateChartData(t('Away'), [away]);
updateChartData(t('Busy'), [busy]);
}, [context, available, away, busy, offline, isSuccess, t, updateChartData]);
return <Chart canvasRef={canvas} {...props} />;
};
export default AgentStatusChart;

View File

@@ -0,0 +1,15 @@
import { Box } from '@rocket.chat/fuselage';
import type { MutableRefObject } from 'react';
export type ChartProps = { canvasRef: MutableRefObject<HTMLCanvasElement | null> };
const style = {
minHeight: '250px',
};
const Chart = ({ canvasRef, ...props }: ChartProps) => (
<Box padding='x20' height='x300' {...props}>
<canvas ref={canvasRef} style={style}></canvas>
</Box>
);
export default Chart;

View File

@@ -0,0 +1,98 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import type { Box } from '@rocket.chat/fuselage';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type * as chartjs from 'chart.js';
import type { TFunction } from 'i18next';
import type { ComponentPropsWithoutRef } from 'react';
import { useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import Chart from './Chart';
import { getMomentChartLabelsAndData } from './getMomentChartLabelsAndData';
import { getMomentCurrentLabel } from './getMomentCurrentLabel';
import { useChartContext } from './useChartContext';
import { useUpdateChartData } from './useUpdateChartData';
import { secondsToHHMMSS } from '../../../../../lib/utils/secondsToHHMMSS';
import { omnichannelQueryKeys } from '../../../../lib/queryKeys';
import { drawLineChart } from '../../chartHandler';
const [labels, initialData] = getMomentChartLabelsAndData();
const tooltipCallbacks = {
callbacks: {
title([ctx]: [chartjs.TooltipItem<'line'>]) {
const { dataset } = ctx;
return dataset.label;
},
label(ctx: chartjs.TooltipItem<'line'>) {
const { dataset, dataIndex } = ctx;
const item = dataset.data[dataIndex];
return `${dataset.label}: ${secondsToHHMMSS(typeof item === 'number' ? item : 0)}`;
},
},
};
const init = (canvas: HTMLCanvasElement, context: chartjs.Chart<'line'> | undefined, t: TFunction) =>
drawLineChart(canvas, context, [t('Avg_chat_duration'), t('Longest_chat_duration')], labels, [initialData, initialData.slice()], {
legends: true,
anim: true,
smallTicks: true,
displayColors: false,
tooltipCallbacks,
});
export type ChatDurationChartProps = {
departmentId: ILivechatDepartment['_id'];
dateRange: { start: string; end: string };
} & ComponentPropsWithoutRef<typeof Box>;
const ChatDurationChart = ({ departmentId, dateRange, ...props }: ChatDurationChartProps) => {
const { t } = useTranslation();
const canvas = useRef<HTMLCanvasElement | null>(null);
const getTimings = useEndpoint('GET', '/v1/livechat/analytics/dashboards/charts/timings');
const { isSuccess, data } = useQuery({
queryKey: omnichannelQueryKeys.analytics.timings(departmentId, dateRange),
queryFn: () => getTimings({ departmentId, ...dateRange }),
gcTime: 0,
});
const context = useChartContext({
canvas,
init,
t,
});
const updateChartData = useUpdateChartData({
context,
canvas,
init,
t,
});
const {
chatDuration: { avg, longest },
} = data ?? {
chatDuration: {
avg: 0,
longest: 0,
},
};
useEffect(() => {
if (!context) {
return;
}
if (!isSuccess) {
return;
}
const label = getMomentCurrentLabel();
updateChartData(label, [avg, longest]);
}, [context, avg, longest, isSuccess, t, updateChartData]);
return <Chart canvasRef={canvas} {...props} />;
};
export default ChatDurationChart;

View File

@@ -0,0 +1,85 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import type { Box } from '@rocket.chat/fuselage';
import { useEndpoint, type TranslationKey } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type * as chartjs from 'chart.js';
import type { TFunction } from 'i18next';
import type { ComponentPropsWithoutRef, MutableRefObject } from 'react';
import { useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import Chart from './Chart';
import { useChartContext } from './useChartContext';
import { useUpdateChartData } from './useUpdateChartData';
import { omnichannelQueryKeys } from '../../../../lib/queryKeys';
import { drawDoughnutChart } from '../../chartHandler';
const labels = ['Open', 'Queued', 'On_Hold_Chats', 'Closed'];
const initialData = {
open: 0,
queued: 0,
onhold: 0,
closed: 0,
};
const init = (canvas: HTMLCanvasElement, context: chartjs.Chart<'doughnut'> | undefined, t: TFunction) =>
drawDoughnutChart(
canvas,
t('Chats'),
context,
labels.map((l) => t(l as TranslationKey)),
Object.values(initialData),
);
export type ChatsChartProps = {
departmentId: ILivechatDepartment['_id'];
dateRange: { start: string; end: string };
} & ComponentPropsWithoutRef<typeof Box>;
const ChatsChart = ({ departmentId, dateRange, ...props }: ChatsChartProps) => {
const { t } = useTranslation();
const canvas: MutableRefObject<HTMLCanvasElement | null> = useRef(null);
const getChats = useEndpoint('GET', '/v1/livechat/analytics/dashboards/charts/chats');
const { isSuccess, data } = useQuery({
queryKey: omnichannelQueryKeys.analytics.chats(departmentId, dateRange),
queryFn: () => getChats({ departmentId, ...dateRange }),
gcTime: 0,
});
const context = useChartContext({
canvas,
init,
t,
});
const updateChartData = useUpdateChartData({
context,
canvas,
init,
t,
});
const { open, queued, closed, onhold } = data ?? initialData;
useEffect(() => {
if (!context) {
return;
}
if (!isSuccess) {
return;
}
updateChartData(t('Open'), [open]);
updateChartData(t('Closed'), [closed]);
updateChartData(t('On_Hold_Chats'), [onhold]);
updateChartData(t('Queued'), [queued]);
}, [context, closed, open, queued, onhold, isSuccess, t, updateChartData]);
return <Chart canvasRef={canvas} {...props} />;
};
export default ChatsChart;

View File

@@ -0,0 +1,72 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import type { Box } from '@rocket.chat/fuselage';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type * as chartjs from 'chart.js';
import type { TFunction } from 'i18next';
import type { ComponentPropsWithoutRef } from 'react';
import { useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import Chart from './Chart';
import { useChartContext } from './useChartContext';
import { useUpdateChartData } from './useUpdateChartData';
import { omnichannelQueryKeys } from '../../../../lib/queryKeys';
import { drawLineChart, resetChart } from '../../chartHandler';
const init = (canvas: HTMLCanvasElement, context: chartjs.Chart<'line'> | undefined, t: TFunction) =>
drawLineChart(canvas, context, [t('Open'), t('Closed'), t('On_Hold_Chats')], [], [[], []], {
legends: true,
anim: true,
smallTicks: true,
});
export type ChatsPerAgentChartProps = {
departmentId: ILivechatDepartment['_id'];
dateRange: { start: string; end: string };
} & ComponentPropsWithoutRef<typeof Box>;
const ChatsPerAgentChart = ({ departmentId, dateRange, ...props }: ChatsPerAgentChartProps) => {
const { t } = useTranslation();
const canvas = useRef<HTMLCanvasElement | null>(null);
const getChatsPerAgent = useEndpoint('GET', '/v1/livechat/analytics/dashboards/charts/chats-per-agent');
const { isSuccess, data } = useQuery({
queryKey: omnichannelQueryKeys.analytics.chatsPerAgent(departmentId, dateRange),
queryFn: () => getChatsPerAgent({ departmentId, ...dateRange }),
select: ({ success: _, ...data }) => Object.entries(data),
gcTime: 0,
});
const context = useChartContext({
canvas,
init,
t,
});
const updateChartData = useUpdateChartData({
context,
canvas,
init,
t,
});
useEffect(() => {
if (!context) return;
if (!isSuccess) {
return;
}
resetChart(context);
data.forEach(([name, value]) => {
updateChartData(name, [value.open, value.closed, value.onhold]);
});
}, [context, data, isSuccess, t, updateChartData]);
return <Chart canvasRef={canvas} {...props} />;
};
export default ChatsPerAgentChart;

View File

@@ -0,0 +1,74 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import type { Box } from '@rocket.chat/fuselage';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type * as chartjs from 'chart.js';
import type { TFunction } from 'i18next';
import type { ComponentPropsWithoutRef } from 'react';
import { useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import Chart from './Chart';
import { useChartContext } from './useChartContext';
import { useUpdateChartData } from './useUpdateChartData';
import { omnichannelQueryKeys } from '../../../../lib/queryKeys';
import { drawLineChart, resetChart } from '../../chartHandler';
const init = (canvas: HTMLCanvasElement, context: chartjs.Chart<'line'> | undefined, t: TFunction) =>
drawLineChart(canvas, context, [t('Open'), t('Closed')], [], [[], []], {
legends: true,
anim: true,
smallTicks: true,
});
export type ChatsPerDepartmentChartProps = {
departmentId: ILivechatDepartment['_id'];
dateRange: { start: string; end: string };
} & Omit<ComponentPropsWithoutRef<typeof Box>, 'data'>;
const ChatsPerDepartmentChart = ({ departmentId, dateRange, ...props }: ChatsPerDepartmentChartProps) => {
const { t } = useTranslation();
const canvas = useRef<HTMLCanvasElement | null>(null);
const getChatsPerDepartment = useEndpoint('GET', '/v1/livechat/analytics/dashboards/charts/chats-per-department');
const { isSuccess, data } = useQuery({
queryKey: omnichannelQueryKeys.analytics.chatsPerDepartment(departmentId, dateRange),
queryFn: () => getChatsPerDepartment({ departmentId, ...dateRange }),
select: ({ success: _, ...data }) => Object.entries(data),
gcTime: 0,
});
const context = useChartContext({
canvas,
init,
t,
});
const updateChartData = useUpdateChartData({
context,
canvas,
init,
t,
});
useEffect(() => {
if (!context) {
return;
}
if (!isSuccess) {
return;
}
resetChart(context);
data.forEach(([name, value]) => {
updateChartData(name, [value.open, value.closed]);
});
}, [context, data, isSuccess, t, updateChartData]);
return <Chart canvasRef={canvas} {...props} />;
};
export default ChatsPerDepartmentChart;

View File

@@ -0,0 +1,102 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import type { Box } from '@rocket.chat/fuselage';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type * as chartjs from 'chart.js';
import type { TFunction } from 'i18next';
import type { ComponentPropsWithoutRef } from 'react';
import { useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import Chart from './Chart';
import { getMomentChartLabelsAndData } from './getMomentChartLabelsAndData';
import { getMomentCurrentLabel } from './getMomentCurrentLabel';
import { useChartContext } from './useChartContext';
import { useUpdateChartData } from './useUpdateChartData';
import { secondsToHHMMSS } from '../../../../../lib/utils/secondsToHHMMSS';
import { omnichannelQueryKeys } from '../../../../lib/queryKeys';
import { drawLineChart } from '../../chartHandler';
const [labels, initialData] = getMomentChartLabelsAndData();
const tooltipCallbacks = {
callbacks: {
title([ctx]: [chartjs.TooltipItem<'line'>]) {
const { dataset } = ctx;
return dataset.label;
},
label(ctx: chartjs.TooltipItem<'line'>) {
const { dataset, dataIndex } = ctx;
const item = dataset.data[dataIndex];
return `${dataset.label}: ${secondsToHHMMSS(typeof item === 'number' ? item : 0)}`;
},
},
};
const init = (canvas: HTMLCanvasElement, context: chartjs.Chart<'line'> | undefined, t: TFunction) =>
drawLineChart(
canvas,
context,
[t('Avg_reaction_time'), t('Longest_reaction_time'), t('Avg_response_time'), t('Longest_response_time')],
labels,
[initialData, initialData.slice(), initialData.slice(), initialData.slice()],
{ legends: true, anim: true, smallTicks: true, displayColors: false, tooltipCallbacks },
);
export type ResponseTimesChartProps = {
departmentId: ILivechatDepartment['_id'];
dateRange: { start: string; end: string };
} & Omit<ComponentPropsWithoutRef<typeof Box>, 'data'>;
const ResponseTimesChart = ({ departmentId, dateRange, ...props }: ResponseTimesChartProps) => {
const { t } = useTranslation();
const canvas = useRef<HTMLCanvasElement | null>(null);
const getTimings = useEndpoint('GET', '/v1/livechat/analytics/dashboards/charts/timings');
const {
isSuccess,
data: { reaction: { avg: reactionAvg, longest: reactionLongest }, response: { avg: responseAvg, longest: responseLongest } } = {
reaction: {
avg: 0,
longest: 0,
},
response: {
avg: 0,
longest: 0,
},
},
} = useQuery({
queryKey: omnichannelQueryKeys.analytics.timings(departmentId, dateRange),
queryFn: () => getTimings({ departmentId, ...dateRange }),
gcTime: 0,
});
const context = useChartContext({
canvas,
init,
t,
});
const updateChartData = useUpdateChartData({
context,
canvas,
init,
t,
});
useEffect(() => {
if (!context) {
return;
}
if (!isSuccess) {
return;
}
const label = getMomentCurrentLabel();
updateChartData(label, [reactionAvg, reactionLongest, responseAvg, responseLongest]);
}, [context, reactionAvg, reactionLongest, responseAvg, responseLongest, isSuccess, t, updateChartData]);
return <Chart canvasRef={canvas} {...props} />;
};
export default ResponseTimesChart;

View File

@@ -0,0 +1,23 @@
import { getMomentChartLabelsAndData } from './getMomentChartLabelsAndData';
const expectedTimingLabels = [
'12AM-1AM',
'1AM-2AM',
'2AM-3AM',
'3AM-4AM',
'4AM-5AM',
'5AM-6AM',
'6AM-7AM',
'7AM-8AM',
'8AM-9AM',
'9AM-10AM',
'10AM-11AM',
'11AM-12PM',
];
describe('getMomentChartLabelsAndData', () => {
it('should create timing labels from midnight to noon', () => {
const [timingLabels] = getMomentChartLabelsAndData(12 * 60 * 60 * 1000);
expect(timingLabels).toStrictEqual(expectedTimingLabels);
});
});

View File

@@ -0,0 +1,17 @@
import { startOfDay, addHours, format } from 'date-fns';
export const getMomentChartLabelsAndData = (timestamp = Date.now()) => {
const timingLabels: string[] = [];
const initData: number[] = [];
const now = new Date(timestamp);
let m = startOfDay(timestamp);
while (m < now) {
const n = addHours(m, 1);
timingLabels.push(`${format(m, 'ha')}-${format(n, 'ha')}`);
initData.push(0);
m = n;
}
return [timingLabels, initData] as const;
};

View File

@@ -0,0 +1,8 @@
import { getMomentCurrentLabel } from './getMomentCurrentLabel';
describe('getMomentCurrentLabel', () => {
it('should create timing labels from midnight to noon', () => {
const label = getMomentCurrentLabel(12 * 60 * 60 * 1000);
expect(label).toStrictEqual('12PM-1PM');
});
});

View File

@@ -0,0 +1,8 @@
import { format, addHours } from 'date-fns';
export const getMomentCurrentLabel = (timestamp = Date.now()) => {
const m = new Date(timestamp);
const n = addHours(m, 1);
return `${format(m, 'ha')}-${format(n, 'ha')}`;
};

View File

@@ -0,0 +1,44 @@
import type { Chart, ChartType } from 'chart.js';
import type { TFunction } from 'i18next';
import type { MutableRefObject } from 'react';
import { useEffect, useState } from 'react';
type UseChartContextProps<TChart> = {
canvas: MutableRefObject<HTMLCanvasElement | null>;
init: (canvas: HTMLCanvasElement, context: TChart | undefined, t: TFunction) => Promise<TChart>;
t: TFunction;
};
export const useChartContext = <TChartType extends ChartType>({ canvas, init, t }: UseChartContextProps<Chart<TChartType>>) => {
const [context, setContext] = useState<Chart<TChartType>>();
useEffect(() => {
let chart: Chart<TChartType> | undefined;
let unmounted = false;
const initializeChart = async () => {
if (!canvas.current) {
return;
}
chart = await init(canvas.current, undefined, t);
if (unmounted) {
chart?.destroy();
return;
}
setContext(chart);
};
void initializeChart();
return () => {
unmounted = true;
chart?.destroy();
setContext(undefined);
};
}, [canvas, init, t]);
return context;
};

View File

@@ -0,0 +1,32 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import type * as chartjs from 'chart.js';
import type { TFunction } from 'i18next';
import type { MutableRefObject } from 'react';
import { updateChart } from '../../chartHandler';
type UseUpdateChartDataOptions<TChart> = {
context: TChart | undefined;
canvas: MutableRefObject<HTMLCanvasElement | null>;
init: (canvas: HTMLCanvasElement, context: TChart | undefined, t: TFunction) => Promise<TChart>;
t: TFunction;
};
export function useUpdateChartData<TChartType extends chartjs.ChartType>({
canvas: canvasRef,
context,
init,
t,
}: UseUpdateChartDataOptions<chartjs.Chart<TChartType>>) {
return useStableCallback(async (label: string, data: number[]) => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const chartContext = context ?? (await init(canvas, undefined, t));
await updateChart(chartContext, label, data);
});
}

View File

@@ -0,0 +1,20 @@
import type { StoryObj, Meta } from '@storybook/react';
import CounterContainer from './CounterContainer';
export default {
component: CounterContainer,
} satisfies Meta<typeof CounterContainer>;
export const Default: StoryObj<typeof CounterContainer> = {
name: 'CounterContainer',
args: {
totals: [
{ title: 'Total_conversations', value: 10 },
{ title: 'Open_conversations', value: 10 },
{ title: 'Total_messages', value: 10 },
{ title: 'Total_visitors', value: 0 },
],
},
};

View File

@@ -0,0 +1,29 @@
import type { Box } from '@rocket.chat/fuselage';
import { Skeleton } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import type { ComponentPropsWithoutRef } from 'react';
import { useTranslation } from 'react-i18next';
import CounterItem from './CounterItem';
import CounterRow from './CounterRow';
export type CounterContainerProps = {
totals: {
title: string;
value: number | string;
}[];
} & Omit<ComponentPropsWithoutRef<typeof Box>, 'data'>;
const CounterContainer = ({ totals, ...props }: CounterContainerProps) => {
const { t } = useTranslation();
return (
<CounterRow {...props}>
{totals.map(({ title, value }, i) => (
<CounterItem key={i} title={title ? t(title as TranslationKey) : <Skeleton width='x60' />} count={value} />
))}
</CounterRow>
);
};
export default CounterContainer;

View File

@@ -0,0 +1,23 @@
import { Box } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
const CounterItem = ({
title = '',
count = '-',
...props
}: {
title: ReactNode;
count: string | number;
flexShrink?: number;
paddingBlock?: number;
flexBasis?: string;
}) => (
<Box display='flex' flexDirection='column' justifyContent='space-between' alignItems='center' flexGrow={1} {...props}>
<Box fontScale='h4' textTransform='uppercase' color='hint' textAlign='center' paddingInline={8}>
{title}
</Box>
<Box fontScale='h2'>{count}</Box>
</Box>
);
export default CounterItem;

View File

@@ -0,0 +1,36 @@
import { Box, Divider } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import { Fragment } from 'react';
import flattenChildren from 'react-keyed-flatten-children';
export type CounterRowProps = {
children?: ReactNode[];
} & ComponentPropsWithoutRef<typeof Box>;
const CounterRow = ({ children, ...props }: CounterRowProps) => (
<Box
paddingBlock={28}
paddingInline={20}
display='flex'
flexDirection='row'
justifyContent='space-around'
alignItems='center'
flexGrow={1}
{...props}
>
{children &&
flattenChildren(children).reduce(
(acc: ReactNode[], child, i) =>
children.length - 1 !== i
? [
...acc,
<Fragment key={i}>{child}</Fragment>,
<Divider key={(i + 1) * children.length} width='x2' margin='none' alignSelf='stretch' />,
]
: [...acc, child],
[],
)}
</Box>
);
export default CounterRow;

View File

@@ -0,0 +1,35 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import type { Box } from '@rocket.chat/fuselage';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { ComponentPropsWithoutRef } from 'react';
import { omnichannelQueryKeys } from '../../../../lib/queryKeys';
import CounterContainer from '../counter/CounterContainer';
const overviewInitalValue = {
title: '',
value: '-',
};
type AgentsOverviewChartsProps = {
departmentId: ILivechatDepartment['_id'];
dateRange: { start: string; end: string };
} & ComponentPropsWithoutRef<typeof Box>;
const initialData = [overviewInitalValue, overviewInitalValue, overviewInitalValue];
const AgentsOverview = ({ departmentId, dateRange, ...props }: AgentsOverviewChartsProps) => {
const getAgentsProductivityTotals = useEndpoint('GET', '/v1/livechat/analytics/dashboards/agents-productivity-totalizers');
const { data = initialData } = useQuery({
queryKey: omnichannelQueryKeys.analytics.agentsProductivityTotals(departmentId, dateRange),
queryFn: async () => {
const { totalizers } = await getAgentsProductivityTotals({ departmentId, ...dateRange });
return totalizers;
},
});
return <CounterContainer totals={data} {...props} />;
};
export default AgentsOverview;

View File

@@ -0,0 +1,34 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import type { Box } from '@rocket.chat/fuselage';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { ComponentPropsWithoutRef } from 'react';
import { omnichannelQueryKeys } from '../../../../lib/queryKeys';
import CounterContainer from '../counter/CounterContainer';
const initialData = [
{ title: '', value: 0 },
{ title: '', value: '0%' },
{ title: '', value: '00:00:00' },
];
export type ChatsOverviewProps = {
departmentId: ILivechatDepartment['_id'];
dateRange: { start: string; end: string };
} & ComponentPropsWithoutRef<typeof Box>;
const ChatsOverview = ({ departmentId, dateRange, ...props }: ChatsOverviewProps) => {
const getChatsTotalizers = useEndpoint('GET', '/v1/livechat/analytics/dashboards/chats-totalizers');
const { data = initialData } = useQuery({
queryKey: omnichannelQueryKeys.analytics.chatsTotals(departmentId, dateRange),
queryFn: async () => {
const { totalizers } = await getChatsTotalizers({ departmentId, ...dateRange });
return totalizers;
},
});
return <CounterContainer totals={data} {...props} />;
};
export default ChatsOverview;

View File

@@ -0,0 +1,35 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import type { Box } from '@rocket.chat/fuselage';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { ComponentPropsWithoutRef } from 'react';
import { omnichannelQueryKeys } from '../../../../lib/queryKeys';
import CounterContainer from '../counter/CounterContainer';
const overviewInitalValue = {
title: '',
value: 0,
};
const initialData = [overviewInitalValue, overviewInitalValue, overviewInitalValue, overviewInitalValue];
export type ConversationOverviewProps = {
departmentId: ILivechatDepartment['_id'];
dateRange: { start: string; end: string };
} & ComponentPropsWithoutRef<typeof Box>;
const ConversationOverview = ({ departmentId, dateRange, ...props }: ConversationOverviewProps) => {
const getConversationTotals = useEndpoint('GET', '/v1/livechat/analytics/dashboards/conversation-totalizers');
const { data = initialData } = useQuery({
queryKey: omnichannelQueryKeys.analytics.conversationTotals(departmentId, dateRange),
queryFn: async () => {
const { totalizers } = await getConversationTotals({ departmentId, ...dateRange });
return totalizers;
},
});
return <CounterContainer totals={data} {...props} />;
};
export default ConversationOverview;

View File

@@ -0,0 +1,32 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import type { Box } from '@rocket.chat/fuselage';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { ComponentPropsWithoutRef } from 'react';
import { omnichannelQueryKeys } from '../../../../lib/queryKeys';
import CounterContainer from '../counter/CounterContainer';
const defaultValue = { title: '', value: '00:00:00' };
const initialData = [defaultValue, defaultValue, defaultValue, defaultValue];
export type ProductivityOverviewProps = {
departmentId: ILivechatDepartment['_id'];
dateRange: { start: string; end: string };
} & ComponentPropsWithoutRef<typeof Box>;
const ProductivityOverview = ({ departmentId, dateRange, ...props }: ProductivityOverviewProps) => {
const getProductivityTotals = useEndpoint('GET', '/v1/livechat/analytics/dashboards/productivity-totalizers');
const { data = initialData } = useQuery({
queryKey: omnichannelQueryKeys.analytics.productivityTotals(departmentId, dateRange),
queryFn: async () => {
const { totalizers } = await getProductivityTotals({ departmentId, ...dateRange });
return totalizers;
},
});
return <CounterContainer totals={data} {...props} />;
};
export default ProductivityOverview;