*/ class PizzaPilot_Kitchen { /** * The ID of this plugin. * * @since 1.1.0 * @access private * @var string $plugin_name The ID of this plugin. */ private $plugin_name; /** * The version of this plugin. * * @since 1.1.0 * @access private * @var string $version The current version of this plugin. */ private $version; /** * Initialize the class and set its properties. * * @since 1.1.0 * @param string $plugin_name The name of this plugin. * @param string $version The version of this plugin. */ public function __construct( $plugin_name, $version ) { $this->plugin_name = $plugin_name; $this->version = $version; } /** * Register the kitchen orders submenu page under PizzaPilot. * * @since 1.1.0 * @return void */ public function add_kitchen_menu() { add_submenu_page( 'pizzapilot-settings', __( 'Kitchen Orders', 'pizzapilot' ), __( 'Kitchen Orders', 'pizzapilot' ), 'edit_shop_orders', 'pizzapilot-kitchen', array( $this, 'render_kitchen_page' ) ); } /** * Enqueue styles for the kitchen page. * * @since 1.1.0 * @param string $hook_suffix The current admin page hook suffix. * @return void */ public function enqueue_kitchen_styles( $hook_suffix ) { if ( 'pizzapilot_page_pizzapilot-kitchen' !== $hook_suffix ) { return; } wp_enqueue_style( $this->plugin_name . '-kitchen', plugin_dir_url( __FILE__ ) . 'css/pizzapilot-kitchen.css', array(), $this->version, 'all' ); } /** * Add contextual help tab to the Kitchen Orders page. * * Provides guidance on using the kitchen order view via the * WordPress Help dropdown at the top of the screen. * * @since 1.1.0 * @return void */ public function add_kitchen_help_tab() { $screen = get_current_screen(); if ( ! $screen || 'pizzapilot_page_pizzapilot-kitchen' !== $screen->id ) { return; } $screen->add_help_tab( array( 'id' => 'pizzapilot-help-kitchen', 'title' => __( 'Kitchen Orders', 'pizzapilot' ), 'content' => '

' . __( 'This page shows today\'s orders grouped by delivery time slot.', 'pizzapilot' ) . '

' . '', ) ); } /** * Render the kitchen orders admin page. * * @since 1.1.0 * @return void */ public function render_kitchen_page() { $today = current_time( 'Y-m-d' ); $grouped_orders = $this->get_orders_grouped_by_slot( $today ); echo '
'; echo '

' . esc_html__( 'Kitchen Orders', 'pizzapilot' ) . '

'; $this->render_pro_upsell_banner(); echo '

'; echo esc_html__( 'Orders for today, grouped by time slot.', 'pizzapilot' ) . ' '; echo ''; echo esc_html__( 'Refresh', 'pizzapilot' ); echo ''; echo '

'; if ( empty( $grouped_orders ) ) { echo '
'; echo '

' . esc_html__( 'No orders for today.', 'pizzapilot' ) . '

'; echo '
'; } else { foreach ( $grouped_orders as $group ) { $this->render_slot_group( $group['label'], $group['orders'] ); } } echo '
'; } /** * Render a group of orders under a time slot heading. * * @since 1.1.0 * @param string $slot_label The time slot label (e.g., "2:00 PM - 2:30 PM"). * @param array $orders Array of WC_Order objects in this slot. * @return void */ private function render_slot_group( $slot_label, $orders ) { $order_count = count( $orders ); // Derive the slot date from the first order's delivery-time meta so the // heading reflects the actual stored slot, not "today". $slot_date = ''; if ( ! empty( $orders ) ) { $first_order = reset( $orders ); $delivery_time = $first_order->get_meta( '_wc_other/pizzapilot/delivery-time', true ); if ( empty( $delivery_time ) ) { $delivery_time = $first_order->get_meta( '_pizzapilot_delivery_time', true ); } if ( ! empty( $delivery_time ) && is_numeric( $delivery_time ) ) { $slot_date = wp_date( 'D jS M', (int) $delivery_time ); } } echo '
'; echo '

'; if ( '' !== $slot_date ) { echo '' . esc_html( $slot_date ) . ' '; } echo esc_html( $slot_label ); echo ' '; echo '(' . esc_html( sprintf( /* translators: %d: number of orders */ _n( '%d order', '%d orders', $order_count, 'pizzapilot' ), $order_count ) ) . ')'; echo ''; echo '

'; echo '
'; foreach ( $orders as $order ) { $this->render_order_card( $order ); } echo '
'; echo '
'; } /** * Render a single order card. * * @since 1.1.0 * @param WC_Order $order The WooCommerce order object. * @return void */ private function render_order_card( $order ) { $order_id = $order->get_id(); $customer_name = $order->get_billing_first_name() . ' ' . $order->get_billing_last_name(); $completed = 'yes' === $order->get_meta( '_pizzapilot_kitchen_completed', true ); $delivery_type = $order->get_meta( '_wc_other/pizzapilot/delivery-type', true ); if ( empty( $delivery_type ) ) { $delivery_type = $order->get_meta( '_pizzapilot_delivery_type', true ); } $card_class = 'pizzapilot-order-card'; if ( $completed ) { $card_class .= ' pizzapilot-order-completed'; } echo '
'; echo '
'; echo '

'; echo ''; /* translators: %d: order number */ echo esc_html( sprintf( __( 'Order #%d', 'pizzapilot' ), $order_id ) ); echo ''; echo ' — ' . esc_html( $customer_name ); echo '

'; if ( ! empty( $delivery_type ) ) { echo ''; echo esc_html( ucfirst( $delivery_type ) ); echo ''; } echo '
'; echo ''; echo ''; echo '
'; } /** * Render the completion toggle form for an order. * * @since 1.1.0 * @param int $order_id The order ID. * @param bool $completed Whether the order is marked as completed. * @return void */ private function render_completion_toggle( $order_id, $completed ) { $action_url = admin_url( 'admin-post.php' ); echo '
'; echo ''; echo ''; echo ''; wp_nonce_field( 'pizzapilot_kitchen_toggle_' . $order_id, 'pizzapilot_kitchen_nonce' ); if ( $completed ) { echo ''; } else { echo ''; } echo '
'; } /** * Handle the mark completed/incomplete form submission. * * @since 1.1.0 * @return void */ public function handle_mark_completed() { if ( ! isset( $_POST['pizzapilot_kitchen_nonce'], $_POST['order_id'] ) ) { wp_die( esc_html__( 'Invalid request.', 'pizzapilot' ) ); } $order_id = absint( $_POST['order_id'] ); if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['pizzapilot_kitchen_nonce'] ) ), 'pizzapilot_kitchen_toggle_' . $order_id ) ) { wp_die( esc_html__( 'Security check failed.', 'pizzapilot' ) ); } if ( ! current_user_can( 'edit_shop_orders' ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'pizzapilot' ) ); } $order = wc_get_order( $order_id ); if ( ! $order ) { wp_die( esc_html__( 'Order not found.', 'pizzapilot' ) ); } $new_status = isset( $_POST['completed'] ) ? sanitize_text_field( wp_unslash( $_POST['completed'] ) ) : 'no'; $new_status = 'yes' === $new_status ? 'yes' : 'no'; $order->update_meta_data( '_pizzapilot_kitchen_completed', $new_status ); // Sync with WooCommerce order status so completed orders drop out of // the kitchen view (which only queries processing / on-hold orders). if ( 'yes' === $new_status ) { $order->set_status( 'completed', __( 'Marked completed from PizzaPilot Kitchen.', 'pizzapilot' ) ); } elseif ( 'no' === $new_status && 'completed' === $order->get_status() ) { $order->set_status( 'processing', __( 'Returned to processing from PizzaPilot Kitchen.', 'pizzapilot' ) ); } $order->save(); /** * Fires when an order's kitchen completion status is toggled. * * @since 1.1.0 * @param int $order_id The order ID. * @param string $new_status The new completion status ('yes' or 'no'). */ do_action( 'pizzapilot_kitchen_order_toggled', $order_id, $new_status ); wp_safe_redirect( admin_url( 'admin.php?page=pizzapilot-kitchen' ) ); exit; } /** * Render the Pro upsell banner on the kitchen page. * * Shows a dismissible notice encouraging upgrade to Pro for advanced * kitchen features. Tracks dismissal in user meta so it stays hidden. * Only shown when Pro is not active. * * @since 1.1.0 * @return void */ private function render_pro_upsell_banner() { if ( Pizzapilot_Helpers::pizzapilot_is_pro_active( 'Pizzapilot_Pro' ) ) { return; } $user_id = get_current_user_id(); $dismissed = get_user_meta( $user_id, 'pizzapilot_kitchen_pro_dismissed', true ); if ( $dismissed ) { return; } $upgrade_url = admin_url( 'admin.php?page=pizzapilot-upgrade' ); // No 'page' argument here, deliberately. wp-admin/admin.php renders the // plugin page for a 'page' request and exits before it reaches the // do_action( "admin_action_{$action}" ) dispatch further down the file, // so including it meant handle_dismiss_kitchen_pro() never ran and the // banner could never be dismissed. The handler redirects back to the // kitchen page itself, so the argument was never needed. $dismiss_url = wp_nonce_url( add_query_arg( array( 'action' => 'pizzapilot_dismiss_kitchen_pro', ), admin_url( 'admin.php' ) ), 'pizzapilot_dismiss_kitchen_pro' ); echo '
'; echo '

'; echo '' . esc_html__( 'PizzaPilot Pro', 'pizzapilot' ) . ' — '; echo esc_html__( 'Upgrade for live-updating orders, drag-and-drop reordering, and kitchen ticket printing.', 'pizzapilot' ); echo ' ' . esc_html__( 'Learn more', 'pizzapilot' ) . ''; echo '

'; echo ''; echo ''; echo ''; echo '
'; } /** * Handle dismissal of the kitchen Pro upsell banner. * * Saves a user meta flag so the banner stays hidden for that user. * Verifies nonce and redirects back to the kitchen page. * * @since 1.1.0 * @return void */ public function handle_dismiss_kitchen_pro() { if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'pizzapilot_dismiss_kitchen_pro' ) ) { wp_die( esc_html__( 'Security check failed.', 'pizzapilot' ) ); } update_user_meta( get_current_user_id(), 'pizzapilot_kitchen_pro_dismissed', '1' ); wp_safe_redirect( admin_url( 'admin.php?page=pizzapilot-kitchen' ) ); exit; } /** * Get orders for a given date, grouped by time slot. * * Queries WooCommerce orders that have PizzaPilot delivery time meta * for the specified date, and groups them by their time slot. * * @since 1.1.0 * @param string $date Date in Y-m-d format. * @return array Associative array of slot_label => array of WC_Order objects. */ public function get_orders_grouped_by_slot( $date ) { $timezone = wp_timezone(); $date_obj = new DateTime( $date, $timezone ); $day_start = (int) $date_obj->setTime( 0, 0, 0 )->format( 'U' ); $day_end = (int) $date_obj->setTime( 23, 59, 59 )->format( 'U' ); // Query orders with PizzaPilot delivery time within today's range. // A meta query is required here: WooCommerce stores the slot timestamp // as order meta, and the kitchen view is admin-only and scoped to a // single day, so the result set is small. // // The slot timestamp lives under one of two keys: block checkout stores // it as '_wc_other/pizzapilot/delivery-time', classic checkout as // '_pizzapilot_delivery_time'. Hence the OR. // // Deliberately no 'orderby' => 'meta_value_num' / 'meta_key' here. // Setting them joins on that single meta key, which discards every // order that carries only the other one - so a store on classic // checkout saw an empty kitchen page. Ordering is not lost: the slot // groups built below are sorted by timestamp with uasort(), and orders // inside a group all share the same slot time anyway. $query_args = array( 'limit' => -1, 'status' => array( 'wc-processing', 'wc-on-hold' ), ); if ( \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled() ) { $query_args['meta_query'] = self::delivery_time_meta_query( $day_start, $day_end ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query } else { // The legacy (posts) order store does not accept 'meta_query': it // logs a _doing_it_wrong notice and drops it, which returned every // processing/on-hold order from any date. Pass the range as a // custom query var instead; filter_legacy_orders_query() turns it // back into a meta_query on the underlying WP_Query. $query_args['pizzapilot_delivery_time_range'] = array( $day_start, $day_end ); add_filter( 'woocommerce_order_data_store_cpt_get_orders_query', array( __CLASS__, 'filter_legacy_orders_query' ), 10, 2 ); } $orders = wc_get_orders( $query_args ); remove_filter( 'woocommerce_order_data_store_cpt_get_orders_query', array( __CLASS__, 'filter_legacy_orders_query' ), 10 ); $groups = array(); foreach ( $orders as $order ) { $delivery_time = $order->get_meta( '_wc_other/pizzapilot/delivery-time', true ); if ( empty( $delivery_time ) ) { $delivery_time = $order->get_meta( '_pizzapilot_delivery_time', true ); } if ( empty( $delivery_time ) || ! is_numeric( $delivery_time ) ) { continue; } $delivery_time = (int) $delivery_time; // Backstop in case a query filter or order store ignored the range. if ( $delivery_time < $day_start || $delivery_time > $day_end ) { continue; } $slot_datetime = new DateTime( '@' . $delivery_time ); $slot_datetime->setTimezone( $timezone ); $end_datetime = clone $slot_datetime; $end_datetime->modify( '+30 minutes' ); $slot_label = $slot_datetime->format( 'g:i A' ) . ' - ' . $end_datetime->format( 'g:i A' ); // Key groups by the full slot start (date + time) so identical // times on different dates do not collapse into one group. $group_key = $slot_datetime->format( 'YmdHi' ); if ( ! isset( $groups[ $group_key ] ) ) { $groups[ $group_key ] = array( 'timestamp' => $delivery_time, 'label' => $slot_label, 'orders' => array(), ); } $groups[ $group_key ]['orders'][] = $order; } // Sort groups by the full slot timestamp (date + time) ASC. uasort( $groups, function ( $a, $b ) { return $a['timestamp'] <=> $b['timestamp']; } ); return $groups; } /** * Build the meta query matching either PizzaPilot slot-time key within a range. * * @since 1.2.2 * @param int $day_start Range start (Unix timestamp, inclusive). * @param int $day_end Range end (Unix timestamp, inclusive). * @return array Meta query array. */ private static function delivery_time_meta_query( $day_start, $day_end ) { return array( 'relation' => 'OR', array( 'key' => '_wc_other/pizzapilot/delivery-time', 'value' => array( $day_start, $day_end ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC', ), array( 'key' => '_pizzapilot_delivery_time', 'value' => array( $day_start, $day_end ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC', ), ); } /** * Apply the slot-time range to a legacy (posts) order store query. * * Hooked to woocommerce_order_data_store_cpt_get_orders_query only for * the duration of the kitchen query in get_orders_grouped_by_slot(). * * @since 1.2.2 * @param array $query WP_Query arguments built by WooCommerce. * @param array $query_vars Arguments passed to wc_get_orders(). * @return array WP_Query arguments. */ public static function filter_legacy_orders_query( $query, $query_vars ) { if ( empty( $query_vars['pizzapilot_delivery_time_range'] ) ) { return $query; } list( $day_start, $day_end ) = $query_vars['pizzapilot_delivery_time_range']; $query['meta_query'] = isset( $query['meta_query'] ) ? (array) $query['meta_query'] : array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query $query['meta_query'][] = self::delivery_time_meta_query( (int) $day_start, (int) $day_end ); return $query; } }