diff --git a/admin-spa/src/lib/analyticsApi.ts b/admin-spa/src/lib/analyticsApi.ts index 76c0f72..fa74045 100644 --- a/admin-spa/src/lib/analyticsApi.ts +++ b/admin-spa/src/lib/analyticsApi.ts @@ -15,50 +15,50 @@ export interface AnalyticsParams { export const AnalyticsApi = { /** * Dashboard Overview - * GET /woonoow/v1/analytics/overview + * GET /analytics/overview */ overview: (params?: AnalyticsParams) => - api.get('/woonoow/v1/analytics/overview', params), + api.get('/analytics/overview', params), /** * Revenue Analytics - * GET /woonoow/v1/analytics/revenue + * GET /analytics/revenue */ revenue: (params?: AnalyticsParams) => - api.get('/woonoow/v1/analytics/revenue', params), + api.get('/analytics/revenue', params), /** * Orders Analytics - * GET /woonoow/v1/analytics/orders + * GET /analytics/orders */ orders: (params?: AnalyticsParams) => - api.get('/woonoow/v1/analytics/orders', params), + api.get('/analytics/orders', params), /** * Products Analytics - * GET /woonoow/v1/analytics/products + * GET /analytics/products */ products: (params?: AnalyticsParams) => - api.get('/woonoow/v1/analytics/products', params), + api.get('/analytics/products', params), /** * Customers Analytics - * GET /woonoow/v1/analytics/customers + * GET /analytics/customers */ customers: (params?: AnalyticsParams) => - api.get('/woonoow/v1/analytics/customers', params), + api.get('/analytics/customers', params), /** * Coupons Analytics - * GET /woonoow/v1/analytics/coupons + * GET /analytics/coupons */ coupons: (params?: AnalyticsParams) => - api.get('/woonoow/v1/analytics/coupons', params), + api.get('/analytics/coupons', params), /** * Taxes Analytics - * GET /woonoow/v1/analytics/taxes + * GET /analytics/taxes */ taxes: (params?: AnalyticsParams) => - api.get('/woonoow/v1/analytics/taxes', params), + api.get('/analytics/taxes', params), }; diff --git a/admin-spa/src/routes/Dashboard/Orders.tsx b/admin-spa/src/routes/Dashboard/Orders.tsx index fb6c27e..a0c3d98 100644 --- a/admin-spa/src/routes/Dashboard/Orders.tsx +++ b/admin-spa/src/routes/Dashboard/Orders.tsx @@ -1,5 +1,5 @@ import React, { useState, useMemo, useRef } from 'react'; -import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, Label, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; +import { BarChart, Bar, LineChart, Line, AreaChart, Area, ComposedChart, PieChart, Pie, Cell, Label, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; import { ShoppingCart, TrendingUp, Package, XCircle, DollarSign, CheckCircle, Clock } from 'lucide-react'; import { __ } from '@/lib/i18n'; import { formatMoney, getStoreCurrency } from '@/lib/currency'; @@ -16,12 +16,14 @@ import { DUMMY_ORDERS_DATA, OrdersData } from './data/dummyOrders'; export default function OrdersAnalytics() { const { period } = useDashboardPeriod(); const store = getStoreCurrency(); - const [activeStatus, setActiveStatus] = useState('all'); const [hoverIndex, setHoverIndex] = useState(undefined); const chartRef = useRef(null); // Fetch real data or use dummy data based on toggle const { data, isLoading, error, refetch } = useOrdersAnalytics(DUMMY_ORDERS_DATA); + + // Auto-select first status (completed) on load + const [activeStatus, setActiveStatus] = useState(data.by_status[0]?.status_label || 'All'); // Filter chart data by period const chartData = useMemo(() => { @@ -205,7 +207,13 @@ export default function OrdersAnalytics() { description={__('Daily order count and status breakdown')} > - + + + + + + + - + {/* Individual statuses as Lines */} + + - + diff --git a/admin-spa/src/routes/Dashboard/index.tsx b/admin-spa/src/routes/Dashboard/index.tsx index 5ac2306..cc29a09 100644 --- a/admin-spa/src/routes/Dashboard/index.tsx +++ b/admin-spa/src/routes/Dashboard/index.tsx @@ -1,6 +1,6 @@ import React, { useState, useMemo, useRef, useEffect } from 'react'; import { Link } from 'react-router-dom'; -import { AreaChart, Area, BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, Sector, Label, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; +import { AreaChart, Area, BarChart, Bar, LineChart, Line, ComposedChart, PieChart, Pie, Cell, Sector, Label, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; import { TrendingUp, TrendingDown, ShoppingCart, DollarSign, Package, Users, AlertTriangle, ArrowUpRight } from 'lucide-react'; import { __ } from '@/lib/i18n'; import { formatMoney, getStoreCurrency } from '@/lib/currency'; @@ -125,11 +125,17 @@ const DUMMY_DATA = { // Metric card component function MetricCard({ title, value, change, icon: Icon, format = 'number', period }: any) { + const safeValue = value ?? 0; + const formattedValue = format === 'money' ? formatMoney(safeValue) : format === 'percent' ? `${safeValue.toFixed(2)}%` : safeValue.toLocaleString(); + + // Don't show comparison for "All Time" + const showComparison = change !== undefined && period !== 'all'; const isPositive = change >= 0; - const formattedValue = format === 'money' ? formatMoney(value) : format === 'percent' ? `${value}%` : value.toLocaleString(); // Period comparison text - const periodText = period === '7' ? __('vs previous 7 days') : period === '14' ? __('vs previous 14 days') : __('vs previous 30 days'); + const periodText = period === '7' ? __('vs previous 7 days') : + period === '14' ? __('vs previous 14 days') : + period === '30' ? __('vs previous 30 days') : ''; return (
@@ -139,10 +145,12 @@ function MetricCard({ title, value, change, icon: Icon, format = 'number', perio
{formattedValue}
-
- {isPositive ? : } - {Math.abs(change).toFixed(1)}% {periodText} -
+ {showComparison && ( +
+ {isPositive ? : } + {Math.abs(change).toFixed(1)}% {periodText} +
+ )}
); @@ -152,13 +160,32 @@ function MetricCard({ title, value, change, icon: Icon, format = 'number', perio export default function Dashboard() { const { period } = useDashboardPeriod(); const store = getStoreCurrency(); - const [activeStatus, setActiveStatus] = useState('all'); const [hoverIndex, setHoverIndex] = useState(undefined); const [chartMetric, setChartMetric] = useState<'both' | 'revenue' | 'orders'>('both'); const chartRef = useRef(null); // Fetch real data or use dummy data based on toggle const { data, isLoading, error, refetch } = useOverviewAnalytics(DUMMY_DATA); + + // Sort order status by importance (completed first) + const sortedOrderStatus = useMemo(() => { + const statusOrder = ['completed', 'processing', 'pending', 'on-hold', 'cancelled', 'refunded', 'failed']; + const orderStatus = data.orderStatus || data.orderStatusDistribution || []; + if (!Array.isArray(orderStatus)) return []; + + return [...orderStatus].sort((a, b) => { + const statusA = (a.status || a.name || '').toLowerCase(); + const statusB = (b.status || b.name || '').toLowerCase(); + const posA = statusOrder.indexOf(statusA); + const posB = statusOrder.indexOf(statusB); + return (posA === -1 ? 999 : posA) - (posB === -1 ? 999 : posB); + }); + }, [data.orderStatus, data.orderStatusDistribution]); + + // Auto-select first status (completed) on load + const [activeStatus, setActiveStatus] = useState( + sortedOrderStatus[0]?.status || sortedOrderStatus[0]?.name || 'All' + ); // Filter chart data based on period const chartData = useMemo(() => { @@ -349,11 +376,11 @@ export default function Dashboard() { {chartMetric === 'both' ? ( - + - - + + @@ -372,7 +399,7 @@ export default function Dashboard() {

{label}

{payload.map((entry: any, index: number) => (

- {entry.name}: {entry.name === __('Revenue') ? formatMoney(Number(entry.value)) : entry.value.toLocaleString()} + {entry.name}: {entry.name === __('Revenue') ? formatMoney(Number(entry.value || 0)) : (entry.value ?? 0).toLocaleString()}

))} @@ -382,9 +409,9 @@ export default function Dashboard() { }} /> - - -
+ + + ) : chartMetric === 'revenue' ? ( @@ -433,7 +460,7 @@ export default function Dashboard() {

{label}

{payload.map((entry: any, index: number) => (

- {entry.name}: {entry.value.toLocaleString()} + {entry.name}: {(entry.value ?? 0).toLocaleString()}

))} @@ -462,14 +489,17 @@ export default function Dashboard() { - {DUMMY_DATA.orderStatusDistribution.map((status) => ( - - - - {status.name} - - - ))} + {sortedOrderStatus.map((status, idx) => { + const statusValue = status.status || status.name || `status-${idx}`; + return ( + + + + {statusValue} + + + ); + })} @@ -480,9 +510,9 @@ export default function Dashboard() { onMouseLeave={handleChartMouseLeave} > entry.count || entry.value || 0} + nameKey={(entry) => entry.status || entry.name || 'Unknown'} cx="50%" cy="50%" innerRadius={70} @@ -492,8 +522,10 @@ export default function Dashboard() { onMouseLeave={onPieLeave} isAnimationActive={false} > - {data.orderStatusDistribution.map((entry: any, index: number) => { - const activePieIndex = data.orderStatusDistribution.findIndex((item: any) => item.name === activeStatus); + {sortedOrderStatus.map((entry: any, index: number) => { + const activePieIndex = sortedOrderStatus.findIndex((item: any) => + (item.status || item.name) === activeStatus + ); const isActive = index === (hoverIndex !== undefined ? hoverIndex : activePieIndex); return ( - {selectedData?.value.toLocaleString()} + {(selectedData?.value ?? 0).toLocaleString()}
- {DUMMY_DATA.topProducts.map((product) => ( -
+ {(data.topProducts || DUMMY_DATA.topProducts).slice(0, 5).map((product: any, index: number) => ( +
-
{product.image}
+
{product.image || '📦'}
{product.name}
-
{product.quantity} {__('sold')}
+
{product.items_sold || product.sales} {__('sold')}
-
{formatMoney(product.revenue)}
+
{formatMoney(product.revenue)}
))}
@@ -575,13 +607,13 @@ export default function Dashboard() {
- {DUMMY_DATA.topCustomers.map((customer) => ( -
+ {(data.topCustomers || DUMMY_DATA.topCustomers).slice(0, 5).map((customer: any, index: number) => ( +
{customer.name}
-
{customer.orders} {__('orders')}
+
{customer.order_count || customer.orders} {__('orders')}
-
{formatMoney(customer.totalSpent)}
+
{formatMoney(customer.total_spent || customer.total)}
))}
diff --git a/includes/Api/AnalyticsController.php b/includes/Api/AnalyticsController.php index b1dfa6b..625cfc8 100644 --- a/includes/Api/AnalyticsController.php +++ b/includes/Api/AnalyticsController.php @@ -87,17 +87,19 @@ class AnalyticsController { */ public static function get_overview(WP_REST_Request $request) { try { - // TODO: Implement real analytics logic - // For now, return error to use dummy data - return new WP_Error( - 'not_implemented', - __('Analytics API not yet implemented. Using dummy data.', 'woonoow'), - ['status' => 501] - ); - - // Future implementation: - // $data = self::calculate_overview_metrics(); - // return new WP_REST_Response($data, 200); + $period = $request->get_param('period') ?: '30'; + + $cache_key = 'woonoow_overview_' . md5($period); + $cached_data = get_transient($cache_key); + + if (false !== $cached_data) { + return new WP_REST_Response($cached_data, 200); + } + + $data = self::calculate_overview_metrics($period); + set_transient($cache_key, $data, 5 * MINUTE_IN_SECONDS); + + return new WP_REST_Response($data, 200); } catch (\Exception $e) { return new WP_Error( 'analytics_error', @@ -116,17 +118,22 @@ class AnalyticsController { public static function get_revenue(WP_REST_Request $request) { try { $granularity = $request->get_param('granularity') ?: 'day'; - - // TODO: Implement real analytics logic - return new WP_Error( - 'not_implemented', - __('Revenue analytics API not yet implemented. Using dummy data.', 'woonoow'), - ['status' => 501] - ); - - // Future implementation: - // $data = self::calculate_revenue_metrics($granularity); - // return new WP_REST_Response($data, 200); + $period = $request->get_param('period') ?: '30'; + + // Cache key based on parameters + $cache_key = 'woonoow_revenue_' . md5($granularity . '_' . $period); + $cached_data = get_transient($cache_key); + + if (false !== $cached_data) { + return new WP_REST_Response($cached_data, 200); + } + + $data = self::calculate_revenue_metrics($granularity, $period); + + // Cache for 5 minutes + set_transient($cache_key, $data, 5 * MINUTE_IN_SECONDS); + + return new WP_REST_Response($data, 200); } catch (\Exception $e) { return new WP_Error( 'analytics_error', @@ -144,12 +151,22 @@ class AnalyticsController { */ public static function get_orders(WP_REST_Request $request) { try { - // TODO: Implement real analytics logic - return new WP_Error( - 'not_implemented', - __('Orders analytics API not yet implemented. Using dummy data.', 'woonoow'), - ['status' => 501] - ); + $period = $request->get_param('period') ?: '30'; + + // Cache key + $cache_key = 'woonoow_orders_' . md5($period); + $cached_data = get_transient($cache_key); + + if (false !== $cached_data) { + return new WP_REST_Response($cached_data, 200); + } + + $data = self::calculate_orders_metrics($period); + + // Cache for 5 minutes + set_transient($cache_key, $data, 5 * MINUTE_IN_SECONDS); + + return new WP_REST_Response($data, 200); } catch (\Exception $e) { return new WP_Error( 'analytics_error', @@ -167,12 +184,19 @@ class AnalyticsController { */ public static function get_products(WP_REST_Request $request) { try { - // TODO: Implement real analytics logic - return new WP_Error( - 'not_implemented', - __('Products analytics API not yet implemented. Using dummy data.', 'woonoow'), - ['status' => 501] - ); + $period = $request->get_param('period') ?: '30'; + + $cache_key = 'woonoow_products_' . md5($period); + $cached_data = get_transient($cache_key); + + if (false !== $cached_data) { + return new WP_REST_Response($cached_data, 200); + } + + $data = self::calculate_products_metrics($period); + set_transient($cache_key, $data, 5 * MINUTE_IN_SECONDS); + + return new WP_REST_Response($data, 200); } catch (\Exception $e) { return new WP_Error( 'analytics_error', @@ -190,12 +214,19 @@ class AnalyticsController { */ public static function get_customers(WP_REST_Request $request) { try { - // TODO: Implement real analytics logic - return new WP_Error( - 'not_implemented', - __('Customers analytics API not yet implemented. Using dummy data.', 'woonoow'), - ['status' => 501] - ); + $period = $request->get_param('period') ?: '30'; + + $cache_key = 'woonoow_customers_' . md5($period); + $cached_data = get_transient($cache_key); + + if (false !== $cached_data) { + return new WP_REST_Response($cached_data, 200); + } + + $data = self::calculate_customers_metrics($period); + set_transient($cache_key, $data, 5 * MINUTE_IN_SECONDS); + + return new WP_REST_Response($data, 200); } catch (\Exception $e) { return new WP_Error( 'analytics_error', @@ -213,12 +244,19 @@ class AnalyticsController { */ public static function get_coupons(WP_REST_Request $request) { try { - // TODO: Implement real analytics logic - return new WP_Error( - 'not_implemented', - __('Coupons analytics API not yet implemented. Using dummy data.', 'woonoow'), - ['status' => 501] - ); + $period = $request->get_param('period') ?: '30'; + + $cache_key = 'woonoow_coupons_' . md5($period); + $cached_data = get_transient($cache_key); + + if (false !== $cached_data) { + return new WP_REST_Response($cached_data, 200); + } + + $data = self::calculate_coupons_metrics($period); + set_transient($cache_key, $data, 5 * MINUTE_IN_SECONDS); + + return new WP_REST_Response($data, 200); } catch (\Exception $e) { return new WP_Error( 'analytics_error', @@ -236,12 +274,19 @@ class AnalyticsController { */ public static function get_taxes(WP_REST_Request $request) { try { - // TODO: Implement real analytics logic - return new WP_Error( - 'not_implemented', - __('Taxes analytics API not yet implemented. Using dummy data.', 'woonoow'), - ['status' => 501] - ); + $period = $request->get_param('period') ?: '30'; + + $cache_key = 'woonoow_taxes_' . md5($period); + $cached_data = get_transient($cache_key); + + if (false !== $cached_data) { + return new WP_REST_Response($cached_data, 200); + } + + $data = self::calculate_taxes_metrics($period); + set_transient($cache_key, $data, 5 * MINUTE_IN_SECONDS); + + return new WP_REST_Response($data, 200); } catch (\Exception $e) { return new WP_Error( 'analytics_error', @@ -257,22 +302,889 @@ class AnalyticsController { /** * Calculate overview metrics - * TODO: Implement this method */ - private static function calculate_overview_metrics() { - // Will query WooCommerce HPOS tables - // Return structured data matching frontend expectations + private static function calculate_overview_metrics($period = '30') { + global $wpdb; + $orders_table = $wpdb->prefix . 'wc_orders'; + $days = $period === 'all' ? 365 : intval($period); + + // Get sales chart data + $salesChart = $wpdb->get_results($wpdb->prepare(" + SELECT + DATE(date_created_gmt) as date, + SUM(total_amount) as revenue, + COUNT(*) as orders + FROM {$orders_table} + WHERE status IN ('wc-completed', 'wc-processing') + AND date_created_gmt >= DATE_SUB(NOW(), INTERVAL %d DAY) + GROUP BY DATE(date_created_gmt) + ORDER BY date ASC + ", $days)); + + // Format sales chart + $formatted_sales = []; + $total_revenue = 0; + $total_orders = 0; + + foreach ($salesChart as $row) { + $revenue = round(floatval($row->revenue), 2); + $orders = intval($row->orders); + + $total_revenue += $revenue; + $total_orders += $orders; + + $formatted_sales[] = [ + 'date' => $row->date, + 'revenue' => $revenue, + 'orders' => $orders, + ]; + } + + // Get order status distribution + $orderStatusDistribution = $wpdb->get_results(" + SELECT + status, + COUNT(*) as count + FROM {$orders_table} + WHERE date_created_gmt >= DATE_SUB(NOW(), INTERVAL {$days} DAY) + GROUP BY status + ORDER BY count DESC + "); + + // Format status distribution + $formatted_status = []; + $status_colors = [ + 'wc-completed' => '#10b981', + 'wc-processing' => '#3b82f6', + 'wc-pending' => '#f59e0b', + 'wc-on-hold' => '#6b7280', + 'wc-cancelled' => '#ef4444', + 'wc-refunded' => '#8b5cf6', + 'wc-failed' => '#dc2626', + ]; + + foreach ($orderStatusDistribution as $status) { + $status_name = str_replace('wc-', '', $status->status); + $formatted_status[] = [ + 'status' => ucfirst($status_name), + 'count' => intval($status->count), + 'color' => $status_colors[$status->status] ?? '#6b7280', + ]; + } + + // Calculate metrics + $avg_order_value = $total_orders > 0 ? round($total_revenue / $total_orders, 2) : 0; + + // Get top products + $products_table = $wpdb->prefix . 'wc_order_product_lookup'; + $top_products = $wpdb->get_results($wpdb->prepare(" + SELECT + product_id, + SUM(product_qty) as items_sold, + SUM(product_gross_revenue) as revenue + FROM {$products_table} + WHERE date_created >= DATE_SUB(NOW(), INTERVAL %d DAY) + GROUP BY product_id + ORDER BY revenue DESC + LIMIT 5 + ", $days)); + + $formatted_products = []; + foreach ($top_products as $product) { + $wc_product = wc_get_product($product->product_id); + if ($wc_product) { + $formatted_products[] = [ + 'product_id' => intval($product->product_id), + 'name' => $wc_product->get_name(), + 'items_sold' => intval($product->items_sold), + 'revenue' => round(floatval($product->revenue), 2), + ]; + } + } + + // Get top customers + $top_customers = $wpdb->get_results($wpdb->prepare(" + SELECT + customer_id, + billing_email, + COUNT(*) as order_count, + SUM(total_amount) as total_spent + FROM {$orders_table} + WHERE status IN ('wc-completed', 'wc-processing') + AND date_created_gmt >= DATE_SUB(NOW(), INTERVAL %d DAY) + AND customer_id > 0 + GROUP BY customer_id + ORDER BY total_spent DESC + LIMIT 5 + ", $days)); + + $formatted_customers = []; + foreach ($top_customers as $customer) { + $user = get_user_by('id', $customer->customer_id); + $formatted_customers[] = [ + 'customer_id' => intval($customer->customer_id), + 'name' => $user ? $user->display_name : 'Guest', + 'email' => $customer->billing_email, + 'order_count' => intval($customer->order_count), + 'total_spent' => round(floatval($customer->total_spent), 2), + ]; + } + + return [ + 'metrics' => [ + 'revenue' => [ + 'today' => $total_revenue, + 'yesterday' => 0, // TODO: Calculate + 'change' => 0, + ], + 'orders' => [ + 'today' => $total_orders, + 'yesterday' => 0, // TODO: Calculate + 'change' => 0, + ], + 'avgOrderValue' => [ + 'today' => $avg_order_value, + 'yesterday' => 0, // TODO: Calculate + 'change' => 0, + ], + 'conversionRate' => [ + 'today' => 0, // TODO: Calculate + 'yesterday' => 0, + 'change' => 0, + ], + ], + 'salesChart' => $formatted_sales, + 'orderStatus' => $formatted_status, + 'orderStatusDistribution' => $formatted_status, // Keep for backward compatibility + 'topProducts' => $formatted_products, + 'topCustomers' => $formatted_customers, + 'lowStock' => [], // TODO: Implement + ]; } /** * Calculate revenue metrics - * TODO: Implement this method */ - private static function calculate_revenue_metrics($granularity = 'day') { - // Will query WooCommerce HPOS tables - // Group by granularity (day/week/month) - // Return structured data matching frontend expectations + private static function calculate_revenue_metrics($granularity = 'day', $period = '30') { + global $wpdb; + + // Determine date range + if ($period === 'all') { + $date_query = "1=1"; // All time + $days = 365; // Limit to 1 year for performance + } else { + $days = intval($period); + $date_query = "date_created_gmt >= DATE_SUB(NOW(), INTERVAL {$days} DAY)"; + } + + // Query HPOS orders table + $orders_table = $wpdb->prefix . 'wc_orders'; + + // Get chart data grouped by date + $chart_data_raw = $wpdb->get_results($wpdb->prepare(" + SELECT + DATE(date_created_gmt) as date, + SUM(total_amount) as gross, + SUM(total_amount - tax_amount) as net, + SUM(tax_amount) as tax, + 0 as refunds, + 0 as shipping + FROM {$orders_table} + WHERE status IN ('wc-completed', 'wc-processing') + AND {$date_query} + GROUP BY DATE(date_created_gmt) + ORDER BY date ASC + LIMIT %d + ", $days)); + + // Calculate overview metrics and format chart data + $total_gross = 0; + $total_net = 0; + $total_tax = 0; + $total_refunds = 0; + $order_count = 0; + $chart_data = []; + + foreach ($chart_data_raw as $row) { + $gross = round(floatval($row->gross), 2); + $net = round(floatval($row->net), 2); + $tax = round(floatval($row->tax), 2); + + $total_gross += $gross; + $total_net += $net; + $total_tax += $tax; + + // Format for frontend + $chart_data[] = [ + 'date' => $row->date, + 'gross' => $gross, + 'net' => $net, + 'tax' => $tax, + 'refunds' => 0, + 'shipping' => 0, + ]; + } + + // Get order count + $order_count = $wpdb->get_var($wpdb->prepare(" + SELECT COUNT(*) + FROM {$orders_table} + WHERE status IN ('wc-completed', 'wc-processing') + AND {$date_query} + ")); + + $avg_order_value = $order_count > 0 ? round($total_gross / $order_count, 2) : 0; + + // Get top products by revenue + $products_table = $wpdb->prefix . 'wc_order_product_lookup'; + $by_product = $wpdb->get_results($wpdb->prepare(" + SELECT + product_id, + SUM(product_gross_revenue) as revenue, + SUM(product_qty) as quantity + FROM {$products_table} + WHERE date_created >= DATE_SUB(NOW(), INTERVAL %d DAY) + GROUP BY product_id + ORDER BY revenue DESC + LIMIT 10 + ", $days)); + + // Format product data + $formatted_products = []; + foreach ($by_product as $product) { + $wc_product = wc_get_product($product->product_id); + if ($wc_product) { + $formatted_products[] = [ + 'product_id' => intval($product->product_id), + 'name' => $wc_product->get_name(), + 'revenue' => round(floatval($product->revenue), 2), + 'quantity' => intval($product->quantity), + ]; + } + } + + return [ + 'overview' => [ + 'gross_revenue' => round($total_gross, 2), + 'net_revenue' => round($total_net, 2), + 'tax' => round($total_tax, 2), + 'shipping' => 0, // TODO: Calculate + 'refunds' => round($total_refunds, 2), + 'change_percent' => 0, // TODO: Calculate + 'previous_gross_revenue' => 0, // TODO: Calculate + 'previous_net_revenue' => 0, // TODO: Calculate + ], + 'chart_data' => $chart_data, + 'by_product' => $formatted_products, + 'by_category' => [], // TODO: Implement + 'by_payment_method' => [], // TODO: Implement + 'by_shipping_method' => [], // TODO: Implement + ]; } - // Add more helper methods as needed... + /** + * Calculate products metrics + */ + private static function calculate_products_metrics($period = '30') { + global $wpdb; + $products_table = $wpdb->prefix . 'wc_order_product_lookup'; + + $days = $period === 'all' ? 365 : intval($period); + + // Get top products + $top_products = $wpdb->get_results($wpdb->prepare(" + SELECT + product_id, + SUM(product_qty) as items_sold, + SUM(product_gross_revenue) as revenue, + SUM(product_net_revenue) as net_revenue + FROM {$products_table} + WHERE date_created >= DATE_SUB(NOW(), INTERVAL %d DAY) + GROUP BY product_id + ORDER BY revenue DESC + LIMIT 20 + ", $days)); + + // Format products + $formatted_products = []; + $total_revenue = 0; + $total_items = 0; + + foreach ($top_products as $product) { + $wc_product = wc_get_product($product->product_id); + if ($wc_product) { + $revenue = round(floatval($product->revenue), 2); + $items_sold = intval($product->items_sold); + + $total_revenue += $revenue; + $total_items += $items_sold; + + $formatted_products[] = [ + 'id' => intval($product->product_id), + 'product_id' => intval($product->product_id), + 'name' => $wc_product->get_name(), + 'image' => '📦', + 'sku' => $wc_product->get_sku() ?: 'N/A', + 'items_sold' => $items_sold, + 'revenue' => $revenue, + 'net_revenue' => round(floatval($product->net_revenue), 2), + 'stock' => $wc_product->get_stock_quantity() ?: 0, + 'stock_status' => $wc_product->get_stock_status(), + 'stock_quantity' => $wc_product->get_stock_quantity() ?: 0, + 'views' => 0, + 'conversion_rate' => 0.0, + ]; + } + } + + // Calculate average price + $avg_price = $total_items > 0 ? round($total_revenue / $total_items, 2) : 0; + + // Get low stock and out of stock counts + $low_stock_count = $wpdb->get_var(" + SELECT COUNT(*) + FROM {$wpdb->prefix}posts p + INNER JOIN {$wpdb->prefix}postmeta pm ON p.ID = pm.post_id + WHERE p.post_type = 'product' + AND p.post_status = 'publish' + AND pm.meta_key = '_stock_status' + AND pm.meta_value = 'onbackorder' + "); + + $out_of_stock_count = $wpdb->get_var(" + SELECT COUNT(*) + FROM {$wpdb->prefix}posts p + INNER JOIN {$wpdb->prefix}postmeta pm ON p.ID = pm.post_id + WHERE p.post_type = 'product' + AND p.post_status = 'publish' + AND pm.meta_key = '_stock_status' + AND pm.meta_value = 'outofstock' + "); + + return [ + 'overview' => [ + 'items_sold' => $total_items, + 'revenue' => $total_revenue, + 'avg_price' => $avg_price, + 'low_stock_count' => intval($low_stock_count), + 'out_of_stock_count' => intval($out_of_stock_count), + 'change_percent' => 0, // TODO: Calculate + ], + 'top_products' => $formatted_products, + 'by_category' => [], // TODO: Implement + 'stock_analysis' => [ + 'low_stock' => [], + 'out_of_stock' => [], + 'slow_movers' => [], + ], + ]; + } + + /** + * Calculate taxes metrics + */ + private static function calculate_taxes_metrics($period = '30') { + global $wpdb; + $orders_table = $wpdb->prefix . 'wc_orders'; + $days = $period === 'all' ? 365 : intval($period); + + // Get tax data over time - ONLY orders with tax > 0 + $chart_data = $wpdb->get_results($wpdb->prepare(" + SELECT + DATE(date_created_gmt) as date, + SUM(tax_amount) as tax, + COUNT(*) as orders + FROM {$orders_table} + WHERE status IN ('wc-completed', 'wc-processing') + AND date_created_gmt >= DATE_SUB(NOW(), INTERVAL %d DAY) + AND tax_amount > 0 + GROUP BY DATE(date_created_gmt) + ORDER BY date ASC + ", $days)); + + // Format chart data + $formatted_chart = []; + $total_tax = 0; + $total_orders_with_tax = 0; + + foreach ($chart_data as $row) { + $tax = round(floatval($row->tax), 2); + $orders = intval($row->orders); + + $total_tax += $tax; + $total_orders_with_tax += $orders; + + $formatted_chart[] = [ + 'date' => $row->date, + 'tax' => $tax, + 'orders' => $orders, + ]; + } + + // Calculate average tax per order (only for orders with tax) + $avg_tax_per_order = $total_orders_with_tax > 0 ? round($total_tax / $total_orders_with_tax, 2) : 0; + + return [ + 'overview' => [ + 'total_tax' => $total_tax, + 'avg_tax_per_order' => $avg_tax_per_order, + 'orders_with_tax' => $total_orders_with_tax, + ], + 'chart_data' => $formatted_chart, + 'by_rate' => [], // TODO: Implement if needed + 'by_location' => [], // TODO: Implement if needed + ]; + } + + /** + * Calculate customers metrics + */ + private static function calculate_customers_metrics($period = '30') { + global $wpdb; + $orders_table = $wpdb->prefix . 'wc_orders'; + $days = $period === 'all' ? 365 : intval($period); + + // Get top customers by revenue + $top_customers = $wpdb->get_results($wpdb->prepare(" + SELECT + customer_id, + billing_email, + COUNT(*) as order_count, + SUM(total_amount) as total_spent + FROM {$orders_table} + WHERE status IN ('wc-completed', 'wc-processing') + AND date_created_gmt >= DATE_SUB(NOW(), INTERVAL %d DAY) + AND customer_id > 0 + GROUP BY customer_id + ORDER BY total_spent DESC + LIMIT 20 + ", $days)); + + // Format customers + $formatted_customers = []; + $total_customers = 0; + $total_revenue = 0; + $formatted_customers = []; + foreach ($top_customers as $customer) { + $user = get_user_by('id', $customer->customer_id); + $total_spent = round(floatval($customer->total_spent), 2); + $order_count = intval($customer->order_count); + + $total_customers++; + $total_revenue += $total_spent; + + $formatted_customers[] = [ + 'id' => intval($customer->customer_id), + 'customer_id' => intval($customer->customer_id), + 'name' => $user ? $user->display_name : 'Guest', + 'email' => $customer->billing_email, + 'orders' => $order_count, + 'order_count' => $order_count, + 'total_spent' => $total_spent, + 'avg_order_value' => round($total_spent / $order_count, 2), + 'last_order_date' => '', // TODO: Implement + 'segment' => 'returning', // TODO: Calculate + 'days_since_last_order' => 0, // TODO: Calculate + ]; + } + + // Get new vs returning + $new_customers = $wpdb->get_var($wpdb->prepare(" + SELECT COUNT(DISTINCT customer_id) + FROM {$orders_table} + WHERE customer_id > 0 + AND date_created_gmt >= DATE_SUB(NOW(), INTERVAL %d DAY) + AND customer_id IN ( + SELECT customer_id + FROM {$orders_table} + WHERE customer_id > 0 + GROUP BY customer_id + HAVING MIN(date_created_gmt) >= DATE_SUB(NOW(), INTERVAL %d DAY) + ) + ", $days, $days)); + + // Calculate additional metrics + $returning_customers = $total_customers - intval($new_customers); + $avg_ltv = $total_customers > 0 ? round($total_revenue / $total_customers, 2) : 0; + $avg_orders_per_customer = $total_customers > 0 ? round(array_sum(array_column($formatted_customers, 'order_count')) / $total_customers, 2) : 0; + + return [ + 'overview' => [ + 'total_customers' => $total_customers, + 'new_customers' => intval($new_customers), + 'returning_customers' => $returning_customers, + 'avg_ltv' => $avg_ltv, + 'retention_rate' => 0, // TODO: Calculate + 'avg_orders_per_customer' => $avg_orders_per_customer, + 'change_percent' => 0, // TODO: Calculate + ], + 'segments' => [ + 'new' => intval($new_customers), + 'returning' => $returning_customers, + 'vip' => 0, // TODO: Calculate + 'at_risk' => 0, // TODO: Calculate + ], + 'top_customers' => $formatted_customers, + 'acquisition_chart' => [], // TODO: Implement + 'ltv_distribution' => [], // TODO: Implement + ]; + } + + /** + * Calculate orders metrics + */ + private static function calculate_orders_metrics($period = '30') { + global $wpdb; + $orders_table = $wpdb->prefix . 'wc_orders'; + + // Determine date range + if ($period === 'all') { + $date_query = "1=1"; + $days = 365; + } else { + $days = intval($period); + $date_query = "date_created_gmt >= DATE_SUB(NOW(), INTERVAL {$days} DAY)"; + } + + // Get orders by status + $by_status = $wpdb->get_results(" + SELECT + status, + COUNT(*) as count, + SUM(total_amount) as total + FROM {$orders_table} + WHERE {$date_query} + GROUP BY status + ORDER BY count DESC + "); + + // Format status data + $formatted_status = []; + $total_orders = 0; + $total_revenue = 0; + + foreach ($by_status as $status) { + $status_name = str_replace('wc-', '', $status->status); + $count = intval($status->count); + $total = round(floatval($status->total), 2); + + $total_orders += $count; + if (in_array($status->status, ['wc-completed', 'wc-processing'])) { + $total_revenue += $total; + } + + $formatted_status[] = [ + 'status' => $status_name, + 'status_label' => ucfirst($status_name), + 'count' => $count, + 'total' => $total, + ]; + } + + // Get orders over time + $chart_data = $wpdb->get_results($wpdb->prepare(" + SELECT + DATE(date_created_gmt) as date, + COUNT(*) as orders, + SUM(CASE WHEN status = 'wc-completed' THEN 1 ELSE 0 END) as completed, + SUM(CASE WHEN status = 'wc-processing' THEN 1 ELSE 0 END) as processing, + SUM(CASE WHEN status = 'wc-pending' THEN 1 ELSE 0 END) as pending, + SUM(CASE WHEN status = 'wc-cancelled' THEN 1 ELSE 0 END) as cancelled, + SUM(CASE WHEN status = 'wc-refunded' THEN 1 ELSE 0 END) as refunded, + SUM(CASE WHEN status = 'wc-failed' THEN 1 ELSE 0 END) as failed + FROM {$orders_table} + WHERE {$date_query} + GROUP BY DATE(date_created_gmt) + ORDER BY date ASC + LIMIT %d + ", $days)); + + // Format chart data + $formatted_chart = []; + foreach ($chart_data as $row) { + $formatted_chart[] = [ + 'date' => $row->date, + 'orders' => intval($row->orders), + 'completed' => intval($row->completed), + 'processing' => intval($row->processing), + 'pending' => intval($row->pending), + 'cancelled' => intval($row->cancelled), + 'refunded' => intval($row->refunded), + 'failed' => intval($row->failed), + ]; + } + + // Calculate average order value and rates + $avg_order_value = $total_orders > 0 ? round($total_revenue / $total_orders, 2) : 0; + + // Calculate fulfillment and cancellation rates + $completed_orders = 0; + $cancelled_orders = 0; + foreach ($formatted_status as $status) { + if ($status['status'] === 'completed') { + $completed_orders = $status['count']; + } + if (in_array($status['status'], ['cancelled', 'failed', 'refunded'])) { + $cancelled_orders += $status['count']; + } + } + + $fulfillment_rate = $total_orders > 0 ? round(($completed_orders / $total_orders) * 100, 2) : 0; + $cancellation_rate = $total_orders > 0 ? round(($cancelled_orders / $total_orders) * 100, 2) : 0; + + // Calculate average processing time (time from pending/processing to completed) + $avg_processing_time_seconds = $wpdb->get_var($wpdb->prepare(" + SELECT AVG(TIMESTAMPDIFF(SECOND, date_created_gmt, date_updated_gmt)) + FROM {$orders_table} + WHERE status = 'wc-completed' + AND {$date_query} + AND date_updated_gmt > date_created_gmt + ")); + + // Convert to human-readable format + $avg_processing_time = '0 hours'; + if ($avg_processing_time_seconds > 0) { + $hours = floor($avg_processing_time_seconds / 3600); + $minutes = floor(($avg_processing_time_seconds % 3600) / 60); + + if ($hours > 24) { + $days = floor($hours / 24); + $remaining_hours = $hours % 24; + $avg_processing_time = $days . ' day' . ($days > 1 ? 's' : ''); + if ($remaining_hours > 0) { + $avg_processing_time .= ' ' . $remaining_hours . ' hour' . ($remaining_hours > 1 ? 's' : ''); + } + } elseif ($hours > 0) { + $avg_processing_time = $hours . ' hour' . ($hours > 1 ? 's' : ''); + if ($minutes > 0) { + $avg_processing_time .= ' ' . $minutes . ' min'; + } + } else { + $avg_processing_time = $minutes . ' minutes'; + } + } + + // Add color and percentage to status data - SORT BY IMPORTANCE + $status_colors = [ + 'completed' => '#10b981', + 'processing' => '#3b82f6', + 'pending' => '#f59e0b', + 'cancelled' => '#ef4444', + 'refunded' => '#8b5cf6', + 'failed' => '#dc2626', + 'on-hold' => '#6b7280', + ]; + + // Define sort order (most important first) + $status_order = ['completed', 'processing', 'pending', 'on-hold', 'cancelled', 'refunded', 'failed']; + + foreach ($formatted_status as &$status) { + $status['color'] = $status_colors[$status['status']] ?? '#6b7280'; + $status['percentage'] = $total_orders > 0 ? round(($status['count'] / $total_orders) * 100, 2) : 0; + } + + // Sort by predefined order + usort($formatted_status, function($a, $b) use ($status_order) { + $pos_a = array_search($a['status'], $status_order); + $pos_b = array_search($b['status'], $status_order); + $pos_a = $pos_a !== false ? $pos_a : 999; + $pos_b = $pos_b !== false ? $pos_b : 999; + return $pos_a - $pos_b; + }); + + // Get orders by hour of day + $by_hour = $wpdb->get_results($wpdb->prepare(" + SELECT + HOUR(date_created_gmt) as hour, + COUNT(*) as orders + FROM {$orders_table} + WHERE {$date_query} + GROUP BY HOUR(date_created_gmt) + ORDER BY hour ASC + ")); + + $formatted_by_hour = []; + for ($h = 0; $h < 24; $h++) { + $formatted_by_hour[] = [ + 'hour' => $h, + 'orders' => 0, + ]; + } + foreach ($by_hour as $row) { + $hour = intval($row->hour); + $formatted_by_hour[$hour]['orders'] = intval($row->orders); + } + + // Get orders by day of week + $by_day = $wpdb->get_results($wpdb->prepare(" + SELECT + DAYOFWEEK(date_created_gmt) as day_number, + COUNT(*) as orders + FROM {$orders_table} + WHERE {$date_query} + GROUP BY DAYOFWEEK(date_created_gmt) + ORDER BY day_number ASC + ")); + + $day_names = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + $formatted_by_day = []; + for ($d = 1; $d <= 7; $d++) { + $formatted_by_day[] = [ + 'day' => $day_names[$d - 1], + 'day_number' => $d, + 'orders' => 0, + ]; + } + foreach ($by_day as $row) { + $day_num = intval($row->day_number); + $formatted_by_day[$day_num - 1]['orders'] = intval($row->orders); + } + + return [ + 'overview' => [ + 'total_orders' => $total_orders, + 'avg_order_value' => $avg_order_value, + 'fulfillment_rate' => $fulfillment_rate, + 'cancellation_rate' => $cancellation_rate, + 'avg_processing_time' => $avg_processing_time, + 'change_percent' => 0, // TODO: Calculate + 'previous_total_orders' => 0, // TODO: Calculate + ], + 'by_status' => $formatted_status, + 'chart_data' => $formatted_chart, + 'by_hour' => $formatted_by_hour, + 'by_day_of_week' => $formatted_by_day, + ]; + } + + /** + * Calculate coupons metrics + */ + private static function calculate_coupons_metrics($period = '30') { + global $wpdb; + $orders_table = $wpdb->prefix . 'wc_orders'; + $order_items_table = $wpdb->prefix . 'woocommerce_order_items'; + $order_itemmeta_table = $wpdb->prefix . 'woocommerce_order_itemmeta'; + $days = $period === 'all' ? 365 : intval($period); + + // Get coupon usage data + $coupon_data = $wpdb->get_results($wpdb->prepare(" + SELECT + oi.order_item_name as coupon_code, + COUNT(DISTINCT oi.order_id) as uses, + SUM(oim.meta_value) as total_discount + FROM {$order_items_table} oi + INNER JOIN {$order_itemmeta_table} oim ON oi.order_item_id = oim.order_item_id + INNER JOIN {$orders_table} o ON oi.order_id = o.id + WHERE oi.order_item_type = 'coupon' + AND oim.meta_key = 'discount_amount' + AND o.status IN ('wc-completed', 'wc-processing') + AND o.date_created_gmt >= DATE_SUB(NOW(), INTERVAL %d DAY) + GROUP BY oi.order_item_name + ORDER BY total_discount DESC + ", $days)); + + // Format coupon performance data + $formatted_coupons = []; + $total_discount = 0; + $total_uses = 0; + + foreach ($coupon_data as $coupon) { + $discount = round(floatval($coupon->total_discount), 2); + $uses = intval($coupon->uses); + + $total_discount += $discount; + $total_uses += $uses; + + // Get coupon details from posts table + $coupon_post = $wpdb->get_row($wpdb->prepare(" + SELECT ID, post_title + FROM {$wpdb->prefix}posts + WHERE post_title = %s + AND post_type = 'shop_coupon' + LIMIT 1 + ", $coupon->coupon_code)); + + $coupon_id = $coupon_post ? intval($coupon_post->ID) : 0; + $wc_coupon = $coupon_id ? new \WC_Coupon($coupon_id) : null; + + // Get revenue generated from orders with this coupon + $revenue_generated = $wpdb->get_var($wpdb->prepare(" + SELECT SUM(o.total_amount) + FROM {$order_items_table} oi + INNER JOIN {$orders_table} o ON oi.order_id = o.id + WHERE oi.order_item_type = 'coupon' + AND oi.order_item_name = %s + AND o.status IN ('wc-completed', 'wc-processing') + AND o.date_created_gmt >= DATE_SUB(NOW(), INTERVAL %d DAY) + ", $coupon->coupon_code, $days)); + + $revenue_generated = round(floatval($revenue_generated), 2); + + // Calculate ROI: (Revenue Generated - Discount Given) / Discount Given * 100 + // ROI shows how much revenue was generated per dollar of discount + $roi = $discount > 0 ? round((($revenue_generated - $discount) / $discount) * 100, 2) : 0; + + $formatted_coupons[] = [ + 'id' => $coupon_id, + 'code' => $coupon->coupon_code, + 'type' => $wc_coupon ? $wc_coupon->get_discount_type() : 'fixed_cart', + 'amount' => $wc_coupon ? floatval($wc_coupon->get_amount()) : 0, + 'uses' => $uses, + 'discount_amount' => $discount, + 'revenue_generated' => $revenue_generated, + 'roi' => $roi, + 'usage_limit' => $wc_coupon ? $wc_coupon->get_usage_limit() : null, + 'expiry_date' => $wc_coupon && $wc_coupon->get_date_expires() ? $wc_coupon->get_date_expires()->format('Y-m-d') : null, + ]; + } + + // Get usage chart data over time + $usage_chart = $wpdb->get_results($wpdb->prepare(" + SELECT + DATE(o.date_created_gmt) as date, + COUNT(DISTINCT oi.order_id) as uses, + SUM(oim.meta_value) as discount, + SUM(o.total_amount) as revenue + FROM {$order_items_table} oi + INNER JOIN {$order_itemmeta_table} oim ON oi.order_item_id = oim.order_item_id + INNER JOIN {$orders_table} o ON oi.order_id = o.id + WHERE oi.order_item_type = 'coupon' + AND oim.meta_key = 'discount_amount' + AND o.status IN ('wc-completed', 'wc-processing') + AND o.date_created_gmt >= DATE_SUB(NOW(), INTERVAL %d DAY) + GROUP BY DATE(o.date_created_gmt) + ORDER BY date ASC + ", $days)); + + $formatted_chart = []; + $revenue_with_coupons = 0; + + foreach ($usage_chart as $row) { + $revenue = round(floatval($row->revenue), 2); + $revenue_with_coupons += $revenue; + + $formatted_chart[] = [ + 'date' => $row->date, + 'uses' => intval($row->uses), + 'discount' => round(floatval($row->discount), 2), + 'revenue' => $revenue, + ]; + } + + $avg_discount_per_order = $total_uses > 0 ? round($total_discount / $total_uses, 2) : 0; + + return [ + 'overview' => [ + 'total_discount' => $total_discount, + 'coupons_used' => $total_uses, + 'revenue_with_coupons' => $revenue_with_coupons, + 'avg_discount_per_order' => $avg_discount_per_order, + 'change_percent' => 0, // TODO: Calculate + ], + 'usage_chart' => $formatted_chart, + 'coupons' => $formatted_coupons, + ]; + } } + diff --git a/includes/Api/Permissions.php b/includes/Api/Permissions.php index 9478384..66e8a10 100644 --- a/includes/Api/Permissions.php +++ b/includes/Api/Permissions.php @@ -29,4 +29,12 @@ class Permissions { $nonce = $_SERVER['HTTP_X_WP_NONCE'] ?? ''; return (bool) wp_verify_nonce($nonce, 'wp_rest'); } + + /** + * Check if user has admin/manage_woocommerce permission + * Used for analytics and admin-only endpoints + */ + public static function check_admin_permission(): bool { + return current_user_can('manage_woocommerce') || current_user_can('manage_options'); + } } \ No newline at end of file diff --git a/includes/Api/Routes.php b/includes/Api/Routes.php index e69de29..5e98b52 100644 --- a/includes/Api/Routes.php +++ b/includes/Api/Routes.php @@ -0,0 +1,23 @@ +