feat: Display how many conversations are considered for the metric calculation (#4273)
* feat: Display how many conversations are considered for the metric calculation
This commit is contained in:
parent
ba0188aefc
commit
0ba6e772a4
11 changed files with 379 additions and 47 deletions
|
@ -18,10 +18,16 @@ class V2::ReportBuilder
|
|||
|
||||
# For backward compatible with old report
|
||||
def build
|
||||
if %w[avg_first_response_time avg_resolution_time].include?(params[:metric])
|
||||
timeseries.each_with_object([]) do |p, arr|
|
||||
arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i, count: @grouped_values.count[p[0]] }
|
||||
end
|
||||
else
|
||||
timeseries.each_with_object([]) do |p, arr|
|
||||
arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def summary
|
||||
{
|
||||
|
@ -68,7 +74,7 @@ class V2::ReportBuilder
|
|||
end
|
||||
|
||||
def get_grouped_values(object_scope)
|
||||
object_scope.group_by_period(
|
||||
@grouped_values = object_scope.group_by_period(
|
||||
params[:group_by] || DEFAULT_GROUP_BY,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
|
|
|
@ -18,6 +18,13 @@
|
|||
font-size: $font-size-small;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $color-heading;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
color: var(--b-400);
|
||||
margin-left: var(--space-micro);
|
||||
}
|
||||
|
||||
.metric-wrap {
|
||||
|
|
|
@ -5,7 +5,14 @@
|
|||
@click="onClick(index)"
|
||||
>
|
||||
<h3 class="heading">
|
||||
{{ heading }}
|
||||
<span>{{ heading }}</span>
|
||||
<fluent-icon
|
||||
v-if="infoText"
|
||||
v-tooltip="infoText"
|
||||
size="14"
|
||||
icon="info"
|
||||
class="info-icon"
|
||||
/>
|
||||
</h3>
|
||||
<div class="metric-wrap">
|
||||
<h4 class="metric">
|
||||
|
@ -22,6 +29,7 @@
|
|||
export default {
|
||||
props: {
|
||||
heading: { type: String, default: '' },
|
||||
infoText: { type: String, default: '' },
|
||||
point: { type: [Number, String], default: '' },
|
||||
trend: { type: Number, default: null },
|
||||
index: { type: Number, default: null },
|
||||
|
|
|
@ -3,7 +3,7 @@ import { Bar } from 'vue-chartjs';
|
|||
const fontFamily =
|
||||
'-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
|
||||
|
||||
const chartOptions = {
|
||||
const defaultChartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
legend: {
|
||||
|
@ -11,10 +11,14 @@ const chartOptions = {
|
|||
fontFamily,
|
||||
},
|
||||
},
|
||||
datasets: {
|
||||
bar: {
|
||||
barPercentage: 1.0,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
xAxes: [
|
||||
{
|
||||
barPercentage: 1.1,
|
||||
ticks: {
|
||||
fontFamily,
|
||||
},
|
||||
|
@ -39,8 +43,20 @@ const chartOptions = {
|
|||
|
||||
export default {
|
||||
extends: Bar,
|
||||
props: ['collection'],
|
||||
props: {
|
||||
collection: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
chartOptions: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.renderChart(this.collection, chartOptions);
|
||||
this.renderChart(this.collection, {
|
||||
...defaultChartOptions,
|
||||
...this.chartOptions,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
@ -18,12 +18,14 @@
|
|||
"DESC": "( Total )"
|
||||
},
|
||||
"FIRST_RESPONSE_TIME": {
|
||||
"NAME": "First response time",
|
||||
"DESC": "( Avg )"
|
||||
"NAME": "First Response Time",
|
||||
"DESC": "( Avg )",
|
||||
"INFO_TEXT": "Total number of conversations used for computation:"
|
||||
},
|
||||
"RESOLUTION_TIME": {
|
||||
"NAME": "Resolution Time",
|
||||
"DESC": "( Avg )"
|
||||
"DESC": "( Avg )",
|
||||
"INFO_TEXT": "Total number of conversations used for computation:"
|
||||
},
|
||||
"RESOLUTION_COUNT": {
|
||||
"NAME": "Resolution Count",
|
||||
|
@ -98,12 +100,14 @@
|
|||
"DESC": "( Total )"
|
||||
},
|
||||
"FIRST_RESPONSE_TIME": {
|
||||
"NAME": "First response time",
|
||||
"DESC": "( Avg )"
|
||||
"NAME": "First Response Time",
|
||||
"DESC": "( Avg )",
|
||||
"INFO_TEXT": "Total number of conversations used for computation:"
|
||||
},
|
||||
"RESOLUTION_TIME": {
|
||||
"NAME": "Resolution Time",
|
||||
"DESC": "( Avg )"
|
||||
"DESC": "( Avg )",
|
||||
"INFO_TEXT": "Total number of conversations used for computation:"
|
||||
},
|
||||
"RESOLUTION_COUNT": {
|
||||
"NAME": "Resolution Count",
|
||||
|
@ -161,12 +165,14 @@
|
|||
"DESC": "( Total )"
|
||||
},
|
||||
"FIRST_RESPONSE_TIME": {
|
||||
"NAME": "First response time",
|
||||
"DESC": "( Avg )"
|
||||
"NAME": "First Response Time",
|
||||
"DESC": "( Avg )",
|
||||
"INFO_TEXT": "Total number of conversations used for computation:"
|
||||
},
|
||||
"RESOLUTION_TIME": {
|
||||
"NAME": "Resolution Time",
|
||||
"DESC": "( Avg )"
|
||||
"DESC": "( Avg )",
|
||||
"INFO_TEXT": "Total number of conversations used for computation:"
|
||||
},
|
||||
"RESOLUTION_COUNT": {
|
||||
"NAME": "Resolution Count",
|
||||
|
@ -224,12 +230,14 @@
|
|||
"DESC": "( Total )"
|
||||
},
|
||||
"FIRST_RESPONSE_TIME": {
|
||||
"NAME": "First response time",
|
||||
"DESC": "( Avg )"
|
||||
"NAME": "First Response Time",
|
||||
"DESC": "( Avg )",
|
||||
"INFO_TEXT": "Total number of conversations used for computation:"
|
||||
},
|
||||
"RESOLUTION_TIME": {
|
||||
"NAME": "Resolution Time",
|
||||
"DESC": "( Avg )"
|
||||
"DESC": "( Avg )",
|
||||
"INFO_TEXT": "Total number of conversations used for computation:"
|
||||
},
|
||||
"RESOLUTION_COUNT": {
|
||||
"NAME": "Resolution Count",
|
||||
|
@ -287,12 +295,14 @@
|
|||
"DESC": "( Total )"
|
||||
},
|
||||
"FIRST_RESPONSE_TIME": {
|
||||
"NAME": "First response time",
|
||||
"DESC": "( Avg )"
|
||||
"NAME": "First Response Time",
|
||||
"DESC": "( Avg )",
|
||||
"INFO_TEXT": "Total number of conversations used for computation:"
|
||||
},
|
||||
"RESOLUTION_TIME": {
|
||||
"NAME": "Resolution Time",
|
||||
"DESC": "( Avg )"
|
||||
"DESC": "( Avg )",
|
||||
"INFO_TEXT": "Total number of conversations used for computation:"
|
||||
},
|
||||
"RESOLUTION_COUNT": {
|
||||
"NAME": "Resolution Count",
|
||||
|
|
|
@ -5,6 +5,7 @@ export default {
|
|||
computed: {
|
||||
...mapGetters({
|
||||
accountSummary: 'getAccountSummary',
|
||||
accountReport: 'getAccountReports',
|
||||
}),
|
||||
calculateTrend() {
|
||||
return metric_key => {
|
||||
|
@ -19,15 +20,32 @@ export default {
|
|||
},
|
||||
displayMetric() {
|
||||
return metric_key => {
|
||||
if (
|
||||
['avg_first_response_time', 'avg_resolution_time'].includes(
|
||||
metric_key
|
||||
)
|
||||
) {
|
||||
if (this.isAverageMetricType(metric_key)) {
|
||||
return formatTime(this.accountSummary[metric_key]);
|
||||
}
|
||||
return this.accountSummary[metric_key];
|
||||
};
|
||||
},
|
||||
displayInfoText() {
|
||||
return metric_key => {
|
||||
if (this.metrics[this.currentSelection].KEY !== metric_key) {
|
||||
return '';
|
||||
}
|
||||
if (this.isAverageMetricType(metric_key)) {
|
||||
const total = this.accountReport.data
|
||||
.map(item => item.count)
|
||||
.reduce((prev, curr) => prev + curr, 0);
|
||||
return `${this.metrics[this.currentSelection].INFO_TEXT} ${total}`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
},
|
||||
isAverageMetricType() {
|
||||
return metric_key => {
|
||||
return ['avg_first_response_time', 'avg_resolution_time'].includes(
|
||||
metric_key
|
||||
);
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -11,6 +11,7 @@ describe('reportMixin', () => {
|
|||
beforeEach(() => {
|
||||
getters = {
|
||||
getAccountSummary: () => reportFixtures.summary,
|
||||
getAccountReports: () => reportFixtures.report,
|
||||
};
|
||||
store = new Vuex.Store({ getters });
|
||||
});
|
||||
|
@ -38,4 +39,67 @@ describe('reportMixin', () => {
|
|||
expect(wrapper.vm.calculateTrend('conversations_count')).toEqual(25);
|
||||
expect(wrapper.vm.calculateTrend('resolutions_count')).toEqual(0);
|
||||
});
|
||||
|
||||
it('display info text', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [reportMixin],
|
||||
data() {
|
||||
return {
|
||||
currentSelection: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
metrics() {
|
||||
return [
|
||||
{
|
||||
DESC: '( Avg )',
|
||||
INFO_TEXT: 'Total number of conversations used for computation:',
|
||||
KEY: 'avg_first_response_time',
|
||||
NAME: 'First Response Time',
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
expect(wrapper.vm.displayInfoText('avg_first_response_time')).toEqual(
|
||||
'Total number of conversations used for computation: 4'
|
||||
);
|
||||
});
|
||||
|
||||
it('do not display info text', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [reportMixin],
|
||||
data() {
|
||||
return {
|
||||
currentSelection: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
metrics() {
|
||||
return [
|
||||
{
|
||||
DESC: '( Total )',
|
||||
INFO_TEXT: '',
|
||||
KEY: 'conversation_count',
|
||||
NAME: 'Conversations',
|
||||
},
|
||||
{
|
||||
DESC: '( Avg )',
|
||||
INFO_TEXT: 'Total number of conversations used for computation:',
|
||||
KEY: 'avg_first_response_time',
|
||||
NAME: 'First Response Time',
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
expect(wrapper.vm.displayInfoText('conversation_count')).toEqual('');
|
||||
expect(wrapper.vm.displayInfoText('incoming_messages_count')).toEqual('');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -15,4 +15,15 @@ export default {
|
|||
},
|
||||
resolutions_count: 3,
|
||||
},
|
||||
report: {
|
||||
data: [
|
||||
{ value: '0.00', timestamp: 1647541800, count: 0 },
|
||||
{ value: '0.00', timestamp: 1647628200, count: 0 },
|
||||
{ value: '0.00', timestamp: 1647714600, count: 0 },
|
||||
{ value: '0.00', timestamp: 1647801000, count: 0 },
|
||||
{ value: '0.01', timestamp: 1647887400, count: 4 },
|
||||
{ value: '0.00', timestamp: 1647973800, count: 0 },
|
||||
{ value: '0.00', timestamp: 1648060200, count: 0 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
|
@ -22,6 +22,7 @@
|
|||
:key="metric.NAME"
|
||||
:desc="metric.DESC"
|
||||
:heading="metric.NAME"
|
||||
:info-text="displayInfoText(metric.KEY)"
|
||||
:index="index"
|
||||
:on-click="changeSelection"
|
||||
:point="displayMetric(metric.KEY)"
|
||||
|
@ -35,7 +36,11 @@
|
|||
:message="$t('REPORT.LOADING_CHART')"
|
||||
/>
|
||||
<div v-else class="chart-container">
|
||||
<woot-bar v-if="accountReport.data.length" :collection="collection" />
|
||||
<woot-bar
|
||||
v-if="accountReport.data.length"
|
||||
:collection="collection"
|
||||
:chart-options="chartOptions"
|
||||
/>
|
||||
<span v-else class="empty-state">
|
||||
{{ $t('REPORT.NO_ENOUGH_DATA') }}
|
||||
</span>
|
||||
|
@ -49,7 +54,7 @@ import { mapGetters } from 'vuex';
|
|||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import format from 'date-fns/format';
|
||||
import ReportFilterSelector from './components/FilterSelector';
|
||||
import { GROUP_BY_FILTER } from './constants';
|
||||
import { GROUP_BY_FILTER, METRIC_CHART } from './constants';
|
||||
import reportMixin from '../../../../mixins/reportMixin';
|
||||
|
||||
const REPORTS_KEYS = {
|
||||
|
@ -108,16 +113,38 @@ export default {
|
|||
}
|
||||
return format(fromUnixTime(element.timestamp), 'dd-MMM-yyyy');
|
||||
});
|
||||
const data = this.accountReport.data.map(element => element.value);
|
||||
|
||||
const datasets = METRIC_CHART[
|
||||
this.metrics[this.currentSelection].KEY
|
||||
].datasets.map(dataset => {
|
||||
switch (dataset.type) {
|
||||
case 'bar':
|
||||
return {
|
||||
...dataset,
|
||||
yAxisID: 'y-left',
|
||||
label: this.metrics[this.currentSelection].NAME,
|
||||
data: this.accountReport.data.map(element => element.value),
|
||||
};
|
||||
case 'line':
|
||||
return {
|
||||
...dataset,
|
||||
yAxisID: 'y-right',
|
||||
label: this.metrics[0].NAME,
|
||||
data: this.accountReport.data.map(element => element.count),
|
||||
};
|
||||
default:
|
||||
return dataset;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: this.metrics[this.currentSelection].NAME,
|
||||
backgroundColor: '#1f93ff',
|
||||
data,
|
||||
datasets,
|
||||
};
|
||||
},
|
||||
],
|
||||
chartOptions() {
|
||||
return {
|
||||
scales: METRIC_CHART[this.metrics[this.currentSelection].KEY].scales,
|
||||
};
|
||||
},
|
||||
metrics() {
|
||||
|
@ -133,6 +160,7 @@ export default {
|
|||
NAME: this.$t(`REPORT.METRICS.${key}.NAME`),
|
||||
KEY: REPORTS_KEYS[key],
|
||||
DESC: this.$t(`REPORT.METRICS.${key}.DESC`),
|
||||
INFO_TEXT: this.$t(`REPORT.METRICS.${key}.INFO_TEXT`),
|
||||
}));
|
||||
},
|
||||
},
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
:key="metric.NAME"
|
||||
:desc="metric.DESC"
|
||||
:heading="metric.NAME"
|
||||
:info-text="displayInfoText(metric.KEY)"
|
||||
:index="index"
|
||||
:on-click="changeSelection"
|
||||
:point="displayMetric(metric.KEY)"
|
||||
|
@ -41,6 +42,7 @@
|
|||
<woot-bar
|
||||
v-if="accountReport.data.length && filterItemsList.length"
|
||||
:collection="collection"
|
||||
:chart-options="chartOptions"
|
||||
/>
|
||||
<span v-else class="empty-state">
|
||||
{{ $t('REPORT.NO_ENOUGH_DATA') }}
|
||||
|
@ -55,7 +57,7 @@
|
|||
import ReportFilters from './ReportFilters';
|
||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import format from 'date-fns/format';
|
||||
import { GROUP_BY_FILTER } from '../constants';
|
||||
import { GROUP_BY_FILTER, METRIC_CHART } from '../constants';
|
||||
import reportMixin from '../../../../../mixins/reportMixin';
|
||||
|
||||
const REPORTS_KEYS = {
|
||||
|
@ -137,16 +139,38 @@ export default {
|
|||
}
|
||||
return format(fromUnixTime(element.timestamp), 'dd-MMM-yyyy');
|
||||
});
|
||||
const data = this.accountReport.data.map(element => element.value);
|
||||
|
||||
const datasets = METRIC_CHART[
|
||||
this.metrics[this.currentSelection].KEY
|
||||
].datasets.map(dataset => {
|
||||
switch (dataset.type) {
|
||||
case 'bar':
|
||||
return {
|
||||
...dataset,
|
||||
yAxisID: 'y-left',
|
||||
label: this.metrics[this.currentSelection].NAME,
|
||||
data: this.accountReport.data.map(element => element.value),
|
||||
};
|
||||
case 'line':
|
||||
return {
|
||||
...dataset,
|
||||
yAxisID: 'y-right',
|
||||
label: this.metrics[0].NAME,
|
||||
data: this.accountReport.data.map(element => element.count),
|
||||
};
|
||||
default:
|
||||
return dataset;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: this.metrics[this.currentSelection].NAME,
|
||||
backgroundColor: '#1f93ff',
|
||||
data,
|
||||
datasets,
|
||||
};
|
||||
},
|
||||
],
|
||||
chartOptions() {
|
||||
return {
|
||||
scales: METRIC_CHART[this.metrics[this.currentSelection].KEY].scales,
|
||||
};
|
||||
},
|
||||
metrics() {
|
||||
|
@ -168,6 +192,7 @@ export default {
|
|||
NAME: this.$t(`REPORT.METRICS.${key}.NAME`),
|
||||
KEY: REPORTS_KEYS[key],
|
||||
DESC: this.$t(`REPORT.METRICS.${key}.DESC`),
|
||||
INFO_TEXT: this.$t(`REPORT.METRICS.${key}.INFO_TEXT`),
|
||||
}));
|
||||
},
|
||||
},
|
||||
|
|
|
@ -4,3 +4,142 @@ export const GROUP_BY_FILTER = {
|
|||
3: { id: 3, period: 'month' },
|
||||
4: { id: 4, period: 'year' },
|
||||
};
|
||||
|
||||
export const CHART_FONT_FAMILY =
|
||||
'-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
|
||||
|
||||
export const DEFAULT_LINE_CHART = {
|
||||
type: 'line',
|
||||
fill: false,
|
||||
borderColor: '#779BBB',
|
||||
pointBackgroundColor: '#779BBB',
|
||||
};
|
||||
|
||||
export const DEFAULT_BAR_CHART = {
|
||||
type: 'bar',
|
||||
backgroundColor: 'rgb(31, 147, 255, 0.5)',
|
||||
};
|
||||
|
||||
export const DEFAULT_CHART = {
|
||||
datasets: [DEFAULT_BAR_CHART],
|
||||
scales: {
|
||||
xAxes: [
|
||||
{
|
||||
ticks: {
|
||||
fontFamily: CHART_FONT_FAMILY,
|
||||
},
|
||||
gridLines: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
yAxes: [
|
||||
{
|
||||
id: 'y-left',
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
ticks: {
|
||||
fontFamily: CHART_FONT_FAMILY,
|
||||
beginAtZero: true,
|
||||
stepSize: 1,
|
||||
},
|
||||
gridLines: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const METRIC_CHART = {
|
||||
conversations_count: DEFAULT_CHART,
|
||||
incoming_messages_count: DEFAULT_CHART,
|
||||
outgoing_messages_count: DEFAULT_CHART,
|
||||
avg_first_response_time: {
|
||||
datasets: [DEFAULT_BAR_CHART, DEFAULT_LINE_CHART],
|
||||
scales: {
|
||||
xAxes: [
|
||||
{
|
||||
ticks: {
|
||||
fontFamily: CHART_FONT_FAMILY,
|
||||
},
|
||||
gridLines: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
yAxes: [
|
||||
{
|
||||
id: 'y-left',
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
ticks: {
|
||||
fontFamily: CHART_FONT_FAMILY,
|
||||
beginAtZero: true,
|
||||
precision: 2,
|
||||
},
|
||||
gridLines: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'y-right',
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
ticks: {
|
||||
fontFamily: CHART_FONT_FAMILY,
|
||||
beginAtZero: true,
|
||||
stepSize: 1,
|
||||
},
|
||||
gridLines: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
avg_resolution_time: {
|
||||
datasets: [DEFAULT_BAR_CHART, DEFAULT_LINE_CHART],
|
||||
scales: {
|
||||
xAxes: [
|
||||
{
|
||||
ticks: {
|
||||
fontFamily: CHART_FONT_FAMILY,
|
||||
},
|
||||
gridLines: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
yAxes: [
|
||||
{
|
||||
id: 'y-left',
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
ticks: {
|
||||
fontFamily: CHART_FONT_FAMILY,
|
||||
beginAtZero: true,
|
||||
precision: 2,
|
||||
},
|
||||
gridLines: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'y-right',
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
ticks: {
|
||||
fontFamily: CHART_FONT_FAMILY,
|
||||
beginAtZero: true,
|
||||
stepSize: 1,
|
||||
},
|
||||
gridLines: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
resolutions_count: DEFAULT_CHART,
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue