From fd14af0f9e76c7f1c9d03d48ac3554827a308ccb Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 4 Feb 2026 12:24:11 -0500 Subject: [PATCH] Stat cards fixes, mini component tweaks --- docs/prod_registry.class.php | 1106 +++++++++++++++++ .../dashboard/acot-server/routes/events.js | 855 +++++++++++-- .../components/dashboard/MiniEventFeed.jsx | 32 +- .../dashboard/MiniRealtimeAnalytics.jsx | 10 +- .../components/dashboard/MiniSalesChart.jsx | 43 +- .../components/dashboard/MiniStatCards.jsx | 39 +- .../src/components/dashboard/StatCards.jsx | 196 +-- .../shared/DashboardStatCardMini.tsx | 186 ++- mountremote.command | 7 - 9 files changed, 2139 insertions(+), 335 deletions(-) create mode 100644 docs/prod_registry.class.php delete mode 100755 mountremote.command diff --git a/docs/prod_registry.class.php b/docs/prod_registry.class.php new file mode 100644 index 0000000..b12a68b --- /dev/null +++ b/docs/prod_registry.class.php @@ -0,0 +1,1106 @@ + "localpickup", + 2 => "free", + + 5 => "standard", + + 10 => "usps_mediamail", + 15 => "usps_firstclass", + 16 => "usps_ground_advantage", + 20 => "usps_priority", + 21 => "usps_priority_flat_env", + 22 => "usps_priority_flat_box", + 23 => "usps_parcelpost", + 24 => "usps_express_flat_padded_env", + 25 => "usps_priority_flat_padded_env", + + 30 => "all", + 31 => "basic", + 32 => "fast", + 33 => "inter", + 34 => "basic_48", + 35 => "non_basic", + 36 => "basic_not_48", + 37 => "basic_us_not_48", + 38 => "basic_international", + 39 => "basic_us_only", + + 50 => "usps_fcmi", + 55 => "usps_pmi", + 56 => "usps_pmi_flat_env", + 57 => "usps_pmi_flat_box", + 58 => "usps_emi_flat_padded_env", + 60 => "usps_alp", + 61 => "usps_app", + 62 => "usps_ep", + 100 => "ups_gndcomm", + 101 => "ups_gndres", + 102 => "ups_gnd", + 105 => "ups_1dasaver", + 106 => "ups_2da", + 150 => "ups_wwxpd", + 151 => "ups_wwxpr", + + 202 => "fedex_smartpost", + 200 => "fedex_homedelivery", + 201 => "fedex_ground", + 205 => "fedex_expresssaver", + 210 => "fedex_2day", + 220 => "fedex_so", + 230 => "fedex_iground", + 235 => "fedex_ieconomy", + 240 => "fedex_ipriority" + ); + public $ship_method_index2 = array( + "localpickup" => 1, + "free" => 2, + + "standard" => 5, + + "usps_mediamail" => 10, + "usps_firstclass" => 15, + "usps_ground_advantage" => 16, + "usps_priority" => 20, + "usps_priority_flat_env" => 21, + "usps_priority_flat_box" => 22, + "usps_parcelpost" => 23, + "usps_express_flat_padded_env" => 24, + "usps_priority_flat_padded_env" => 25, + + "all_us" => 29, + "all" => 30, + "basic" => 31, + "fast" => 32, + "inter" => 33, + "basic_48" => 34, + "non_basic" => 35, + "basic_not_48" => 36, /*dont use this one*/ + + "basic_us_not_48" => 37, + "basic_international" => 38, + "basic_us_only" => 39, + + "usps_fcmi" => 50, + "usps_pmi" => 55, + "usps_pmi_flat_env" => 56, + "usps_pmi_flat_box" => 57, + "usps_emi_flat_padded_env" => 58, + "usps_alp" => 60, + "usps_app" => 61, + "usps_ep" => 62, + "ups_gndcomm" => 100, + "ups_gndres" => 101, + "ups_gnd" => 102, + "ups_1dasaver" => 105, + "ups_2da" => 106, + "ups_wwxpd" => 150, + "ups_wwxpr" => 151, + "fedex_smartpost" => 202, + "fedex_homedelivery" => 200, + "fedex_ground" => 201, + "fedex_expresssaver" => 205, + "fedex_2day" => 210, + "fedex_2day__1rate_env" => 210, + "fedex_2day__1rate_pack" => 210, + "fedex_2day__1rate_small_box" => 210, + "fedex_2day__1rate_med_box" => 210, + "fedex_so" => 220, + "fedex_iground" => 230, + "fedex_ieconomy" => 235, + "fedex_ipriority" => 240, + + "31" => array(5, 10, 15, 16, 20, 21, 22, 23, 24, 25, 50,55,56,57,58,60,61,62, 100, 101, 102, 202, 200, 201, 230), + "34" => array(5, 10, 15, 16, 20, 21, 22, 23, 24, 25, 100, 101, 102, 202, 200, 201), + "37" => array(5, 10, 15, 16, 20, 21, 22, 23, 24, 25, 100, 101, 102, 202, 200, 201, 210), + "32" => array(105, 106, 205, 210, 220), + "33" => array(5, 50, 55, 56, 57, 58, 60, 61, 62, 150, 151, 230, 235, 240), + "35" => array(50,55,56,57,58,60,61,62,105,106,150,151,205,210,220,230,235,240), + "36" => array(5,10, 15, 16, 20, 21, 22, 23, 24, 25, 100, 101, 102, 202, 200, 201, 50, 55, 56, 57, 58, 60, 61, 62, 230, 210), + "38" => array(5, 100, 101, 102, 202, 200, 201, 50, 55, 56, 57, 58, 60, 61, 62, 230, 230, 235, 240), + "39" => array(5, 10, 15, 16, 20, 21, 22, 23, 24, 25, 100, 101, 102, 202, 200, 201) + ); + + //PO Statuses + public $po_status_canceled = 0; + public $po_status_created = 1; + public $po_status_electronically_ready_send = 10; + public $po_status_ordered = 11; + public $po_status_preordered = 12; + public $po_status_electronically_sent = 13; + public $po_status_receiving_started = 15; + public $po_status_done = 50; + + //Receivings Statuses + public $receivings_status_canceled = 0; + public $receivings_status_created = 1; + public $receivings_status_partial_received = 30; + public $receivings_status_full_received = 40; + public $receivings_status_paid = 50; + + + + //Product Shipping Restrictions + public $shipping_restrictions_none = 0; + public $shipping_restrictions_usa_only = 1; + public $shipping_restrictions_ormd = 2; + public $shipping_restrictions_usa_canada_only = 3; + public $shipping_restrictions_no_fedex_2_day = 4; + public $shipping_restrictions_north_america_only = 5; + + //Notification types (used by notify.class.php) (old system) + public $notify_type_new_pm = 1; + public $notify_type_order_done = 2; + public $notify_type_win_hunt = 3; + public $notify_type_got_auto_promo = 4; + public $notify_type_promo_almost_expired = 5; + public $notify_type_issue_updated = 6; + public $notify_type_event = 7; + public $notify_type_special_count_down = 8; + public $notify_type_special_promo = 9; + public $notify_type_special_offer = 10; + public $notify_type_product_back_in = 11; + public $notify_type_product_in = 12; + public $notify_type_system = 99; + + //Special Offer Types + public $special_offer_type_message_only = 1; + public $special_offer_type_price_discount_for = 2; + public $special_offer_type_price_discount_off = 3; + public $special_offer_type_temp_point_increase = 4; + public $special_offer_type_promo_access = 5; + + //Notifies + public $notify_account_created = 1; + public $notify_account_created_auto = 2; + public $notify_order_unpaid = 10; + public $notify_order_placed = 11; + public $notify_order_placed_waiting = 17; + public $notify_order_shipped = 12; + public $notify_order_canceled = 13; + public $notify_order_pickup_ready = 14; + public $notify_order_picked_up = 34; + public $notify_order_returned = 15; + public $notify_order_bo_canceled = 16; + public $notify_order_refund = 22; + public $notify_order_package_shipped = 19; + public $notify_order_last_package_shipped = 18; + public $notify_order_followup = 20; + public $notify_order_gc_shipped = 21; + public $notify_order_soon_canceled = 23; + public $notify_order_preorder_waiting = 24; + public $notify_order_items_waiting = 25; + public $notify_referral_points_new_cust = 30; + public $notify_referral_points_old_cust = 31; + public $notify_cherrybox_renewal_reminder_soon = 32; + public $notify_promo_expiring = 33; + public $notify_order_pickup_ready_reminder = 35; + public $notify_order_surveys = 36; + public $notify_new_promo_code = 37; + + //Inventory Sources (old) + public $inventory_source_none = 0; + public $inventory_source_acot = 1; + public $inventory_source_preorder = 2; + public $inventory_source_digi = 3; + public $inventory_source_notions = 4; + + //Warehouses (old) + public $warehouse_fixme = 0; + public $warehouse_acot = 1; + public $warehouse_notions = 2; + public $warehouse_digi = 3; + + //New warehouse/inventory source (old ways where different values when they should have been the same things) + public $warehouse_inventory_source_none = 0; + public $warehouse_inventory_source_acot = 10; + public $warehouse_inventory_source_preorder = 11; + public $warehouse_inventory_source_digi = 12; + public $warehouse_inventory_source_notions = 13; + public $warehouse_inventory_source_amazon_fba = 14; + public $warehouse_inventory_source_storefront = 15; + public $warehouse_inventory_source_notions_not_drop = 16; + + + //Package Status + public $package_status_init = 0; + public $package_status_picking = 1; + public $package_status_picked = 2; + public $package_status_remote_sent = 10; + public $package_status_remote_sent_manually = 11; + public $package_status_canceled = 48; + public $package_status_not_shipping = 49; + public $package_status_shipped = 50; + + //Basket Price types + public $basket_price_type_reg = 1; + public $basket_price_type_promo = 2; + public $basket_price_type_gc = 3; + + //User Shipping Preferences + public $user_ship_pref_no_usps = 1; + public $user_ship_pref_no_fedex = 2; + public $user_ship_pref_no_smartpost = 3; + public $user_ship_pref_no_puffy = 4; + public $user_ship_pref_offer_signature = 5; + public $user_ship_pref_never_signature = 6; + + //User point change reasons + public $user_point_change_init = 1; + public $user_point_change_hunt = 2; + public $user_point_change_order_shipped = 3; + public $user_point_change_used = 4; + public $user_point_change_manual_change = 5; + public $user_point_change_referral_referee = 6; //the new customer + public $user_point_change_referral_referrer = 7; + public $user_point_change_award = 8; + public $user_point_change_review = 9; + public $user_point_change_moved = 10; + public $user_point_change_treasure = 11; + public $user_point_change_gallery_comment = 12; + public $user_point_change_gallery_submission = 13; + + //Subscription Sources + public $subscription_source_unset = 0; + public $subscription_source_order = 1; + public $subscription_source_popup = 2; + public $subscription_source_shop = 3; + public $subscription_source_myaccount = 4; + public $subscription_source_articles = 5; + public $subscription_source_gallery = 6; + + //Newsletter Types + public $newsletter_type_regular = 0; + public $newsletter_type_welcome = 1; + public $newsletter_type_product_notification = 2; + public $newsletter_type_come_back_many = 3; + public $newsletter_type_come_back_single = 4; + public $newsletter_type_come_back_again_many = 5; + public $newsletter_type_come_back_again_single = 6; + public $newsletter_type_come_backs_list = array(3,4,5,6); + public $newsletter_type_come_back_points = 7; + public $newsletter_type_sign_up_bonus = 8; + public $newsletter_type_waiting_preorder = 9; + public $newsletter_type_expiring_promos = 10; + public $newsletter_type_waiting_items = 11; + + //Newsletter Limits + public $newsletter_limit_all = 0; + public $newsletter_limit_daily = 1; + public $newsletter_limit_weekly = 2; + public $newsletter_limit_sms = 99; + + // + public $sms_newsletter_status_none = 0; + public $sms_newsletter_status_subscribed = 1; + public $sms_newsletter_status_unsubscribed = 2; + + public $sms_newsletter_signup_source_web = 1; + public $sms_newsletter_signup_source_sms = 2; + public $sms_newsletter_signup_source_checkout = 3; + + //Lockdown Types: + public $lockdown_type_cc = 1; + public $lockdown_type_login = 2; + public $lockdown_type_login_longer = 3; + public $lockdown_type_login_longest = 4; + public $lockdown_type_password_reset = 5; + public $lockdown_type_email_login_code = 6; + + //Lockdown Reasons: + public $lockdown_reason_same_card_declined = 0; + public $lockdown_reason_many_card_declines = 1; + public $lockdown_reason_many_failed_logins = 2; + public $lockdown_reason_manual = 3; + public $lockdown_reason_too_often = 4; + + + //Menu Types + public $shopmenu_type_tab = 0; + public $shopmenu_type_regular = 1; + public $shopmenu_type_subitem = 2; + public $shopmenu_type_bold = 3; + public $shopmenu_type_sub_all = 40; + public $shopmenu_type_auto = 50; + public $shopmenu_type_image = 100; + public $shopmenu_type_line = 200; + public $shopmenu_type_empty_space = 201; + //Menu Subtypes + public $shopmenu_subtype_no_dropdown = 0; + public $shopmenu_subtype_simple_dropdown = 1; + public $shopmenu_subtype_mega_dropdown = 2; + public $shopmenu_subtype_mega_dropdown_centered = 3; + + public $shopmenu_auto_group_by_none = 0; + public $shopmenu_auto_group_by_company = 1; + public $shopmenu_auto_group_by_cat = 2; + public $shopmenu_auto_group_by_theme = 3; + public $shopmenu_auto_group_by_color = 4; + public $shopmenu_auto_group_by_artist = 5; + + public $shopmenu_auto_pick_by_alpha = 1; + public $shopmenu_auto_pick_by_count = 2; + public $shopmenu_auto_pick_by_hot = 3; + + public $shopmenu_auto_sort_by_alpha = 1; + public $shopmenu_auto_sort_by_count = 2; + public $shopmenu_auto_sort_by_hot = 3; + + //Product Todo Types + public $product_todo_check_shelf_count = 1; + public $product_todo_check_storefront_count = 2; + public $product_todo_image_wrong = 3; + public $product_todo_image_poor_quality = 4; + public $product_todo_image_too_small = 5; + public $product_todo_description_needs_fix = 6; + + //Product Last Verification Types + public $product_last_verification_shelf_count = 1; + public $product_last_verification_sf_count = 2; + public $product_last_verification_description_set = 3; + public $product_last_verification_storefront_count = 4; + + //Product Ignore Missing + public $product_ignore_missing_theme = 1; + public $product_ignore_missing_color = 2; + + //Product Auto Pricing Options + public $product_auto_pricing_allow = 0; + public $product_auto_pricing_disallow = 1; + public $product_auto_pricing_ = 2; + + // + public $track_happened_email_expiring_promos = 1; + + + public $product_price_source_amazon = 1; + + //Class Types + public $class_type_normal = 0; + public $class_type_free_online = 1; + + //Gallery Index Types + public $gallery_index_theme = 0; //Was 'category' + public $gallery_index_tech = 1; + public $gallery_index_companyline = 2; //Company/Line/Subline + public $gallery_index_product = 3; + public $gallery_index_cac = 4; //Contest/Challange + public $gallery_index_favorite = 5; + + //Amazon shipping groups + public $amazon_shipping_group_none = 0; + public $amazon_shipping_group_default = 1; + public $amazon_shipping_group_autoed = 2; + public $amazon_shipping_group_autoed_large = 3; + public $amazon_shipping_group_autoed_no_fast = 4; + + public $amazon_sg_index = array( + 1 => "Default Template", + 2 => "Autoed Template", + 3 => "Autoed Template Large", + 4 => "Autoed Template No Fast", + ); + public $amazon_sg_index2 = array( + "Default Template" => 1, + "Autoed Template" => 2, + "Autoed Template Large" => 3, + "Autoed Template No Fast" => 4, + ); + + + //Uploaded file types + public $uploaded_file_web_content = 1; + public $uploaded_file_video = 2; + public $uploaded_file_user_avatar = 3; + public $uploaded_file_user_picture = 4; + //public $uploaded_file_downloadable;? + + //Shortlink types + public $shortlink_type_general = 1; + public $shortlink_type_sms_newsletter = 2; + + //Attempt types + public $attempt_send_login_code = 1; + public $attempt_use_login_code = 2; + + //Sync who's + public $sync_who_klaviyo = 1; + + //Sync events + public $sync_event_account_created = 1; + public $sync_event_requested_login_code = 2; + public $sync_event_requested_password_reset = 3; + public $sync_event_requested_wishlist_send = 4; + public $sync_event_cherrybox_renewal_upcoming = 5; + public $sync_event_cherrybox_renewal_payment_failed = 6; + public $sync_event_cherrybox_order_shipped = 7; + public $sync_event_placed_class_signup = 8; + public $sync_event_order_placed = 9; + public $sync_event_order_shipped = 10; + public $sync_event_order_canceled = 11; + public $sync_event_order_split = 12; + public $sync_event_backorder_placed = 13; + public $sync_event_combined_order = 14; + public $sync_event_processed_return = 15; + public $sync_event_processed_refund = 16; + public $sync_event_referral_points_awarded = 17; + public $sync_event_order_waiting_payment_will_cancel = 18; + public $sync_event_send_gc = 19; + public $sync_event_order_pickup_reminder = 20; + public $sync_event_payment_refunded = 21; + public $sync_event_order_updated = 22; + public $sync_event_order_local_receipt = 23; + public $sync_event_awarded_points = 24; + public $sync_event_account_update = 25; + public $sync_event_order_pickup_ready = 26; + public $sync_event_waiting_preorders = 27; + public $sync_event_product_notify = 28; + public $sync_event_order_delivered = 29; + public $sync_event_customer_review_added = 30; + public $sync_event_product_review_report = 31; + public $sync_event_waiting_held = 32; + + + //Error Type Source (for do_save_error()) + public $error_type_source_uploader = 1; + public $error_type_source_klaviyo_event = 2; + + + + //Log ID Type + public $log_id_type_order = 1; + public $log_id_type_customer = 5; + public $log_id_type_ucustomer = 6; + public $log_id_type_item = 10; + public $log_id_type_po = 15; + public $log_id_type_pt = 20; + public $log_id_type_supplier = 25; + public $log_id_type_receiving = 30; + public $log_id_type_auto = 35; + + //Log actions: + public $log_debug = 1; + public $log_error = 2; + public $log_order_info = 3; + + + public $log_order_printed_receipt = 10; + + public $log_order_email_created = 25; + public $log_order_email_sent = 26; + public $log_order_email_error = 27; + + public $log_order_shipped = 30; + public $log_order_shipped_item = 31; + public $log_order_unshipped = 35; + public $log_order_unshipped_item = 36; + + public $log_order_sent_gc = 37; + public $log_order_downloadable_activate = 38; + + public $log_order_discount_add = 40; + public $log_order_discount_remove = 41; + public $log_order_discount_adjust = 42; + public $log_order_discount_dup_fix = 43; + public $log_order_discount_fix = 44; + + public $log_order_payment_add = 45; + public $log_order_payment_remove = 46; + public $log_order_payment_init = 47; + public $log_order_payment_complete = 48; + public $log_order_payment_error = 49; + + public $log_order_ship_rate_reduce = 50; + + public $log_order_change_ship_selected = 55; + + public $log_order_item_sale_change = 60; + + public $log_order_item_stats = 65; + + public $log_order_cc_attempt = 74; + public $log_order_cc_error = 75; + public $log_order_cc_declined = 76; + public $log_order_cc_verified = 80; + public $log_order_cc_forced = 81; + public $log_order_cc_credited = 82; + public $log_order_cc_sale = 83; + public $log_order_cc_voided = 84; + + public $log_order_postage_original = 90; + public $log_order_postage_new = 91; + + public $log_order_create_user = 93; + public $log_order_action_placeorder = 94; + public $log_order_select_payment_paypal = 95; + public $log_order_select_payment_check = 96; + public $log_order_select_payment_cc = 97; + public $log_order_select_payment_none = 98; + public $log_order_via_mobile = 99; + + public $log_order_missing_info = 100; + + public $log_order_paypal_express = 110; + public $log_order_paypal_refund = 111; + public $log_order_paypal_error = 112; + + public $log_order_sent_cinderella = 120; + public $log_order_sent_amazon = 121; + + public $log_order_points_cleared = 130; + + public $log_order_package_unshipped = 140; + + public $log_order_checkout_step = 150; + public $log_order_checkout_error = 151; + + public $log_order_class_change = 160; + + public $log_picking_ticket_add = 200; + public $log_picking_ticket_picked = 203; + public $log_picking_ticket_manual_delete = 205; + public $log_picking_ticket_done_delete = 210; + public $log_picking_ticket_changed_to = 220; + public $log_picking_ticket_changed_from = 221; + + public $log_shipfli_scanned_order = 300; + public $log_shipfli_weight_set = 330; + public $log_shipfli_click_finish = 340; + public $log_shipfli_init_postage = 341; + public $log_shipfli_init_postage_error = 342; + public $log_shipfli_ups_postage_printed = 350; + public $log_shipfli_usps_postage_printed = 351; + public $log_shipfli_fedex_postage_printed = 352; + public $log_shipfli_message = 360; + public $log_shipfli_debug_log = 361; + + public $log_auto_error_paypal_invalid = 400; + public $log_auto_error_paypal_invalid_stats = 401; + public $log_auto_error_paypal_invalid_order = 402; + public $log_auto_error_paypal_already_paid = 403; + public $log_auto_paypal_adding = 404; + + public $log_item_manual_change_qty = 510; + public $log_item_manual_change_reason = 511; + public $log_item_sale_change_qty = 515; + public $log_item_location_change_original = 520; + public $log_item_location_change_new = 521; + public $log_item_sale_change_original = 525; + public $log_item_sale_change_new = 526; + public $log_item_price_change_original = 525; + public $log_item_price_change_new = 526; + public $log_item_weight_change_original = 527; + public $log_item_weight_change_new = 528; + public $log_item_receiving = 529; + public $log_item_pi_fix = 530; + public $log_item_date_ol_change = 531; + + //used by az/backend only + public $log_logged_in = 600; + public $log_logged_out = 601; + public $log_logged_out_auto = 602; + + public $log_customer_points_add = 700; + public $log_customer_points_remove = 701; + public $log_customer_customer_credit_add = 705; + public $log_customer_customer_credit_remove = 706; + public $log_customer_customer_credit_use = 707; + public $log_customer_permission_added = 710; + public $log_customer_emailed_recommendation = 715; + public $log_customer_emailed_password = 720; + public $log_customer_emailed_activation = 721; + public $log_customer_emailed_alert = 722; + public $log_customer_changed_screenname = 723; + public $log_customer_social_add = 724; + + public $log_customer_create_by_social = 725; + public $log_customer_sign_in_social = 726; + + public $log_customer_sign_in = 730; + public $log_customer_employee_sign_in = 731; + public $log_customer_sign_out = 732; + public $log_customer_sign_in_remember = 733; + public $log_customer_sign_in_secure = 734; + public $log_customer_session_delete = 735; + public $log_customer_sign_in_as = 736; + public $log_customer_sign_in_from = 737; + public $log_customer_cookie_error = 740; + + public $log_customer_account_disabled = 745; + public $log_customer_account_enabled = 746; + public $log_customer_account_login_unlocked = 747; + + public $log_customer_basket_change = 750; + public $log_customer_basket_delete = 751; + public $log_customer_basket_move = 752; + + public $log_customer_savedcc_added = 760; + public $log_customer_savedcc_removed = 761; + public $log_customer_savedcc_failed = 762; + public $log_customer_savedcc_edit = 763; + + public $log_customer_cherrybox_subscription_add = 764; + public $log_customer_cherrybox_address_change = 765; + public $log_customer_cherrybox_isgift_change = 766; + public $log_customer_cherrybox_pause_change = 767; + public $log_customer_cherrybox_auto_renew_plan_change = 768; + public $log_customer_cherrybox_auto_renew_card_change = 769; + public $log_customer_cherrybox_subscription_renew = 770; + public $log_customer_cherrybox_auto_renew_ship_method_change = 773; + public $log_cherrybox_manual_credit_change = 775; + + public $log_customer_address_created = 774; + public $log_customer_address_deleted = 771; + public $log_customer_address_changed = 772; + + + public $log_customer_email_subscribe = 795; + public $log_customer_email_unsubscribe = 796; + public $log_customer_email_no_newsletter_change = 797; + public $log_customer_email_off_suppression_list = 798; + + public $log_product_inv_adjustment = 900; + + public $log_order_status_error = 999; + public $log_order_status_created = 1000; + public $log_order_status_unfinished = 1010; + public $log_order_status_combined = 1016; + public $log_order_status_placed = 1020; + public $log_order_status_placed_incomplete = 1022; + public $log_order_status_cancelled = 1030; + public $log_order_status_cancelled_reason = 1031; + public $log_order_status_awaiting_payment = 1040; + public $log_order_status_payment_pending = 1045; + public $log_order_status_awaiting_products = 1050; + public $log_order_status_shipping_later = 1055; + public $log_order_status_shipping_together = 1056; + public $log_order_status_ready = 1060; + public $log_order_status_flagged = 1061; + public $log_order_status_fix_before_pick = 1062; + public $log_order_status_manual_picking = 1065; + public $log_order_status_remote_send = 1067; + public $log_order_status_in_pt = 1070; + public $log_order_status_picked = 1080; + public $log_order_status_awaiting_shipment = 1090; + public $log_order_status_remote_wait = 1091; + public $log_order_status_fix_before_ship = 1093; + public $log_order_status_shipped_confirmed = 1095; + public $log_order_status_shipped = 1100; + public $log_order_status_awaiting_pickup = 1110; // incase needed + public $log_order_status_pickedup = 1120; // incase needed + + + public $log_po_status_canceled = 1200; + public $log_po_status_created = 1201; + public $log_po_status_ordered = 1203; + public $log_po_status_preordered = 1204; + public $log_po_status_electronically_ready_send = 1207; + public $log_po_status_electronically_sent = 1208; + public $log_po_status_receiving_started = 1205; + public $log_po_status_done = 1207; + public $log_po_printed = 1210; + + + public $log_receivings_status_canceled = 1300; + public $log_receivings_status_created = 1301; + public $log_receivings_status_partial_received = 1302; + public $log_receivings_status_full_received = 1303; + public $log_receivings_status_paid = 1304; + public $log_receivings_product_received = 1310; + public $log_receivings_product_qty_change = 1311; + + + //To be removed after logging helped find bug + public $log_debug_basket_gc_add = 2000; //help to find bug where gc item info is missing in order + public $log_debug_order_gc_add = 2001; //help to find bug where gc item info is missing in order + public $log_debug_owes = 2002; //help find bug where customer is over charged initally. + public $log_debug_notify_issued = 2003; //help to track down bug causing multiple emails to be sent out + public $log_debug_package_method_set = 2004; // + + var $statuses; + var $status_descriptions; + private $in_construct=false; + + function __construct() { + $this->in_construct = true; + foreach ($this as $name=>$value) { + if (preg_match("/^order_status_([\w]+)/",$name,$matches)) { + $this->statuses[] = array($value, ucwords(str_replace('_',' ',$matches[1]))); + $this->status_descriptions[$value] = ucwords(str_replace('_',' ',$matches[1])); + } + } + $this->in_construct = false; + } + + /*function __get($name) { + if ($this->$name) {return $this->$name;} + }*/ + + function __set($name,$value) { + if ($name != "in_construct") { + if ($this->in_construct) { + $this->$name = $value; + } else { + // don't allow variables to be redefined in this class + } + } else { + $this->$name = $value; + } + } + + static function list_with_descriptions($list_type) { + switch ($list_type) { + case "product_todo": + $conv = array( + clsApp::$reg->product_todo_check_shelf_count => "Check Shelf Count", + clsApp::$reg->product_todo_check_storefront_count => "Check Store Front Count", + clsApp::$reg->product_todo_image_wrong => "Wrong Image", + clsApp::$reg->product_todo_image_poor_quality => "Poor Quality Image", + clsApp::$reg->product_todo_image_too_small => "Image Too Small", + clsApp::$reg->product_todo_description_needs_fix => "Description Needs Fix" + ); + break; + case "order_payment": + $conv = array( + clsApp::$reg->order_payment_cc => "CC", + clsApp::$reg->order_payment_cc_visa_mastercard => "CC", + clsApp::$reg->order_payment_cc_amex => "CC", + clsApp::$reg->order_payment_cc_discover => "CC", + clsApp::$reg->order_payment_cc_from_order => "CC From O", + clsApp::$reg->order_payment_cc_saved => "Save CC", + clsApp::$reg->order_payment_paypal => "PayPal", + clsApp::$reg->order_payment_paypal_cc => "PayPal CC", + clsApp::$reg->order_payment_paypal_express => "PayPal E", + clsApp::$reg->order_payment_check => "Check", + clsApp::$reg->order_payment_wire_transfer => "Wire", + clsApp::$reg->order_payment_gc => "GC", + clsApp::$reg->order_payment_mo => "MO", + clsApp::$reg->order_payment_cash => "Cash", + clsApp::$reg->order_payment_cod => "COD", + clsApp::$reg->order_payment_po => "PO", + clsApp::$reg->order_payment_net30 => "Net30", + clsApp::$reg->order_payment_shopatron => "Shopatron", + clsApp::$reg->order_payment_amazon => "Amazon", + clsApp::$reg->order_payment_ebay => "Ebay", + clsApp::$reg->order_payment_buycom => "Buycom", + clsApp::$reg->order_payment_artfire => "Artfire", + clsApp::$reg->order_payment_walmart => "Walmart", + clsApp::$reg->order_payment_michaels => "Michaels", + clsApp::$reg->order_payment_amazon_pay => "Amazon Pay", + clsApp::$reg->order_payment_loss => "Loss" + ); + + //clsApp::$reg->order_payment_customercredit = 200; // positive = redeemed, negative = owes on account + //clsApp::$reg->order_payment_customercredit_refund = 201; // negative = created customer credit + break; + } + return $conv; + } + +} + +?> diff --git a/inventory-server/dashboard/acot-server/routes/events.js b/inventory-server/dashboard/acot-server/routes/events.js index 5070cec..9dd4fa8 100644 --- a/inventory-server/dashboard/acot-server/routes/events.js +++ b/inventory-server/dashboard/acot-server/routes/events.js @@ -55,8 +55,9 @@ router.get('/stats', async (req, res) => { const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate); // Main order stats query (optionally excludes Cherry Box orders) + // Note: order_status > 15 excludes cancelled (15), so cancelled stats are queried separately const mainStatsQuery = ` - SELECT + SELECT COUNT(*) as orderCount, SUM(summary_total) as revenue, SUM(stats_prod_pieces) as itemCount, @@ -64,17 +65,29 @@ router.get('/stats', async (req, res) => { AVG(stats_prod_pieces) as averageItemsPerOrder, SUM(CASE WHEN stats_waiting_preorder > 0 THEN 1 ELSE 0 END) as preOrderCount, SUM(CASE WHEN ship_method_selected = 'localpickup' THEN 1 ELSE 0 END) as localPickupCount, - SUM(CASE WHEN ship_method_selected = 'holdit' THEN 1 ELSE 0 END) as onHoldCount, - SUM(CASE WHEN order_status IN (100, 92) THEN 1 ELSE 0 END) as shippedCount, - SUM(CASE WHEN order_status = 15 THEN 1 ELSE 0 END) as cancelledCount, - SUM(CASE WHEN order_status = 15 THEN summary_total ELSE 0 END) as cancelledTotal - FROM _order + SUM(CASE WHEN ship_method_selected = 'holdit' THEN 1 ELSE 0 END) as onHoldCount + FROM _order WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause} `; - + const [mainStats] = await connection.execute(mainStatsQuery, params); const stats = mainStats[0]; - + + // Cancelled orders query - uses date_cancelled instead of date_placed + // Shows orders cancelled during the selected period, regardless of when they were placed + const cancelledQuery = ` + SELECT + COUNT(*) as cancelledCount, + SUM(summary_total) as cancelledTotal + FROM _order + WHERE order_status = 15 + AND ${getCherryBoxClause(excludeCB)} + AND ${whereClause.replace('date_placed', 'date_cancelled')} + `; + + const [cancelledResult] = await connection.execute(cancelledQuery, params); + const cancelledStats = cancelledResult[0] || { cancelledCount: 0, cancelledTotal: 0 }; + // Refunds query (optionally excludes Cherry Box orders) const refundsQuery = ` SELECT @@ -86,7 +99,20 @@ router.get('/stats', async (req, res) => { `; const [refundStats] = await connection.execute(refundsQuery, params); - + + // Shipped orders query - uses date_shipped instead of date_placed + // This counts orders that were SHIPPED during the selected period, regardless of when they were placed + const shippedQuery = ` + SELECT COUNT(*) as shippedCount + FROM _order + WHERE order_status IN (92, 95, 100) + AND ${getCherryBoxClause(excludeCB)} + AND ${whereClause.replace('date_placed', 'date_shipped')} + `; + + const [shippedResult] = await connection.execute(shippedQuery, params); + const shippedCount = parseInt(shippedResult[0]?.shippedCount || 0); + // Best revenue day query (optionally excludes Cherry Box orders) const bestDayQuery = ` SELECT @@ -102,20 +128,20 @@ router.get('/stats', async (req, res) => { const [bestDayResult] = await connection.execute(bestDayQuery, params); - // Peak hour query (for single day periods, optionally excludes Cherry Box orders) + // Peak hour query - uses selected time range for the card value let peakHour = null; if (['today', 'yesterday'].includes(timeRange)) { const peakHourQuery = ` - SELECT + SELECT HOUR(date_placed) as hour, COUNT(*) as count - FROM _order + FROM _order WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause} GROUP BY HOUR(date_placed) ORDER BY count DESC LIMIT 1 `; - + const [peakHourResult] = await connection.execute(peakHourQuery, params); if (peakHourResult.length > 0) { const hour = peakHourResult[0].hour; @@ -123,68 +149,125 @@ router.get('/stats', async (req, res) => { date.setHours(hour, 0, 0); peakHour = { hour, - count: peakHourResult[0].count, + count: parseInt(peakHourResult[0].count), displayHour: date.toLocaleString("en-US", { hour: "numeric", hour12: true }) }; } } + + // Hourly breakdown for detail chart - always rolling 24 hours (like revenue/orders use 30 days) + // Returns data ordered chronologically: [24hrs ago, 23hrs ago, ..., 1hr ago, current hour] + let hourlyOrders = null; + if (['today', 'yesterday'].includes(timeRange)) { + // Get hourly counts AND current hour from MySQL to avoid timezone mismatch + const hourlyQuery = ` + SELECT + HOUR(date_placed) as hour, + COUNT(*) as count, + HOUR(NOW()) as currentHour + FROM _order + WHERE order_status > 15 + AND ${getCherryBoxClause(excludeCB)} + AND date_placed >= NOW() - INTERVAL 24 HOUR + GROUP BY HOUR(date_placed) + `; + + const [hourlyResult] = await connection.execute(hourlyQuery); + + // Get current hour from MySQL (same timezone as the WHERE clause) + const currentHour = hourlyResult.length > 0 ? parseInt(hourlyResult[0].currentHour) : new Date().getHours(); + + // Build map of hour -> count + const hourCounts = {}; + hourlyResult.forEach(row => { + hourCounts[parseInt(row.hour)] = parseInt(row.count); + }); + + // Build array in chronological order starting from (currentHour + 1) which is 24 hours ago + hourlyOrders = []; + for (let i = 0; i < 24; i++) { + const hour = (currentHour + 1 + i) % 24; // Start from 24hrs ago, end at current hour + hourlyOrders.push({ + hour, + count: hourCounts[hour] || 0 + }); + } + } - // Brands and categories query - simplified for now since we don't have the category tables - // We'll use a simple approach without company table for now (optionally excludes Cherry Box orders) + // Brands query - products.company links to product_categories.cat_id for brand name + // Only include products that have a brand assigned (INNER JOIN) const brandsQuery = ` - SELECT - 'Various Brands' as brandName, + SELECT + pc.cat_id as catId, + pc.name as brandName, COUNT(DISTINCT oi.order_id) as orderCount, SUM(oi.qty_ordered) as itemCount, SUM(oi.qty_ordered * oi.prod_price) as revenue FROM order_items oi JOIN _order o ON oi.order_id = o.order_id JOIN products p ON oi.prod_pid = p.pid + JOIN product_categories pc ON p.company = pc.cat_id WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')} + GROUP BY pc.cat_id, pc.name HAVING revenue > 0 + ORDER BY revenue DESC + LIMIT 100 `; - + const [brandsResult] = await connection.execute(brandsQuery, params); - - // For categories, we'll use a simplified approach (optionally excludes Cherry Box orders) + + // Categories query - uses product_category_index to get category assignments + // Only include categories with valid types (no NULL/uncategorized) const categoriesQuery = ` - SELECT - 'General' as categoryName, + SELECT + pc.cat_id as catId, + pc.name as categoryName, COUNT(DISTINCT oi.order_id) as orderCount, SUM(oi.qty_ordered) as itemCount, SUM(oi.qty_ordered * oi.prod_price) as revenue FROM order_items oi JOIN _order o ON oi.order_id = o.order_id JOIN products p ON oi.prod_pid = p.pid - WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')} + JOIN product_category_index pci ON p.pid = pci.pid + JOIN product_categories pc ON pci.cat_id = pc.cat_id + WHERE o.order_status > 15 + AND ${getCherryBoxClauseAliased('o', excludeCB)} + AND ${whereClause.replace('date_placed', 'o.date_placed')} + AND pc.type IN (10, 20, 11, 21, 12, 13) + GROUP BY pc.cat_id, pc.name HAVING revenue > 0 + ORDER BY revenue DESC + LIMIT 100 `; - + const [categoriesResult] = await connection.execute(categoriesQuery, params); - // Shipping locations query (optionally excludes Cherry Box orders) + // Shipping locations query - uses date_shipped to match shippedCount const shippingQuery = ` - SELECT + SELECT ship_country, ship_state, ship_method_selected, COUNT(*) as count - FROM _order - WHERE order_status IN (100, 92) AND ${getCherryBoxClause(excludeCB)} AND ${whereClause} + FROM _order + WHERE order_status IN (92, 95, 100) + AND ${getCherryBoxClause(excludeCB)} + AND ${whereClause.replace('date_placed', 'date_shipped')} GROUP BY ship_country, ship_state, ship_method_selected `; const [shippingResult] = await connection.execute(shippingQuery, params); // Process shipping data - const shippingStats = processShippingData(shippingResult, stats.shippedCount); + const shippingStats = processShippingData(shippingResult, shippedCount); // Order value range query (optionally excludes Cherry Box orders) + // Excludes $0 orders from min calculation const orderRangeQuery = ` - SELECT - MIN(summary_total) as smallest, + SELECT + MIN(CASE WHEN summary_total > 0 THEN summary_total END) as smallest, MAX(summary_total) as largest - FROM _order + FROM _order WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause} `; @@ -226,7 +309,7 @@ router.get('/stats', async (req, res) => { // Shipping shipping: { - shippedCount: parseInt(stats.shippedCount || 0), + shippedCount: parseInt(shippedCount || 0), locations: shippingStats.locations, methodStats: shippingStats.methods }, @@ -235,15 +318,17 @@ router.get('/stats', async (req, res) => { brands: { total: brandsResult.length, list: brandsResult.slice(0, 50).map(brand => ({ + id: brand.catId, name: brand.brandName, count: parseInt(brand.itemCount), revenue: parseFloat(brand.revenue) })) }, - + categories: { total: categoriesResult.length, list: categoriesResult.slice(0, 50).map(category => ({ + id: category.catId, name: category.categoryName, count: parseInt(category.itemCount), revenue: parseFloat(category.revenue) @@ -257,8 +342,8 @@ router.get('/stats', async (req, res) => { }, canceledOrders: { - total: parseFloat(stats.cancelledTotal || 0), - count: parseInt(stats.cancelledCount || 0) + total: parseFloat(cancelledStats.cancelledTotal || 0), + count: parseInt(cancelledStats.cancelledCount || 0) }, // Best day @@ -270,7 +355,8 @@ router.get('/stats', async (req, res) => { // Peak hour (for single days) peakOrderHour: peakHour, - + hourlyOrders: hourlyOrders, // Array of 24 hourly order counts for the detail chart + // Order value range orderValueRange: orderRangeResult.length > 0 ? { smallest: parseFloat(orderRangeResult[0].smallest || 0), @@ -324,27 +410,139 @@ router.get('/stats', async (req, res) => { router.get('/stats/details', async (req, res) => { let release; try { - const { timeRange, startDate, endDate, metric, daily, excludeCherryBox } = req.query; + const { timeRange, startDate, endDate, metric, daily, excludeCherryBox, orderType, eventType } = req.query; const excludeCB = parseBoolParam(excludeCherryBox); const { connection, release: releaseConn } = await getDbConnection(); release = releaseConn; const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate); + // Handle special event types (refunds, cancellations) + if (eventType === 'PAYMENT_REFUNDED') { + // Refunds query - from order_payment table + const refundsQuery = ` + SELECT + DATE(op.payment_date) as date, + COUNT(*) as count, + ABS(SUM(op.payment_amount)) as total + FROM order_payment op + JOIN _order o ON op.order_id = o.order_id + WHERE op.payment_amount < 0 + AND o.order_status > 15 + AND ${getCherryBoxClauseAliased('o', excludeCB)} + AND ${whereClause.replace('date_placed', 'op.payment_date')} + GROUP BY DATE(op.payment_date) + ORDER BY DATE(op.payment_date) + `; + + const [refundResults] = await connection.execute(refundsQuery, params); + + // Format matches what frontend expects: day.refunds.total, day.refunds.count + const stats = refundResults.map(day => ({ + timestamp: day.date, + date: day.date, + refunds: { + total: parseFloat(day.total || 0), + count: parseInt(day.count || 0), + reasons: {} + } + })); + + if (release) release(); + return res.json({ stats }); + } + + if (eventType === 'CANCELED_ORDER') { + // Cancellations query - uses date_cancelled to show when orders were actually cancelled + const cancelQuery = ` + SELECT + DATE(date_cancelled) as date, + COUNT(*) as count, + SUM(summary_total) as total + FROM _order + WHERE order_status = 15 + AND ${getCherryBoxClause(excludeCB)} + AND ${whereClause.replace('date_placed', 'date_cancelled')} + GROUP BY DATE(date_cancelled) + ORDER BY DATE(date_cancelled) + `; + + const [cancelResults] = await connection.execute(cancelQuery, params); + + // Format matches what frontend expects: day.canceledOrders.total, day.canceledOrders.count + const stats = cancelResults.map(day => ({ + timestamp: day.date, + date: day.date, + canceledOrders: { + total: parseFloat(day.total || 0), + count: parseInt(day.count || 0), + reasons: {} + } + })); + + if (release) release(); + return res.json({ stats }); + } + + if (eventType === 'PLACED_ORDER') { + // Order range query - daily min/max/average order values + const orderRangeQuery = ` + SELECT + DATE(date_placed) as date, + COUNT(*) as orders, + MIN(CASE WHEN summary_total > 0 THEN summary_total END) as smallest, + MAX(summary_total) as largest, + AVG(summary_total) as averageOrderValue + FROM _order + WHERE order_status > 15 + AND ${getCherryBoxClause(excludeCB)} + AND ${whereClause} + GROUP BY DATE(date_placed) + ORDER BY DATE(date_placed) + `; + + const [orderRangeResults] = await connection.execute(orderRangeQuery, params); + + // Format matches what frontend OrderRangeDetails expects + const stats = orderRangeResults.map(day => ({ + timestamp: day.date, + date: day.date, + orders: parseInt(day.orders || 0), + orderValueRange: { + smallest: parseFloat(day.smallest || 0), + largest: parseFloat(day.largest || 0) + }, + averageOrderValue: parseFloat(day.averageOrderValue || 0) + })); + + if (release) release(); + return res.json({ stats }); + } + + // Build order type filter based on orderType parameter + let orderTypeFilter = ''; + if (orderType === 'pre_orders') { + orderTypeFilter = 'AND stats_waiting_preorder > 0'; + } else if (orderType === 'local_pickup') { + orderTypeFilter = "AND ship_method_selected = 'localpickup'"; + } else if (orderType === 'on_hold') { + orderTypeFilter = "AND ship_method_selected = 'holdit'"; + } + // Daily breakdown query (optionally excludes Cherry Box orders) const dailyQuery = ` - SELECT + SELECT DATE(date_placed) as date, COUNT(*) as orders, SUM(summary_total) as revenue, AVG(summary_total) as averageOrderValue, SUM(stats_prod_pieces) as itemCount - FROM _order - WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause} + FROM _order + WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause} ${orderTypeFilter} GROUP BY DATE(date_placed) ORDER BY DATE(date_placed) `; - + const [dailyResults] = await connection.execute(dailyQuery, params); // Get previous period data using the same logic as main stats endpoint @@ -370,13 +568,13 @@ router.get('/stats/details', async (req, res) => { // Get previous period daily data (optionally excludes Cherry Box orders) const prevQuery = ` - SELECT + SELECT DATE(date_placed) as date, COUNT(*) as prevOrders, SUM(summary_total) as prevRevenue, AVG(summary_total) as prevAvgOrderValue - FROM _order - WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${prevWhereClause} + FROM _order + WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${prevWhereClause} ${orderTypeFilter} GROUP BY DATE(date_placed) `; @@ -619,50 +817,74 @@ router.get('/products', async (req, res) => { // Projection endpoint - replaces /api/klaviyo/events/projection router.get('/projection', async (req, res) => { + const startTime = Date.now(); let release; try { const { timeRange, startDate, endDate, excludeCherryBox } = req.query; const excludeCB = parseBoolParam(excludeCherryBox); - + console.log(`[PROJECTION] Starting request for timeRange: ${timeRange}`); + // Only provide projections for incomplete periods if (!['today', 'thisWeek', 'thisMonth'].includes(timeRange)) { - return res.json({ projectedRevenue: 0, confidence: 0 }); + return res.json({ projectedRevenue: 0, confidence: 0, method: 'none' }); } - + const { connection, release: releaseConn } = await getDbConnection(); release = releaseConn; - + console.log(`[PROJECTION] DB connection obtained in ${Date.now() - startTime}ms`); + + const now = DateTime.now().setZone(TIMEZONE); + // Get current period data const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate); - + // Current period query (optionally excludes Cherry Box orders) const currentQuery = ` - SELECT + SELECT SUM(summary_total) as currentRevenue, COUNT(*) as currentOrders - FROM _order + FROM _order WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause} `; - + const [currentResult] = await connection.execute(currentQuery, params); const current = currentResult[0]; - - // Get historical data for the same period type - const historicalQuery = await getHistoricalProjectionData(connection, timeRange, excludeCB); - - // Calculate projection based on current progress and historical patterns + console.log(`[PROJECTION] Current period data fetched in ${Date.now() - startTime}ms`); + + // Fetch pattern data in parallel for performance + const patternStart = Date.now(); + const [hourlyPattern, dayOfWeekPattern, dailyStats] = await Promise.all([ + getHourlyRevenuePattern(connection, excludeCB), + getDayOfWeekRevenuePattern(connection, excludeCB), + getAverageDailyRevenue(connection, excludeCB) + ]); + console.log(`[PROJECTION] Pattern data fetched in ${Date.now() - patternStart}ms`); + + // Calculate period progress (for logging/debugging) const periodProgress = calculatePeriodProgress(timeRange); + + // Calculate pattern-based projection const projection = calculateSmartProjection( + timeRange, parseFloat(current.currentRevenue || 0), parseInt(current.currentOrders || 0), periodProgress, - historicalQuery + hourlyPattern, + dayOfWeekPattern, + dailyStats, + now ); - + + // Add some useful debug info + projection.periodProgress = periodProgress; + projection.currentRevenue = parseFloat(current.currentRevenue || 0); + projection.currentOrders = parseInt(current.currentOrders || 0); + + console.log(`[PROJECTION] Request completed in ${Date.now() - startTime}ms - method: ${projection.method}, projected: $${projection.projectedRevenue?.toFixed(2)}`); res.json(projection); - + } catch (error) { - console.error('Error in /projection:', error); + console.error(`[PROJECTION] Error after ${Date.now() - startTime}ms:`, error); res.status(500).json({ error: error.message }); } finally { // Release connection back to pool @@ -725,7 +947,7 @@ function processShippingData(shippingResult, totalShipped) { return { locations: { - total: totalShipped, + total: Object.keys(states).length, // Count of unique states/regions shipped to byCountry: Object.entries(countries) .map(([country, count]) => ({ country, @@ -1049,40 +1271,491 @@ function getPreviousTimeRange(timeRange) { return map[timeRange] || timeRange; } -async function getHistoricalProjectionData(connection, timeRange, excludeCherryBox = false) { - // Get historical data for projection calculations (optionally excludes Cherry Box orders) - // This is a simplified version - you could make this more sophisticated - const historicalQuery = ` - SELECT - SUM(summary_total) as revenue, - COUNT(*) as orders - FROM _order - WHERE order_status > 15 - AND ${getCherryBoxClause(excludeCherryBox)} - AND date_placed >= DATE_SUB(NOW(), INTERVAL 30 DAY) - AND date_placed < DATE_SUB(NOW(), INTERVAL 1 DAY) +/** + * Get hourly revenue distribution pattern from last 8 weeks (same day of week) + * Returns array of 24 objects with hour and avgShare (0-1 representing % of daily revenue) + * Optimized: Uses JOIN instead of correlated subquery for O(n) instead of O(n²) + */ +async function getHourlyRevenuePattern(connection, excludeCherryBox = false) { + const now = DateTime.now().setZone(TIMEZONE); + const dayOfWeek = now.weekday; // 1=Monday, 7=Sunday (Luxon) + const mysqlDayOfWeek = dayOfWeek === 7 ? 1 : dayOfWeek + 1; + + // Step 1: Get daily totals and hourly breakdowns in one efficient query + const query = ` + SELECT + hourly.hour_of_day, + AVG(hourly.hour_revenue / daily.daily_revenue) as avgShare + FROM ( + SELECT + DATE(date_placed) as order_date, + HOUR(date_placed) as hour_of_day, + SUM(summary_total) as hour_revenue + FROM _order + WHERE order_status > 15 + AND ${getCherryBoxClause(excludeCherryBox)} + AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK) + AND date_placed < DATE(NOW()) + AND DAYOFWEEK(date_placed) = ? + GROUP BY DATE(date_placed), HOUR(date_placed) + ) hourly + JOIN ( + SELECT + DATE(date_placed) as order_date, + SUM(summary_total) as daily_revenue + FROM _order + WHERE order_status > 15 + AND ${getCherryBoxClause(excludeCherryBox)} + AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK) + AND date_placed < DATE(NOW()) + AND DAYOFWEEK(date_placed) = ? + GROUP BY DATE(date_placed) + HAVING daily_revenue > 0 + ) daily ON hourly.order_date = daily.order_date + GROUP BY hourly.hour_of_day + ORDER BY hourly.hour_of_day `; - - const [result] = await connection.execute(historicalQuery); - return result; + + const [result] = await connection.execute(query, [mysqlDayOfWeek, mysqlDayOfWeek]); + + // Convert to a full 24-hour array, filling gaps with 0 + const hourlyPattern = Array(24).fill(0).map((_, i) => ({ hour: i, avgShare: 0 })); + result.forEach(row => { + hourlyPattern[row.hour_of_day] = { + hour: row.hour_of_day, + avgShare: parseFloat(row.avgShare) || 0 + }; + }); + + // Normalize so shares sum to 1.0 + const totalShare = hourlyPattern.reduce((sum, h) => sum + h.avgShare, 0); + if (totalShare > 0) { + hourlyPattern.forEach(h => h.avgShare = h.avgShare / totalShare); + } + + return hourlyPattern; } -function calculateSmartProjection(currentRevenue, currentOrders, periodProgress, historicalData) { +/** + * Get day-of-week revenue distribution pattern from last 8 weeks + * Returns array of 7 objects with dayOfWeek (1-7, Sunday=1) and avgShare + * Optimized: Uses JOIN instead of correlated subquery + */ +async function getDayOfWeekRevenuePattern(connection, excludeCherryBox = false) { + const query = ` + SELECT + daily.day_of_week, + AVG(daily.day_revenue / weekly.weekly_revenue) as avgShare + FROM ( + SELECT + YEARWEEK(date_placed, 0) as year_week, + DAYOFWEEK(date_placed) as day_of_week, + SUM(summary_total) as day_revenue + FROM _order + WHERE order_status > 15 + AND ${getCherryBoxClause(excludeCherryBox)} + AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK) + AND date_placed < DATE(NOW()) + GROUP BY YEARWEEK(date_placed, 0), DAYOFWEEK(date_placed) + ) daily + JOIN ( + SELECT + YEARWEEK(date_placed, 0) as year_week, + SUM(summary_total) as weekly_revenue + FROM _order + WHERE order_status > 15 + AND ${getCherryBoxClause(excludeCherryBox)} + AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK) + AND date_placed < DATE(NOW()) + GROUP BY YEARWEEK(date_placed, 0) + HAVING weekly_revenue > 0 + ) weekly ON daily.year_week = weekly.year_week + GROUP BY daily.day_of_week + ORDER BY daily.day_of_week + `; + + const [result] = await connection.execute(query); + + // Convert to array indexed by MySQL day of week (1=Sunday, 2=Monday, etc.) + const weekPattern = Array(8).fill(0).map((_, i) => ({ dayOfWeek: i, avgShare: 0 })); + result.forEach(row => { + weekPattern[row.day_of_week] = { + dayOfWeek: row.day_of_week, + avgShare: parseFloat(row.avgShare) || 0 + }; + }); + + // Normalize (indices 1-7 are used, 0 is unused) + const totalShare = weekPattern.slice(1).reduce((sum, d) => sum + d.avgShare, 0); + if (totalShare > 0) { + weekPattern.forEach(d => { if (d.dayOfWeek > 0) d.avgShare = d.avgShare / totalShare; }); + } + + return weekPattern; +} + +/** + * Get average daily revenue for projection (last 30 days, excluding today) + * Also gets same-day-of-week stats for more accurate confidence calculation + */ +async function getAverageDailyRevenue(connection, excludeCherryBox = false) { + const now = DateTime.now().setZone(TIMEZONE); + const mysqlDayOfWeek = now.weekday === 7 ? 1 : now.weekday + 1; + + // Get both overall 30-day stats AND same-day-of-week stats + const query = ` + SELECT + AVG(daily_revenue) as avgDailyRevenue, + STDDEV(daily_revenue) as stdDev, + COUNT(*) as dayCount, + ( + SELECT AVG(day_rev) FROM ( + SELECT SUM(summary_total) as day_rev + FROM _order + WHERE order_status > 15 + AND ${getCherryBoxClause(excludeCherryBox)} + AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK) + AND date_placed < DATE(NOW()) + AND DAYOFWEEK(date_placed) = ${mysqlDayOfWeek} + GROUP BY DATE(date_placed) + ) same_day + ) as sameDayAvg, + ( + SELECT STDDEV(day_rev) FROM ( + SELECT SUM(summary_total) as day_rev + FROM _order + WHERE order_status > 15 + AND ${getCherryBoxClause(excludeCherryBox)} + AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK) + AND date_placed < DATE(NOW()) + AND DAYOFWEEK(date_placed) = ${mysqlDayOfWeek} + GROUP BY DATE(date_placed) + ) same_day_std + ) as sameDayStdDev, + ( + SELECT COUNT(*) FROM ( + SELECT DATE(date_placed) as d + FROM _order + WHERE order_status > 15 + AND ${getCherryBoxClause(excludeCherryBox)} + AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK) + AND date_placed < DATE(NOW()) + AND DAYOFWEEK(date_placed) = ${mysqlDayOfWeek} + GROUP BY DATE(date_placed) + ) same_day_count + ) as sameDayCount + FROM ( + SELECT + DATE(date_placed) as order_date, + SUM(summary_total) as daily_revenue + FROM _order + WHERE order_status > 15 + AND ${getCherryBoxClause(excludeCherryBox)} + AND date_placed >= DATE_SUB(NOW(), INTERVAL 30 DAY) + AND date_placed < DATE(NOW()) + GROUP BY DATE(date_placed) + ) daily_totals + `; + + const [result] = await connection.execute(query); + const row = result[0] || {}; + + return { + avgDailyRevenue: parseFloat(row.avgDailyRevenue) || 0, + stdDev: parseFloat(row.stdDev) || 0, + dayCount: parseInt(row.dayCount) || 0, + sameDayAvg: parseFloat(row.sameDayAvg) || 0, + sameDayStdDev: parseFloat(row.sameDayStdDev) || 0, + sameDayCount: parseInt(row.sameDayCount) || 0 + }; +} + +/** + * Calculate meaningful confidence score based on multiple factors + * Returns score between 0-1 and breakdown of contributing factors + */ +function calculateConfidence({ + expectedProgress, + currentRevenue, + patternProjection, + historicalDailyAvg, + sameDayStdDev, + sameDayCount, + stdDev, + dayCount +}) { + const factors = {}; + + // Factor 1: Time Progress (0-0.3) + // More time elapsed = more data = higher confidence + // Scales from 0 at 0% to 0.3 at 100% + factors.timeProgress = Math.min(0.3, expectedProgress * 0.35); + + // Factor 2: Historical Predictability via Coefficient of Variation (0-0.35) + // CV = stdDev / mean - lower is more predictable + // Use same-day-of-week stats if available (more relevant) + const relevantStdDev = sameDayStdDev || stdDev || 0; + const relevantAvg = historicalDailyAvg || 1; + const cv = relevantStdDev / relevantAvg; + + // CV of 0.1 (10% variation) = very predictable = full points + // CV of 0.5 (50% variation) = unpredictable = minimal points + // Scale: CV 0.1 -> 0.35, CV 0.3 -> 0.15, CV 0.5+ -> 0.05 + if (cv <= 0.1) { + factors.predictability = 0.35; + } else if (cv <= 0.5) { + factors.predictability = Math.max(0.05, 0.35 - (cv - 0.1) * 0.75); + } else { + factors.predictability = 0.05; + } + + // Factor 3: Tracking Accuracy (0-0.25) + // How well is today tracking the expected pattern? + // If we're at 40% progress with 38-42% of expected revenue, that's good + if (expectedProgress > 0.05 && historicalDailyAvg > 0) { + const expectedRevenueSoFar = historicalDailyAvg * expectedProgress; + const trackingRatio = currentRevenue / expectedRevenueSoFar; + + // Perfect tracking (ratio = 1.0) = full points + // 20% off (ratio 0.8 or 1.2) = partial points + // 50%+ off = minimal points + const deviation = Math.abs(1 - trackingRatio); + if (deviation <= 0.1) { + factors.tracking = 0.25; + } else if (deviation <= 0.3) { + factors.tracking = 0.25 - (deviation - 0.1) * 0.5; + } else if (deviation <= 0.5) { + factors.tracking = 0.15 - (deviation - 0.3) * 0.4; + } else { + factors.tracking = 0.05; + } + } else { + // Not enough progress to judge tracking + factors.tracking = 0.1; + } + + // Factor 4: Data Quality (0-0.1) + // More historical data points = more reliable pattern + const dataPoints = sameDayCount || Math.floor(dayCount / 7) || 0; + // 8 weeks of same-day data = full points, less = proportionally less + factors.dataQuality = Math.min(0.1, (dataPoints / 8) * 0.1); + + // Calculate total confidence score + const score = Math.min(0.95, Math.max(0.1, + factors.timeProgress + + factors.predictability + + factors.tracking + + factors.dataQuality + )); + + return { score, factors }; +} + +/** + * Calculate pattern-based projection for different time ranges + */ +function calculateSmartProjection( + timeRange, + currentRevenue, + currentOrders, + periodProgress, + hourlyPattern, + dayOfWeekPattern, + dailyStats, + now +) { if (periodProgress >= 100) { return { projectedRevenue: currentRevenue, projectedOrders: currentOrders, confidence: 1.0 }; } - - // Simple linear projection with confidence based on how much of the period has elapsed - const projectedRevenue = currentRevenue / (periodProgress / 100); - const projectedOrders = Math.round(currentOrders / (periodProgress / 100)); - - // Confidence increases with more data (higher period progress) - const confidence = Math.min(0.95, Math.max(0.1, periodProgress / 100)); - + + const currentHour = now.hour; + const currentDayOfWeek = now.weekday === 7 ? 1 : now.weekday + 1; // Convert to MySQL day (1=Sunday) + + if (timeRange === 'today') { + // Calculate expected progress based on hourly pattern + // Sum up shares for all hours up to and including current hour + let expectedProgress = 0; + for (let h = 0; h <= currentHour; h++) { + expectedProgress += hourlyPattern[h]?.avgShare || 0; + } + + // Adjust for partial hour (how far through current hour we are) + const minuteProgress = now.minute / 60; + const currentHourShare = hourlyPattern[currentHour]?.avgShare || 0; + expectedProgress = expectedProgress - currentHourShare + (currentHourShare * minuteProgress); + + // Avoid division by zero and handle edge cases + if (expectedProgress <= 0.01) { + // Very early in day, use linear projection with low confidence + const linearProjection = currentRevenue / Math.max(periodProgress / 100, 0.01); + return { + projectedRevenue: linearProjection, + projectedOrders: Math.round(currentOrders / Math.max(periodProgress / 100, 0.01)), + confidence: 0.1, + method: 'linear_fallback' + }; + } + + const patternProjection = currentRevenue / expectedProgress; + + // Blend with historical average for stability early in day + // Use same-day-of-week average if available, otherwise fall back to overall average + const historicalDailyAvg = dailyStats.sameDayAvg || dailyStats.avgDailyRevenue || patternProjection; + const actualWeight = Math.pow(expectedProgress, 0.8); // More weight to actual as day progresses + const projectedRevenue = (patternProjection * actualWeight) + (historicalDailyAvg * (1 - actualWeight)); + + // Calculate meaningful confidence based on multiple factors + const confidence = calculateConfidence({ + expectedProgress, + currentRevenue, + patternProjection, + historicalDailyAvg, + sameDayStdDev: dailyStats.sameDayStdDev, + sameDayCount: dailyStats.sameDayCount, + stdDev: dailyStats.stdDev, + dayCount: dailyStats.dayCount + }); + + return { + projectedRevenue, + projectedOrders: Math.round(currentOrders / expectedProgress), + confidence: confidence.score, + confidenceFactors: confidence.factors, + method: 'hourly_pattern', + debug: { expectedProgress, actualWeight, patternProjection, historicalDailyAvg } + }; + } + + if (timeRange === 'thisWeek') { + // Calculate revenue expected so far this week based on day-of-week pattern + // And project remaining days + + // Days completed so far (Sunday = day 1 in MySQL) + // If today is Tuesday (MySQL day 3), completed days are Sunday(1) and Monday(2) + let expectedProgressSoFar = 0; + for (let d = 1; d < currentDayOfWeek; d++) { + expectedProgressSoFar += dayOfWeekPattern[d]?.avgShare || 0; + } + + // Add partial progress through today using hourly pattern + let todayExpectedProgress = 0; + for (let h = 0; h <= currentHour; h++) { + todayExpectedProgress += hourlyPattern[h]?.avgShare || 0; + } + const minuteProgress = now.minute / 60; + const currentHourShare = hourlyPattern[currentHour]?.avgShare || 0; + todayExpectedProgress = todayExpectedProgress - currentHourShare + (currentHourShare * minuteProgress); + + // Add today's partial contribution + const todayFullShare = dayOfWeekPattern[currentDayOfWeek]?.avgShare || (1/7); + expectedProgressSoFar += todayFullShare * todayExpectedProgress; + + // Avoid division by zero + if (expectedProgressSoFar <= 0.01) { + return { + projectedRevenue: currentRevenue / Math.max(periodProgress / 100, 0.01), + projectedOrders: Math.round(currentOrders / Math.max(periodProgress / 100, 0.01)), + confidence: 0.1, + method: 'linear_fallback' + }; + } + + const projectedWeekRevenue = currentRevenue / expectedProgressSoFar; + const projectedWeekOrders = Math.round(currentOrders / expectedProgressSoFar); + + // Calculate meaningful confidence + const historicalWeeklyAvg = dailyStats.avgDailyRevenue * 7; + const confidence = calculateConfidence({ + expectedProgress: expectedProgressSoFar, + currentRevenue, + patternProjection: projectedWeekRevenue, + historicalDailyAvg: historicalWeeklyAvg, + sameDayStdDev: dailyStats.stdDev * Math.sqrt(7), // Approximate weekly stdDev + sameDayCount: Math.floor(dailyStats.dayCount / 7), + stdDev: dailyStats.stdDev * Math.sqrt(7), + dayCount: dailyStats.dayCount + }); + + return { + projectedRevenue: projectedWeekRevenue, + projectedOrders: projectedWeekOrders, + confidence: confidence.score, + confidenceFactors: confidence.factors, + method: 'weekly_pattern', + debug: { expectedProgressSoFar, currentDayOfWeek, todayExpectedProgress } + }; + } + + if (timeRange === 'thisMonth') { + // For month projection, use days elapsed and average daily revenue + const currentDay = now.day; + const daysInMonth = now.daysInMonth; + + // Calculate average daily revenue so far this month + const daysElapsed = currentDay - 1; // Full days completed + + // Add partial progress through today + let todayExpectedProgress = 0; + for (let h = 0; h <= currentHour; h++) { + todayExpectedProgress += hourlyPattern[h]?.avgShare || 0; + } + const minuteProgress = now.minute / 60; + const currentHourShare = hourlyPattern[currentHour]?.avgShare || 0; + todayExpectedProgress = todayExpectedProgress - currentHourShare + (currentHourShare * minuteProgress); + + const effectiveDaysElapsed = daysElapsed + todayExpectedProgress; + + if (effectiveDaysElapsed <= 0.1) { + // Very early in month, use historical average + const projectedRevenue = dailyStats.avgDailyRevenue * daysInMonth; + return { + projectedRevenue, + projectedOrders: Math.round(currentOrders / Math.max(periodProgress / 100, 0.01)), + confidence: 0.15, + method: 'historical_monthly' + }; + } + + // Calculate implied daily rate from current data + const impliedDailyRate = currentRevenue / effectiveDaysElapsed; + + // Blend with historical average (more weight to actual data as month progresses) + const actualWeight = Math.min(0.9, effectiveDaysElapsed / 10); // Full weight after ~10 days + const blendedDailyRate = (impliedDailyRate * actualWeight) + (dailyStats.avgDailyRevenue * (1 - actualWeight)); + + const projectedMonthRevenue = blendedDailyRate * daysInMonth; + const projectedMonthOrders = Math.round((currentOrders / effectiveDaysElapsed) * daysInMonth); + + // Calculate meaningful confidence + const historicalMonthlyAvg = dailyStats.avgDailyRevenue * daysInMonth; + const confidence = calculateConfidence({ + expectedProgress: effectiveDaysElapsed / daysInMonth, + currentRevenue, + patternProjection: projectedMonthRevenue, + historicalDailyAvg: historicalMonthlyAvg, + sameDayStdDev: dailyStats.stdDev * Math.sqrt(daysInMonth), + sameDayCount: 1, // Only ~1 month of same-month data typically + stdDev: dailyStats.stdDev * Math.sqrt(daysInMonth), + dayCount: dailyStats.dayCount + }); + + return { + projectedRevenue: projectedMonthRevenue, + projectedOrders: projectedMonthOrders, + confidence: confidence.score, + confidenceFactors: confidence.factors, + method: 'monthly_blend', + debug: { effectiveDaysElapsed, daysInMonth, impliedDailyRate, blendedDailyRate } + }; + } + + // Fallback for any other case + const linearProjection = currentRevenue / (periodProgress / 100); return { - projectedRevenue, - projectedOrders, - confidence + projectedRevenue: linearProjection, + projectedOrders: Math.round(currentOrders / (periodProgress / 100)), + confidence: Math.min(0.95, Math.max(0.1, periodProgress / 100)), + method: 'linear_fallback' }; } diff --git a/inventory/src/components/dashboard/MiniEventFeed.jsx b/inventory/src/components/dashboard/MiniEventFeed.jsx index 0b8c424..f8901d4 100644 --- a/inventory/src/components/dashboard/MiniEventFeed.jsx +++ b/inventory/src/components/dashboard/MiniEventFeed.jsx @@ -8,7 +8,6 @@ import { CardTitle, } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { Package, Truck, @@ -16,7 +15,6 @@ import { XCircle, DollarSign, Activity, - AlertCircle, FileText, ChevronLeft, ChevronRight, @@ -66,7 +64,7 @@ const EVENT_TYPES = { gradient: "from-red-800 to-red-700", }, [METRIC_IDS.PAYMENT_REFUNDED]: { - label: "Payment Refunded", + label: "Payment Refund", color: "bg-orange-200", textColor: "text-orange-50", iconColor: "text-orange-800", @@ -94,22 +92,22 @@ const EVENT_ICONS = { const LoadingState = () => (
{[...Array(6)].map((_, i) => ( - +
- - + +
-
- +
+
- +
- +
@@ -120,12 +118,12 @@ const LoadingState = () => ( // Empty State Component const EmptyState = () => ( - + -
- +
+
-

+

No recent activity

@@ -141,14 +139,14 @@ const EventCard = ({ event }) => { return ( - +
- + {eventType.label} {event.datetime && ( - + {format(new Date(event.datetime), "h:mm a")} )} diff --git a/inventory/src/components/dashboard/MiniRealtimeAnalytics.jsx b/inventory/src/components/dashboard/MiniRealtimeAnalytics.jsx index 61f8cdf..600a806 100644 --- a/inventory/src/components/dashboard/MiniRealtimeAnalytics.jsx +++ b/inventory/src/components/dashboard/MiniRealtimeAnalytics.jsx @@ -85,7 +85,7 @@ const MiniRealtimeAnalytics = () => { ); } - if (loading) { + if (loading && !basicData.byMinute?.length) { return (
@@ -141,18 +141,18 @@ const MiniRealtimeAnalytics = () => {
diff --git a/inventory/src/components/dashboard/MiniSalesChart.jsx b/inventory/src/components/dashboard/MiniSalesChart.jsx index b311484..f4c5262 100644 --- a/inventory/src/components/dashboard/MiniSalesChart.jsx +++ b/inventory/src/components/dashboard/MiniSalesChart.jsx @@ -140,31 +140,20 @@ const MiniSalesChart = ({ className = "" }) => { ); } - // Helper to calculate trend direction - const getRevenueTrend = () => { - const current = summaryStats.periodProgress < 100 - ? (projection?.projectedRevenue || summaryStats.totalRevenue) - : summaryStats.totalRevenue; - return current >= summaryStats.prevRevenue ? "up" : "down"; - }; - + // Helper to calculate trend values (positive = up, negative = down) const getRevenueTrendValue = () => { const current = summaryStats.periodProgress < 100 ? (projection?.projectedRevenue || summaryStats.totalRevenue) : summaryStats.totalRevenue; - return `${Math.abs(Math.round((current - summaryStats.prevRevenue) / summaryStats.prevRevenue * 100))}%`; - }; - - const getOrdersTrend = () => { - const projected = Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress)); - const current = summaryStats.periodProgress < 100 ? projected : summaryStats.totalOrders; - return current >= summaryStats.prevOrders ? "up" : "down"; + if (!summaryStats.prevRevenue) return 0; + return ((current - summaryStats.prevRevenue) / summaryStats.prevRevenue) * 100; }; const getOrdersTrendValue = () => { const projected = Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress)); const current = summaryStats.periodProgress < 100 ? projected : summaryStats.totalOrders; - return `${Math.abs(Math.round((current - summaryStats.prevOrders) / summaryStats.prevOrders * 100))}%`; + if (!summaryStats.prevOrders) return 0; + return ((current - summaryStats.prevOrders) / summaryStats.prevOrders) * 100; }; if (loading && !data) { @@ -190,7 +179,7 @@ const MiniSalesChart = ({ className = "" }) => {
{/* Stat Cards */}
- {loading ? ( + {loading && !data?.length ? ( <> @@ -200,13 +189,10 @@ const MiniSalesChart = ({ className = "" }) => { toggleMetric('revenue')} @@ -214,13 +200,10 @@ const MiniSalesChart = ({ className = "" }) => { toggleMetric('orders')} @@ -233,7 +216,7 @@ const MiniSalesChart = ({ className = "" }) => {
- {loading ? ( + {loading && !data?.length ? ( ) : ( diff --git a/inventory/src/components/dashboard/MiniStatCards.jsx b/inventory/src/components/dashboard/MiniStatCards.jsx index 1a11121..de87bdc 100644 --- a/inventory/src/components/dashboard/MiniStatCards.jsx +++ b/inventory/src/components/dashboard/MiniStatCards.jsx @@ -30,7 +30,6 @@ import { ShippingDetails, DetailDialog, formatCurrency, - formatPercentage, } from "./StatCards"; import { DashboardStatCardMini, @@ -112,8 +111,14 @@ const MiniStatCards = ({ const calculateOrderTrend = useCallback(() => { if (!stats?.prevPeriodOrders) return null; - return calculateTrend(stats.orderCount, stats.prevPeriodOrders); - }, [stats, calculateTrend]); + + // If period is incomplete, use projected orders for fair comparison + const currentOrders = stats.periodProgress < 100 + ? (projection?.projectedOrders || Math.round(stats.orderCount / (stats.periodProgress / 100))) + : stats.orderCount; + + return calculateTrend(currentOrders, stats.prevPeriodOrders); + }, [stats, projection, calculateTrend]); const calculateAOVTrend = useCallback(() => { if (!stats?.prevPeriodAOV) return null; @@ -284,18 +289,18 @@ const MiniStatCards = ({ setSelectedMetric("revenue")} @@ -304,14 +309,16 @@ const MiniStatCards = ({ setSelectedMetric("orders")} @@ -321,14 +328,14 @@ const MiniStatCards = ({ title="Today's AOV" value={stats?.averageOrderValue?.toFixed(2)} valuePrefix="$" - description={`${stats?.averageItemsPerOrder?.toFixed(1)} items per order`} + subtitle={`${stats?.averageItemsPerOrder?.toFixed(1)} items/order`} trend={ aovTrend?.trend - ? { direction: aovTrend.trend, value: formatPercentage(aovTrend.value) } + ? { value: aovTrend.trend === "up" ? aovTrend.value : -aovTrend.value } : undefined } icon={CircleDollarSign} - iconBackground="bg-violet-300" + iconBackground="bg-violet-400" gradient="violet" className="h-[150px]" onClick={() => setSelectedMetric("average_order")} @@ -337,9 +344,9 @@ const MiniStatCards = ({ setSelectedMetric("shipping")} diff --git a/inventory/src/components/dashboard/StatCards.jsx b/inventory/src/components/dashboard/StatCards.jsx index 12cac14..a1a29b7 100644 --- a/inventory/src/components/dashboard/StatCards.jsx +++ b/inventory/src/components/dashboard/StatCards.jsx @@ -237,8 +237,7 @@ const OrdersDetails = ({ data }) => { dataKey="orders" name="Orders" type="bar" - color=" - " + color="hsl(221.2 83.2% 53.3%)" />
)} @@ -376,7 +375,7 @@ const BrandsCategoriesDetails = ({ data }) => { {brandsList.map((brand) => ( - + {brand.name} {brand.count?.toLocaleString()} @@ -407,7 +406,7 @@ const BrandsCategoriesDetails = ({ data }) => { {categoriesList.map((category) => ( - + {category.name} {category.count?.toLocaleString()} @@ -563,9 +562,9 @@ const OrderTypeDetails = ({ data, type }) => { ); const timeSeriesData = data.map((day) => ({ - timestamp: day.timestamp, - count: day.count, - value: day.value, + timestamp: day.timestamp || day.date, + count: day.count ?? day.orders, // Backend returns 'orders' + value: day.value ?? day.revenue, // Backend returns 'revenue' percentage: day.percentage, })); @@ -623,10 +622,11 @@ const PeakHourDetails = ({ data }) => {
); + // hourlyOrders is now an array of {hour, count} objects in chronological order (rolling 24hrs) const hourlyData = - data[0]?.hourlyOrders?.map((count, hour) => ({ - timestamp: hour, // Use raw hour number for x-axis - orders: count, + data[0]?.hourlyOrders?.map((item) => ({ + timestamp: item.hour, // The actual hour (0-23) + orders: item.count, })) || []; return ( @@ -996,13 +996,11 @@ const StatCards = ({ const [lastUpdate, setLastUpdate] = useState(null); const [timeRange, setTimeRange] = useState(initialTimeRange); const [selectedMetric, setSelectedMetric] = useState(null); - const [dateRange, setDateRange] = useState(null); const [detailDataLoading, setDetailDataLoading] = useState({}); const [detailData, setDetailData] = useState({}); - const [isInitialLoad, setIsInitialLoad] = useState(true); const [projection, setProjection] = useState(null); const [projectionLoading, setProjectionLoading] = useState(false); - const { setCacheData, getCacheData, clearCache } = useDataCache(); + const { setCacheData, getCacheData } = useDataCache(); // Function to determine if we should use last30days for trend charts const shouldUseLast30Days = useCallback( @@ -1218,8 +1216,14 @@ const StatCards = ({ const calculateOrderTrend = useCallback(() => { if (!stats?.prevPeriodOrders) return null; - return calculateTrend(stats.orderCount, stats.prevPeriodOrders); - }, [stats, calculateTrend]); + + // If period is incomplete, use projected orders for fair comparison + const currentOrders = stats.periodProgress < 100 + ? (projection?.projectedOrders || Math.round(stats.orderCount / (stats.periodProgress / 100))) + : stats.orderCount; + + return calculateTrend(currentOrders, stats.prevPeriodOrders); + }, [stats, projection, calculateTrend]); const calculateAOVTrend = useCallback(() => { if (!stats?.prevPeriodAOV) return null; @@ -1242,7 +1246,6 @@ const StatCards = ({ if (!isMounted) return; - setDateRange(response.timeRange); setStats(response.stats); setLastUpdate(DateTime.now().setZone("America/New_York")); setError(null); @@ -1257,7 +1260,6 @@ const StatCards = ({ } finally { if (isMounted) { setLoading(false); - setIsInitialLoad(false); } } }; @@ -1321,69 +1323,30 @@ const StatCards = ({ return () => clearInterval(interval); }, [timeRange]); - // Modified AsyncDetailView component - const AsyncDetailView = memo(({ metric, type, orderCount }) => { - const detailTimeRange = shouldUseLast30Days(metric) + // Fetch detail data when a metric is selected (if not already cached) + useEffect(() => { + if (!selectedMetric) return; + + // Skip metrics that use stats directly instead of fetched detail data + if (["brands_categories", "shipping", "peak_hour"].includes(selectedMetric)) { + return; + } + + const detailTimeRange = shouldUseLast30Days(selectedMetric) ? "last30days" : timeRange; - const cachedData = - detailData[metric] || getCacheData(detailTimeRange, metric); - const isLoading = detailDataLoading[metric]; - const isOrderTypeMetric = [ - "pre_orders", - "local_pickup", - "on_hold", - ].includes(metric); + const cachedData = detailData[selectedMetric] || getCacheData(detailTimeRange, selectedMetric); + const isLoading = detailDataLoading[selectedMetric]; - useEffect(() => { - let isMounted = true; - - const loadData = async () => { - if (!cachedData && !isLoading) { - // Pass type only for order type metrics - const data = await fetchDetailData( - metric, - isOrderTypeMetric ? metric : undefined - ); - if (!isMounted) return; - // The state updates are handled in fetchDetailData - } - }; - - loadData(); - return () => { - isMounted = false; - }; - }, [metric, timeRange, isOrderTypeMetric]); // Depend on isOrderTypeMetric - - if (isLoading || (!cachedData && !error)) { - switch (metric) { - case "revenue": - case "orders": - case "average_order": - return ; - case "refunds": - case "cancellations": - case "order_range": - case "pre_orders": - case "local_pickup": - case "on_hold": - return ; - case "brands_categories": - case "shipping": - return ; - case "peak_hour": - return ; - default: - return
Loading...
; - } + if (!cachedData && !isLoading) { + const isOrderTypeMetric = ["pre_orders", "local_pickup", "on_hold"].includes(selectedMetric); + fetchDetailData(selectedMetric, isOrderTypeMetric ? selectedMetric : undefined); } + }, [selectedMetric, timeRange, shouldUseLast30Days, detailData, detailDataLoading, getCacheData, fetchDetailData]); - if (!cachedData && error) { - return ; - } - - if (!cachedData) { + // Modified getDetailComponent to use memoized components + const getDetailComponent = useCallback(() => { + if (!selectedMetric || !stats) { return ( ; - case "orders": - return ; - case "average_order": - return ( - - ); - case "refunds": - return ; - case "cancellations": - return ; - case "order_range": - return ; - case "pre_orders": - case "local_pickup": - case "on_hold": - return ; - default: - return ( -
Invalid metric selected.
- ); - } - }); - - AsyncDetailView.displayName = "AsyncDetailView"; - - // Modified getDetailComponent to use memoized components - const getDetailComponent = useCallback(() => { - if (!selectedMetric || !stats) { - return ( -
- No data available for the selected time range. -
- ); - } - const data = detailData[selectedMetric]; const isLoading = detailDataLoading[selectedMetric]; - const isOrderTypeMetric = [ - "pre_orders", - "local_pickup", - "on_hold", - ].includes(selectedMetric); if (isLoading) { - return ; + // Show metric-specific loading skeletons + switch (selectedMetric) { + case "brands_categories": + case "shipping": + return ; + case "revenue": + case "orders": + case "average_order": + return ; + default: + return ; + } } switch (selectedMetric) { @@ -1659,7 +1587,7 @@ const StatCards = ({ projectionLoading && stats?.periodProgress < 100 ? undefined : revenueTrend?.value - ? { value: revenueTrend.value, moreIsBetter: revenueTrend.trend === "up" } + ? { value: revenueTrend.trend === "up" ? revenueTrend.value : -revenueTrend.value, moreIsBetter: true } : undefined } icon={DollarSign} @@ -1672,7 +1600,13 @@ const StatCards = ({ title="Orders" value={stats?.orderCount} subtitle={`${stats?.itemCount} total items`} - trend={orderTrend?.value ? { value: orderTrend.value, moreIsBetter: orderTrend.trend === "up" } : undefined} + trend={ + projectionLoading && stats?.periodProgress < 100 + ? undefined + : orderTrend?.value + ? { value: orderTrend.trend === "up" ? orderTrend.value : -orderTrend.value, moreIsBetter: true } + : undefined + } icon={ShoppingCart} iconColor="blue" onClick={() => setSelectedMetric("orders")} @@ -1684,7 +1618,7 @@ const StatCards = ({ value={stats?.averageOrderValue?.toFixed(2)} valuePrefix="$" subtitle={`${stats?.averageItemsPerOrder?.toFixed(1)} items per order`} - trend={aovTrend?.value ? { value: aovTrend.value, moreIsBetter: aovTrend.trend === "up" } : undefined} + trend={aovTrend?.value ? { value: aovTrend.trend === "up" ? aovTrend.value : -aovTrend.value, moreIsBetter: true } : undefined} icon={CircleDollarSign} iconColor="purple" onClick={() => setSelectedMetric("average_order")} @@ -1714,7 +1648,9 @@ const StatCards = ({ 0 + ? ((stats?.orderTypes?.preOrders?.count / stats?.orderCount) * 100).toFixed(1) + : "0" } valueSuffix="%" subtitle={`${stats?.orderTypes?.preOrders?.count || 0} orders`} @@ -1727,7 +1663,9 @@ const StatCards = ({ 0 + ? ((stats?.orderTypes?.localPickup?.count / stats?.orderCount) * 100).toFixed(1) + : "0" } valueSuffix="%" subtitle={`${stats?.orderTypes?.localPickup?.count || 0} orders`} @@ -1740,7 +1678,9 @@ const StatCards = ({ 0 + ? ((stats?.orderTypes?.heldItems?.count / stats?.orderCount) * 100).toFixed(1) + : "0" } valueSuffix="%" subtitle={`${stats?.orderTypes?.heldItems?.count || 0} orders`} diff --git a/inventory/src/components/dashboard/shared/DashboardStatCardMini.tsx b/inventory/src/components/dashboard/shared/DashboardStatCardMini.tsx index 4a7c6e4..4eee7a1 100644 --- a/inventory/src/components/dashboard/shared/DashboardStatCardMini.tsx +++ b/inventory/src/components/dashboard/shared/DashboardStatCardMini.tsx @@ -10,12 +10,18 @@ * value="$12,345" * gradient="emerald" * icon={DollarSign} + * trend={{ value: 12.5, label: "vs last month" }} * /> */ import React from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { TrendingUp, TrendingDown, type LucideIcon } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { ArrowUp, ArrowDown, Minus, Info, type LucideIcon } from "lucide-react"; import { cn } from "@/lib/utils"; // ============================================================================= @@ -35,6 +41,17 @@ export type GradientVariant = | "sky" | "custom"; +export interface TrendProps { + /** The percentage or absolute change value */ + value: number; + /** Optional label to show after the trend (e.g., "vs last month") */ + label?: string; + /** Whether a higher value is better (affects color). Defaults to true. */ + moreIsBetter?: boolean; + /** Suffix for the trend value (defaults to "%"). Use "" for no suffix. */ + suffix?: string; +} + export interface DashboardStatCardMiniProps { /** Card title/label */ title: string; @@ -44,13 +61,10 @@ export interface DashboardStatCardMiniProps { valuePrefix?: string; /** Optional suffix for the value (e.g., "%") */ valueSuffix?: string; - /** Optional description text or element */ - description?: React.ReactNode; - /** Trend direction and value */ - trend?: { - direction: "up" | "down"; - value: string; - }; + /** Optional subtitle or description (can be string or JSX) */ + subtitle?: React.ReactNode; + /** Optional trend indicator */ + trend?: TrendProps; /** Optional icon component */ icon?: LucideIcon; /** Icon background color class (e.g., "bg-emerald-500/20") */ @@ -61,6 +75,12 @@ export interface DashboardStatCardMiniProps { className?: string; /** Click handler */ onClick?: () => void; + /** Loading state */ + loading?: boolean; + /** Tooltip text shown via info icon next to title */ + tooltip?: string; + /** Additional content to render below the main value */ + children?: React.ReactNode; } // ============================================================================= @@ -81,6 +101,53 @@ const GRADIENT_PRESETS: Record = { custom: "", }; +// ============================================================================= +// HELPER COMPONENTS +// ============================================================================= + +/** + * Get trend colors optimized for dark gradient backgrounds + */ +const getTrendColors = (value: number, moreIsBetter: boolean = true): string => { + const isPositive = value > 0; + const isGood = moreIsBetter ? isPositive : !isPositive; + + if (value === 0) { + return "text-gray-400"; + } + return isGood ? "text-emerald-400" : "text-rose-400"; +}; + +interface TrendIndicatorProps { + value: number; + label?: string; + moreIsBetter?: boolean; + suffix?: string; +} + +const TrendIndicator: React.FC = ({ + value, + label, + moreIsBetter = true, + suffix = "%", +}) => { + const colorClass = getTrendColors(value, moreIsBetter); + const IconComponent = value > 0 ? ArrowUp : value < 0 ? ArrowDown : Minus; + + // Format the value - round to integer for compact display (preserves sign for negatives) + const formattedValue = Math.round(value); + + return ( + + + {value > 0 ? "+" : ""} + {formattedValue} + {suffix} + {label && {label}} + + ); +}; + // ============================================================================= // MAIN COMPONENT // ============================================================================= @@ -90,16 +157,41 @@ export const DashboardStatCardMini: React.FC = ({ value, valuePrefix, valueSuffix, - description, + subtitle, trend, icon: Icon, iconBackground, gradient = "slate", className, onClick, + loading = false, + tooltip, + children, }) => { const gradientClass = gradient === "custom" ? "" : GRADIENT_PRESETS[gradient]; + // Loading state + if (loading) { + return ( + + +
+ {Icon &&
} + + +
+ {subtitle &&
} + + + ); + } + return ( = ({ )} onClick={onClick} > - - - {title} - + +
+ + {title} + + {tooltip && ( + + + + + +

{tooltip}

+
+
+ )} +
{Icon && (
{iconBackground && ( @@ -121,11 +231,11 @@ export const DashboardStatCardMini: React.FC = ({ className={cn("absolute inset-0 rounded-full", iconBackground)} /> )} - +
)}
- +
{valuePrefix} {typeof value === "number" ? value.toLocaleString() : value} @@ -133,32 +243,24 @@ export const DashboardStatCardMini: React.FC = ({ {valueSuffix} )}
- {(description || trend) && ( -
- {trend && ( - - {trend.direction === "up" ? ( - - ) : ( - - )} - {trend.value} + {(subtitle || trend) && ( +
+ {subtitle && ( + + {subtitle} )} - {description && ( - - {description} - + {trend && ( + )}
)} + {children} ); @@ -170,12 +272,14 @@ export const DashboardStatCardMini: React.FC = ({ export interface DashboardStatCardMiniSkeletonProps { gradient?: GradientVariant; + hasIcon?: boolean; + hasSubtitle?: boolean; className?: string; } export const DashboardStatCardMiniSkeleton: React.FC< DashboardStatCardMiniSkeletonProps -> = ({ gradient = "slate", className }) => { +> = ({ gradient = "slate", hasIcon = true, hasSubtitle = true, className }) => { const gradientClass = gradient === "custom" ? "" : GRADIENT_PRESETS[gradient]; return ( @@ -186,13 +290,13 @@ export const DashboardStatCardMiniSkeleton: React.FC< className )} > - +
-
+ {hasIcon &&
} - +
-
+ {hasSubtitle &&
} ); diff --git a/mountremote.command b/mountremote.command deleted file mode 100755 index f1697cd..0000000 --- a/mountremote.command +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/zsh - -#Clear previous mount in case it’s still there -umount '/Users/matt/Dev/inventory/inventory-server' - -#Mount -sshfs matt@dashboard.kent.pw:/var/www/html/inventory -p 22122 '/Users/matt/Dev/inventory/inventory-server/' \ No newline at end of file