ソースを参照

Admin summary: order-number filter, sortable columns, CSV export — v1.3.5 -> v1.3.6

- Filter: new "Order #" numeric input (exact match on order_id) alongside
  the existing status / product / file-name search. The DB layer already
  supported an order_id WHERE clause, so only the view + render_summary_page
  wiring needed to change.
- Sort: Order, Status and Date column headers are now clickable sort links
  using WP's sortable/.sorted classes. Default remains Date DESC. A fresh
  click on Order or Date starts DESC; Status starts ASC. The active sort is
  preserved via hidden inputs on the filter form and via query args on the
  pagination links, and render_summary_page enforces a whitelist of allowed
  orderby values to stay defensive against tampering.
- Bulk action: new "Download as CSV" option. Writes a UTF-8-BOM CSV of the
  selected rows (ID, File Name, Product, Variation ID, Order, Customer,
  Status, Date, File URL) to studiou-fpp-chunks/fpp-records-*.csv, returns
  a nonce-protected download URL served by handle_csv_download which
  streams the file and unlinks it — same shape as the existing ZIP flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dalibor Votruba 2 ヶ月 前
コミット
ee4ed1cb52

+ 2 - 2
studiou-wc-free-photo-product/CLAUDE.md

@@ -2,7 +2,7 @@
 
 ## Project Overview
 
-WordPress/WooCommerce plugin called **studiou-wc-free-photo-product** (QDR - Studiou WC Free Photo Product) v1.3.5.
+WordPress/WooCommerce plugin called **studiou-wc-free-photo-product** (QDR - Studiou WC Free Photo Product) v1.3.6.
 Allows publishing special variable products where customers upload custom raw images to be printed for a price.
 
 ## Initial Requirements
@@ -40,7 +40,7 @@ Allows publishing special variable products where customers upload custom raw im
 - `Studiou_WC_FPP_Single_Product` - Upload area on product page (`woocommerce_single_product_summary` priority 35), WC session storage, server-side validation (`woocommerce_add_to_cart_validation` priority 1) + safety net (`woocommerce_add_to_cart` removes items without photo). Exposes the shared `resolve_uploaded_file(Studiou_WC_FPP_DB $db)` helper that reads WC session first, then falls back to the upload cookies, and verifies the record is unattached to an order.
 - `Studiou_WC_FPP_Shortcode` - `[studiou_free_photo_product id="X"]` renders product with WC native variation form
 - `Studiou_WC_FPP_Cart` - Reads file data from WC session via `woocommerce_add_cart_item_data`, replaces cart/order item thumbnails (classic cart via `woocommerce_cart_item_thumbnail`, block cart via `woocommerce_product_get_image_id`/`woocommerce_product_variation_get_image_id`), order item meta, order linking
-- `Studiou_WC_FPP_Admin` - Summary page with filters/pagination, status management, ZIP download, file download with auto-status-change, bulk actions with confirmation dialogs
+- `Studiou_WC_FPP_Admin` - Summary page with filters (status, product, order number, file-name search) and pagination, sortable columns (Order / Status / Date — default Date DESC, whitelist enforced in both the view and `Studiou_WC_FPP_DB::get_file_records`), status management, ZIP download, CSV export (bulk "Download as CSV" — writes a UTF-8-BOM CSV of the selected rows into `studiou-fpp-chunks/`, returns a nonce-protected download URL served by `handle_csv_download` and deleted after streaming), file download with auto-status-change, bulk actions with confirmation dialogs
 - `Studiou_WC_FPP_Pricing` - Per-variation quantity discount tiers. Admin editor on `woocommerce_variation_options_pricing`, save via `woocommerce_save_product_variation`, runtime discount via `woocommerce_before_calculate_totals` (priority 20) using `set_price()` on the cart item's product instance. Tier meta stored as a PHP array in `_studiou_fpp_qty_tiers` on each `product_variation` post. Feature is gated to FPP-enabled parent products. Frontend: pre-selection notice on `woocommerce_single_product_summary@25`, inline tier-data JSON emitted via `wp_footer` as `window.studiouFppTierData`, consumed by `initVariantTableTiers()` in `assets/js/frontend.js` to wire up per-row qty→price updates on the pvtfw variant table. Native WC `variations_form` handling is kept as a fallback path.
 
 ## Frontend Flow

