[NEW][ENTERPRISE] Download engagement data (#17920)

pull/17949/head
Guilherme Gazzo 6 years ago committed by GitHub
parent cf58d739fd
commit b8ccd82ba4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      client/components/basic/Buttons/ActionButton.js
  2. 9
      client/lib/saveFile.js
  3. 11
      ee/app/engagement-dashboard/client/components/ChannelsTab/TableSection.js
  4. 20
      ee/app/engagement-dashboard/client/components/EngagementDashboardPage.js
  5. 12
      ee/app/engagement-dashboard/client/components/MessagesTab/MessagesPerChannelSection.js
  6. 11
      ee/app/engagement-dashboard/client/components/MessagesTab/MessagesSentSection.js
  7. 16
      ee/app/engagement-dashboard/client/components/Section.js
  8. 22
      ee/app/engagement-dashboard/client/components/UsersTab/ActiveUsersSection.js
  9. 160
      ee/app/engagement-dashboard/client/components/UsersTab/BusiestChatTimesSection.js
  10. 11
      ee/app/engagement-dashboard/client/components/UsersTab/NewUsersSection.js
  11. 163
      ee/app/engagement-dashboard/client/components/UsersTab/UsersByTimeOfTheDaySection.js
  12. 20
      ee/app/engagement-dashboard/client/components/UsersTab/index.js
  13. 1
      ee/app/engagement-dashboard/server/index.js

@ -0,0 +1,4 @@
import React from 'react';
import { Button, Icon } from '@rocket.chat/fuselage';
// TODO fuselage
export const ActionButton = ({ icon, ...props }) => <Button {...props} square ghost small><Icon name={icon} size='x20'/></Button>;

@ -0,0 +1,9 @@
export const saveFile = (content, name = 'download') => {
const blob = new Blob([content], { type: 'text/plain' });
const anchor = document.createElement('a');
anchor.download = name;
anchor.href = (window.webkitURL || window.URL).createObjectURL(blob);
anchor.dataset.downloadurl = ['text/plain', anchor.download, anchor.href].join(':');
anchor.click();
};

@ -6,6 +6,11 @@ import { useTranslation } from '../../../../../../client/contexts/TranslationCon
import { useEndpointData } from '../../../../../../client/hooks/useEndpointData';
import Growth from '../../../../../../client/components/data/Growth';
import { Section } from '../Section';
import { ActionButton } from '../../../../../../client/components/basic/Buttons/ActionButton';
import { saveFile } from '../../../../../../client/lib/saveFile';
const convertDataToCSV = (data) => `// type, name, messagesCount, updatedAt, createdAt
${ data.map(({ createdAt, messagesCount, name, t, updatedAt }) => `${ t }, ${ name }, ${ messagesCount }, ${ updatedAt }, ${ createdAt }`).join('\n') }`;
export function TableSection() {
const t = useTranslation();
@ -73,7 +78,11 @@ export function TableSection() {
}));
}, [data]);
return <Section filter={<Select options={periodOptions} value={periodId} onChange={handlePeriodChange} />}>
const downloadData = () => {
saveFile(convertDataToCSV(channels), `Channels_start_${ params.start }_end_${ params.end }.csv`);
};
return <Section filter={<><Select options={periodOptions} value={periodId} onChange={handlePeriodChange} /><ActionButton mis='x16' disabled={!channels} onClick={downloadData} aria-label={t('Download_Info')} icon='download'/></>}>
<Box>
{channels && !channels.length && <Tile fontScale='p1' color='info' style={{ textAlign: 'center' }}>
{t('No_data_found')}

@ -1,4 +1,4 @@
import { Box, Margins, Tabs } from '@rocket.chat/fuselage';
import { Box, Tabs } from '@rocket.chat/fuselage';
import React, { useMemo } from 'react';
import { useTranslation } from '../../../../../client/contexts/TranslationContext';
@ -7,8 +7,6 @@ import { UsersTab } from './UsersTab';
import { MessagesTab } from './MessagesTab';
import { ChannelsTab } from './ChannelsTab';
const style = { padding: 0 };
export function EngagementDashboardPage({
tab = 'users',
onSelectTab,
@ -24,14 +22,12 @@ export function EngagementDashboardPage({
<Tabs.Item selected={tab === 'messages'} onClick={handleTabClick('messages')}>{t('Messages')}</Tabs.Item>
<Tabs.Item selected={tab === 'channels'} onClick={handleTabClick('channels')}>{t('Channels')}</Tabs.Item>
</Tabs>
<Page.Content style={style}>
<Margins all='x24'>
<Box>
{(tab === 'users' && <UsersTab />)
|| (tab === 'messages' && <MessagesTab />)
|| (tab === 'channels' && <ChannelsTab />)}
</Box>
</Margins>
</Page.Content>
<Page.ScrollableContent padding={0}>
<Box m='x24'>
{(tab === 'users' && <UsersTab />)
|| (tab === 'messages' && <MessagesTab />)
|| (tab === 'channels' && <ChannelsTab />)}
</Box>
</Page.ScrollableContent>
</Page>;
}

@ -7,6 +7,11 @@ import { useTranslation } from '../../../../../../client/contexts/TranslationCon
import { useEndpointData } from '../../../../../../client/hooks/useEndpointData';
import { LegendSymbol } from '../data/LegendSymbol';
import { Section } from '../Section';
import { ActionButton } from '../../../../../../client/components/basic/Buttons/ActionButton';
import { saveFile } from '../../../../../../client/lib/saveFile';
const convertDataToCSV = (data) => `// type, messagesSent
${ data.map(({ t, messages }) => `${ t }, ${ messages }`).join('\n') }`;
export function MessagesPerChannelSection() {
const t = useTranslation();
@ -64,9 +69,14 @@ export function MessagesPerChannelSection() {
return [pie, table];
}, [period, pieData, tableData]);
const downloadData = () => {
saveFile(convertDataToCSV(pieData.origins), `MessagesPerChannelSection_start_${ params.start }_end_${ params.end }.csv`);
};
return <Section
title={t('Where_are_the_messages_being_sent?')}
filter={<Select options={periodOptions} value={periodId} onChange={handlePeriodChange} />}
filter={<><Select options={periodOptions} value={periodId} onChange={handlePeriodChange} /><ActionButton mis='x16' disabled={!pieData} onClick={downloadData} aria-label={t('Download_Info')} icon='download'/></>}
>
<Flex.Container>
<Margins inline='neg-x12'>

@ -7,6 +7,11 @@ import { useTranslation } from '../../../../../../client/contexts/TranslationCon
import { useEndpointData } from '../../../../../../client/hooks/useEndpointData';
import CounterSet from '../../../../../../client/components/data/CounterSet';
import { Section } from '../Section';
import { ActionButton } from '../../../../../../client/components/basic/Buttons/ActionButton';
import { saveFile } from '../../../../../../client/lib/saveFile';
const convertDataToCSV = (data) => `// date, newMessages
${ data.map(({ date, newMessages }) => `${ date }, ${ newMessages }`).join('\n') }`;
export function MessagesSentSection() {
const t = useTranslation();
@ -81,9 +86,13 @@ export function MessagesSentSection() {
];
}, [data, period]);
const downloadData = () => {
saveFile(convertDataToCSV(values), `MessagesSentSection_start_${ params.start }_end_${ params.end }.csv`);
};
return <Section
title={t('Messages_sent')}
filter={<Select options={periodOptions} value={periodId} onChange={handlePeriodChange} />}
filter={<><Select options={periodOptions} value={periodId} onChange={handlePeriodChange} /><ActionButton mis='x16' disabled={!data} onClick={downloadData} aria-label={t('Download_Info')} icon='download'/></>}
>
<CounterSet
counters={[

@ -8,16 +8,14 @@ export function Section({
}) {
return <Box>
<Margins block='x24'>
<Flex.Container alignItems='center' wrap='no-wrap'>
<Box>
<Flex.Item grow={1}>
<Box fontScale='s2' color='default'>{title}</Box>
</Flex.Item>
{filter && <Flex.Item grow={0}>
<Box display='flex' alignItems='center' wrap='no-wrap'>
<Box flexGrow={1} fontScale='s2' color='default'>{title}</Box>
{filter && <Flex.Item grow={0}>
<Margins mi='x24'>
{filter}
</Flex.Item>}
</Box>
</Flex.Container>
</Margins>
</Flex.Item>}
</Box>
{children}
</Margins>
</Box>;

@ -8,6 +8,11 @@ import { useEndpointData } from '../../../../../../client/hooks/useEndpointData'
import CounterSet from '../../../../../../client/components/data/CounterSet';
import { LegendSymbol } from '../data/LegendSymbol';
import { Section } from '../Section';
import { ActionButton } from '../../../../../../client/components/basic/Buttons/ActionButton';
import { saveFile } from '../../../../../../client/lib/saveFile';
const convertDataToCSV = ({ countDailyActiveUsers, diffDailyActiveUsers, countWeeklyActiveUsers, diffWeeklyActiveUsers, countMonthlyActiveUsers, diffMonthlyActiveUsers, dauValues, wauValues, mauValues }) => `// countDailyActiveUsers, diffDailyActiveUsers, countWeeklyActiveUsers, diffWeeklyActiveUsers, countMonthlyActiveUsers, diffMonthlyActiveUsers, dauValues, wauValues, mauValues
${ countDailyActiveUsers }, ${ diffDailyActiveUsers }, ${ countWeeklyActiveUsers }, ${ diffWeeklyActiveUsers }, ${ countMonthlyActiveUsers }, ${ diffMonthlyActiveUsers }, ${ dauValues }, ${ wauValues }, ${ mauValues }`;
export function ActiveUsersSection() {
const t = useTranslation();
@ -91,7 +96,22 @@ export function ActiveUsersSection() {
];
}, [period, data]);
return <Section title={t('Active_users')} filter={null}>
const downloadData = () => {
saveFile(convertDataToCSV({
countDailyActiveUsers,
diffDailyActiveUsers,
countWeeklyActiveUsers,
diffWeeklyActiveUsers,
countMonthlyActiveUsers,
diffMonthlyActiveUsers,
dauValues,
wauValues,
mauValues,
}), `ActiveUsersSection_start_${ params.start }_end_${ params.end }.csv`);
};
return <Section title={t('Active_users')} filter={<ActionButton disabled={!data} onClick={downloadData} aria-label={t('Download_Info')} icon='download'/>}>
<CounterSet
counters={[
{

@ -35,92 +35,82 @@ function ContentForHours({ displacement, onPreviousDateClick, onNextDateClick })
}, [data]);
return <>
<Flex.Container alignItems='center' justifyContent='center'>
<Box>
<Button ghost square small onClick={onPreviousDateClick}>
<Chevron left size='20' style={{ verticalAlign: 'middle' }} />
</Button>
<Flex.Item basis='25%'>
<Margins inline='x8'>
<Box is='span' style={{ textAlign: 'center' }}>
{currentDate.format(displacement < 7 ? 'dddd' : 'L')}
</Box>
</Margins>
</Flex.Item>
<Button ghost square small disabled={displacement === 0} onClick={onNextDateClick}>
<Chevron right size='20' style={{ verticalAlign: 'middle' }} />
</Button>
<Box display='flex' alignItems='center' justifyContent='center'>
<Button ghost square small onClick={onPreviousDateClick}>
<Chevron left size='x20' style={{ verticalAlign: 'middle' }} />
</Button>
<Box mi='x8' flexBasis='25%' is='span' style={{ textAlign: 'center' }}>
{currentDate.format(displacement < 7 ? 'dddd' : 'L')}
</Box>
</Flex.Container>
<Flex.Container>
{data
? <Box style={{ height: 196 }}>
<Flex.Item align='stretch' grow={1} shrink={0}>
<Box style={{ position: 'relative' }}>
<Box style={{ position: 'absolute', width: '100%', height: '100%' }}>
<ResponsiveBar
data={values}
indexBy='hour'
keys={['users']}
groupMode='grouped'
padding={0.25}
margin={{
// TODO: Get it from theme
bottom: 20,
}}
colors={[
// TODO: Get it from theme
'#1d74f5',
]}
enableLabel={false}
enableGridY={false}
axisTop={null}
axisRight={null}
axisBottom={{
tickSize: 0,
// TODO: Get it from theme
tickPadding: 4,
tickRotation: 0,
tickValues: 'every 2 hours',
format: (hour) => moment().set({ hour, minute: 0, second: 0 }).format('LT'),
}}
axisLeft={null}
animate={true}
motionStiffness={90}
motionDamping={15}
theme={{
// TODO: Get it from theme
axis: {
ticks: {
text: {
fill: '#9EA2A8',
fontFamily: 'Inter, -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Meiryo UI", Arial, sans-serif',
fontSize: '10px',
fontStyle: 'normal',
fontWeight: '600',
letterSpacing: '0.2px',
lineHeight: '12px',
},
},
},
tooltip: {
container: {
backgroundColor: '#1F2329',
boxShadow: '0px 0px 12px rgba(47, 52, 61, 0.12), 0px 0px 2px rgba(47, 52, 61, 0.08)',
borderRadius: 2,
},
<Button ghost square small disabled={displacement === 0} onClick={onNextDateClick}>
<Chevron right size='x20' style={{ verticalAlign: 'middle' }} />
</Button>
</Box>
{data
? <Box display='flex' height='196px'>
<Box align='stretch' flexGrow={1} flexShrink={0} position='relative'>
<Box position='absolute' width='100%' height='100%'>
<ResponsiveBar
data={values}
indexBy='hour'
keys={['users']}
groupMode='grouped'
padding={0.25}
margin={{
// TODO: Get it from theme
bottom: 20,
}}
colors={[
// TODO: Get it from theme
'#1d74f5',
]}
enableLabel={false}
enableGridY={false}
axisTop={null}
axisRight={null}
axisBottom={{
tickSize: 0,
// TODO: Get it from theme
tickPadding: 4,
tickRotation: 0,
tickValues: 'every 2 hours',
format: (hour) => moment().set({ hour, minute: 0, second: 0 }).format('LT'),
}}
axisLeft={null}
animate={true}
motionStiffness={90}
motionDamping={15}
theme={{
// TODO: Get it from theme
axis: {
ticks: {
text: {
fill: '#9EA2A8',
fontFamily: 'Inter, -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Meiryo UI", Arial, sans-serif',
fontSize: '10px',
fontStyle: 'normal',
fontWeight: '600',
letterSpacing: '0.2px',
lineHeight: '12px',
},
}}
tooltip={({ value }) => <Box fontScale='p2' color='alternative'>
{t('Value_users', { value })}
</Box>}
/>
</Box>
</Box>
</Flex.Item>
},
},
tooltip: {
container: {
backgroundColor: '#1F2329',
boxShadow: '0px 0px 12px rgba(47, 52, 61, 0.12), 0px 0px 2px rgba(47, 52, 61, 0.08)',
borderRadius: 2,
},
},
}}
tooltip={({ value }) => <Box fontScale='p2' color='alternative'>
{t('Value_users', { value })}
</Box>}
/>
</Box>
</Box>
: <Skeleton variant='rect' height={196} />}
</Flex.Container>
</Box>
: <Skeleton variant='rect' height={196} />}
</>;
}
@ -141,7 +131,7 @@ function ContentForDays({ displacement, onPreviousDateClick, onNextDateClick })
<Flex.Container alignItems='center' justifyContent='center'>
<Box>
<Button ghost square small onClick={onPreviousDateClick}>
<Chevron left size='20' style={{ verticalAlign: 'middle' }} />
<Chevron left size='x20' style={{ verticalAlign: 'middle' }} />
</Button>
<Flex.Item basis='50%'>
<Margins inline='x8'>
@ -151,7 +141,7 @@ function ContentForDays({ displacement, onPreviousDateClick, onNextDateClick })
</Margins>
</Flex.Item>
<Button ghost square small disabled={displacement === 0} onClick={onNextDateClick}>
<Chevron right size='20' style={{ verticalAlign: 'middle' }} />
<Chevron right size='x20' style={{ verticalAlign: 'middle' }} />
</Button>
</Box>
</Flex.Container>

@ -7,6 +7,11 @@ import { useTranslation } from '../../../../../../client/contexts/TranslationCon
import { useEndpointData } from '../../../../../../client/hooks/useEndpointData';
import CounterSet from '../../../../../../client/components/data/CounterSet';
import { Section } from '../Section';
import { ActionButton } from '../../../../../../client/components/basic/Buttons/ActionButton';
import { saveFile } from '../../../../../../client/lib/saveFile';
const convertDataToCSV = (data) => `// date, newUsers
${ data.map(({ date, newUsers }) => `${ date }, ${ newUsers }`).join('\n') }`;
export function NewUsersSection() {
const t = useTranslation();
@ -81,9 +86,13 @@ export function NewUsersSection() {
];
}, [data, period]);
const downloadData = () => {
saveFile(convertDataToCSV(values), `NewUsersSection_start_${ params.start }_end_${ params.end }.csv`);
};
return <Section
title={t('New_users')}
filter={<Select options={periodOptions} value={periodId} onChange={handlePeriodChange} />}
filter={<><Select small options={periodOptions} value={periodId} onChange={handlePeriodChange} /><ActionButton mis='x16' disabled={!data} onClick={downloadData} aria-label={t('Download_Info')} icon='download'/></>}
>
<CounterSet
counters={[

@ -6,6 +6,12 @@ import React, { useMemo, useState } from 'react';
import { useTranslation } from '../../../../../../client/contexts/TranslationContext';
import { useEndpointData } from '../../../../../../client/hooks/useEndpointData';
import { Section } from '../Section';
import { ActionButton } from '../../../../../../client/components/basic/Buttons/ActionButton';
import { saveFile } from '../../../../../../client/lib/saveFile';
const convertDataToCSV = (data) => `// date, users
${ data.map(({ users, hour, day, month, year }) => ({ date: moment([year, month - 1, day, hour, 0, 0, 0]), users })).sort((a, b) => a > b).map(({ date, users }) => `${ date.toISOString() }, ${ users }`).join('\n') }`;
export function UsersByTimeOfTheDaySection() {
const t = useTranslation();
@ -77,90 +83,91 @@ export function UsersByTimeOfTheDaySection() {
];
}, [data, period.end, period.start]);
const downloadData = () => {
saveFile(convertDataToCSV(data.week), `UsersByTimeOfTheDaySection_start_${ params.start }_end_${ params.end }.csv`);
};
return <Section
title={t('Users_by_time_of_day')}
filter={<Select options={periodOptions} value={periodId} onChange={handlePeriodChange} />}
filter={<><Select options={periodOptions} value={periodId} onChange={handlePeriodChange} />{<ActionButton mis='x16' onClick={downloadData} aria-label={t('Download_Info')} icon='download'/>}</>}
>
<Flex.Container>
{data
? <Box style={{ height: 696 }}>
<Flex.Item align='stretch' grow={1} shrink={0}>
<Box style={{ position: 'relative' }}>
<Box style={{ position: 'absolute', width: '100%', height: '100%' }}>
<ResponsiveHeatMap
data={values}
indexBy='hour'
keys={dates}
padding={4}
margin={{
// TODO: Get it from theme
left: 40,
bottom: 20,
}}
colors={[
// TODO: Get it from theme
'#E8F2FF',
'#D1EBFE',
'#A4D3FE',
'#76B7FC',
'#549DF9',
'#1D74F5',
'#10529E',
]}
cellOpacity={1}
enableLabels={false}
axisTop={null}
axisRight={null}
axisBottom={{
// TODO: Get it from theme
tickSize: 0,
tickPadding: 4,
tickRotation: 0,
format: (isoString) => (dates.length === 7 ? moment(isoString).format('dddd') : ''),
}}
axisLeft={{
// TODO: Get it from theme
tickSize: 0,
tickPadding: 4,
tickRotation: 0,
format: (hour) => moment().set({ hour: parseInt(hour, 10), minute: 0, second: 0 }).format('LT'),
}}
hoverTarget='cell'
animate={dates.length <= 7}
motionStiffness={90}
motionDamping={15}
theme={{
// TODO: Get it from theme
axis: {
ticks: {
text: {
fill: '#9EA2A8',
fontFamily: 'Inter, -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Meiryo UI", Arial, sans-serif',
fontSize: 10,
fontStyle: 'normal',
fontWeight: '600',
letterSpacing: '0.2px',
lineHeight: '12px',
},
{data
? <Box display='flex' style={{ height: 696 }}>
<Flex.Item align='stretch' grow={1} shrink={0}>
<Box style={{ position: 'relative' }}>
<Box style={{ position: 'absolute', width: '100%', height: '100%' }}>
<ResponsiveHeatMap
data={values}
indexBy='hour'
keys={dates}
padding={4}
margin={{
// TODO: Get it from theme
left: 40,
bottom: 20,
}}
colors={[
// TODO: Get it from theme
'#E8F2FF',
'#D1EBFE',
'#A4D3FE',
'#76B7FC',
'#549DF9',
'#1D74F5',
'#10529E',
]}
cellOpacity={1}
enableLabels={false}
axisTop={null}
axisRight={null}
axisBottom={{
// TODO: Get it from theme
tickSize: 0,
tickPadding: 4,
tickRotation: 0,
format: (isoString) => (dates.length === 7 ? moment(isoString).format('dddd') : ''),
}}
axisLeft={{
// TODO: Get it from theme
tickSize: 0,
tickPadding: 4,
tickRotation: 0,
format: (hour) => moment().set({ hour: parseInt(hour, 10), minute: 0, second: 0 }).format('LT'),
}}
hoverTarget='cell'
animate={dates.length <= 7}
motionStiffness={90}
motionDamping={15}
theme={{
// TODO: Get it from theme
axis: {
ticks: {
text: {
fill: '#9EA2A8',
fontFamily: 'Inter, -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Meiryo UI", Arial, sans-serif',
fontSize: 10,
fontStyle: 'normal',
fontWeight: '600',
letterSpacing: '0.2px',
lineHeight: '12px',
},
},
tooltip: {
container: {
backgroundColor: '#1F2329',
boxShadow: '0px 0px 12px rgba(47, 52, 61, 0.12), 0px 0px 2px rgba(47, 52, 61, 0.08)',
borderRadius: 2,
},
},
tooltip: {
container: {
backgroundColor: '#1F2329',
boxShadow: '0px 0px 12px rgba(47, 52, 61, 0.12), 0px 0px 2px rgba(47, 52, 61, 0.08)',
borderRadius: 2,
},
}}
tooltip={({ value }) => <Box fontScale='p2' color='alternative'>
{t('Value_users', { value })}
</Box>}
/>
</Box>
},
}}
tooltip={({ value }) => <Box fontScale='p2' color='alternative'>
{t('Value_users', { value })}
</Box>}
/>
</Box>
</Flex.Item>
</Box>
: <Skeleton variant='rect' height={696} />}
</Flex.Container>
</Box>
</Flex.Item>
</Box>
: <Skeleton variant='rect' height={696} />}
</Section>;
}

@ -12,21 +12,15 @@ export function UsersTab() {
<Divider />
<ActiveUsersSection />
<Divider />
<Flex.Container>
<Box display='flex' mi='x12'>
<Margins inline='x12'>
<Box>
<Margins inline='x12'>
<Flex.Item grow={1} shrink={0} basis='0'>
<UsersByTimeOfTheDaySection />
</Flex.Item>
<Flex.Item grow={1} shrink={0} basis='0'>
<Box>
<BusiestChatTimesSection />
</Box>
</Flex.Item>
</Margins>
<Flex.Item grow={1} shrink={0} basis='0'>
<UsersByTimeOfTheDaySection />
</Flex.Item>
<Box flexGrow={1} flexShrink={0} flexBasis='0'>
<BusiestChatTimesSection />
</Box>
</Margins>
</Flex.Container>
</Box>
</>;
}

@ -7,7 +7,6 @@ import { fillFirstDaysOfUsersIfNeeded } from './lib/users';
onLicense('engagement-dashboard', async () => {
await import('./listeners');
await import('./api');
Meteor.startup(async () => {
const date = new Date();
fillFirstDaysOfUsersIfNeeded(date);

Loading…
Cancel
Save