woocommerce/plugins/woocommerce-admin/client/analytics/report/revenue/index.js

500 lines
12 KiB
JavaScript

/** @format */
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Component, Fragment } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import { format as formatDate } from '@wordpress/date';
import { map, find, orderBy } from 'lodash';
import PropTypes from 'prop-types';
import { withSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import {
Card,
Chart,
ChartPlaceholder,
EmptyContent,
ReportFilters,
SummaryList,
SummaryListPlaceholder,
SummaryNumber,
TableCard,
TablePlaceholder,
} from '@woocommerce/components';
import { downloadCSVFile, generateCSVDataFromTable, generateCSVFileName } from 'lib/csv';
import { formatCurrency, getCurrencyFormatDecimal } from 'lib/currency';
import { getAdminLink, getNewPath, onQueryChange } from 'lib/nav-utils';
import { getAllReportData, isReportDataEmpty } from 'store/reports/utils';
import {
getCurrentDates,
isoDateFormat,
getPreviousDate,
getIntervalForQuery,
getAllowedIntervalsForQuery,
getDateFormatsForInterval,
} from 'lib/date';
import { MAX_PER_PAGE } from 'store/constants';
export class RevenueReport extends Component {
constructor() {
super();
this.onDownload = this.onDownload.bind( this );
}
onDownload( headers, rows, query ) {
// @TODO The current implementation only downloads the contents displayed in the table.
// Another solution is required when the data set is larger (see #311).
return () => {
downloadCSVFile(
generateCSVFileName( 'revenue', query ),
generateCSVDataFromTable( headers, rows )
);
};
}
getHeadersContent() {
return [
{
label: __( 'Date', 'wc-admin' ),
key: 'date_start',
required: true,
defaultSort: true,
isSortable: true,
},
{
label: __( 'Orders', 'wc-admin' ),
key: 'orders_count',
required: false,
isSortable: true,
},
{
label: __( 'Gross Revenue', 'wc-admin' ),
key: 'gross_revenue',
required: true,
isSortable: true,
isNumeric: true,
},
{
label: __( 'Refunds', 'wc-admin' ),
key: 'refunds',
required: false,
isSortable: true,
isNumeric: true,
},
{
label: __( 'Coupons', 'wc-admin' ),
key: 'coupons',
required: false,
isSortable: true,
isNumeric: true,
},
{
label: __( 'Taxes', 'wc-admin' ),
key: 'taxes',
required: false,
isSortable: true,
isNumeric: true,
},
{
label: __( 'Shipping', 'wc-admin' ),
key: 'shipping',
required: false,
isSortable: true,
isNumeric: true,
},
{
label: __( 'Net Revenue', 'wc-admin' ),
key: 'net_revenue',
required: false,
isSortable: true,
isNumeric: true,
},
];
}
getRowsContent( data = [] ) {
const { query } = this.props;
const currentInterval = getIntervalForQuery( query );
const formats = getDateFormatsForInterval( currentInterval );
return map( data, row => {
const {
coupons,
gross_revenue,
net_revenue,
orders_count,
refunds,
shipping,
taxes,
} = row.subtotals;
// @TODO How to create this per-report? Can use `w`, `year`, `m` to build time-specific order links
// we need to know which kind of report this is, and parse the `label` to get this row's date
const orderLink = (
<a
href={ getAdminLink(
'edit.php?post_type=shop_order&m=' + formatDate( 'Ymd', row.date_start )
) }
>
{ orders_count }
</a>
);
return [
{
display: formatDate( formats.tableFormat, row.date_start ),
value: row.date_start,
},
{
display: orderLink,
value: Number( orders_count ),
},
{
display: formatCurrency( gross_revenue ),
value: getCurrencyFormatDecimal( gross_revenue ),
},
{
display: formatCurrency( refunds ),
value: getCurrencyFormatDecimal( refunds ),
},
{
display: formatCurrency( coupons ),
value: getCurrencyFormatDecimal( coupons ),
},
{
display: formatCurrency( taxes ),
value: getCurrencyFormatDecimal( taxes ),
},
{
display: formatCurrency( shipping ),
value: getCurrencyFormatDecimal( shipping ),
},
{
display: formatCurrency( net_revenue ),
value: getCurrencyFormatDecimal( net_revenue ),
},
];
} );
}
getCharts() {
return [
{
key: 'gross_revenue',
label: __( 'Gross Revenue', 'wc-admin' ),
type: 'currency',
},
{
key: 'refunds',
label: __( 'Refunds', 'wc-admin' ),
type: 'currency',
},
{
key: 'coupons',
label: __( 'Coupons', 'wc-admin' ),
type: 'currency',
},
{
key: 'taxes',
label: __( 'Taxes', 'wc-admin' ),
type: 'currency',
},
{
key: 'shipping',
label: __( 'Shipping', 'wc-admin' ),
type: 'currency',
},
{
key: 'net_revenue',
label: __( 'Net Revenue', 'wc-admin' ),
type: 'currency',
},
];
}
getSelectedChart() {
const { query } = this.props;
const charts = this.getCharts();
const chart = find( charts, { key: query.chart } );
if ( chart ) {
return chart;
}
return charts[ 0 ];
}
// TODO since this pattern will exist on every report, this possibly should become a component
renderChartSummaryNumbers() {
const selectedChart = this.getSelectedChart();
const { primaryData, secondaryData } = this.props;
const { totals } = primaryData.data;
const secondaryTotals = secondaryData.data.totals || {};
const summaryNumbers = map( this.getCharts(), chart => {
const { key, label, type } = chart;
const isSelected = selectedChart.key === key;
let value = parseFloat( totals[ key ] );
let secondaryValue =
( secondaryTotals[ key ] && parseFloat( secondaryTotals[ key ] ) ) || undefined;
let delta = 0;
if ( secondaryValue && secondaryValue !== 0 ) {
delta = Math.round( ( value - secondaryValue ) / secondaryValue * 100 );
}
switch ( type ) {
// TODO: implement other format handlers
case 'currency':
value = formatCurrency( value );
secondaryValue = secondaryValue && formatCurrency( secondaryValue );
break;
}
const href = getNewPath( { chart: key } );
return (
<SummaryNumber
key={ key }
value={ value }
label={ label }
selected={ isSelected }
prevValue={ secondaryValue }
delta={ delta }
href={ href }
/>
);
} );
return <SummaryList>{ summaryNumbers }</SummaryList>;
}
renderChart() {
const { primaryData, secondaryData, query } = this.props;
const currentInterval = getIntervalForQuery( query );
const allowedIntervals = getAllowedIntervalsForQuery( query );
const formats = getDateFormatsForInterval( currentInterval );
const { primary, secondary } = getCurrentDates( query );
const selectedChart = this.getSelectedChart();
const primaryKey = `${ primary.label } (${ primary.range })`;
const secondaryKey = `${ secondary.label } (${ secondary.range })`;
const chartData = primaryData.data.intervals.map( function( interval, index ) {
const secondaryDate = getPreviousDate(
formatDate( 'Y-m-d', interval.date_start ),
primary.after,
secondary.after,
query.compare,
currentInterval
);
/**
* When looking at weeks, getting the previous date doesn't always work
* because subtracting the correct number of weeks from `interval.date_start`
* yeilds the start of the week correct week, but not necessarily the of the
* period in question.
*
* When https://github.com/woocommerce/woocommerce/issues/21298 is resolved and
* data will be zero-filled, there is a strong argument for all this logic to be
* removed in favor of simply matching up the indices in each array of data.
*/
const secondaryInterval =
0 === index && 'week' === currentInterval
? secondaryData.data.intervals[ 0 ]
: find( secondaryData.data.intervals, {
date_start:
secondaryDate.format( isoDateFormat ) +
' ' +
formatDate( 'H:i:s', interval.date_start ),
} );
return {
date: formatDate( 'Y-m-d\\TH:i:s', interval.date_start ),
[ primaryKey ]: {
labelDate: interval.date_start,
value: interval.subtotals[ selectedChart.key ] || 0,
},
[ secondaryKey ]: {
labelDate: secondaryDate,
value: ( secondaryInterval && secondaryInterval.subtotals[ selectedChart.key ] ) || 0,
},
};
} );
return (
<Chart
data={ chartData }
title={ selectedChart.label }
interval={ currentInterval }
allowedIntervals={ allowedIntervals }
pointLabelFormat={ formats.pointLabelFormat }
tooltipTitle={ selectedChart.label }
xFormat={ formats.xFormat }
x2Format={ formats.x2Format }
dateParser={ '%Y-%m-%dT%H:%M:%S' }
/>
);
}
renderTable() {
const { primaryData, query } = this.props;
const intervals = primaryData.data.intervals;
const page = parseInt( query.page ) || 1;
const rowsPerPage = parseInt( query.per_page ) || 25;
const rows =
this.getRowsContent(
orderBy(
intervals,
function( interval ) {
return 'undefined' === typeof interval.subtotals[ query.orderby ]
? interval.date_start
: interval.subtotals[ query.orderby ];
},
query.order || 'asc'
).slice( ( page - 1 ) * rowsPerPage, page * rowsPerPage )
) || [];
const headers = this.getHeadersContent();
const tableQuery = {
...query,
orderby: query.orderby || 'date_start',
order: query.order || 'asc',
};
return (
<TableCard
title={ __( 'Revenue', 'wc-admin' ) }
rows={ rows }
totalRows={ intervals.length }
rowsPerPage={ rowsPerPage }
headers={ headers }
onClickDownload={ this.onDownload( headers, rows, tableQuery ) }
onQueryChange={ onQueryChange }
query={ tableQuery }
summary={ null }
/>
);
}
renderPlaceholder() {
const { path, query } = this.props;
const headers = this.getHeadersContent();
const charts = this.getCharts();
return (
<Fragment>
<ReportFilters query={ query } path={ path } />
<span className="screen-reader-text">
{ __( 'Your requested data is loading', 'wc-admin' ) }
</span>
<SummaryListPlaceholder numberOfItems={ charts.length } />
<ChartPlaceholder />
<Card
title={ __( 'Revenue', 'wc-admin' ) }
className="woocommerce-analytics__table-placeholder"
>
<TablePlaceholder caption={ __( 'Revenue', 'wc-admin' ) } headers={ headers } />
</Card>
</Fragment>
);
}
render() {
const { path, query, primaryData, secondaryData } = this.props;
if ( primaryData.isRequesting || secondaryData.isRequesting ) {
return this.renderPlaceholder();
}
if ( isReportDataEmpty( primaryData ) || primaryData.isError || secondaryData.isError ) {
let title, actionLabel, actionURL, actionCallback;
if ( primaryData.isError || secondaryData.isError ) {
title = __( 'There was an error getting your stats. Please try again.', 'wc-admin' );
actionLabel = __( 'Reload', 'wc-admin' );
actionCallback = () => {
// TODO Add tracking for how often an error is displayed, and the reload action is clicked.
window.location.reload();
};
} else {
title = __( 'No results could be found for this date range.', 'wc-admin' );
actionLabel = __( 'View Orders', 'wc-admin' );
actionURL = getAdminLink( 'edit.php?post_type=shop_order' );
}
return (
<Fragment>
<ReportFilters query={ query } path={ path } />
<EmptyContent
title={ title }
actionLabel={ actionLabel }
actionURL={ actionURL }
actionCallback={ actionCallback }
/>
</Fragment>
);
}
return (
<Fragment>
<ReportFilters query={ query } path={ path } />
{ this.renderChartSummaryNumbers() }
{ this.renderChart() }
{ this.renderTable() }
</Fragment>
);
}
}
RevenueReport.propTypes = {
params: PropTypes.object.isRequired,
path: PropTypes.string.isRequired,
query: PropTypes.object.isRequired,
};
export default compose(
withSelect( ( select, props ) => {
const { query } = props;
const datesFromQuery = getCurrentDates( query );
const interval = getIntervalForQuery( query );
const baseArgs = {
order: 'asc',
interval: interval,
per_page: MAX_PER_PAGE,
};
const primaryData = getAllReportData(
'revenue',
{
...baseArgs,
after: datesFromQuery.primary.after,
before: datesFromQuery.primary.before,
},
select
);
const secondaryData = getAllReportData(
'revenue',
{
...baseArgs,
after: datesFromQuery.secondary.after,
before: datesFromQuery.secondary.before,
},
select
);
return {
primaryData,
secondaryData,
};
} )
)( RevenueReport );