+ 34 - 0
studiou-wc-free-photo-product/assets/js/admin.js

@@ -213,6 +213,8 @@
 
                 if (action === 'download_zip') {
                     bulkDownloadZip(fileIds);
+                } else if (action === 'download_csv') {
+                    bulkDownloadCsv(fileIds);
                 } else if (action === 'delete') {
                     bulkDelete(fileIds);
                 } else if (action.startsWith('status_')) {
@@ -262,6 +264,38 @@
                 });
             }
 
+            function bulkDownloadCsv(fileIds) {
+                showBulkSpinner(true);
+                showAdminNotice(studiouWcfpp.i18n.generatingCsv || studiouWcfpp.i18n.generatingZip, 'info');
+
+                $.ajax({
+                    url: studiouWcfpp.ajaxUrl,
+                    type: 'POST',
+                    data: {
+                        action: 'studiou_wcfpp_download_csv',
+                        nonce: studiouWcfpp.nonce,
+                        file_ids: fileIds
+                    },
+                    success: function (response) {
+                        if (response.success) {
+                            window.location.href = response.data.download_url;
+                            showAdminNotice(
+                                response.data.row_count + ' row(s) exported.',
+                                'success'
+                            );
+                        } else {
+                            showAdminNotice(response.data ? response.data.message : studiouWcfpp.i18n.error, 'error');
+                        }
+                    },
+                    error: function () {
+                        showAdminNotice(studiouWcfpp.i18n.error, 'error');
+                    },
+                    complete: function () {
+                        showBulkSpinner(false);
+                    }
+                });
+            }
+
             function bulkDelete(fileIds) {
                 showBulkSpinner(true);
 

+ 148 - 13
studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-admin.php

@@ -15,38 +15,52 @@ class Studiou_WC_FPP_Admin {
         add_action('wp_ajax_studiou_wcfpp_update_status', array($this, 'ajax_update_status'));
         add_action('wp_ajax_studiou_wcfpp_delete_files', array($this, 'ajax_delete_files'));
         add_action('wp_ajax_studiou_wcfpp_download_zip', array($this, 'ajax_download_zip'));
+        add_action('wp_ajax_studiou_wcfpp_download_csv', array($this, 'ajax_download_csv'));
         add_action('wp_ajax_studiou_wcfpp_bulk_status', array($this, 'ajax_bulk_status'));
 
-        // Handle direct ZIP download and file download (marks as downloaded)
+        // Handle direct ZIP / CSV download and file download (marks as downloaded)
         add_action('admin_init', array($this, 'handle_zip_download'));
+        add_action('admin_init', array($this, 'handle_csv_download'));
         add_action('admin_init', array($this, 'handle_file_download'));
     }
 
     public function render_summary_page() {
         // Process filters
-        $current_status = isset($_GET['status']) ? sanitize_text_field($_GET['status']) : '';
+        $current_status  = isset($_GET['status']) ? sanitize_text_field($_GET['status']) : '';
         $current_product = isset($_GET['product_id']) ? absint($_GET['product_id']) : 0;
-        $current_search = isset($_GET['s']) ? sanitize_text_field($_GET['s']) : '';
-        $current_page = isset($_GET['paged']) ? max(1, absint($_GET['paged'])) : 1;
-        $per_page = 30;
+        $current_order   = isset($_GET['order_id']) ? absint($_GET['order_id']) : 0;
+        $current_search  = isset($_GET['s']) ? sanitize_text_field($_GET['s']) : '';
+        $current_page    = isset($_GET['paged']) ? max(1, absint($_GET['paged'])) : 1;
+        $per_page        = 30;
+
+        $allowed_orderby = array('order_id', 'status', 'created_at');
+        $orderby_in      = isset($_GET['orderby']) ? sanitize_text_field($_GET['orderby']) : 'created_at';
+        $orderby         = in_array($orderby_in, $allowed_orderby, true) ? $orderby_in : 'created_at';
+        $order_in        = isset($_GET['order']) ? strtoupper(sanitize_text_field($_GET['order'])) : 'DESC';
+        $order           = ($order_in === 'ASC') ? 'ASC' : 'DESC';
 
         $args = array(
-            'status'  => $current_status,
+            'status'     => $current_status,
             'product_id' => $current_product,
-            'search'  => $current_search,
-            'limit'   => $per_page,
-            'offset'  => ($current_page - 1) * $per_page,
-            'orderby' => isset($_GET['orderby']) ? sanitize_text_field($_GET['orderby']) : 'created_at',
-            'order'   => isset($_GET['order']) ? sanitize_text_field($_GET['order']) : 'DESC',
+            'order_id'   => $current_order,
+            'search'     => $current_search,
+            'limit'      => $per_page,
+            'offset'     => ($current_page - 1) * $per_page,
+            'orderby'    => $orderby,
+            'order'      => $order,
         );
 
-        $records = $this->db->get_file_records($args);
-        $total = $this->db->count_file_records($args);
+        $records     = $this->db->get_file_records($args);
+        $total       = $this->db->count_file_records($args);
         $total_pages = ceil($total / $per_page);
 
         // Get products that have FPP enabled for filter dropdown
         $fpp_products = $this->get_fpp_products();
 
+        // Expose active sort to the view
+        $current_orderby = $orderby;
+        $current_order_dir = $order;
+
         include STUDIOU_WCFPP_PLUGIN_DIR . 'views/admin-summary.php';
     }
 
@@ -255,6 +269,127 @@ class Studiou_WC_FPP_Admin {
         exit;
     }
 
+    public function ajax_download_csv() {
+        check_ajax_referer('studiou-wcfpp-nonce', 'nonce');
+
+        if (!current_user_can('manage_woocommerce')) {
+            wp_send_json_error(array('message' => __('Insufficient permissions.', 'studiou-wc-free-photo-product')));
+            return;
+        }
+
+        $file_ids = isset($_POST['file_ids']) ? array_map('absint', (array) $_POST['file_ids']) : array();
+        if (empty($file_ids)) {
+            wp_send_json_error(array('message' => __('No files selected.', 'studiou-wc-free-photo-product')));
+            return;
+        }
+
+        $records = $this->db->get_file_records_by_ids($file_ids);
+        if (empty($records)) {
+            wp_send_json_error(array('message' => __('No files found.', 'studiou-wc-free-photo-product')));
+            return;
+        }
+
+        $upload_dir = wp_upload_dir();
+        $csv_name   = 'fpp-records-' . date('Y-m-d-His') . '.csv';
+        $csv_dir    = $upload_dir['basedir'] . '/studiou-fpp-chunks';
+        if (!file_exists($csv_dir)) {
+            wp_mkdir_p($csv_dir);
+        }
+        $csv_path = $csv_dir . '/' . $csv_name;
+
+        $fh = fopen($csv_path, 'wb');
+        if (!$fh) {
+            wp_send_json_error(array('message' => __('Could not create CSV file.', 'studiou-wc-free-photo-product')));
+            return;
+        }
+
+        // UTF-8 BOM so Excel opens the file with the correct encoding
+        fwrite($fh, "\xEF\xBB\xBF");
+
+        // Header row — column labels translated
+        fputcsv($fh, array(
+            __('ID', 'studiou-wc-free-photo-product'),
+            __('File Name', 'studiou-wc-free-photo-product'),
+            __('Product', 'studiou-wc-free-photo-product'),
+            __('Variation ID', 'studiou-wc-free-photo-product'),
+            __('Order', 'studiou-wc-free-photo-product'),
+            __('Customer', 'studiou-wc-free-photo-product'),
+            __('Status', 'studiou-wc-free-photo-product'),
+            __('Date', 'studiou-wc-free-photo-product'),
+            __('File URL', 'studiou-wc-free-photo-product'),
+        ));
+
+        foreach ($records as $record) {
+            $product_title = get_the_title($record->product_id);
+
+            if ((int) $record->customer_id > 0) {
+                $user = get_userdata((int) $record->customer_id);
+                $customer_name = $user ? $user->display_name : '#' . (int) $record->customer_id;
+            } else {
+                $customer_name = __('Guest', 'studiou-wc-free-photo-product');
+            }
+
+            $file_url = wp_get_attachment_url((int) $record->attachment_id);
+
+            fputcsv($fh, array(
+                (int) $record->id,
+                (string) $record->file_name,
+                (string) $product_title,
+                (int) $record->variation_id,
+                (int) $record->order_id ? '#' . (int) $record->order_id : '',
+                (string) $customer_name,
+                (string) self::get_status_label($record->status),
+                (string) $record->created_at,
+                (string) ($file_url ?: ''),
+            ));
+        }
+
+        fclose($fh);
+
+        $download_url = add_query_arg(array(
+            'studiou_fpp_download_csv' => 1,
+            'file'     => $csv_name,
+            '_wpnonce' => wp_create_nonce('studiou-fpp-csv-download'),
+        ), admin_url('admin.php'));
+
+        wp_send_json_success(array(
+            'download_url' => $download_url,
+            'row_count'    => count($records),
+        ));
+    }
+
+    public function handle_csv_download() {
+        if (!isset($_GET['studiou_fpp_download_csv']) || !isset($_GET['file'])) {
+            return;
+        }
+
+        if (!wp_verify_nonce($_GET['_wpnonce'] ?? '', 'studiou-fpp-csv-download')) {
+            wp_die(__('Security check failed.', 'studiou-wc-free-photo-product'));
+        }
+
+        if (!current_user_can('manage_woocommerce')) {
+            wp_die(__('Insufficient permissions.', 'studiou-wc-free-photo-product'));
+        }
+
+        $file_name  = sanitize_file_name($_GET['file']);
+        $upload_dir = wp_upload_dir();
+        $file_path  = $upload_dir['basedir'] . '/studiou-fpp-chunks/' . $file_name;
+
+        if (!file_exists($file_path) || pathinfo($file_name, PATHINFO_EXTENSION) !== 'csv') {
+            wp_die(__('File not found.', 'studiou-wc-free-photo-product'));
+        }
+
+        header('Content-Type: text/csv; charset=UTF-8');
+        header('Content-Disposition: attachment; filename="' . $file_name . '"');
+        header('Content-Length: ' . filesize($file_path));
+        header('Pragma: no-cache');
+
+        readfile($file_path);
+
+        unlink($file_path);
+        exit;
+    }
+
     /**
      * Handle single file download — serves the file and marks status as "downloaded".
      */

+ 22 - 1
studiou-wc-free-photo-product/languages/studiou-wc-free-photo-product-cs_CZ.po

@@ -3,7 +3,7 @@
 # This file is distributed under the GPL v2 or later.
 msgid ""
 msgstr ""
-"Project-Id-Version: QDR - Studiou WC Free Photo Product 1.3.5\n"
+"Project-Id-Version: QDR - Studiou WC Free Photo Product 1.3.6\n"
 "Report-Msgid-Bugs-To: https://www.quadarax.com\n"
 "POT-Creation-Date: 2026-04-02 00:00+0000\n"
 "PO-Revision-Date: 2026-04-02 00:00+0000\n"
@@ -396,3 +396,24 @@ msgstr "Je dostupná množstevní sleva — ušetříte až %s %%. Vyberte varia
 
 msgid "Quantity discount available — select a variant to see the full tier table."
 msgstr "Je dostupná množstevní sleva — vyberte variantu pro zobrazení celé tabulky."
+
+msgid "Order #"
+msgstr "Číslo objednávky"
+
+msgid "Download as CSV"
+msgstr "Stáhnout jako CSV"
+
+msgid "Generating CSV file..."
+msgstr "Generování CSV souboru..."
+
+msgid "Could not create CSV file."
+msgstr "Nepodařilo se vytvořit CSV soubor."
+
+msgid "ID"
+msgstr "ID"
+
+msgid "Variation ID"
+msgstr "ID varianty"
+
+msgid "File URL"
+msgstr "URL souboru"

+ 29 - 1
studiou-wc-free-photo-product/languages/studiou-wc-free-photo-product.pot

@@ -2,7 +2,7 @@
 # This file is distributed under the GPL v2 or later.
 msgid ""
 msgstr ""
-"Project-Id-Version: QDR - Studiou WC Free Photo Product 1.3.5\n"
+"Project-Id-Version: QDR - Studiou WC Free Photo Product 1.3.6\n"
 "Report-Msgid-Bugs-To: https://www.quadarax.com\n"
 "POT-Creation-Date: 2026-04-02 00:00+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
@@ -520,3 +520,31 @@ msgstr ""
 #: includes/class-studiou-wc-fpp-pricing.php
 msgid "Quantity discount available — select a variant to see the full tier table."
 msgstr ""
+
+#: views/admin-summary.php
+msgid "Order #"
+msgstr ""
+
+#: views/admin-summary.php
+msgid "Download as CSV"
+msgstr ""
+
+#: studiou-wc-free-photo-product.php
+msgid "Generating CSV file..."
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-admin.php
+msgid "Could not create CSV file."
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-admin.php
+msgid "ID"
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-admin.php
+msgid "Variation ID"
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-admin.php
+msgid "File URL"
+msgstr ""

+ 7 - 4
studiou-wc-free-photo-product/readme.md

@@ -101,15 +101,17 @@ Navigate to **Products > Free Photo Files** to manage uploaded files.
 
 ### Features
 
-- Filter by status (Ready / Downloaded / Solved), product, or file name search
+- Filter by status (Ready / Downloaded / Solved), product, **order number** (exact match on the numeric `order_id`), or file-name search
+- Sortable columns: **Order**, **Status**, and **Date** (default sort is Date DESC). Toggle ASC/DESC by clicking the header. Sort and all active filters are preserved across pagination.
 - Inline status change per file via dropdown
 - Single file download (automatically marks status as "downloaded" if currently "ready")
 - Single file delete with media attachment cleanup
-- Bulk actions with confirmation dialogs: Download as ZIP, Set Status (Ready / Downloaded / Solved), Delete
-- Downloads always serve the **original, unscaled** file. WordPress auto-creates a `-scaled.jpg` for images above the big-image threshold (2560 px by default), but both single and ZIP downloads use `wp_get_original_image_path()` to return the raw upload.
+- Bulk actions with confirmation dialogs: Download ZIP, **Download as CSV**, Set Status (Ready / Downloaded / Solved), Delete
+- The CSV export writes a UTF-8-BOM-prefixed file (Excel-friendly) with columns: ID, File Name, Product, Variation ID, Order, Customer, Status, Date, File URL — mirroring the grid. The file is stored briefly in `studiou-fpp-chunks/`, served via a nonce-protected URL, and deleted after streaming.
+- File downloads always serve the **original, unscaled** file. WordPress auto-creates a `-scaled.jpg` for images above the big-image threshold (2560 px by default), but both single and ZIP downloads use `wp_get_original_image_path()` to return the raw upload.
 - Order number links (HPOS-compatible)
 - Customer name display
-- Pagination (30 files per page, preserves active filters across pages)
+- Pagination (30 files per page, preserves active filters and the active sort across pages)
 
 ### File Statuses
 
@@ -232,6 +234,7 @@ studiou-wc-free-photo-product/
 | `studiou_wcfpp_bulk_status` | Admin | Bulk update file statuses |
 | `studiou_wcfpp_delete_files` | Admin | Delete files (single or bulk) |
 | `studiou_wcfpp_download_zip` | Admin | Generate ZIP of selected files |
+| `studiou_wcfpp_download_csv` | Admin | Export selected file rows to a UTF-8 CSV (Excel-friendly) |
 
 ### WooCommerce Hooks Used
 

+ 4 - 2
studiou-wc-free-photo-product/studiou-wc-free-photo-product.php

@@ -3,7 +3,7 @@
  * Plugin Name: QDR - Studiou WC Free Photo Product
  * Plugin URI: https://www.quadarax.com/plugins/studiou-wc-free-photo-product
  * Description: Allows publishing special WooCommerce products with variants and custom raw image upload for photo printing
- * Version: 1.3.5
+ * Version: 1.3.6
  * Requires at least: 6.9.4
  * Requires PHP: 8.2
  * Requires Plugins: woocommerce, product-variant-table-for-woocommerce
@@ -22,7 +22,7 @@ if (!defined('WPINC')) {
     die;
 }
 
-if (!defined('STUDIOU_WCFPP_VERSION')) define('STUDIOU_WCFPP_VERSION', '1.3.5');
+if (!defined('STUDIOU_WCFPP_VERSION')) define('STUDIOU_WCFPP_VERSION', '1.3.6');
 if (!defined('STUDIOU_WCFPP_PLUGIN_DIR')) define('STUDIOU_WCFPP_PLUGIN_DIR', plugin_dir_path(__FILE__));
 if (!defined('STUDIOU_WCFPP_PLUGIN_URL')) define('STUDIOU_WCFPP_PLUGIN_URL', plugin_dir_url(__FILE__));
 if (!defined('STUDIOU_WCFPP_PLUGIN_BASENAME')) define('STUDIOU_WCFPP_PLUGIN_BASENAME', plugin_basename(__FILE__));
@@ -179,6 +179,7 @@ class Studiou_WC_Free_Photo_Product {
                     'statusUpdated' => __('Status updated.', 'studiou-wc-free-photo-product'),
                     'error' => __('An error occurred.', 'studiou-wc-free-photo-product'),
                     'generatingZip' => __('Generating ZIP archive...', 'studiou-wc-free-photo-product'),
+                    'generatingCsv' => __('Generating CSV file...', 'studiou-wc-free-photo-product'),
                     'deleting' => __('Deleting...', 'studiou-wc-free-photo-product'),
                 ),
             ));
