PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.3
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.3
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.3, at inc/TemplateHooks/Admin/AdminStatisticsReportTable.php

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