diff --git a/admin/app/components/solidus_admin/orders/show/adjustments/index/component.rb b/admin/app/components/solidus_admin/orders/show/adjustments/index/component.rb new file mode 100644 index 00000000000..dd6e8edc757 --- /dev/null +++ b/admin/app/components/solidus_admin/orders/show/adjustments/index/component.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +class SolidusAdmin::Orders::Show::Adjustments::Index::Component < SolidusAdmin::UI::Pages::Index::Component + def model_class + Spree::Adjustment + end + + def back_url + solidus_admin.order_path(@order) + end + + def title + t(".title", number: @order.number) + end + + NBSP = " ".html_safe + + def initialize(order:, adjustments:) + @order = order + @adjustments = adjustments + @page = GearedPagination::Recordset.new(adjustments, per_page: adjustments.size).page(1) + end + + def batch_actions + [ + { + display_name: t(".actions.lock"), + action: solidus_admin.lock_order_adjustments_path(@order), + method: :put, + icon: "lock-line" + }, + { + display_name: t(".actions.unlock"), + action: solidus_admin.unlock_order_adjustments_path(@order), + method: :put, + icon: "lock-unlock-line" + }, + { + display_name: t(".actions.delete"), + action: spree.admin_order_adjustment_path(@order, '[]'), + method: :delete, + icon: "delete-bin-7-line" + }, + ] + end + + def search_key + :label_cont + end + + def search_url + solidus_admin.order_adjustments_path(@order) + end + + def columns + [ + { + header: :state, + wrap: true, + col: { class: 'w-[calc(5rem+2rem+2.5rem+1px)]' }, + data: ->(adjustment) { + if adjustment.finalized? + icon = 'lock-fill' + title = t(".state.locked") + else + icon = 'lock-unlock-line' + title = t(".state.unlocked") + end + icon_tag(icon, class: "w-5 h-5 align-middle") + tag.span(title) + } + }, + { + header: :adjustable, + col: { class: 'w-56' }, + data: ->(adjustment) { + tag.figure(safe_join([ + render(component("ui/thumbnail").for(adjustment.adjustable, class: "basis-10")), + figcaption_for_adjustable(adjustment), + ]), class: "flex items-center gap-2") + } + }, + { + header: :source, + col: { class: "w-56" }, + data: ->(adjustment) { + tag.figure(safe_join([ + figcaption_for_source(adjustment), + ]), class: "flex items-center gap-2") + } + }, + { + header: :amount, + col: { class: 'w-24' }, + data: ->(adjustment) { tag.span adjustment.display_amount.to_html, class: "grow text-right whitespace-nowrap" } + }, + { + header: tag.span(t(".actions.title"), class: 'sr-only'), + col: { class: 'w-24' }, + wrap: false, + data: ->(adjustment) do + actions = [] + + unless adjustment.source + actions << link_to( + t(".actions.edit"), + spree.edit_admin_order_adjustment_path(@order, adjustment), + class: 'body-link', + ) + end + + if adjustment.finalized? + actions << link_to( + t(".actions.unlock"), + solidus_admin.unlock_order_adjustments_path(@order, id: adjustment), + "data-turbo-method": :put, + "data-turbo-confirm": t('.confirm'), + class: 'body-link', + ) + else + actions << link_to( + t(".actions.lock"), + solidus_admin.lock_order_adjustments_path(@order, id: adjustment), + "data-turbo-method": :put, + "data-turbo-confirm": t('.confirm'), + class: 'body-link', + ) + actions << link_to( + t(".actions.delete"), + spree.admin_order_adjustment_path(@order, adjustment), + "data-turbo-method": :delete, + "data-turbo-confirm": t('.confirm'), + class: 'body-link !text-red-500', + ) + end + + render component('ui/dropdown').new( + direction: :right, + class: 'relative w-fit m-auto', + ).with_content(safe_join(actions)) + end + }, + ] + end + + def icon_thumbnail(name) + render component("ui/thumbnail").new(src: svg_data_uri(icon_tag(name))) + end + + def svg_data_uri(data) + "data:image/svg+xml;base64,#{Base64.strict_encode64(data)}" + end + + def figcaption_for_source(adjustment) + return thumbnail_caption(adjustment.label, nil) unless adjustment.source_type + + # ["Spree::PromotionAction", nil, "Spree::UnitCancel", "Spree::TaxRate"] + record = adjustment.source + record_class = adjustment.source_type&.constantize + model_name = record_class.model_name.human + + case record || record_class + when Spree::TaxRate + detail = link_to("#{model_name}: #{record.zone&.name || t('spree.all_zones')}", spree.edit_admin_tax_rate_path(adjustment.source_id), class: "body-link") + when Spree::PromotionAction + detail = link_to("#{model_name}: #{record.promotion.name}", spree.edit_admin_promotion_path(adjustment.source_id), class: "body-link") + else + detail = "#{model_name}: #{record.display_amount}" if record.respond_to?(:display_amount) + end + + thumbnail_caption(adjustment.label, detail) + end + + def figcaption_for_adjustable(adjustment) + # ["Spree::LineItem", "Spree::Order", "Spree::Shipment"] + record = adjustment.adjustable + record_class = adjustment.adjustable_type&.constantize + + case record || record_class + when Spree::LineItem + variant = record.variant + options_text = variant.options_text.presence + + description = options_text || variant.sku + detail = link_to(variant.product.name, solidus_admin.product_path(record.variant.product), class: "body-link") + when Spree::Order + description = "#{Spree::Order.model_name.human} ##{record.number}" + detail = record.display_total + when Spree::Shipment + description = "#{t('spree.shipment')} ##{record.number}" + detail = link_to(record.shipping_method.name, spree.edit_admin_shipping_method_path(record.shipping_method), class: "body-link") + when nil + # noop + else + name_method = [:display_name, :name, :number].find { record.respond_to? _1 } if record + price_method = [:display_amount, :display_total, :display_cost].find { record.respond_to? _1 } if record + + description = record_class.model_name.human + description = "#{description} - #{record.public_send(name_method)}" if name_method + + # attempt creating a link + url_options = [:admin, record, :edit, { only_path: true }] + url = begin; spree.url_for(url_options); rescue NoMethodError => e; logger.error(e.to_s); nil end + + description = link_to(description, url, class: "body-link") if url + detail = record.public_send(price_method) if price_method + end + + thumbnail_caption(description, detail) + end + + def thumbnail_caption(first_line, second_line) + tag.figcaption(safe_join([ + tag.div(first_line || NBSP, class: 'text-black body-small whitespace-nowrap text-ellipsis overflow-hidden'), + tag.div(second_line || NBSP, class: 'text-gray-500 body-small whitespace-nowrap text-ellipsis overflow-hidden') + ]), class: "flex flex-col gap-0 max-w-[15rem]") + end +end diff --git a/admin/app/components/solidus_admin/orders/show/adjustments/index/component.yml b/admin/app/components/solidus_admin/orders/show/adjustments/index/component.yml new file mode 100644 index 00000000000..544d2c90487 --- /dev/null +++ b/admin/app/components/solidus_admin/orders/show/adjustments/index/component.yml @@ -0,0 +1,21 @@ +en: + title: "Order %{number} / Adjustments" + save: "Save" + discard: "Discard" + none: "—" + + actions: + title: "Actions" + delete: "Delete" + lock: "Lock" + unlock: "Unlock" + edit: "Edit" + + state: + locked: "Locked" + unlocked: "Unlocked" + confirm: "Are you sure?" + + totals: + adjustable: "Totals (by Adjustable)" + source: "Totals (by Source)" diff --git a/admin/app/components/solidus_admin/orders/show/summary/component.html.erb b/admin/app/components/solidus_admin/orders/show/summary/component.html.erb index ba626a1a589..c801ee45a24 100644 --- a/admin/app/components/solidus_admin/orders/show/summary/component.html.erb +++ b/admin/app/components/solidus_admin/orders/show/summary/component.html.erb @@ -6,7 +6,7 @@ { label: t('.taxes'), value: number_to_currency(@order.additional_tax_total) }, { label: t('.shipping'), value: number_to_currency(@order.shipment_total) }, { label: link_to(t('.add_promo_code'), '#', class: "body-link"), value: number_to_currency(@order.promo_total) }, - { label: link_to(t('.adjustments'), '#', class: "body-link"), value: number_to_currency(@order.adjustment_total) }, + { label: link_to(t('.adjustments'), solidus_admin.order_adjustments_path(@order), class: "body-link"), value: number_to_currency(@order.adjustment_total) }, { label: t('.total'), value: number_to_currency(@order.total), class: 'font-semibold' } ] ) %> diff --git a/admin/app/controllers/solidus_admin/adjustments_controller.rb b/admin/app/controllers/solidus_admin/adjustments_controller.rb new file mode 100644 index 00000000000..6ad88acf2d8 --- /dev/null +++ b/admin/app/controllers/solidus_admin/adjustments_controller.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class SolidusAdmin::AdjustmentsController < SolidusAdmin::BaseController + before_action :load_order + + def index + @adjustments = @order + .all_adjustments + .eligible + .order("adjustable_type ASC, created_at ASC") + .ransack(params[:q]) + .result + + set_page_and_extract_portion_from(@adjustments) + + respond_to do |format| + format.html do + render component('orders/show/adjustments/index').new( + order: @order, + adjustments: @adjustments, + ) + end + end + end + + def lock + @adjustments = @order.all_adjustments.not_finalized.where(id: params[:id]) + @adjustments.each(&:finalize!) + flash[:success] = t('.success') + + redirect_to order_adjustments_path(@order), status: :see_other + end + + def unlock + @adjustments = @order.all_adjustments.finalized.where(id: params[:id]) + @adjustments.each(&:unfinalize!) + flash[:success] = t('.success') + + redirect_to order_adjustments_path(@order), status: :see_other + end + + def destroy + @adjustments = @order.all_adjustments.where(id: params[:id]) + @adjustments.destroy_all + flash[:success] = t('.success') + + redirect_to order_adjustments_path(@order), status: :see_other + end + + private + + def load_order + @order = Spree::Order.find_by!(number: params[:order_id]) + end +end diff --git a/admin/config/locales/adjustments.en.yml b/admin/config/locales/adjustments.en.yml new file mode 100644 index 00000000000..5308b28945d --- /dev/null +++ b/admin/config/locales/adjustments.en.yml @@ -0,0 +1,10 @@ +en: + solidus_admin: + adjustments: + title: "Adjustments" + lock: + success: "Locked successfully" + unlock: + success: "Unlocked successfully" + destroy: + success: "Deleted successfully" diff --git a/admin/config/routes.rb b/admin/config/routes.rb index 49f13e99b67..c938ea27de6 100644 --- a/admin/config/routes.rb +++ b/admin/config/routes.rb @@ -26,6 +26,14 @@ admin_resources :orders, except: [ :destroy, :index ], constraints: ->{ SolidusAdmin::Config.enable_alpha_features? } do + resources :adjustments, only: [:index] do + collection do + delete :destroy + put :lock + put :unlock + end + end + resources :line_items, only: [:destroy, :create, :update] resource :customer resource :ship_address, only: [:show, :edit, :update], controller: "addresses", type: "ship" diff --git a/admin/spec/components/previews/solidus_admin/orders/show/summary/component_preview.rb b/admin/spec/components/previews/solidus_admin/orders/show/summary/component_preview.rb index 3d9adbf7d7b..a092a4e709f 100644 --- a/admin/spec/components/previews/solidus_admin/orders/show/summary/component_preview.rb +++ b/admin/spec/components/previews/solidus_admin/orders/show/summary/component_preview.rb @@ -5,8 +5,14 @@ class SolidusAdmin::Orders::Show::Summary::ComponentPreview < ViewComponent::Pre include SolidusAdmin::Preview def overview - order = fake_order(item_total: 340, additional_tax_total: 10, shipment_total: 20, promo_total: 10, adjustment_total: 20) - render_with_template(locals: { order: order }) + render_with_template(locals: { order: fake_order( + number: "R123", + item_total: 340, + additional_tax_total: 10, + shipment_total: 20, + promo_total: 10, + adjustment_total: 20 + ) }) end # @param item_total [Float] @@ -15,29 +21,31 @@ def overview # @param promo_total [Float] # @param adjustment_total [Float] def playground(item_total: 100, additional_tax_total: 10, shipment_total: 5, promo_total: 0, adjustment_total: 0) - fake_order = fake_order( + render current_component.new(order: fake_order( + number: "R123", item_total: item_total, additional_tax_total: additional_tax_total, shipment_total: shipment_total, promo_total: promo_total, adjustment_total: adjustment_total - ) - - render current_component.new(order: fake_order) + )) end private - def fake_order(item_total:, additional_tax_total:, shipment_total:, promo_total:, adjustment_total:) - order = Spree::Order.new + def fake_order(**attributes) + order = Spree::Order.new(attributes) + + attributes.each do |key, value| + order.define_singleton_method(key) { value } + end - order.define_singleton_method(:item_total) { item_total } - order.define_singleton_method(:additional_tax_total) { additional_tax_total } - order.define_singleton_method(:shipment_total) { shipment_total } - order.define_singleton_method(:promo_total) { promo_total } - order.define_singleton_method(:adjustment_total) { adjustment_total } order.define_singleton_method(:total) { - item_total.to_f + additional_tax_total.to_f + shipment_total.to_f - promo_total.to_f - adjustment_total.to_f + item_total.to_f + + additional_tax_total.to_f + + shipment_total.to_f - + promo_total.to_f - + adjustment_total.to_f } order diff --git a/admin/spec/features/orders/adjustments_spec.rb b/admin/spec/features/orders/adjustments_spec.rb new file mode 100644 index 00000000000..65251226733 --- /dev/null +++ b/admin/spec/features/orders/adjustments_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe "Order", :js, type: :feature do + before { sign_in create(:admin_user, email: 'admin@example.com') } + + it "allows detaching a customer from an order" do + allow(SolidusAdmin::Config).to receive(:enable_alpha_features?) { true } + + order = create(:order, number: "R123456789", user: create(:user)) + Spree::Adjustment.insert_all([ + { + order_id: order.id, + adjustable_id: order.id, + adjustable_type: "Spree::Order", + amount: 10, + label: "Test Adjustment", + eligible: true, + finalized: false, + created_at: Time.current, + updated_at: Time.current, + included: false, + source_type: "Spree::Order", + source_id: order.id, + promotion_code_id: nil, + }, + ]) + visit "/admin/orders/R123456789" + + click_on "Adjustments" + expect(page).to have_content("Test Adjustment") + + expect(page).to be_axe_clean + + select_row("Test Adjustment") + click_on "Lock" + expect(page).to have_content("Locked successfully", wait: 5) + + select_row("Test Adjustment") + click_on "Unlock" + expect(page).to have_content("Unlocked successfully") + + select_row("Test Adjustment") + click_on "Delete" + expect(page).to have_content("Deleted successfully") + expect(page).not_to have_content("Test Adjustment") + expect(Spree::AdjustmentReason.count).to eq(0) + + expect(page).to be_axe_clean + end +end diff --git a/core/app/models/spree/adjustment.rb b/core/app/models/spree/adjustment.rb index 71673c57d5c..3ec22c90a9e 100644 --- a/core/app/models/spree/adjustment.rb +++ b/core/app/models/spree/adjustment.rb @@ -50,6 +50,8 @@ class Adjustment < Spree::Base singleton_class.deprecate :return_authorization, deprecator: Spree.deprecator + allowed_ransackable_attributes << 'label' + extend DisplayMoney money_methods :amount