@@ -211,6 +212,7 @@ class Studiou_WC_Free_Photo_Product {
                     'statusUpdated' => __('Status updated.', 'studiou-wc-free-photo-product'),
                     'error' => __('An error occurred.', 'studiou-wc-free-photo-product'),
                     'generatingZip' => __('Generating ZIP archive...', 'studiou-wc-free-photo-product'),
+                    'generatingCsv' => __('Generating CSV file...', 'studiou-wc-free-photo-product'),
                     'deleting' => __('Deleting...', 'studiou-wc-free-photo-product'),
                 ),
             ));

+ 60 - 11
studiou-wc-free-photo-product/views/admin-summary.php

@@ -9,10 +9,58 @@ if (!defined('WPINC')) {
 /** @var int $current_page */
 /** @var string $current_status */
 /** @var int $current_product */
+/** @var int $current_order */
 /** @var string $current_search */
 /** @var int $per_page */
 /** @var array $fpp_products */
+/** @var string $current_orderby */
+/** @var string $current_order_dir */
 $base_url = admin_url('edit.php?post_type=product&page=studiou-fpp-summary');
+
+// Build the base filter state that must survive on sort/pagination links
+$filter_state = array(
+    'post_type' => 'product',
+    'page'      => 'studiou-fpp-summary',
+);
+if ($current_status)  $filter_state['status']     = $current_status;
+if ($current_product) $filter_state['product_id'] = $current_product;
+if ($current_order)   $filter_state['order_id']   = $current_order;
+if ($current_search)  $filter_state['s']          = $current_search;
+
+/**
+ * Produce a <th> cell for a sortable column: toggles asc/desc and preserves filters.
+ */
+$sort_header = function ($column_key, $label) use ($filter_state, $current_orderby, $current_order_dir) {
+    $is_active = ($current_orderby === $column_key);
+    $next_dir  = ($is_active && $current_order_dir === 'ASC') ? 'DESC' : 'ASC';
+    if ($is_active && $current_order_dir === 'DESC') {
+        // If currently DESC, next click should flip to ASC
+        $next_dir = 'ASC';
+    } elseif (!$is_active) {
+        // Fresh click on a non-active column defaults to DESC for date/order, ASC feels odd
+        $next_dir = ($column_key === 'created_at' || $column_key === 'order_id') ? 'DESC' : 'ASC';
+    }
+
+    $args = $filter_state;
+    $args['orderby'] = $column_key;
+    $args['order']   = $next_dir;
+    $url = add_query_arg($args, admin_url('edit.php'));
+
+    $th_class = 'manage-column column-' . esc_attr($column_key) . ' sortable ';
+    if ($is_active) {
+        $th_class .= 'sorted ' . strtolower($current_order_dir);
+    } else {
+        $th_class .= 'desc';
+    }
+    ?>
+    <th class="<?php echo $th_class; ?>">
+        <a href="<?php echo esc_url($url); ?>">
+            <span><?php echo esc_html($label); ?></span>
+            <span class="sorting-indicator"></span>
+        </a>
+    </th>
+    <?php
+};
 ?>
 <div class="wrap studiou-fpp-admin-wrap">
     <h1><?php esc_html_e('Free Photo Files', 'studiou-wc-free-photo-product'); ?></h1>
@@ -24,6 +72,8 @@ $base_url = admin_url('edit.php?post_type=product&page=studiou-fpp-summary');
         <form method="get" action="<?php echo esc_url(admin_url('edit.php')); ?>">
             <input type="hidden" name="post_type" value="product" />
             <input type="hidden" name="page" value="studiou-fpp-summary" />
+            <input type="hidden" name="orderby" value="<?php echo esc_attr($current_orderby); ?>" />
+            <input type="hidden" name="order" value="<?php echo esc_attr($current_order_dir); ?>" />
 
             <select name="status">
                 <option value=""><?php esc_html_e('All Statuses', 'studiou-wc-free-photo-product'); ?></option>
@@ -39,6 +89,8 @@ $base_url = admin_url('edit.php?post_type=product&page=studiou-fpp-summary');
                 <?php endforeach; ?>
             </select>
 
+            <input type="number" name="order_id" min="0" step="1" value="<?php echo $current_order ? esc_attr($current_order) : ''; ?>" placeholder="<?php esc_attr_e('Order #', 'studiou-wc-free-photo-product'); ?>" class="studiou-fpp-filter-order" />
+
             <input type="search" name="s" value="<?php echo esc_attr($current_search); ?>" placeholder="<?php esc_attr_e('Search file name...', 'studiou-wc-free-photo-product'); ?>" />
 
             <button type="submit" class="button"><?php esc_html_e('Filter', 'studiou-wc-free-photo-product'); ?></button>
@@ -50,6 +102,7 @@ $base_url = admin_url('edit.php?post_type=product&page=studiou-fpp-summary');
         <select id="studiou-fpp-bulk-action">
             <option value=""><?php esc_html_e('Bulk Actions', 'studiou-wc-free-photo-product'); ?></option>
             <option value="download_zip"><?php esc_html_e('Download ZIP', 'studiou-wc-free-photo-product'); ?></option>
+            <option value="download_csv"><?php esc_html_e('Download as CSV', 'studiou-wc-free-photo-product'); ?></option>
             <option value="status_ready"><?php esc_html_e('Set Status: Ready', 'studiou-wc-free-photo-product'); ?></option>
             <option value="status_downloaded"><?php esc_html_e('Set Status: Downloaded', 'studiou-wc-free-photo-product'); ?></option>
             <option value="status_solved"><?php esc_html_e('Set Status: Solved', 'studiou-wc-free-photo-product'); ?></option>
@@ -76,10 +129,10 @@ $base_url = admin_url('edit.php?post_type=product&page=studiou-fpp-summary');
                 <th class="column-thumbnail"><?php esc_html_e('Preview', 'studiou-wc-free-photo-product'); ?></th>
                 <th class="column-filename"><?php esc_html_e('File Name', 'studiou-wc-free-photo-product'); ?></th>
                 <th class="column-product"><?php esc_html_e('Product', 'studiou-wc-free-photo-product'); ?></th>
-                <th class="column-order"><?php esc_html_e('Order', 'studiou-wc-free-photo-product'); ?></th>
+                <?php $sort_header('order_id', __('Order', 'studiou-wc-free-photo-product')); ?>
                 <th class="column-customer"><?php esc_html_e('Customer', 'studiou-wc-free-photo-product'); ?></th>
-                <th class="column-status"><?php esc_html_e('Status', 'studiou-wc-free-photo-product'); ?></th>
-                <th class="column-date"><?php esc_html_e('Date', 'studiou-wc-free-photo-product'); ?></th>
+                <?php $sort_header('status', __('Status', 'studiou-wc-free-photo-product')); ?>
+                <?php $sort_header('created_at', __('Date', 'studiou-wc-free-photo-product')); ?>
                 <th class="column-actions"><?php esc_html_e('Actions', 'studiou-wc-free-photo-product'); ?></th>
             </tr>
         </thead>
@@ -179,14 +232,10 @@ $base_url = admin_url('edit.php?post_type=product&page=studiou-fpp-summary');
                     )); ?>
                 </span>
                 <?php
-                // Preserve filters in pagination links
-                $pagination_args = array(
-                    'post_type'  => 'product',
-                    'page'       => 'studiou-fpp-summary',
-                );
-                if ($current_status) $pagination_args['status'] = $current_status;
-                if ($current_product) $pagination_args['product_id'] = $current_product;
-                if ($current_search) $pagination_args['s'] = $current_search;
+                // Preserve filters + active sort across pagination
+                $pagination_args = $filter_state;
+                $pagination_args['orderby'] = $current_orderby;
+                $pagination_args['order']   = $current_order_dir;
 
                 $pagination = paginate_links(array(
                     'base'      => add_query_arg('paged', '%#%', admin_url('edit.php?' . http_build_query($pagination_args))),