PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.5
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.5
4.4.8 4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.9.1 4.3.9 4.3.8 4.3.7 4.1.6.9 4.1.6.9.1 4.1.6.9.2 4.1.6.9.3 4.1.6.9.4 4.1.7 4.1.7.1 4.1.7.2 4.1.7.3 4.1.7.3.1 4.1.7.3.2 4.2.0 All 139 releases
learnpress / inc / TemplateHooks / Admin / AdminStatisticsReportTable.php

AdminStatisticsReportTable.php in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses 4.4.5, at inc/TemplateHooks/Admin/AdminStatisticsReportTable.php

1,107 lines 33.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace LearnPress\TemplateHooks\Admin;
4
5 use LearnPress\Helpers\Singleton;
6 use LearnPress\Helpers\Template;
7 use LearnPress\Statistics\DashboardStatisticsDB;
8 use LearnPress\Statistics\InstructorStatisticsProvider;
9 use LearnPress\Statistics\OrderExceptionsProvider;
10 use LearnPress\Statistics\PeriodHelper;
11 use LearnPress\Statistics\PeriodResolver;
12 use LearnPress\Statistics\StatisticsScope;
13 use LearnPress\TemplateHooks\Table\TableListTemplate;
14 use LP_Debug;
15 use LP_Helper;
16 use stdClass;
17 use Throwable;
18 use Exception;
19
20 defined( 'ABSPATH' ) || exit();
21
22 /**
23 * Server-rendered statistics report tables.
24 *
25 * Replaces the JS-rendered report-popup tables with the author's
26 * TableListTemplate, delivered/paginated through TemplateAJAX + loadAJAX.js
27 * (same pattern as AdminListStudentsEnrolled). The heavy lifting — filtered,
28 * paginated, searchable queries — reuses the DashboardStatisticsDB /
29 * provider methods; this class only shapes rows into the table markup.
30 *
31 * @since 4.4.2
32 * @version 1.0.0
33 */
34 class AdminStatisticsReportTable {
35 use Singleton;
36
37 const PER_PAGE = 20;
38
39 public function init(): void {
40 add_filter( 'lp/rest/ajax/allow_callback', array( $this, 'allow_callback' ) );
41 }
42
43 /**
44 * Whitelist the AJAX render callbacks.
45 *
46 * @param array $callbacks
47 * @return array
48 */
49 public function allow_callback( array $callbacks ): array {
50 $callbacks[] = self::class . ':render_report_table';
51 $callbacks[] = self::class . ':render_report_csv';
52
53 return $callbacks;
54 }
55
56 /**
57 * Tabs of the Statistics page.
58 *
59 * @return array
60 */
61 private static function get_tabs(): array {
62 return array(
63 'overview' => __( 'Overview', 'learnpress' ),
64 'orders' => __( 'Orders', 'learnpress' ),
65 'courses' => __( 'Courses', 'learnpress' ),
66 'users' => __( 'Users', 'learnpress' ),
67 'instructors' => __( 'Instructors', 'learnpress' ),
68 );
69 }
70
71 public function html_statistics() {
72 $tabs = self::get_tabs();
73 $tab = isset( $_REQUEST['tab'] ) ? LP_Helper::sanitize_params_submitted( $_REQUEST['tab'] ) : 'overview';
74 $tab = array_key_exists( $tab, $tabs ) ? $tab : 'overview';
75
76 $view = "statistics/{$tab}";
77 ob_start();
78 learn_press_admin_view( $view );
79 $content = ob_get_clean();
80
81 echo AdminTemplate::html_on_wp_admin_screen(
82 array(
83 'tabs' => $tabs,
84 'content' => $content,
85 'title' => __( 'Statistics', 'learnpress' ),
86 'id' => 'learn-press-statistics',
87 )
88 );
89 }
90
91 /**
92 * Row cap for the PHP-merged reports (top_courses / course_performance /
93 * instructor_report) and for CSV export.
94 *
95 * @return int
96 */
97 private static function max_rows(): int {
98 return (int) apply_filters( 'learn-press/statistics/report-max-rows', 2000 );
99 }
100
101 /**
102 * AJAX callback: render one page of a report as a TableListTemplate table.
103 *
104 * @param array $data Sent args ( report, filtertype, date, instructor_id, category_id, paged, search, ... ).
105 * @return stdClass { content }
106 */
107 public static function render_report_table( array $data ): stdClass {
108 $content = new stdClass();
109 $content->content = '';
110
111 try {
112 self::guard_permission();
113
114 $self = self::instance();
115 $report = sanitize_key( $data['report'] ?? '' );
116 $paged = max( 1, absint( $data['paged'] ?? 1 ) );
117 $search = trim( (string) LP_Helper::sanitize_params_submitted( $data['search'] ?? '' ) );
118 $per_page = self::PER_PAGE;
119 $total = 0;
120
121 $rows = $self->fetch_rows( $report, $data, $paged, $per_page, $total );
122 /**
123 * Filter the fetched report rows before the table / CSV is built.
124 *
125 * @param array $rows Row data for the current page.
126 * @param string $report Report id.
127 * @param array $data Sent args.
128 * @since 4.4.2
129 */
130 $rows = (array) apply_filters( 'learn-press/statistics/report/rows', $rows, $report, $data );
131 $columns = $self->columns_for( $report, $rows );
132 /**
133 * Filter the report column specs before the table / CSV is built.
134 *
135 * Each column: [ 'label', 'class'?, 'key'?, 'render'?( row ):html, 'csv'?( row ):string ].
136 * A 'render' callback owns its own escaping ( its return is echoed as cell HTML ).
137 *
138 * @param array $columns Column specs.
139 * @param string $report Report id.
140 * @param array $rows Current page rows.
141 * @since 4.4.2
142 */
143 $columns = (array) apply_filters( 'learn-press/statistics/report/columns', $columns, $report, $rows );
144
145 if ( empty( $columns ) ) {
146 throw new Exception( esc_html__( 'Unknown report.', 'learnpress' ) );
147 }
148
149 $content->content = $self->capped_notice( $report, $total ) . $self->html_table( $report, $rows, $columns, $paged, $per_page, $total );
150 } catch ( Throwable $e ) {
151 $content->content = Template::print_message( $e->getMessage(), 'error', false );
152 LP_Debug::error_log( $e );
153 }
154
155 return $content;
156 }
157
158 /**
159 * Notice shown when a PHP-merged report is truncated at max_rows(). Those
160 * reports ( top_courses / course_performance ) merge two query result sets
161 * in PHP and paginate the merged array, so rows beyond the cap are not
162 * reachable — surface that instead of silently hiding them. Empty string
163 * for SQL-paginated reports or when the cap was not hit.
164 *
165 * @param string $report
166 * @param int $total
167 * @return string
168 * @since 4.4.2
169 */
170 private function capped_notice( string $report, int $total ): string {
171 $php_merged = array( 'top_courses', 'course_performance' );
172 if ( ! in_array( $report, $php_merged, true ) || $total < self::max_rows() ) {
173 return '';
174 }
175
176 $message = sprintf(
177 /* translators: %d: maximum number of rows shown. */
178 __( 'Showing the first %d rows. Narrow the period or filters to see the rest.', 'learnpress' ),
179 self::max_rows()
180 );
181
182 return '<p class="lp-stats-report-capped">' . esc_html( $message ) . '</p>';
183 }
184
185 /**
186 * AJAX callback: build the full (capped) result set as a CSV string.
187 *
188 * @param array $data
189 * @return stdClass { csv, filename }
190 */
191 public static function render_report_csv( array $data ): stdClass {
192 $out = new stdClass();
193 $out->csv = '';
194 $out->filename = 'learnpress-report.csv';
195
196 try {
197 self::guard_permission();
198
199 $self = self::instance();
200 $report = sanitize_key( $data['report'] ?? '' );
201 $search = trim( (string) LP_Helper::sanitize_params_submitted( $data['search'] ?? '' ) );
202 $total = 0;
203
204 // One page big enough to hold everything ( capped ).
205 $rows = $self->fetch_rows( $report, $data, 1, self::max_rows(), $total );
206 /** This filter is documented in inc/TemplateHooks/Admin/AdminStatisticsReportTable.php */
207 $rows = (array) apply_filters( 'learn-press/statistics/report/rows', $rows, $report, $data );
208 $columns = $self->columns_for( $report, $rows );
209 /** This filter is documented in inc/TemplateHooks/Admin/AdminStatisticsReportTable.php */
210 $columns = (array) apply_filters( 'learn-press/statistics/report/columns', $columns, $report, $rows );
211
212 if ( ! empty( $columns ) ) {
213 $out->csv = $self->build_csv( $columns, $rows );
214 $out->filename = $self->csv_filename( $report, $data );
215 }
216 } catch ( Throwable $e ) {
217 LP_Debug::error_log( $e );
218 }
219
220 return $out;
221 }
222
223 /**
224 * @throws Exception
225 */
226 private static function guard_permission() {
227 $allowed = apply_filters( 'learnpress/admin-statistics/permission', current_user_can( 'administrator' ) );
228 if ( ! $allowed ) {
229 throw new Exception( esc_html__( 'You do not have permission to view this report.', 'learnpress' ) );
230 }
231 }
232
233 /**
234 * Map the request filtertype/date into [ filter_type, time ].
235 * Same PeriodResolver as LP_REST_Admin_Statistics_Controller::get_statistics_filter(),
236 * so report popups/CSV honor every preset — new and legacy — identically.
237 *
238 * @param array $data
239 * @return array
240 */
241 private function resolve_filter( array $data ): array {
242 $range = PeriodResolver::resolve(
243 (string) ( $data['filtertype'] ?? 'today' ),
244 (string) LP_Helper::sanitize_params_submitted( $data['date'] ?? '' )
245 );
246
247 return $range->legacy_pair();
248 }
249
250 /**
251 * Fetch one page of rows for a report ( total set by-ref ).
252 *
253 * @param string $report
254 * @param array $data
255 * @param int $paged
256 * @param int $limit
257 * @param int $total
258 * @return array
259 */
260 private function fetch_rows( string $report, array $data, int $paged, int $limit, int &$total ): array {
261 $filter = $this->resolve_filter( $data );
262 $scope = StatisticsScope::from_params( $data );
263 $type = $filter['filter_type'];
264 $time = (string) $filter['time'];
265 $offset = ( $paged - 1 ) * $limit;
266 $db = DashboardStatisticsDB::getInstance();
267 $search = trim( (string) LP_Helper::sanitize_params_submitted( $data['search'] ?? '' ) );
268
269 /**
270 * Filter the report query args before fetching a page of rows.
271 *
272 * Bounded scalars only ( no raw SQL ); every value is re-sanitized below
273 * so a handler cannot smuggle unsafe input. Use to change page size,
274 * inject a default search term, or repoint the report.
275 *
276 * @param array $args [ report, paged, limit, search ].
277 * @param string $report Report id.
278 * @param array $data Raw sent args.
279 * @since 4.4.2
280 */
281 $args = apply_filters(
282 'learn-press/statistics/report/query-args',
283 array(
284 'report' => $report,
285 'paged' => $paged,
286 'limit' => $limit,
287 'search' => $search,
288 ),
289 $report,
290 $data
291 );
292
293 $report = sanitize_key( $args['report'] ?? $report );
294 $paged = max( 1, absint( $args['paged'] ?? $paged ) );
295 $limit = max( 1, absint( $args['limit'] ?? $limit ) );
296 $search = trim( (string) ( $args['search'] ?? $search ) );
297 $offset = ( $paged - 1 ) * $limit; // Derived from the resolved paged/limit so it can never desync.
298
299 switch ( $report ) {
300 case 'top_courses':
301 $all = $db->get_top_courses_performance( $type, $time, $scope, self::max_rows(), $search );
302 $total = count( $all );
303 return $this->enrich_course_rows( $db, array_slice( $all, $offset, $limit ) );
304
305 case 'course_performance':
306 $all = $db->get_top_courses_performance( $type, $time, $scope, self::max_rows(), $search );
307 $total = count( $all );
308 return $this->format_course_performance_rows( $db, array_slice( $all, $offset, $limit ) );
309
310 case 'top_sold_courses':
311 $total = $db->count_top_sold_courses( $type, $time, $scope, $search );
312 $sold = array_map(
313 function ( $row ) {
314 $row['revenue_formatted'] = html_entity_decode( learn_press_format_price( $row['revenue'] ) );
315 $row['aov_formatted'] = null !== $row['aov'] ? html_entity_decode( learn_press_format_price( $row['aov'] ) ) : null;
316 return $row;
317 },
318 $db->get_top_sold_courses_detailed( $type, $time, $scope, $limit, $offset, $search )
319 );
320 return $this->attach_revenue_trend( $db, $filter, $scope, $sold );
321
322 case 'exceptions':
323 $status = sanitize_key( $data['order_status'] ?? '' );
324 $provider = OrderExceptionsProvider::getInstance();
325 $total = $provider->count_exceptions( $type, $time, $scope, $search, $status );
326 return $provider->get_exceptions( $type, $time, $scope, $limit, $offset, $search, $status );
327
328 case 'top_students':
329 $total = $db->count_top_students( $type, $time, $scope, $search );
330 return $db->get_top_students( $type, $time, $scope, $limit, $offset, $search );
331
332 case 'courses_by_students':
333 $total = $db->count_courses_by_students( $type, $time, $scope, $search );
334 return $db->get_courses_by_students( $type, $time, $scope, $limit, $offset, $search );
335
336 case 'instructor_performance':
337 $total = $db->count_instructor_performance( $type, $time, $scope, $search );
338 return InstructorStatisticsProvider::format_performance(
339 $db->get_instructor_performance( $type, $time, $scope, $limit, $offset, $search )
340 );
341
342 case 'instructor_report':
343 $instructor_id = absint( $data['instructor_id'] ?? 0 );
344 $report_data = InstructorStatisticsProvider::get_report( $instructor_id, $paged, $limit, $search );
345 $total = (int) ( $report_data['total'] ?? 0 );
346 return $report_data['rows'] ?? array();
347
348 default:
349 $total = 0;
350 return array();
351 }
352 }
353
354 /**
355 * Shape merged course-performance rows for the Courses report ( name,
356 * instructor, revenue, enrollments, completion ). Self-contained so the AJAX
357 * router never depends on the REST controller being loaded.
358 *
359 * @param DashboardStatisticsDB $db
360 * @param array $rows From get_top_courses_performance().
361 * @return array
362 */
363 private function format_course_performance_rows( DashboardStatisticsDB $db, array $rows ): array {
364 $course_ids = array_map(
365 function ( $row ) {
366 return absint( $row['course_id'] ?? 0 );
367 },
368 $rows
369 );
370 $instructors = $db->get_course_instructor_names( $course_ids );
371 $categories = $db->get_course_category_names( $course_ids );
372
373 return array_map(
374 function ( $row ) use ( $instructors, $categories ) {
375 $course_id = absint( $row['course_id'] ?? 0 );
376 $revenue = (float) ( $row['revenue'] ?? 0 );
377 $cats = $categories[ $course_id ] ?? array();
378
379 return array(
380 'course_id' => $course_id,
381 'name' => (string) ( $row['course_name'] ?? '' ),
382 'category' => implode( ', ', $cats ), // all — CSV
383 'category_primary' => $cats[0] ?? '', // first — table cell
384 'instructor' => $instructors[ $course_id ] ?? '',
385 'revenue' => $revenue,
386 'revenue_formatted' => html_entity_decode( learn_press_format_price( $revenue ) ),
387 'orders' => (int) ( $row['order_count'] ?? 0 ),
388 'enrollments' => (int) ( $row['enrolled'] ?? 0 ),
389 'completed' => (int) ( $row['completed'] ?? 0 ),
390 'completion_rate' => $row['completion_rate'] ?? null,
391 'edit_link' => $course_id > 0 ? (string) get_edit_post_link( $course_id, 'raw' ) : '',
392 );
393 },
394 $rows
395 );
396 }
397
398 /**
399 * Attach instructor + category names and formatted revenue to raw
400 * top-course rows ( keys kept: course_name/order_count/enrolled/... ).
401 *
402 * @param DashboardStatisticsDB $db
403 * @param array $rows From get_top_courses_performance().
404 * @return array
405 */
406 private function enrich_course_rows( DashboardStatisticsDB $db, array $rows ): array {
407 $course_ids = array_map(
408 function ( $row ) {
409 return absint( $row['course_id'] ?? 0 );
410 },
411 $rows
412 );
413 $instructors = $db->get_course_instructor_names( $course_ids );
414 $categories = $db->get_course_category_names( $course_ids );
415
416 return array_map(
417 function ( $row ) use ( $instructors, $categories ) {
418 $course_id = absint( $row['course_id'] ?? 0 );
419 $cats = $categories[ $course_id ] ?? array();
420 $row['instructor'] = $instructors[ $course_id ] ?? '';
421 $row['category'] = implode( ', ', $cats ); // all — CSV
422 $row['category_primary'] = $cats[0] ?? ''; // first — table cell
423 $row['revenue_formatted'] = html_entity_decode( learn_press_format_price( $row['revenue'] ?? 0 ) );
424 return $row;
425 },
426 $rows
427 );
428 }
429
430 /**
431 * Add a revenue "trend" ( vs the equivalent previous period ) to sold-course
432 * rows. One extra query for the page's course IDs; no-op on an empty page.
433 *
434 * @param DashboardStatisticsDB $db
435 * @param array $filter [ filter_type, time ] for the current range.
436 * @param StatisticsScope|null $scope
437 * @param array $rows
438 * @return array
439 */
440 private function attach_revenue_trend( DashboardStatisticsDB $db, array $filter, ?StatisticsScope $scope, array $rows ): array {
441 if ( empty( $rows ) ) {
442 return $rows;
443 }
444
445 $prev = PeriodHelper::get_previous_filter( $filter );
446 $prev_map = array();
447 if ( $prev ) {
448 $course_ids = array_map(
449 function ( $row ) {
450 return absint( $row['course_id'] ?? 0 );
451 },
452 $rows
453 );
454 $prev_map = $db->get_course_revenue_totals(
455 (string) ( $prev['filter_type'] ?? '' ),
456 (string) ( $prev['time'] ?? '' ),
457 $scope,
458 $course_ids
459 );
460 }
461
462 return array_map(
463 function ( $row ) use ( $prev_map ) {
464 $current = (float) ( $row['revenue'] ?? 0 );
465 $previous = (float) ( $prev_map[ absint( $row['course_id'] ?? 0 ) ] ?? 0 );
466 $row['trend'] = $this->trend_direction( $current, $previous );
467 $row['trend_pct'] = $this->trend_pct( $current, $previous );
468 return $row;
469 },
470 $rows
471 );
472 }
473
474 /**
475 * @param float $current
476 * @param float $previous
477 * @return string up|down|flat
478 */
479 private function trend_direction( float $current, float $previous ): string {
480 if ( $current > $previous ) {
481 return 'up';
482 }
483 if ( $current < $previous ) {
484 return 'down';
485 }
486
487 return 'flat';
488 }
489
490 /**
491 * @param float $current
492 * @param float $previous
493 * @return float|null Percentage change, or null when there is no prior baseline.
494 */
495 private function trend_pct( float $current, float $previous ) {
496 if ( $previous <= 0 ) {
497 return null;
498 }
499
500 return round( ( $current - $previous ) / $previous * 100, 1 );
501 }
502
503 /**
504 * @param array $row
505 * @return string HTML trend badge ( arrow + % ).
506 */
507 private function trend_cell( array $row ): string {
508 $dir = (string) ( $row['trend'] ?? 'flat' );
509 $pct = $row['trend_pct'] ?? null;
510 $arrows = array(
511 'up' => '',
512 'down' => '',
513 'flat' => '',
514 );
515 $colors = array(
516 'up' => 'green',
517 'down' => 'red',
518 'flat' => 'grey',
519 );
520 $arrow = $arrows[ $dir ] ?? '';
521 $color = $colors[ $dir ] ?? 'grey';
522
523 if ( null !== $pct ) {
524 $label = $arrow . ' ' . abs( $pct ) . '%';
525 } elseif ( 'up' === $dir ) {
526 $label = $arrow . ' ' . __( 'New', 'learnpress' );
527 } else {
528 $label = $arrow;
529 }
530
531 return $this->badge( $color, $label );
532 }
533
534 /**
535 * @param array $row
536 * @return string CSV representation of the trend.
537 */
538 private function trend_csv( array $row ): string {
539 $pct = $row['trend_pct'] ?? null;
540 if ( null === $pct ) {
541 return 'up' === ( $row['trend'] ?? '' ) ? __( 'New', 'learnpress' ) : '';
542 }
543
544 return $pct . '%';
545 }
546
547 /**
548 * Column specs for a report. Each column:
549 * [ 'label', 'class'?, 'key'?, 'render'?( row ):html, 'csv'?( row ):string ]
550 *
551 * @param string $report
552 * @param array $rows Current page ( used for conditional columns ).
553 * @return array
554 */
555 private function columns_for( string $report, array $rows ): array {
556 $i18n = array(
557 'course' => __( 'Course', 'learnpress' ),
558 'category' => __( 'Category', 'learnpress' ),
559 'instructor' => __( 'Instructor', 'learnpress' ),
560 'revenue' => __( 'Revenue', 'learnpress' ),
561 'total_revenue' => __( 'Total revenue', 'learnpress' ),
562 'orders' => __( 'Orders', 'learnpress' ),
563 'enrolled' => __( 'Enrolled', 'learnpress' ),
564 'enrollments' => __( 'Enrollments', 'learnpress' ),
565 'completion' => __( 'Completion', 'learnpress' ),
566 'avg_completion' => __( 'Avg completion', 'learnpress' ),
567 'completed' => __( 'Completed', 'learnpress' ),
568 'started' => __( 'Started', 'learnpress' ),
569 'active_7d' => __( 'Active 7d', 'learnpress' ),
570 'student' => __( 'Student', 'learnpress' ),
571 'students' => __( 'Students', 'learnpress' ),
572 'active_students' => __( 'Active students', 'learnpress' ),
573 'courses' => __( 'Courses', 'learnpress' ),
574 'courses_managed' => __( 'Courses managed', 'learnpress' ),
575 'status' => __( 'Status', 'learnpress' ),
576 'trend' => __( 'Trend', 'learnpress' ),
577 'aov' => __( 'AOV', 'learnpress' ),
578 'order_id' => __( 'Order ID', 'learnpress' ),
579 'issue' => __( 'Issue', 'learnpress' ),
580 'date' => __( 'Date', 'learnpress' ),
581 'severity' => __( 'Severity', 'learnpress' ),
582 'avg_score' => __( 'Quiz pass rate', 'learnpress' ),
583 'last_active' => __( 'Last active', 'learnpress' ),
584 'sold' => __( 'Sold', 'learnpress' ),
585 );
586
587 $col_text = function ( $key, $label ) {
588 return array(
589 'label' => $label,
590 'key' => $key,
591 );
592 };
593 $col_revenue = function ( $label = null ) use ( $i18n ) {
594 return array(
595 'label' => $label ?: $i18n['revenue'],
596 'render' => function ( $row ) {
597 return esc_html( (string) ( $row['revenue_formatted'] ?? '' ) );
598 },
599 'csv' => function ( $row ) {
600 return (string) ( $row['revenue'] ?? 0 );
601 },
602 );
603 };
604 $col_completion = function ( $key, $label ) {
605 return array(
606 'label' => $label,
607 'render' => function ( $row ) use ( $key ) {
608 return $this->completion_cell( $row[ $key ] ?? null );
609 },
610 'csv' => function ( $row ) use ( $key ) {
611 $val = $row[ $key ] ?? null;
612 return null === $val ? '' : $val . '%';
613 },
614 );
615 };
616 // Table shows only the primary category; CSV exports every category.
617 $col_category = function () use ( $i18n ) {
618 return array(
619 'label' => $i18n['category'],
620 'render' => function ( $row ) {
621 return esc_html( (string) ( $row['category_primary'] ?? '' ) );
622 },
623 'csv' => function ( $row ) {
624 return (string) ( $row['category'] ?? '' );
625 },
626 );
627 };
628
629 switch ( $report ) {
630 case 'top_courses':
631 return array(
632 $col_text( 'course_name', $i18n['course'] ),
633 $col_category(),
634 $col_text( 'instructor', $i18n['instructor'] ),
635 $col_revenue(),
636 $col_text( 'order_count', $i18n['orders'] ),
637 $col_text( 'enrolled', $i18n['enrollments'] ),
638 $col_completion( 'completion_rate', $i18n['completion'] ),
639 );
640
641 case 'course_performance':
642 return array(
643 $col_text( 'name', $i18n['course'] ),
644 $col_category(),
645 $col_text( 'instructor', $i18n['instructor'] ),
646 $col_revenue(),
647 $col_text( 'orders', $i18n['orders'] ),
648 $col_text( 'enrollments', $i18n['enrollments'] ),
649 $col_completion( 'completion_rate', $i18n['completion'] ),
650 );
651
652 case 'top_sold_courses':
653 return array(
654 $col_text( 'name', $i18n['course'] ),
655 $col_revenue(),
656 $col_text( 'orders', $i18n['orders'] ),
657 array(
658 'label' => $i18n['aov'],
659 'render' => function ( $row ) {
660 return esc_html( (string) ( $row['aov_formatted'] ?? '' ) );
661 },
662 'csv' => function ( $row ) {
663 return (string) ( $row['aov'] ?? '' );
664 },
665 ),
666 array(
667 'label' => $i18n['status'],
668 'render' => function ( $row ) {
669 $slug = (string) ( $row['status_label'] ?? '' );
670 return $this->badge( $this->sold_status_color( $slug ), $this->sold_status_label( $slug ) );
671 },
672 'csv' => function ( $row ) {
673 return $this->sold_status_label( (string) ( $row['status_label'] ?? '' ) );
674 },
675 ),
676 array(
677 'label' => $i18n['trend'],
678 'render' => function ( $row ) {
679 return $this->trend_cell( $row );
680 },
681 'csv' => function ( $row ) {
682 return $this->trend_csv( $row );
683 },
684 ),
685 );
686
687 case 'exceptions':
688 return array(
689 array(
690 'label' => $i18n['order_id'],
691 'render' => function ( $row ) {
692 $id = absint( $row['order_id'] ?? 0 );
693 $link = (string) ( $row['edit_link'] ?? '' );
694 if ( $link ) {
695 return sprintf( '<a href="%s">#%d</a>', esc_url( $link ), $id );
696 }
697 return esc_html( (string) $id );
698 },
699 'csv' => function ( $row ) {
700 return (string) absint( $row['order_id'] ?? 0 );
701 },
702 ),
703 $col_text( 'student', $i18n['student'] ),
704 $col_text( 'course', $i18n['course'] ),
705 $col_text( 'issue', $i18n['issue'] ),
706 $col_text( 'date', $i18n['date'] ),
707 array(
708 'label' => $i18n['severity'],
709 'render' => function ( $row ) {
710 $slug = (string) ( $row['severity'] ?? '' );
711 return $this->badge( $this->severity_color( $slug ), $this->severity_label( $slug ) );
712 },
713 'csv' => function ( $row ) {
714 return $this->severity_label( (string) ( $row['severity'] ?? '' ) );
715 },
716 ),
717 );
718
719 case 'top_students':
720 $columns = array(
721 $col_text( 'name', $i18n['student'] ),
722 $col_text( 'enrolled', $i18n['enrolled'] ),
723 $col_text( 'completed', $i18n['completed'] ),
724 );
725
726 $has_scores = false;
727 foreach ( $rows as $row ) {
728 if ( null !== ( $row['avg_score'] ?? null ) ) {
729 $has_scores = true;
730 break;
731 }
732 }
733 if ( $has_scores ) {
734 $columns[] = array(
735 'label' => $i18n['avg_score'],
736 'render' => function ( $row ) {
737 $val = $row['avg_score'] ?? null;
738 return null === $val ? '' : esc_html( $val . '%' );
739 },
740 'csv' => function ( $row ) {
741 $val = $row['avg_score'] ?? null;
742 return null === $val ? '' : $val . '%';
743 },
744 );
745 }
746
747 $columns[] = array(
748 'label' => $i18n['last_active'],
749 'render' => function ( $row ) {
750 return $this->last_active_cell( $row['last_active'] ?? '' );
751 },
752 'csv' => function ( $row ) {
753 return (string) ( $row['last_active'] ?? '' );
754 },
755 );
756 $columns[] = array(
757 'label' => $i18n['status'],
758 'render' => function ( $row ) {
759 $slug = (string) ( $row['status'] ?? '' );
760 return $this->badge( $this->student_status_color( $slug ), $this->student_status_label( $slug ) );
761 },
762 'csv' => function ( $row ) {
763 return $this->student_status_label( (string) ( $row['status'] ?? '' ) );
764 },
765 );
766
767 return $columns;
768
769 case 'courses_by_students':
770 return array(
771 $col_text( 'name', $i18n['course'] ),
772 $col_text( 'enrolled', $i18n['enrolled'] ),
773 $col_text( 'started', $i18n['started'] ),
774 $col_text( 'completed', $i18n['completed'] ),
775 $col_completion( 'completion_rate', $i18n['completion'] ),
776 $col_text( 'active_7d', $i18n['active_7d'] ),
777 );
778
779 case 'instructor_performance':
780 return array(
781 $col_text( 'name', $i18n['instructor'] ),
782 $col_text( 'courses', $i18n['courses_managed'] ),
783 $col_text( 'students', $i18n['active_students'] ),
784 $col_revenue( $i18n['total_revenue'] ),
785 $col_completion( 'avg_completion', $i18n['avg_completion'] ),
786 );
787
788 case 'instructor_report':
789 return array(
790 $col_text( 'name', $i18n['course'] ),
791 $col_text( 'sold', $i18n['sold'] ),
792 $col_revenue(),
793 $col_text( 'enrolled', $i18n['enrolled'] ),
794 );
795
796 default:
797 return array();
798 }
799 }
800
801 /**
802 * Assemble the TableListTemplate table + pagination footer.
803 *
804 * @param string $report
805 * @param array $rows
806 * @param array $columns
807 * @param int $paged
808 * @param int $per_page
809 * @param int $total
810 * @return string
811 */
812 private function html_table( string $report, array $rows, array $columns, int $paged, int $per_page, int $total ): string {
813 $header = array();
814 foreach ( $columns as $key => $col ) {
815 $header[ $key ] = array(
816 'class' => $col['class'] ?? '',
817 'title' => $col['label'] ?? '',
818 );
819 }
820
821 $rows_html = '';
822 foreach ( $rows as $row ) {
823 $tds = '';
824 foreach ( $columns as $col ) {
825 $tds .= sprintf( '<td>%s</td>', $this->cell_html( $col, $row ) );
826 }
827 $rows_html .= '<tr>' . $tds . '</tr>';
828 }
829
830 $footer = sprintf(
831 '<div class="lp-stats-report-table-footer"><span class="lp-stats-report-table-footer__count">%s</span>%s</div>',
832 TableListTemplate::instance()->html_page_result(
833 array(
834 'paged' => $paged,
835 'per_page' => $per_page,
836 'total_rows' => $total,
837 'item_name' => $this->item_name( $report, $total ),
838 )
839 ),
840 $this->html_pagination( $total, $paged, $per_page )
841 );
842
843 $table_args = array(
844 'class_table' => 'lp-stats-report-table',
845 'header' => $header,
846 'body' => array( 'rows_html' => $rows_html ),
847 'footer' => empty( $rows ) ? '' : $footer,
848 );
849
850 return TableListTemplate::instance()->html_table( $table_args );
851 }
852
853 /**
854 * @param array $col
855 * @param array $row
856 * @return string HTML for a single cell.
857 */
858 private function cell_html( array $col, array $row ): string {
859 if ( isset( $col['render'] ) && is_callable( $col['render'] ) ) {
860 return (string) call_user_func( $col['render'], $row );
861 }
862
863 $key = $col['key'] ?? '';
864 return esc_html( (string) ( $row[ $key ] ?? '' ) );
865 }
866
867 /**
868 * @param int $total
869 * @param int $paged
870 * @param int $per_page
871 * @return string
872 */
873 private function html_pagination( int $total, int $paged, int $per_page ): string {
874 $total_pages = max( 1, ceil( $total / max( 1, $per_page ) ) );
875 if ( $total_pages < 2 ) {
876 return '';
877 }
878
879 return Template::instance()->html_pagination(
880 array(
881 'total_pages' => $total_pages,
882 'paged' => $paged,
883 'wrapper' => array(
884 '<nav class="learn-press-pagination navigation pagination lp-pagination">' => '</nav>',
885 ),
886 )
887 );
888 }
889
890 /**
891 * @param string $report
892 * @param int $total
893 * @return string
894 */
895 private function item_name( string $report, int $total ): string {
896 $course_reports = array( 'top_courses', 'course_performance', 'top_sold_courses', 'courses_by_students', 'instructor_report' );
897 if ( in_array( $report, $course_reports, true ) ) {
898 return _n( 'course', 'courses', $total, 'learnpress' );
899 }
900 if ( 'top_students' === $report ) {
901 return _n( 'student', 'students', $total, 'learnpress' );
902 }
903 if ( 'instructor_performance' === $report ) {
904 return _n( 'instructor', 'instructors', $total, 'learnpress' );
905 }
906 if ( 'exceptions' === $report ) {
907 return _n( 'order', 'orders', $total, 'learnpress' );
908 }
909
910 return _n( 'item', 'items', $total, 'learnpress' );
911 }
912
913 /**
914 * @param array $columns
915 * @param array $rows
916 * @return string
917 */
918 private function build_csv( array $columns, array $rows ): string {
919 $lines = array();
920
921 $head = array();
922 foreach ( $columns as $col ) {
923 $head[] = $this->csv_escape( $col['label'] ?? '' );
924 }
925 $lines[] = implode( ',', $head );
926
927 foreach ( $rows as $row ) {
928 $cells = array();
929 foreach ( $columns as $col ) {
930 if ( isset( $col['csv'] ) && is_callable( $col['csv'] ) ) {
931 $value = call_user_func( $col['csv'], $row );
932 } else {
933 $key = $col['key'] ?? '';
934 $value = $row[ $key ] ?? '';
935 }
936 $cells[] = $this->csv_escape( $value );
937 }
938 $lines[] = implode( ',', $cells );
939 }
940
941 return implode( "\r\n", $lines );
942 }
943
944 /**
945 * RFC-4180 escaping + CSV-injection guard ( mirrors csv.js ).
946 *
947 * @param mixed $value
948 * @return string
949 */
950 private function csv_escape( $value ): string {
951 $str = (string) ( $value ?? '' );
952
953 if ( preg_match( '/^[=+\-@]/', $str ) ) {
954 $str = "'" . $str;
955 }
956 if ( preg_match( '/[",\r\n]/', $str ) ) {
957 $str = '"' . str_replace( '"', '""', $str ) . '"';
958 }
959
960 return $str;
961 }
962
963 /**
964 * @param string $report
965 * @param array $data
966 * @return string
967 */
968 private function csv_filename( string $report, array $data ): string {
969 $filtertype = sanitize_key( $data['filtertype'] ?? 'today' );
970 $report = $report ?: 'report';
971
972 return sprintf( 'learnpress-%s-%s.csv', sanitize_title( $report ), sanitize_title( $filtertype ) );
973 }
974
975 // --- Cell / badge helpers ( ported from the tab JS ) ---------------------
976
977 /**
978 * @param string $color '' | green | yellow | red | grey
979 * @param string $label
980 * @return string
981 */
982 private function badge( string $color, string $label ): string {
983 if ( '' === $color ) {
984 return esc_html( $label );
985 }
986
987 return sprintf(
988 '<span class="lp-badge lp-badge--%s">%s</span>',
989 esc_attr( $color ),
990 esc_html( $label )
991 );
992 }
993
994 /**
995 * @param float|null $rate
996 * @return string
997 */
998 private function completion_cell( $rate ): string {
999 if ( null === $rate ) {
1000 return '';
1001 }
1002
1003 return $this->badge( $this->completion_color( (float) $rate ), $rate . '%' );
1004 }
1005
1006 /**
1007 * @param float $rate
1008 * @return string
1009 */
1010 private function completion_color( float $rate ): string {
1011 $bands = (array) apply_filters(
1012 'learn-press/statistics/completion-badge-thresholds',
1013 array(
1014 'green' => 60,
1015 'yellow' => 40,
1016 )
1017 );
1018 $green = (float) ( $bands['green'] ?? 60 );
1019 $yellow = (float) ( $bands['yellow'] ?? 40 );
1020
1021 if ( $rate >= $green ) {
1022 return 'green';
1023 }
1024
1025 return $rate >= $yellow ? 'yellow' : 'red';
1026 }
1027
1028 private function sold_status_label( string $slug ): string {
1029 $labels = array(
1030 'healthy' => __( 'Healthy', 'learnpress' ),
1031 'watch_completion' => __( 'Watch completion', 'learnpress' ),
1032 'high_failed_quizzes' => __( 'High failed quizzes', 'learnpress' ),
1033 );
1034
1035 return $labels[ $slug ] ?? $slug;
1036 }
1037
1038 private function sold_status_color( string $slug ): string {
1039 if ( 'high_failed_quizzes' === $slug ) {
1040 return 'red';
1041 }
1042 if ( 'watch_completion' === $slug ) {
1043 return 'yellow';
1044 }
1045
1046 return 'green';
1047 }
1048
1049 private function severity_label( string $slug ): string {
1050 $labels = array(
1051 'high' => __( 'High', 'learnpress' ),
1052 'medium' => __( 'Medium', 'learnpress' ),
1053 'low' => __( 'Low', 'learnpress' ),
1054 );
1055
1056 return $labels[ $slug ] ?? $slug;
1057 }
1058
1059 private function severity_color( string $slug ): string {
1060 if ( 'high' === $slug ) {
1061 return 'red';
1062 }
1063 if ( 'medium' === $slug ) {
1064 return 'yellow';
1065 }
1066
1067 return 'grey';
1068 }
1069
1070 private function student_status_label( string $slug ): string {
1071 $labels = array(
1072 'active' => __( 'Active', 'learnpress' ),
1073 'at_risk' => __( 'At risk', 'learnpress' ),
1074 'idle' => __( 'Idle', 'learnpress' ),
1075 );
1076
1077 return $labels[ $slug ] ?? $slug;
1078 }
1079
1080 private function student_status_color( string $slug ): string {
1081 $colors = array(
1082 'active' => 'green',
1083 'at_risk' => 'yellow',
1084 'idle' => 'grey',
1085 );
1086
1087 return $colors[ $slug ] ?? '';
1088 }
1089
1090 /**
1091 * @param string $value MySQL datetime.
1092 * @return string
1093 */
1094 private function last_active_cell( string $value ): string {
1095 if ( empty( $value ) ) {
1096 return '';
1097 }
1098
1099 $ts = strtotime( $value );
1100 if ( ! $ts ) {
1101 return '';
1102 }
1103
1104 return esc_html( date_i18n( get_option( 'date_format' ), $ts ) );
1105 }
1106 }
1107