* @package WooCommerce\Admin
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Enums\OrderInternalStatus;
use Automattic\WooCommerce\Utilities\OrderUtil;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
if ( ! class_exists( 'WC_Admin_Dashboard', false ) ) :
* WC_Admin_Dashboard Class.
class WC_Admin_Dashboard {
public function __construct() {
// Only hook in admin parts if the user has admin access.
if ( $this->should_display_widget() ) {
// If on network admin, only load the widget that works in that context and skip the rest.
if ( is_multisite() && is_network_admin() ) {
add_action( 'wp_network_dashboard_setup', array( $this, 'register_network_order_widget' ) );
add_action( 'wp_dashboard_setup', array( $this, 'init' ) );
* Init dashboard widgets.
if ( current_user_can( 'publish_shop_orders' ) && post_type_supports( 'product', 'comments' ) ) {
wp_add_dashboard_widget( 'woocommerce_dashboard_recent_reviews', __( 'WooCommerce Recent Reviews', 'woocommerce' ), array( $this, 'recent_reviews' ) );
wp_add_dashboard_widget( 'woocommerce_dashboard_status', __( 'WooCommerce Status', 'woocommerce' ), array( $this, 'status_widget' ) );
if ( is_multisite() && is_main_site() ) {
$this->register_network_order_widget();
* Register the network order dashboard widget.
public function register_network_order_widget() {
wp_add_dashboard_widget( 'woocommerce_network_orders', __( 'WooCommerce Network Orders', 'woocommerce' ), array( $this, 'network_orders' ) );
* Check to see if we should display the widget.
private function should_display_widget() {
if ( ! WC()->is_wc_admin_active() ) {
$has_permission = current_user_can( 'view_woocommerce_reports' ) || current_user_can( 'manage_woocommerce' ) || current_user_can( 'publish_shop_orders' );
$task_completed_or_hidden = 'yes' === get_option( 'woocommerce_task_list_complete' ) || 'yes' === get_option( 'woocommerce_task_list_hidden' );
return $task_completed_or_hidden && $has_permission;
* Get top seller from DB.
private function get_top_seller() {
$hpos_enabled = OrderUtil::custom_orders_table_usage_is_enabled();
$orders_table = OrderUtil::get_table_for_orders();
$orders_column_id = $hpos_enabled ? 'id' : 'ID';
$orders_column_type = $hpos_enabled ? 'type' : 'post_type';
$orders_column_status = $hpos_enabled ? 'status' : 'post_status';
$orders_column_date = $hpos_enabled ? 'date_created_gmt' : 'post_date_gmt';
$query['fields'] = "SELECT SUM( order_item_meta.meta_value ) as qty, order_item_meta_2.meta_value as product_id FROM {$orders_table} AS orders";
$query['join'] = "INNER JOIN {$wpdb->prefix}woocommerce_order_items AS order_items ON orders.{$orders_column_id} = order_id ";
$query['join'] .= "INNER JOIN {$wpdb->prefix}woocommerce_order_itemmeta AS order_item_meta ON order_items.order_item_id = order_item_meta.order_item_id ";
$query['join'] .= "INNER JOIN {$wpdb->prefix}woocommerce_order_itemmeta AS order_item_meta_2 ON order_items.order_item_id = order_item_meta_2.order_item_id ";
$query['where'] = "WHERE orders.{$orders_column_type} IN ( '" . implode( "','", wc_get_order_types( 'order-count' ) ) . "' ) ";
* Allows modifying the order statuses used in the top seller query inside the dashboard status widget.
* @param string[] $order_statuses Order statuses.
$order_statuses = apply_filters( 'woocommerce_reports_order_statuses', array( OrderStatus::COMPLETED, OrderStatus::PROCESSING, OrderStatus::ON_HOLD ) );
$query['where'] .= "AND orders.{$orders_column_status} IN ( 'wc-" . implode( "','wc-", $order_statuses ) . "' ) ";
$query['where'] .= "AND order_item_meta.meta_key = '_qty' ";
$query['where'] .= "AND order_item_meta_2.meta_key = '_product_id' ";
$query['where'] .= "AND orders.{$orders_column_date} >= '" . gmdate( 'Y-m-01', current_time( 'timestamp' ) ) . "' "; // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested
$query['where'] .= "AND orders.{$orders_column_date} <= '" . gmdate( 'Y-m-d H:i:s', current_time( 'timestamp' ) ) . "' "; // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested
$query['groupby'] = 'GROUP BY product_id';
$query['orderby'] = 'ORDER BY qty DESC';
$query['limits'] = 'LIMIT 1';
* Allows modification of the query to determine the top seller product in the dashboard status widget.
* @param array $query SQL query parts.
$query = apply_filters( 'woocommerce_dashboard_status_widget_top_seller_query', $query );
$sql = implode( ' ', $query );
return $wpdb->get_row( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
public function status_widget() {
$suffix = Constants::is_true( 'SCRIPT_DEBUG' ) ? '' : '.min';
$version = Constants::get_constant( 'WC_VERSION' );
wp_enqueue_script( 'wc-status-widget', WC()->plugin_url() . '/assets/js/admin/wc-status-widget' . $suffix . '.js', array( 'jquery', 'wc-flot' ), $version, true );
wp_enqueue_script( 'wc-status-widget-async', WC()->plugin_url() . '/assets/js/admin/wc-status-widget-async' . $suffix . '.js', array( 'jquery' ), $version, true );
'wc-status-widget-async',
'wc_status_widget_params',
'ajax_url' => admin_url( 'admin-ajax.php' ),
'security' => wp_create_nonce( 'wc-status-widget' ),
'error_message' => esc_html__( 'Error loading widget', 'woocommerce' ),
// Display loading placeholder.
echo '<div id="wc-status-widget-loading" class="wc-status-widget-loading">';
echo '<p>' . esc_html__( 'Loading status data...', 'woocommerce' ) . ' <span class="spinner is-active"></span></p>';
echo '<div id="wc-status-widget-content" style="display:none;"></div>';
* Generate the actual status widget content.
* This contains the original content of the status_widget() method.
public function status_widget_content() {
$is_wc_admin_disabled = apply_filters( 'woocommerce_admin_disabled', false ) || ! Features::is_enabled( 'analytics' );
$status_widget_reports = array(
'net_sales_link' => 'admin.php?page=wc-admin&path=%2Fanalytics%2Frevenue&chart=net_revenue&orderby=net_revenue&period=month&compare=previous_period',
'top_seller_link' => 'admin.php?page=wc-admin&filter=single_product&path=%2Fanalytics%2Fproducts&products=',
'lowstock_link' => 'admin.php?page=wc-admin&type=lowstock&path=%2Fanalytics%2Fstock',
'outofstock_link' => 'admin.php?page=wc-admin&type=outofstock&path=%2Fanalytics%2Fstock',
'get_sales_sparkline' => array( $this, 'get_sales_sparkline' ),
if ( $is_wc_admin_disabled ) {
* Filter to change the reports of the status widget on the Dashboard page.
* Please note that this filter is mainly for backward compatibility with the legacy reports.
* It's not recommended to use this filter to change the data of this widget.
$status_widget_reports = apply_filters( 'woocommerce_dashboard_status_widget_reports', $status_widget_reports );
$status_widget_reports['report_data'] = $this->get_wc_admin_performance_data();
echo '<ul class="wc_status_list">';
if ( current_user_can( 'view_woocommerce_reports' ) ) {
$report_data = $status_widget_reports['report_data'];
$get_sales_sparkline = $status_widget_reports['get_sales_sparkline'];
$net_sales_link = $status_widget_reports['net_sales_link'];
$top_seller_link = $status_widget_reports['top_seller_link'];
$days = max( 7, (int) gmdate( 'd', current_time( 'timestamp' ) ) ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested
$sparkline_allowed_html = array(
'data-barwidth' => array(),
'data-sparkline' => array(),
if ( $report_data && is_callable( $get_sales_sparkline ) ) {
$sparkline = call_user_func_array( $get_sales_sparkline, array( '', $days ) );
$sparkline = $this->sales_sparkline_markup( 'sales', $days, $sparkline['total'], $sparkline['data'] );
<li class="sales-this-month">
<a href="<?php echo esc_url( admin_url( $net_sales_link ) ); ?>">
<?php echo wp_kses( $sparkline, $sparkline_allowed_html ); ?>
/* translators: %s: net sales */
esc_html__( '%s net sales this month', 'woocommerce' ),
'<strong>' . wc_price( $report_data->net_sales ) . '</strong>'
); // phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped
$top_seller = $this->get_top_seller();
if ( $top_seller && $top_seller->qty && is_callable( $get_sales_sparkline ) ) {
$sparkline = call_user_func_array( $get_sales_sparkline, array( $top_seller->product_id, $days, 'count' ) );
$sparkline = $this->sales_sparkline_markup( 'count', $days, $sparkline['total'], $sparkline['data'] );
<li class="best-seller-this-month">
<a href="<?php echo esc_url( admin_url( $top_seller_link . $top_seller->product_id ) ); ?>">
<?php echo wp_kses( $sparkline, $sparkline_allowed_html ); ?>
/* translators: 1: top seller product title 2: top seller quantity */
esc_html__( '%1$s top seller this month (sold %2$d)', 'woocommerce' ),
'<strong>' . get_the_title( $top_seller->product_id ) . '</strong>',
); // phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped
$this->status_widget_order_rows();
if ( get_option( 'woocommerce_manage_stock' ) === 'yes' ) {
$this->status_widget_stock_rows( $status_widget_reports['lowstock_link'], $status_widget_reports['outofstock_link'] );
* Filter to change the first argument passed to the `woocommerce_after_dashboard_status_widget` action.
* Please note that this filter is mainly for backward compatibility with the legacy reports.
* It's not recommended to use this filter as it will soon be deprecated along with the retiring of the legacy reports.
$reports = apply_filters( 'woocommerce_after_dashboard_status_widget_parameter', null );
do_action( 'woocommerce_after_dashboard_status_widget', $reports );
* Show order data is status widget.
private function status_widget_order_rows() {
if ( ! current_user_can( 'edit_shop_orders' ) ) {
foreach ( wc_get_order_types( 'order-count' ) as $type ) {
$counts = OrderUtil::get_count_for_type( $type );
$on_hold_count += $counts[ OrderInternalStatus::ON_HOLD ];
$processing_count += $counts[ OrderInternalStatus::PROCESSING ];
<li class="processing-orders">
<a href="<?php echo esc_url( admin_url( 'edit.php?post_status=wc-processing&post_type=shop_order' ) ); ?>">
/* translators: %s: order count */
_n( '<strong>%s order</strong> awaiting processing', '<strong>%s orders</strong> awaiting processing', $processing_count, 'woocommerce' ),
); // phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped
<li class="on-hold-orders">
<a href="<?php echo esc_url( admin_url( 'edit.php?post_status=wc-on-hold&post_type=shop_order' ) ); ?>">
/* translators: %s: order count */
_n( '<strong>%s order</strong> on-hold', '<strong>%s orders</strong> on-hold', $on_hold_count, 'woocommerce' ),
); // phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped
* Show stock data is status widget.
* @param string $lowstock_link Low stock link.
* @param string $outofstock_link Out of stock link.
private function status_widget_stock_rows( $lowstock_link, $outofstock_link ) {
// Requires lookup table added in 3.6.
if ( version_compare( get_option( 'woocommerce_db_version', null ), '3.6', '<' ) ) {
$stock = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) );
$nostock = absint( max( get_option( 'woocommerce_notify_no_stock_amount' ), 0 ) );
$transient_name = 'wc_low_stock_count';
$lowinstock_count = get_transient( $transient_name );
if ( false === $lowinstock_count ) {
* Status widget low in stock count pre query.
* @param null|string $low_in_stock_count Low in stock count, by default null.
* @param int $stock Low stock amount.
* @param int $nostock No stock amount
$lowinstock_count = apply_filters( 'woocommerce_status_widget_low_in_stock_count_pre_query', null, $stock, $nostock );
if ( is_null( $lowinstock_count ) ) {
$lowinstock_count = $wpdb->get_var(
"SELECT COUNT( product_id )
FROM {$wpdb->wc_product_meta_lookup} AS lookup
INNER JOIN {$wpdb->posts} as posts ON lookup.product_id = posts.ID
WHERE stock_quantity <= %d
AND posts.post_status = 'publish'",
set_transient( $transient_name, (int) $lowinstock_count, DAY_IN_SECONDS * 30 );
$transient_name = 'wc_outofstock_count';
$outofstock_count = get_transient( $transient_name );
$lowstock_url = $lowstock_link ? admin_url( $lowstock_link ) : '#';
$outofstock_url = $outofstock_link ? admin_url( $outofstock_link ) : '#';
if ( false === $outofstock_count ) {
* Status widget out of stock count pre query.
* @param null|string $outofstock_count Out of stock count, by default null.
* @param int $nostock No stock amount
$outofstock_count = apply_filters( 'woocommerce_status_widget_out_of_stock_count_pre_query', null, $nostock );
if ( is_null( $outofstock_count ) ) {
$outofstock_count = (int) $wpdb->get_var(
"SELECT COUNT( product_id )
FROM {$wpdb->wc_product_meta_lookup} AS lookup
INNER JOIN {$wpdb->posts} as posts ON lookup.product_id = posts.ID
WHERE stock_quantity <= %d
AND posts.post_status = 'publish'",
set_transient( $transient_name, (int) $outofstock_count, DAY_IN_SECONDS * 30 );
<li class="low-in-stock">
<a href="<?php echo esc_url( $lowstock_url ); ?>">
/* translators: %s: order count */
_n( '<strong>%s product</strong> low in stock', '<strong>%s products</strong> low in stock', $lowinstock_count, 'woocommerce' ),
); // phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped
<li class="out-of-stock">
<a href="<?php echo esc_url( $outofstock_url ); ?>">
/* translators: %s: order count */
_n( '<strong>%s product</strong> out of stock', '<strong>%s products</strong> out of stock', $outofstock_count, 'woocommerce' ),
); // phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped
* Recent reviews widget: legacy implementation.
private function legacy_recent_reviews(): void {
* Filters the from-clause used for fetching latest product reviews.
* @param string $clause The from-clause.
$query_from = apply_filters(
'woocommerce_report_recent_reviews_query_from',
"FROM {$wpdb->comments} comments
LEFT JOIN {$wpdb->posts} posts ON (comments.comment_post_ID = posts.ID)
WHERE comments.comment_approved = '1'
AND comments.comment_type = 'review'
AND posts.post_password = ''
AND posts.post_type = 'product'
AND comments.comment_parent = 0
ORDER BY comments.comment_date_gmt DESC
$comments = $wpdb->get_results(
"SELECT posts.ID, posts.post_title, comments.comment_author, comments.comment_author_email, comments.comment_ID, comments.comment_content {$query_from};" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
foreach ( $comments as $comment ) {
echo get_avatar( $comment->comment_author_email, '32' );
* Filters the product name for display in the latest reviews.
* @param string $product_title The product name.
* @param \stdClass $comment The comment.
$product_title = apply_filters( 'woocommerce_admin_dashboard_recent_reviews', $comment->post_title, $comment );
$rating = intval( get_comment_meta( $comment->comment_ID, 'rating', true ) );
/* translators: %s: rating */
echo '<div class="star-rating"><span style="width:' . esc_attr( $rating * 20 ) . '%">' . sprintf( esc_html__( '%s out of 5', 'woocommerce' ), esc_html( $rating ) ) . '</span></div>';
/* translators: %s: review author */
echo '<h4 class="meta"><a href="' . esc_url( get_permalink( $comment->ID ) ) . '#comment-' . esc_attr( absint( $comment->comment_ID ) ) . '">' . esc_html( $product_title ) . '</a> ' . sprintf( esc_html__( 'reviewed by %s', 'woocommerce' ), esc_html( $comment->comment_author ) ) . '</h4>';
echo '<blockquote>' . wp_kses_data( $comment->comment_content ) . '</blockquote></li>';
echo '<p>' . esc_html__( 'There are no product reviews yet.', 'woocommerce' ) . '</p>';
* Recent reviews widget: placeholder.
public function recent_reviews() {
$suffix = Constants::is_true( 'SCRIPT_DEBUG' ) ? '' : '.min';
$version = Constants::get_constant( 'WC_VERSION' );
wp_enqueue_script( 'wc-recent-reviews-widget-async', WC()->plugin_url() . '/assets/js/admin/wc-recent-reviews-widget-async' . $suffix . '.js', array( 'jquery' ), $version, true );
'wc-recent-reviews-widget-async',
'wc_recent_reviews_widget_params',
'ajax_url' => admin_url( 'admin-ajax.php' ),
'security' => wp_create_nonce( 'wc-recent-reviews-widget' ),
'error_message' => esc_html__( 'Error loading widget', 'woocommerce' ),
// Display loading placeholder.
echo '<div id="wc-recent-reviews-widget-loading" class="wc-recent-reviews-widget-loading">';
echo '<p>' . esc_html__( 'Loading reviews data...', 'woocommerce' ) . ' <span class="spinner is-active"></span></p>';
echo '<div id="wc-recent-reviews-widget-content" style="display:none;"></div>';